passkey.ts

35.0 KB · raw

1import { isProUser } from "./license";2import * as vault from "./vault";3import {4  base64urlDecode,5  base64urlEncode,6  buildAuthenticatorData,7  buildClientDataJSON,8  createAttestationObject,9  generateCredentialId,10  generatePasskeyKeypair,11  pickPasskeyAlgorithm,12  signPasskeyAssertion,13} from "./passkey-crypto";14import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";15import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";16import type {17  PasskeyData,18  PasskeyProviderState,19  PendingPasskeyRequest,20  VaultEntry,21} from "../shared/types";2223declare const __PEACH_E2E_PASSKEYS__: boolean;2425const PENDING_PREFIX = "passkey_pending_";26const APPROVAL_TIMEOUT_MS = 60_000;2728let listenersRegistered = false;29let attached = false;30let conflict: string | undefined;31const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3233const approvals = new Map<34  number,35  { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }36>();3738// Resolvers for GET requests that arrived while the vault was locked and are39// waiting for the user to unlock before the passkey flow can continue.40const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4142export function onVaultUnlocked(): void {43  for (const { resolve, timeoutId } of unlockWaiters.values()) {44    clearTimeout(timeoutId);45    resolve(true);46  }47  unlockWaiters.clear();48}4950/**51 * Resolve every still-pending passkey request as denied and clear unlock52 * waiters. Called from the popup-disconnect path so a closed popup releases53 * the page-side caller in <200ms instead of leaving it stuck for the full54 * APPROVAL_TIMEOUT_MS (60s) (M8/COR-009).55 */56export async function denyAllPendingPasskeyRequests(): Promise<void> {57  for (const [rid, { resolve, timeoutId }] of approvals.entries()) {58    clearTimeout(timeoutId);59    resolve(false);60    approvals.delete(rid);61    await clearPendingRequest(rid).catch(() => {});62  }63  for (const { resolve, timeoutId } of unlockWaiters.values()) {64    clearTimeout(timeoutId);65    resolve(false);66  }67  unlockWaiters.clear();68}6970function isPasskeySupported(): boolean {71  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;72  // Firefox 150+ exposes navigator.credentials.create/get from the popup73  // context with extension host permissions. Cannot intercept page-side74  // calls (no equivalent of webAuthenticationProxy), but registration via75  // Peach's own popup-driven flow works.76  if (detectSharedBrowser() === "firefox") {77    const ver = firefoxVersion();78    if (Number.isFinite(ver) && ver >= 150) return true;79  }80  return false;81}8283export const detectBrowser = detectSharedBrowser;8485function getBrowserLabel(): string {86  const ua = navigator.userAgent;87  if (/Firefox\/(\d+)/.test(ua)) {88    const version = ua.match(/Firefox\/(\d+)/)?.[1];89    return version ? `Firefox ${version}` : "Firefox";90  }91  const version = ua.match(/Chrome\/(\d+)/)?.[1];92  return version ? `Chrome ${version}` : "Chromium";93}9495function pendingStorageKey(requestId: number): string {96  return `${PENDING_PREFIX}${requestId}`;97}9899function originFromRpId(rpId: string): string {100  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)101    ? `http://${rpId}`102    : `https://${rpId}`;103}104105async function setBadge(text: string): Promise<void> {106  if (!chrome.action?.setBadgeText) return;107  await chrome.action.setBadgeText({ text });108  if (text) {109    await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });110  }111}112113async function clearPendingRequest(requestId: number): Promise<void> {114  if (chrome.storage.session) {115    await chrome.storage.session.remove(pendingStorageKey(requestId));116  }117  await setBadge("");118}119120async function openApprovalPopup(): Promise<void> {121  try {122    await chrome.action.openPopup();123  } catch {124    await setBadge("PK");125  }126}127128async function rejectCreate(129  requestId: number,130  name: string,131  message = ""132): Promise<void> {133  await chrome.webAuthenticationProxy.completeCreateRequest({134    requestId,135    error: { name, message },136  });137}138139async function rejectGet(140  requestId: number,141  name: string,142  message = ""143): Promise<void> {144  await chrome.webAuthenticationProxy.completeGetRequest({145    requestId,146    error: { name, message },147  });148}149150export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {151  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts152  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;153  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));154  if (!key) return null;155  const pending = stored[key];156  return pending && typeof pending === "object"157    ? (pending as PendingPasskeyRequest)158    : null;159}160161export async function resolvePendingPasskeyRequest(162  requestId: number,163  approved: boolean164): Promise<void> {165  const pending = approvals.get(requestId);166  if (!pending) {167    // Either the request expired (Chrome) or this is a Firefox-driven168    // pending where the popup performs the navigator.credentials call169    // directly. Clear both possible session keys so getPendingPasskeyRequest()170    // doesn't surface stale state on the next popup open.171    await clearPendingRequest(requestId);172    if (chrome.storage.session) {173      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);174    }175    return;176  }177178  clearTimeout(pending.timeoutId);179  approvals.delete(requestId);180  await clearPendingRequest(requestId);181  pending.resolve(approved);182}183184async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {185  if (chrome.storage.session) {186    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });187  }188  await openApprovalPopup();189190  return new Promise<boolean>((resolve) => {191    const timeoutId = setTimeout(async () => {192      approvals.delete(request.requestId);193      await clearPendingRequest(request.requestId);194      resolve(false);195    }, APPROVAL_TIMEOUT_MS);196197    approvals.set(request.requestId, { resolve, timeoutId });198  });199}200201export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {202  const settings = await vault.ensureSettingsLoaded();203  const supported = isPasskeySupported();204  const enabled = settings.passkeyProviderEnabled === true;205  return {206    supported,207    enabled,208    attached: supported && attached,209    browserLabel: getBrowserLabel(),210    statusLabel: !supported211      ? "Interception unavailable"212      : attached213        ? "Active"214        : conflict215          ? "Another provider is active"216          : enabled217            ? "Inactive"218            : "Disabled",219    conflict,220  };221}222223async function attachPasskeyProxy(): Promise<void> {224  if (!isPasskeySupported()) {225    attached = false;226    conflict = "This browser does not expose chrome.webAuthenticationProxy.";227    return;228  }229230  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context231  // navigator.credentials creator is always ready once the user enables the232  // provider. Treat enabled = attached for state reporting purposes.233  if (detectSharedBrowser() === "firefox") {234    attached = true;235    conflict = undefined;236    return;237  }238239  if (attached) return;240241  try {242    await chrome.webAuthenticationProxy.attach();243    attached = true;244    conflict = undefined;245  } catch (error) {246    attached = false;247    conflict = error instanceof Error ? error.message : "Another provider is active.";248  }249}250251async function detachPasskeyProxy(): Promise<void> {252  if (!isPasskeySupported()) {253    attached = false;254    return;255  }256257  if (detectSharedBrowser() === "firefox") {258    attached = false;259    conflict = undefined;260    return;261  }262263  try {264    await chrome.webAuthenticationProxy.detach();265  } catch {266    // Ignore detach failures; the extension may already be detached.267  }268269  attached = false;270  conflict = undefined;271}272273export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {274  await vault.updateSettings({ passkeyProviderEnabled: enabled });275  if (enabled) {276    await attachPasskeyProxy();277  } else {278    await detachPasskeyProxy();279  }280  return getPasskeyProviderState();281}282283async function refreshPasskeyProxy(): Promise<void> {284  const settings = await vault.ensureSettingsLoaded();285  if (settings.passkeyProviderEnabled === true) {286    await attachPasskeyProxy();287    return;288  }289  await detachPasskeyProxy();290}291292/**293 * Find the entry that already holds a passkey for the given RP, or the most294 * recently-modified login entry whose URL/additionalUrls match the RP — that295 * entry is the natural attach target when the user adds a passkey to a site296 * they already have a password for. Returns null when there's no candidate;297 * the caller creates a new entry in that case.298 */299function findAttachTarget(rpId: string): VaultEntry | null {300  const all = vault.getEntries();301  const passkeyMatch = all.find(302    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt303  );304  if (passkeyMatch) return passkeyMatch;305  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);306  if (loginMatches.length === 0) return null;307  // Pick the most recently modified candidate to be deterministic.308  return loginMatches.reduce((best, e) =>309    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best310  );311}312313async function handleCreateRequest(314  request: chrome.webAuthenticationProxy.CreateRequest315): Promise<void> {316  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;317  const rpId = options.rp.id;318  const rpName = options.rp.name ?? options.rp.id;319  const userName = options.user.name;320  const userDisplayName = options.user.displayName ?? options.user.name;321322  if (!rpId) {323    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");324    return;325  }326327  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {328    await rejectCreate(329      request.requestId,330      "NotAllowedError",331      "Passkey creation requires Peach Pro."332    );333    // Show the inline passkey gate so the user knows what happened and can upgrade.334    broadcastPasskeyProGate();335    return;336  }337338  if (!vault.isUnlocked()) {339    await openApprovalPopup();340    await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");341    return;342  }343344  const approved = await promptUserApproval({345    requestId: request.requestId,346    type: "create",347    rpId,348    rpName,349    userName,350    userDisplayName,351  });352  if (!approved) {353    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");354    return;355  }356357  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);358  if (!algorithm) {359    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");360    return;361  }362363  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));364  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(365    algorithm366  );367  const authData = await buildAuthenticatorData({368    rpId,369    flags: 0x4d,370    signCount: 0,371    credentialId: credentialIdBytes,372    publicKeyCose,373  });374  const attestationObject = createAttestationObject(authData);375  const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));376377  const passkey: PasskeyData = {378    rpId,379    rpName,380    credentialId: base64urlEncode(credentialIdBytes),381    publicKeyCose: base64urlEncode(publicKeyCose),382    privateKeyJwk,383    userHandle: options.user.id,384    userName,385    userDisplayName,386    algorithm,387    signCount: 0,388    aaguid: base64urlEncode(new Uint8Array(16)),389    backupEligible: false,390    backupState: false,391    browser: "chrome",392    transports: ["internal"],393    createdAt: Date.now(),394  };395396  // Attach to an existing login (same domain) when one exists; otherwise397  // create a fresh login entry that exists solely to hold the passkey.398  const target = findAttachTarget(rpId);399  if (target) {400    await vault.updateEntry(target.id, { passkey });401  } else {402    await vault.addEntry({403      type: "login",404      title: rpName,405      url: `https://${rpId}`,406      username: userName,407      passkey,408    });409  }410411  await chrome.webAuthenticationProxy.completeCreateRequest({412    requestId: request.requestId,413    responseJson: JSON.stringify({414      id: passkey.credentialId,415      rawId: passkey.credentialId,416      type: "public-key",417      response: {418        clientDataJSON: base64urlEncode(clientDataJSON),419        authenticatorData: base64urlEncode(authData),420        attestationObject: base64urlEncode(attestationObject),421        transports: ["internal"],422        publicKey: base64urlEncode(publicKeySpki),423        publicKeyAlgorithm: algorithm,424      },425      authenticatorAttachment: "platform",426      clientExtensionResults: {},427    }),428  });429}430431function broadcastPasskeyProGate(): void {432  chrome.tabs.query({}, (tabs) => {433    for (const tab of tabs) {434      if (tab.id !== undefined) {435        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});436      }437    }438  });439}440441async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {442  if (chrome.storage.session) {443    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });444  }445446  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {447    chrome.tabs.query({ active: true, currentWindow: true }, resolve);448  });449  const tab = tabs[0];450451  if (tab?.id) {452    chrome.tabs.sendMessage(tab.id, {453      type: 'SHOW_PASSKEY_TOAST',454      payload: {455        site: request.rpId,456        displayName: request.userDisplayName ?? request.userName,457        username: request.userName,458        requestId: String(request.requestId),459      },460    }).catch(() => {});461  } else {462    await openApprovalPopup();463  }464465  return new Promise<boolean>((resolve) => {466    const timeoutId = setTimeout(async () => {467      approvals.delete(request.requestId);468      await clearPendingRequest(request.requestId);469      resolve(false);470    }, APPROVAL_TIMEOUT_MS);471472    approvals.set(request.requestId, { resolve, timeoutId });473  });474}475476async function handleGetRequest(477  request: chrome.webAuthenticationProxy.GetRequest478): Promise<void> {479  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;480  if (!vault.isUnlocked()) {481    // Summon the inline unlock prompt on the active tab so the user can unlock482    // without leaving the page. Fall back to the popup if the content script483    // is unavailable (e.g. restricted URL, script not yet injected).484    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>485      chrome.tabs.query({ active: true, currentWindow: true }, resolve)486    );487    const activeTab = tabs[0];488    if (activeTab?.id) {489      chrome.tabs.sendMessage(activeTab.id, {490        type: 'SUMMON_AUTOFILL',491        source: 'passkey-unlock',492      }).catch(() => { void openApprovalPopup(); });493    } else {494      await openApprovalPopup();495    }496497    // Hold the request open until the vault unlocks or the timeout fires.498    const unlocked = await new Promise<boolean>((resolve) => {499      const timeoutId = setTimeout(() => {500        unlockWaiters.delete(request.requestId);501        resolve(false);502      }, APPROVAL_TIMEOUT_MS);503      unlockWaiters.set(request.requestId, { resolve, timeoutId });504    });505506    if (!unlocked || !vault.isUnlocked()) {507      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");508      return;509    }510    // Vault is now unlocked — fall through to authenticate.511  }512513  if (!(await isProUser())) {514    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");515    broadcastPasskeyProGate();516    return;517  }518519  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>520    entry.passkey !== undefined && !entry.deletedAt521  );522  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);523524  const match = passkeyBearing.find((entry) => {525    if (allowedCredentialIds.length > 0) {526      return allowedCredentialIds.includes(entry.passkey.credentialId);527    }528    if (!options.rpId) return false;529    return entry.passkey.rpId === options.rpId;530  });531532  if (!match) {533    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");534    return;535  }536537  const credential = match.passkey;538  // Chrome's webAuthenticationProxy path requires Peach-held private key539  // material to sign the assertion. Firefox-created passkeys delegate to the540  // platform authenticator — the browser will route to the platform UI541  // automatically when we reject, which is the right outcome.542  if (!credential.privateKeyJwk) {543    await rejectGet(544      request.requestId,545      "NotAllowedError",546      "This passkey was created on Firefox; sign in via the platform authenticator."547    );548    return;549  }550551  const approved = await promptViaToast({552    requestId: request.requestId,553    type: "get",554    rpId: credential.rpId,555    rpName: credential.rpName,556    userName: credential.userName,557    userDisplayName: credential.userDisplayName,558  });559  if (!approved) {560    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");561    return;562  }563564  const nextSignCount = credential.signCount + 1;565  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);566  const authData = await buildAuthenticatorData({567    rpId: credential.rpId,568    flags,569    signCount: nextSignCount,570  });571  const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));572  const clientDataHash = new Uint8Array(573    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))574  );575  const signature = await signPasskeyAssertion(576    credential.privateKeyJwk,577    credential.algorithm,578    authData,579    clientDataHash580  );581582  await vault.updateEntry(match.id, {583    passkey: {584      ...credential,585      signCount: nextSignCount,586      lastUsedAt: Date.now(),587    },588  });589590  const responseJson = JSON.stringify({591    id: credential.credentialId,592    rawId: credential.credentialId,593    type: "public-key",594    response: {595      clientDataJSON: base64urlEncode(clientDataJSON),596      authenticatorData: base64urlEncode(authData),597      signature: base64urlEncode(signature),598      userHandle: credential.userHandle,599    },600    authenticatorAttachment: "platform",601    clientExtensionResults: {},602  });603604  await chrome.webAuthenticationProxy.completeGetRequest({605    requestId: request.requestId,606    responseJson,607  });608}609610// ── Firefox path ────────────────────────────────────────────────────────611//612// Firefox 150+ permits extensions to call navigator.credentials.create/get613// from the popup context with their host-permitted RP IDs. Peach uses that614// surface to register passkeys on behalf of the user. The page's own615// navigator.credentials call is observed via the MAIN-world detector616// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's617// flow is parallel and explicit (the user must engage with Peach's button).618619const FIREFOX_PENDING_KEY = "passkey_pending_firefox";620const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";621const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days622623export async function initiateFirefoxPasskeyCreate(args: {624  rpId: string;625  rpName: string;626  userName: string;627  userDisplayName?: string;628}): Promise<{ ok: boolean; error?: string }> {629  if (!(await isProUser())) {630    broadcastPasskeyProGate();631    return { ok: false, error: "passkey creation requires Peach Pro" };632  }633  if (!vault.isUnlocked()) {634    await openApprovalPopup();635    return { ok: false, error: "vault locked" };636  }637  if (!chrome.storage.session) {638    return { ok: false, error: "session storage unavailable" };639  }640641  // Date.now() alone collides if two Firefox passkey requests land in the642  // same millisecond. Mix in 20 random bits to match the synthetic-id pattern643  // at requestFirefoxApproval (M9/COR-010).644  const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);645  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));646  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));647648  const pending: PendingPasskeyRequest = {649    requestId,650    type: "create",651    rpId: args.rpId,652    rpName: args.rpName,653    userName: args.userName,654    userDisplayName: args.userDisplayName,655    browser: "firefox",656    challenge,657    userHandle,658  };659660  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });661  await openApprovalPopup();662  return { ok: true };663}664665export async function completeFirefoxPasskeyCreate(payload: {666  requestId: number;667  rpId: string;668  rpName: string;669  userName: string;670  userDisplayName?: string;671  userHandle: string;672  credentialId: string;673  publicKeyCose: string;674  algorithm: -7 | -8;675  aaguid?: string;676  transports?: string[];677  backupEligible?: boolean;678  backupState?: boolean;679}): Promise<{ ok: boolean; error?: string }> {680  if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };681682  const passkey: PasskeyData = {683    rpId: payload.rpId,684    rpName: payload.rpName,685    credentialId: payload.credentialId,686    publicKeyCose: payload.publicKeyCose,687    userHandle: payload.userHandle,688    userName: payload.userName,689    userDisplayName: payload.userDisplayName ?? payload.userName,690    algorithm: payload.algorithm,691    signCount: 0,692    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),693    backupEligible: payload.backupEligible ?? false,694    backupState: payload.backupState ?? false,695    browser: "firefox",696    transports: payload.transports ?? ["internal"],697    createdAt: Date.now(),698  };699700  // Same attach-or-create flow as the Chrome path: prefer attaching to an701  // existing login entry for the same domain so the user ends up with one702  // entry per site, not two (a login + a passkey-only).703  const target = findAttachTarget(payload.rpId);704  if (target) {705    await vault.updateEntry(target.id, { passkey });706  } else {707    await vault.addEntry({708      type: "login",709      title: payload.rpName || payload.rpId,710      url: `https://${payload.rpId}`,711      username: payload.userName,712      passkey,713    });714  }715716  if (chrome.storage.session) {717    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);718  }719  return { ok: true };720}721722export async function cancelFirefoxPasskeyCreate(): Promise<void> {723  if (chrome.storage.session) {724    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);725  }726}727728function declineKey(domain: string): string {729  return `${FIREFOX_DECLINE_PREFIX}${domain}`;730}731732export async function recordPasskeyOfferDecline(domain: string): Promise<void> {733  if (!domain) return;734  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });735}736737export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {738  if (!domain) return false;739  const key = declineKey(domain);740  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;741  const at = stored[key];742  if (typeof at !== "number") return false;743  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {744    await chrome.storage.local.remove(key);745    return false;746  }747  return true;748}749750// ── Firefox content-script proxy path (v2.12.0) ────────────────────────751//752// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces753// navigator.credentials.create / .get on every Firefox page. It builds a754// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),755// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,756// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.757//758// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag759// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates760// to false and these handlers short-circuit. The full removal checklist is761// in that file — the deletion is purely subtractive.762763interface FirefoxAllowCredential {764  id: string;765  type?: string;766}767768interface FirefoxPubKeyCredParam {769  type?: string;770  alg: number;771}772773export interface FirefoxPasskeyHasCredentialsPayload {774  rpId: string;775  allowCredentials: FirefoxAllowCredential[];776}777778export interface FirefoxPasskeyCreatePayload {779  rpId: string;780  rpName?: string;781  userId: string;782  userName: string;783  userDisplayName?: string;784  challenge: string;785  clientDataJSON: number[];786  origin: string;787  pubKeyCredParams?: FirefoxPubKeyCredParam[];788  authenticatorSelection?: Record<string, unknown>;789  timeout?: number;790  attestation?: string;791}792793export interface FirefoxPasskeyGetPayload {794  rpId: string;795  challenge: string;796  clientDataJSON: number[];797  origin: string;798  allowCredentials: FirefoxAllowCredential[];799  userVerification?: string;800}801802interface FirefoxCreateCredentialResponse {803  type: "create";804  id: string;805  clientDataJSON: number[];806  attestationObject: string;807  authenticatorData: string;808  publicKey: string;809  algorithm: -7 | -8;810}811812interface FirefoxGetCredentialResponse {813  type: "get";814  id: string;815  clientDataJSON: number[];816  authenticatorData: string;817  signature: string;818  userHandle: string;819}820821type FirefoxPasskeyResult<T> =822  | { ok: true; credential: T }823  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };824825function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {826  return vault.getEntries().filter(827    (entry): entry is VaultEntry & { passkey: PasskeyData } =>828      entry.passkey !== undefined && !entry.deletedAt829  );830}831832export async function handlePasskeyHasCredentials(833  payload: FirefoxPasskeyHasCredentialsPayload834): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {835  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {836    return { ok: false, error: "not_applicable" };837  }838  if (!vault.isUnlocked()) {839    // Cannot enumerate without the vault key. The MAIN-world proxy treats a840    // false answer as "fall through to the platform" — exactly the right841    // outcome when Peach is locked: the user gets the native UI instead.842    return { ok: true, hasCredentials: false };843  }844  const passkeys = listPasskeyEntries().filter(845    (entry) => entry.passkey.rpId === payload.rpId846  );847  if (passkeys.length === 0) return { ok: true, hasCredentials: false };848849  const allow = payload.allowCredentials ?? [];850  if (allow.length === 0) return { ok: true, hasCredentials: true };851852  const hasMatch = passkeys.some((entry) =>853    allow.some((ac) => ac.id === entry.passkey.credentialId)854  );855  return { ok: true, hasCredentials: hasMatch };856}857858/**859 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing860 * pending-passkey channel (badge + popup + 60s timeout) — the same approval861 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need862 * any new code path. The page-side proxy never sees the request id; the863 * background mints one and discards it after resolution.864 */865async function requestFirefoxApproval(args: {866  type: "create" | "get";867  rpId: string;868  rpName?: string;869  userName: string;870  userDisplayName?: string;871}): Promise<boolean> {872  // Synthetic request id — the Firefox proxy path doesn't have a873  // chrome.webAuthenticationProxy request to tie back to.874  const requestId = Date.now() + Math.floor(Math.random() * 1000);875  return promptUserApproval({876    requestId,877    type: args.type,878    rpId: args.rpId,879    rpName: args.rpName ?? args.rpId,880    userName: args.userName,881    userDisplayName: args.userDisplayName,882  });883}884885export async function handleFirefoxPasskeyCreate(886  payload: FirefoxPasskeyCreatePayload887): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {888  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {889    return { ok: false, error: "not_applicable" };890  }891  if (!(await isProUser())) {892    broadcastPasskeyProGate();893    return { ok: false, error: "pro_required" };894  }895  if (!vault.isUnlocked()) {896    await openApprovalPopup();897    return { ok: false, error: "vault_locked" };898  }899900  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);901  if (!algorithm) {902    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };903  }904905  const approved = await requestFirefoxApproval({906    type: "create",907    rpId: payload.rpId,908    rpName: payload.rpName,909    userName: payload.userName,910    userDisplayName: payload.userDisplayName,911  });912  if (!approved) return { ok: false, error: "user_cancelled" };913914  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();915  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);916917  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).918  // Matches Chrome's create flag set in handleCreateRequest above.919  const authenticatorData = await buildAuthenticatorData({920    rpId: payload.rpId,921    flags: 0x45,922    signCount: 0,923    credentialId: credentialIdBytes,924    publicKeyCose,925  });926  const attestationObject = createAttestationObject(authenticatorData);927928  const passkey: PasskeyData = {929    rpId: payload.rpId,930    rpName: payload.rpName ?? payload.rpId,931    credentialId,932    publicKeyCose: base64urlEncode(publicKeyCose),933    privateKeyJwk,934    userHandle: payload.userId,935    userName: payload.userName,936    userDisplayName: payload.userDisplayName ?? payload.userName,937    algorithm,938    signCount: 0,939    aaguid: base64urlEncode(new Uint8Array(16)),940    backupEligible: false,941    backupState: false,942    browser: "firefox",943    transports: ["internal"],944    createdAt: Date.now(),945  };946947  const target = findAttachTarget(payload.rpId);948  if (target) {949    await vault.updateEntry(target.id, { passkey });950  } else {951    await vault.addEntry({952      type: "login",953      title: payload.rpName || payload.rpId,954      url: `https://${payload.rpId}`,955      username: payload.userName,956      passkey,957    });958  }959960  return {961    ok: true,962    credential: {963      type: "create",964      id: credentialId,965      // The content script already encoded clientDataJSON with the page966      // origin; round-trip the exact bytes back so the RP sees the same967      // hash it computes locally.968      clientDataJSON: payload.clientDataJSON,969      attestationObject: base64urlEncode(attestationObject),970      authenticatorData: base64urlEncode(authenticatorData),971      publicKey: base64urlEncode(publicKeySpki),972      algorithm,973    },974  };975}976977export async function handleFirefoxPasskeyGet(978  payload: FirefoxPasskeyGetPayload979): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {980  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {981    return { ok: false, error: "not_applicable" };982  }983  if (!(await isProUser())) {984    broadcastPasskeyProGate();985    return { ok: false, error: "pro_required" };986  }987  if (!vault.isUnlocked()) {988    await openApprovalPopup();989    return { ok: false, error: "vault_locked" };990  }991992  const allow = payload.allowCredentials ?? [];993  const candidates = listPasskeyEntries().filter((entry) => {994    if (entry.passkey.rpId !== payload.rpId) return false;995    if (allow.length === 0) return true;996    return allow.some((ac) => ac.id === entry.passkey.credentialId);997  });998999  if (candidates.length === 0) return { ok: false, error: "no_credentials" };10001001  // Most-recently-used wins when several credentials match. A future1002  // multi-account selector goes here; for now this matches the Chrome path's1003  // behavior for the common one-passkey-per-rp case.1004  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1005  const match = candidates[0]!;1006  const credential = match.passkey;10071008  if (!credential.privateKeyJwk) {1009    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1010    // — no Peach-held private key to sign with. The MAIN-world proxy treats1011    // no_credentials as a fall-through, which routes to the platform UI.1012    return { ok: false, error: "no_credentials" };1013  }10141015  const approved = await requestFirefoxApproval({1016    type: "get",1017    rpId: credential.rpId,1018    rpName: credential.rpName,1019    userName: credential.userName,1020    userDisplayName: credential.userDisplayName,1021  });1022  if (!approved) return { ok: false, error: "user_cancelled" };10231024  const nextSignCount = credential.signCount + 1;1025  // 0x05 = UP|UV. backup bits track whatever was set at registration time1026  // (consistent with the Chrome path).1027  const flags =1028    0x05 |1029    (credential.backupEligible ? 0x08 : 0) |1030    (credential.backupState ? 0x10 : 0);1031  const authenticatorData = await buildAuthenticatorData({1032    rpId: credential.rpId,1033    flags,1034    signCount: nextSignCount,1035  });10361037  // The content script supplied clientDataJSON bytes built with the page1038  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1039  // diverges from what the RP computes.1040  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1041  const clientDataHash = new Uint8Array(1042    await crypto.subtle.digest("SHA-256", clientDataBytes)1043  );1044  const signature = await signPasskeyAssertion(1045    credential.privateKeyJwk,1046    credential.algorithm,1047    authenticatorData,1048    clientDataHash1049  );10501051  await vault.updateEntry(match.id, {1052    passkey: {1053      ...credential,1054      signCount: nextSignCount,1055      lastUsedAt: Date.now(),1056    },1057  });10581059  return {1060    ok: true,1061    credential: {1062      type: "get",1063      id: credential.credentialId,1064      clientDataJSON: payload.clientDataJSON,1065      authenticatorData: base64urlEncode(authenticatorData),1066      signature: base64urlEncode(signature),1067      userHandle: credential.userHandle,1068    },1069  };1070}10711072export async function setupPasskeyProxy(): Promise<void> {1073  if (!isPasskeySupported()) return;10741075  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1076  // popup-context credential creator is the entry point; nothing to wire here.1077  if (detectSharedBrowser() === "firefox") {1078    await refreshPasskeyProxy();1079    return;1080  }10811082  if (!listenersRegistered) {1083    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1084      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1085        requestId: requestInfo.requestId,1086        isUvpaa: vault.isUnlocked(),1087      });1088    });1089    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1090      void handleCreateRequest(requestInfo).catch(async (error) => {1091        console.error("Peach passkey create failed", error);1092        await rejectCreate(1093          requestInfo.requestId,1094          "UnknownError",1095          error instanceof Error ? error.message : "Passkey creation failed."1096        );1097      });1098    });1099    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1100      void handleGetRequest(requestInfo).catch(async (error) => {1101        console.error("Peach passkey get failed", error);1102        await rejectGet(1103          requestInfo.requestId,1104          "UnknownError",1105          error instanceof Error ? error.message : "Passkey authentication failed."1106        );1107      });1108    });1109    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1110      void resolvePendingPasskeyRequest(requestId, false);1111    });1112    listenersRegistered = true;1113  }11141115  await refreshPasskeyProxy();1116}