passkey.ts

49.7 KB · raw

1import { isProUser } from "./license";2import * as vault from "./vault";3import {4  base64urlEncode,5  buildAuthenticatorData,6  buildClientDataJSON,7  createAttestationObject,8  generateCredentialId,9  generatePasskeyKeypair,10  pickPasskeyAlgorithm,11  signPasskeyAssertion,12} from "./passkey-crypto";13import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";14import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";15import { isValidRpId } from "../shared/passkey-utils";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;3233// Approval must not close and lock the popup before a Firefox ceremony persists.34const firefoxCeremonies = new Map<number, Promise<void>>();3536const approvals = new Map<37  number,38  { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }39>();4041// Resolvers for GET requests that arrived while the vault was locked and are42// waiting for the user to unlock before the passkey flow can continue.43const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4445export function onVaultUnlocked(): void {46  for (const { resolve, timeoutId } of unlockWaiters.values()) {47    clearTimeout(timeoutId);48    resolve(true);49  }50  unlockWaiters.clear();51}5253/**54 * Resolve every still-pending passkey request as denied and clear unlock55 * waiters. Called from the popup-disconnect path so a closed popup releases56 * the page-side caller in <200ms instead of leaving it stuck for the full57 * APPROVAL_TIMEOUT_MS (60s) (M8/COR-009).58 */59export async function denyAllPendingPasskeyRequests(): Promise<void> {60  if (approvals.size === 0 && unlockWaiters.size === 0) return;61  for (const [rid, { resolve, timeoutId }] of approvals.entries()) {62    clearTimeout(timeoutId);63    resolve(false);64    approvals.delete(rid);65    await clearPendingRequest(rid).catch(() => {});66  }67  for (const { resolve, timeoutId } of unlockWaiters.values()) {68    clearTimeout(timeoutId);69    resolve(false);70  }71  unlockWaiters.clear();72}7374function isPasskeySupported(): boolean {75  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;76  // Firefox 150+ exposes navigator.credentials.create/get from the popup77  // context with extension host permissions. Cannot intercept page-side78  // calls (no equivalent of webAuthenticationProxy), but registration via79  // Peach's own popup-driven flow works.80  if (detectSharedBrowser() === "firefox") {81    const ver = firefoxVersion();82    if (Number.isFinite(ver) && ver >= 150) return true;83  }84  return false;85}8687export const detectBrowser = detectSharedBrowser;8889function getBrowserLabel(): string {90  const ua = navigator.userAgent;91  if (/Firefox\/(\d+)/.test(ua)) {92    const version = ua.match(/Firefox\/(\d+)/)?.[1];93    return version ? `Firefox ${version}` : "Firefox";94  }95  const version = ua.match(/Chrome\/(\d+)/)?.[1];96  return version ? `Chrome ${version}` : "Chromium";97}9899function pendingStorageKey(requestId: number): string {100  return `${PENDING_PREFIX}${requestId}`;101}102103function randomRequestId(): number {104  // 53-bit crypto-random integer — unbrute-forceable, unlike the old105  // Date.now()+Math.random synthetic id (~10-20 bits). Fits a JS safe106  // integer so it round-trips through String/Number for the toast path.107  // (Audit 2026-08-20 #1/#10.)108  const u = crypto.getRandomValues(new Uint32Array(2));109  return u[0] * 0x200000 + (u[1] >>> 11);110}111112function originFromRpId(rpId: string): string {113  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)114    ? `http://${rpId}`115    : `https://${rpId}`;116}117118/**119 * The clientDataJSON sent back to the RP must contain the page's actual120 * origin — not a synthesized one. `chrome.webAuthenticationProxy` does not121 * surface the requesting origin in CreateRequest/GetRequest, so we read it122 * from the active tab and validate that its hostname is a registrable123 * suffix match for the rpId. WebAuthn requires this relationship anyway, so124 * a mismatch indicates either a stale active tab or a hostile environment;125 * we fall back to https://<rpId> in that case to avoid silently signing the126 * wrong origin.127 *128 * Without this, a passkey created on https://myaccount.google.com gets129 * clientDataJSON.origin = "https://google.com", and Google's RP rejects the130 * attestation with "Something went wrong" because the origin hash doesn't131 * match what its own JS captured at create time.132 */133async function resolvePageOrigin(rpId: string): Promise<string> {134  try {135    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>136      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)137    );138    const url = tabs[0]?.url;139    if (url) {140      const parsed = new URL(url);141      const host = parsed.hostname.toLowerCase();142      const rp = rpId.toLowerCase();143      if (host === rp || host.endsWith(`.${rp}`)) {144        return parsed.origin;145      }146    }147  } catch {148    // fall through to rpId-derived origin149  }150  return originFromRpId(rpId);151}152153async function setBadge(text: string): Promise<void> {154  if (!chrome.action?.setBadgeText) return;155  await chrome.action.setBadgeText({ text });156  if (text) {157    await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });158  }159}160161async function clearPendingRequest(requestId: number): Promise<void> {162  if (chrome.storage.session) {163    await chrome.storage.session.remove(pendingStorageKey(requestId));164  }165  await setBadge("");166}167168async function openApprovalPopup(): Promise<void> {169  try {170    await chrome.action.openPopup();171  } catch {172    await setBadge("PK");173  }174}175176/**177 * Some ceremonies — especially conditional-UI `navigator.credentials.get`178 * calls — get cancelled by Chrome before we finish processing. Calling179 * complete*Request on a cancelled requestId rejects with "Invalid180 * requestId". That is expected, not exceptional: swallow it. Re-throwing181 * would surface an uncaught promise rejection in the SW and cascade through182 * the catch wrappers in setupPasskeyProxy.183 */184function isInvalidRequestIdError(err: unknown): boolean {185  return err instanceof Error && /invalid requestid/i.test(err.message);186}187188/**189 * Chrome throws "Missing GetResponseDetails.responseJson" when190 * completeGetRequest is called with no responseJson and no error (the191 * pass-through signal). The throw is a Chrome API quirk — the request IS192 * routed to the platform authenticator — but if we let it propagate, the193 * outer catch calls rejectGet, which sends a second completeGetRequest with194 * an error that cancels the pass-through and shows "Something went wrong" on195 * the page. Swallow it exactly as we swallow "Invalid requestId".196 */197function isMissingResponseJsonError(err: unknown): boolean {198  return err instanceof Error && /missing getresponsedetails/i.test(err.message);199}200201async function rejectCreate(202  requestId: number,203  name: string,204  message = ""205): Promise<void> {206  try {207    await chrome.webAuthenticationProxy.completeCreateRequest({208      requestId,209      error: { name, message },210    });211  } catch (err) {212    if (isInvalidRequestIdError(err)) {213      return;214    }215    throw err;216  }217}218219async function rejectGet(220  requestId: number,221  name: string,222  message = ""223): Promise<void> {224  try {225    await chrome.webAuthenticationProxy.completeGetRequest({226      requestId,227      error: { name, message },228    });229  } catch (err) {230    if (isInvalidRequestIdError(err)) {231      return;232    }233    throw err;234  }235}236237export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {238  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts239  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;240  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));241  if (!key) return null;242  const pending = stored[key];243  return pending && typeof pending === "object"244    ? (pending as PendingPasskeyRequest)245    : null;246}247248export async function resolvePendingPasskeyRequest(249  requestId: number,250  approved: boolean251): Promise<void> {252  const pending = approvals.get(requestId);253  if (!pending) {254    // Either the request expired (Chrome) or this is a Firefox-driven255    // pending where the popup performs the navigator.credentials call256    // directly. Clear both possible session keys so getPendingPasskeyRequest()257    // doesn't surface stale state on the next popup open.258    await clearPendingRequest(requestId);259    if (chrome.storage.session) {260      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);261    }262    if (approved) await firefoxCeremonies.get(requestId);263    return;264  }265266  clearTimeout(pending.timeoutId);267  approvals.delete(requestId);268  await clearPendingRequest(requestId);269  pending.resolve(approved);270  if (approved) await firefoxCeremonies.get(requestId);271}272273async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {274  // Register before publishing the request so the popup can approve immediately.275  const approval = new Promise<boolean>((resolve) => {276    const timeoutId = setTimeout(async () => {277      approvals.delete(request.requestId);278      await clearPendingRequest(request.requestId);279      resolve(false);280    }, APPROVAL_TIMEOUT_MS);281    approvals.set(request.requestId, { resolve, timeoutId });282  });283  try {284    if (chrome.storage.session) {285      await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });286    }287    await openApprovalPopup();288  } catch (error) {289    await resolvePendingPasskeyRequest(request.requestId, false);290    throw error;291  }292  return approval;293}294295export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {296  const settings = await vault.ensureSettingsLoaded();297  const supported = isPasskeySupported();298  const enabled = settings.passkeyProviderEnabled ?? (detectSharedBrowser() === "firefox");299  return {300    supported,301    enabled,302    attached: supported && attached,303    browserLabel: getBrowserLabel(),304    statusLabel: !supported305      ? "Interception unavailable"306      : attached307        ? "Active"308        : conflict309          ? "Another provider is active"310          : enabled311            ? "Inactive"312            : "Disabled",313    conflict,314  };315}316317async function attachPasskeyProxy(): Promise<void> {318  if (!isPasskeySupported()) {319    attached = false;320    conflict = "This browser does not expose chrome.webAuthenticationProxy.";321    return;322  }323324  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context325  // navigator.credentials creator is always ready once the user enables the326  // provider. Treat enabled = attached for state reporting purposes.327  if (detectSharedBrowser() === "firefox") {328    attached = true;329    conflict = undefined;330    return;331  }332333  if (attached) return;334335  try {336    await chrome.webAuthenticationProxy.attach();337    attached = true;338    conflict = undefined;339  } catch (error) {340    attached = false;341    conflict = error instanceof Error ? error.message : "Another provider is active.";342    console.warn("[Peach passkey] proxy attach failed", { conflict });343  }344}345346async function detachPasskeyProxy(): Promise<void> {347  if (!isPasskeySupported()) {348    attached = false;349    return;350  }351352  if (detectSharedBrowser() === "firefox") {353    attached = false;354    conflict = undefined;355    return;356  }357358  try {359    await chrome.webAuthenticationProxy.detach();360  } catch {361    // Ignore detach failures; the extension may already be detached.362  }363364  attached = false;365  conflict = undefined;366}367368export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {369  await vault.updateSettings({ passkeyProviderEnabled: enabled });370  if (enabled) {371    await attachPasskeyProxy();372  } else {373    await detachPasskeyProxy();374  }375  return getPasskeyProviderState();376}377378async function refreshPasskeyProxy(): Promise<void> {379  const settings = await vault.ensureSettingsLoaded();380  if (settings.passkeyProviderEnabled ?? (detectSharedBrowser() === "firefox")) {381    await attachPasskeyProxy();382    return;383  }384  await detachPasskeyProxy();385}386387/**388 * Find the entry that already holds a passkey for the given RP, or the most389 * recently-modified login entry whose URL/additionalUrls match the RP — that390 * entry is the natural attach target when the user adds a passkey to a site391 * they already have a password for. Returns null when there's no candidate;392 * the caller creates a new entry in that case.393 */394function findAttachTarget(rpId: string): VaultEntry | null {395  const all = vault.getEntries();396  const passkeyMatch = all.find(397    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt398  );399  if (passkeyMatch) return passkeyMatch;400  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);401  if (loginMatches.length === 0) return null;402  // Pick the most recently modified candidate to be deterministic.403  return loginMatches.reduce((best, e) =>404    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best405  );406}407408async function handleCreateRequest(409  request: chrome.webAuthenticationProxy.CreateRequest410): Promise<void> {411  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;412  const rpId = options.rp.id;413  const rpName = options.rp.name ?? options.rp.id;414  const userName = options.user.name;415  const userDisplayName = options.user.displayName ?? options.user.name;416  if (!rpId) {417    console.warn("[Peach passkey] reject: missing rp.id");418    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");419    return;420  }421422  // Capture the page origin NOW — before any popup opens. Once the extension423  // action popup fires, it can steal lastFocusedWindow so the tabs query424  // returns no results and falls back to https://<rpId>. For sites where the425  // page origin is a subdomain of the rpId (e.g. myaccount.google.com vs426  // rpId google.com) that fallback produces the wrong origin and the RP427  // rejects the attestation with "Something went wrong".428  const pageOrigin = await resolvePageOrigin(rpId);429430  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {431    console.warn("[Peach passkey] reject: user is not Pro");432    await rejectCreate(433      request.requestId,434      "NotAllowedError",435      "Passkey creation requires Peach Pro."436    );437    // Show the inline passkey gate so the user knows what happened and can upgrade.438    broadcastPasskeyProGate();439    return;440  }441442  if (!vault.isUnlocked()) {443    // Same wait-for-unlock pattern handleGetRequest uses: summon the inline444    // unlock prompt on the active tab so the user can unlock without leaving445    // the page, then hold the create request open until the vault unlocks or446    // the timeout fires. Immediately rejecting here means Google shows447    // "Something went wrong" the instant the user picks Peach.448    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>449      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)450    );451    const activeTab = tabs[0];452    if (activeTab?.id) {453      chrome.tabs.sendMessage(activeTab.id, {454        type: 'SUMMON_AUTOFILL',455        source: 'passkey-unlock',456      }).catch((err) => {457        console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);458        void openApprovalPopup();459      });460    } else {461      await openApprovalPopup();462    }463464    const unlocked = await new Promise<boolean>((resolve) => {465      const timeoutId = setTimeout(() => {466        unlockWaiters.delete(request.requestId);467        resolve(false);468      }, APPROVAL_TIMEOUT_MS);469      unlockWaiters.set(request.requestId, { resolve, timeoutId });470    });471472    if (!unlocked || !vault.isUnlocked()) {473      console.warn("[Peach passkey] reject: unlock timeout");474      await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");475      return;476    }477    // Vault is now unlocked — fall through to approval.478  }479480  const approved = await promptUserApproval({481    requestId: request.requestId,482    type: "create",483    rpId,484    rpName,485    userName,486    userDisplayName,487  });488  if (!approved) {489    console.warn("[Peach passkey] reject: user denied");490    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");491    return;492  }493494  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);495  if (!algorithm) {496    console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);497    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");498    return;499  }500  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));501  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(502    algorithm503  );504  const authData = await buildAuthenticatorData({505    rpId,506    flags: 0x4d,507    signCount: 0,508    credentialId: credentialIdBytes,509    publicKeyCose,510  });511  const attestationObject = createAttestationObject(authData);512  const clientDataJSON = buildClientDataJSON(513    "webauthn.create",514    options.challenge,515    pageOrigin516  );517518  const passkey: PasskeyData = {519    rpId,520    rpName,521    credentialId: base64urlEncode(credentialIdBytes),522    publicKeyCose: base64urlEncode(publicKeyCose),523    privateKeyJwk,524    userHandle: options.user.id,525    userName,526    userDisplayName,527    algorithm,528    signCount: 0,529    aaguid: base64urlEncode(new Uint8Array(16)),530    backupEligible: true,531    backupState: false,532    browser: "chrome",533    transports: ["internal"],534    createdAt: Date.now(),535  };536537  // Attach to an existing login (same domain) when one exists; otherwise538  // create a fresh login entry that exists solely to hold the passkey.539  const target = findAttachTarget(rpId);540  if (target) {541    await vault.updateEntry(target.id, { passkey });542  } else {543    await vault.addEntry({544      type: "login",545      title: rpName,546      url: `https://${rpId}`,547      username: userName,548      passkey,549    });550  }551  // Merge any same-domain TOTP-only entries into the passkey entry right now552  // so the user sees one consolidated entry rather than waiting until next553  // unlock for the decodePayload merge pass to run.554  await vault.consolidatePasskeyEntries();555556  try {557    await chrome.webAuthenticationProxy.completeCreateRequest({558      requestId: request.requestId,559      responseJson: JSON.stringify({560        id: passkey.credentialId,561        rawId: passkey.credentialId,562        type: "public-key",563        response: {564          clientDataJSON: base64urlEncode(clientDataJSON),565          authenticatorData: base64urlEncode(authData),566          attestationObject: base64urlEncode(attestationObject),567          transports: ["internal"],568          publicKey: base64urlEncode(publicKeySpki),569          publicKeyAlgorithm: algorithm,570        },571        authenticatorAttachment: "platform",572        clientExtensionResults: {},573      }),574    });575  } catch (err) {576    if (isInvalidRequestIdError(err)) {577      return;578    }579    throw err;580  }581}582583function broadcastPasskeyProGate(): void {584  chrome.tabs.query({}, (tabs) => {585    for (const tab of tabs) {586      if (tab.id !== undefined) {587        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});588      }589    }590  });591}592593async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {594  if (chrome.storage.session) {595    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });596  }597598  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {599    chrome.tabs.query({ active: true, currentWindow: true }, resolve);600  });601  const tab = tabs[0];602603  if (tab?.id) {604    chrome.tabs.sendMessage(tab.id, {605      type: 'SHOW_PASSKEY_TOAST',606      payload: {607        toastToken: crypto.randomUUID(),608        site: request.rpId,609        displayName: request.userDisplayName ?? request.userName,610        username: request.userName,611        requestId: String(request.requestId),612      },613    }).catch(() => {});614  } else {615    await openApprovalPopup();616  }617618  return new Promise<boolean>((resolve) => {619    const timeoutId = setTimeout(async () => {620      approvals.delete(request.requestId);621      await clearPendingRequest(request.requestId);622      resolve(false);623    }, APPROVAL_TIMEOUT_MS);624625    approvals.set(request.requestId, { resolve, timeoutId });626  });627}628629async function handleGetRequest(630  request: chrome.webAuthenticationProxy.GetRequest631): Promise<void> {632  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;633  if (!vault.isUnlocked()) {634    // Summon the inline unlock prompt on the active tab so the user can unlock635    // without leaving the page. Fall back to the popup if the content script636    // is unavailable (e.g. restricted URL, script not yet injected).637    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>638      chrome.tabs.query({ active: true, currentWindow: true }, resolve)639    );640    const activeTab = tabs[0];641    if (activeTab?.id) {642      chrome.tabs.sendMessage(activeTab.id, {643        type: 'SUMMON_AUTOFILL',644        source: 'passkey-unlock',645      }).catch(() => { void openApprovalPopup(); });646    } else {647      await openApprovalPopup();648    }649650    // Hold the request open until the vault unlocks or the timeout fires.651    const unlocked = await new Promise<boolean>((resolve) => {652      const timeoutId = setTimeout(() => {653        unlockWaiters.delete(request.requestId);654        resolve(false);655      }, APPROVAL_TIMEOUT_MS);656      unlockWaiters.set(request.requestId, { resolve, timeoutId });657    });658659    if (!unlocked || !vault.isUnlocked()) {660      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");661      return;662    }663    // Vault is now unlocked — fall through to authenticate.664  }665666  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {667    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");668    broadcastPasskeyProGate();669    return;670  }671672  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>673    entry.passkey !== undefined && !entry.deletedAt674  );675  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);676677  const match = passkeyBearing.find((entry) => {678    // PB-26-015: a credential-ID match must still belong to the requesting679    // RP — the assertion later signs with the stored rpId, so a foreign-RP680    // request must never select a credential from a different rpId.681    if (options.rpId && entry.passkey.rpId !== options.rpId) return false;682    if (allowedCredentialIds.length > 0) {683      return allowedCredentialIds.includes(entry.passkey.credentialId);684    }685    if (!options.rpId) return false;686    return entry.passkey.rpId === options.rpId;687  });688689  if (!match) {690    // No Peach passkey for this RP — pass through to the platform authenticator.691    // completeGetRequest with no error and no responseJson is the webAuthenticationProxy692    // signal for "I'm not handling this"; Chrome routes the request to the next693    // authenticator in the chain (the built-in browser passkey manager etc.).694    // Rejecting with NotAllowedError would propagate as an error to the page and695    // break RPs (e.g. Google) that issue a GET as part of their create-passkey flow.696    try {697      await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });698    } catch (err) {699      if (isInvalidRequestIdError(err)) return;700      if (isMissingResponseJsonError(err)) return;701      throw err;702    }703    return;704  }705706  const credential = match.passkey;707  // Chrome's webAuthenticationProxy path requires Peach-held private key material.708  // Passkeys created on Firefox have no privateKeyJwk — pass through so Chrome709  // can route to a capable authenticator instead of surfacing an error to the page.710  if (!credential.privateKeyJwk) {711    try {712      await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });713    } catch (err) {714      if (isInvalidRequestIdError(err)) return;715      if (isMissingResponseJsonError(err)) return;716      throw err;717    }718    return;719  }720721  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {722    chrome.tabs.query({ active: true, currentWindow: true }, resolve);723  });724  const approvalBinding = {725    tabId: tabs[0]?.id,726    origin: await resolvePageOrigin(credential.rpId),727  };728  const approved = await promptViaToast({729    requestId: request.requestId,730    type: "get",731    rpId: credential.rpId,732    rpName: credential.rpName,733    userName: credential.userName,734    userDisplayName: credential.userDisplayName,735    ...approvalBinding,736  });737  if (!approved) {738    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");739    return;740  }741742  const nextSignCount = credential.signCount + 1;743  // Chrome passkeys have always been registered by Peach with BE=1 (0x4d),744  // but versions before v5.0.87 incorrectly persisted backupEligible=false.745  // WebAuthn requires BE to match registration, so repair that legacy metadata746  // while constructing the assertion GitHub and other strict RPs verify.747  const backupEligible = credential.browser === "chrome" ? true : credential.backupEligible;748  const flags = 0x05 | (backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);749  const authData = await buildAuthenticatorData({750    rpId: credential.rpId,751    flags,752    signCount: nextSignCount,753  });754  const clientDataJSON = buildClientDataJSON(755    "webauthn.get",756    options.challenge,757    await resolvePageOrigin(credential.rpId)758  );759  const clientDataHash = new Uint8Array(760    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))761  );762  const signature = await signPasskeyAssertion(763    credential.privateKeyJwk,764    credential.algorithm,765    authData,766    clientDataHash767  );768769  await vault.updateEntry(match.id, {770    passkey: {771      ...credential,772      backupEligible,773      signCount: nextSignCount,774      lastUsedAt: Date.now(),775    },776  });777778  const responseJson = JSON.stringify({779    id: credential.credentialId,780    rawId: credential.credentialId,781    type: "public-key",782    response: {783      clientDataJSON: base64urlEncode(clientDataJSON),784      authenticatorData: base64urlEncode(authData),785      signature: base64urlEncode(signature),786      userHandle: credential.userHandle,787    },788    authenticatorAttachment: "platform",789    clientExtensionResults: {},790  });791792  try {793    await chrome.webAuthenticationProxy.completeGetRequest({794      requestId: request.requestId,795      responseJson,796    });797  } catch (err) {798    if (isInvalidRequestIdError(err)) {799      return;800    }801    throw err;802  }803}804805// ── Firefox path ────────────────────────────────────────────────────────806//807// Firefox 150+ permits extensions to call navigator.credentials.create/get808// from the popup context with their host-permitted RP IDs. Peach uses that809// surface to register passkeys on behalf of the user. The page's own810// navigator.credentials call is observed via the MAIN-world detector811// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's812// flow is parallel and explicit (the user must engage with Peach's button).813814const FIREFOX_PENDING_KEY = "passkey_pending_firefox";815const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";816const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days817818export async function initiateFirefoxPasskeyCreate(args: {819  rpId: string;820  rpName: string;821  userName: string;822  userDisplayName?: string;823}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {824  // The rpId must belong to the calling page's registrable domain; without825  // this, a page could initiate a ceremony for a foreign site and the826  // approval popup would display an attacker-chosen rpId. Origin is the827  // browser-attested sender origin from messages.ts, never page payload.828  // (Codex Security 2026-08-04, passkey-ceremony-approval sibling.)829  if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {830    return { ok: false, error: "rpid_origin_mismatch" };831  }832  if (!(await isProUser())) {833    broadcastPasskeyProGate();834    return { ok: false, error: "passkey creation requires Peach Pro" };835  }836  if (!vault.isUnlocked()) {837    await openApprovalPopup();838    return { ok: false, error: "vault_locked" };839  }840  if (!chrome.storage.session) {841    return { ok: false, error: "session storage unavailable" };842  }843844  const requestId = randomRequestId();845  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));846  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));847848  const pending: PendingPasskeyRequest = {849    requestId,850    type: "create",851    rpId: args.rpId,852    rpName: args.rpName,853    userName: args.userName,854    userDisplayName: args.userDisplayName,855    browser: "firefox",856    challenge,857    userHandle,858  };859860  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });861  await openApprovalPopup();862  return { ok: true };863}864865export async function completeFirefoxPasskeyCreate(payload: {866  requestId: number;867  rpId: string;868  rpName: string;869  userName: string;870  userDisplayName?: string;871  userHandle: string;872  credentialId: string;873  publicKeyCose: string;874  algorithm: -7 | -8;875  aaguid?: string;876  transports?: string[];877  backupEligible?: boolean;878  backupState?: boolean;879}): Promise<{ ok: boolean; error?: string }> {880  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };881882  // Bind completion to the pending ceremony: requestId, rpId, challenge, and883  // userHandle are minted by the background at INITIATE time and only ever884  // leave extension context through the approval popup, so a page (or anything885  // replaying relayed messages) cannot synthesize a matching completion for an886  // rpId it does not own. (Codex Security 2026-08-04 MEDIUM,887  // broken-access-control.passkey-ceremony-approval.)888  const pending = chrome.storage.session889    ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as890        | PendingPasskeyRequest891        | undefined)892    : undefined;893  if (894    !pending ||895    pending.requestId !== payload.requestId ||896    pending.rpId !== payload.rpId ||897    pending.userHandle !== payload.userHandle898  ) {899    return { ok: false, error: "no_pending_request" };900  }901902  const passkey: PasskeyData = {903    rpId: payload.rpId,904    rpName: payload.rpName,905    credentialId: payload.credentialId,906    publicKeyCose: payload.publicKeyCose,907    userHandle: payload.userHandle,908    userName: payload.userName,909    userDisplayName: payload.userDisplayName ?? payload.userName,910    algorithm: payload.algorithm,911    signCount: 0,912    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),913    backupEligible: payload.backupEligible ?? false,914    backupState: payload.backupState ?? false,915    browser: "firefox",916    transports: payload.transports ?? ["internal"],917    createdAt: Date.now(),918  };919920  // Same attach-or-create flow as the Chrome path: prefer attaching to an921  // existing login entry for the same domain so the user ends up with one922  // entry per site, not two (a login + a passkey-only).923  const target = findAttachTarget(payload.rpId);924  if (target) {925    await vault.updateEntry(target.id, { passkey });926  } else {927    await vault.addEntry({928      type: "login",929      title: payload.rpName || payload.rpId,930      url: `https://${payload.rpId}`,931      username: payload.userName,932      passkey,933    });934  }935  await vault.consolidatePasskeyEntries();936937  if (chrome.storage.session) {938    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);939  }940  return { ok: true };941}942943const MAX_DECLINE_DOMAIN_LENGTH = 253;944945function normaliseDeclineDomain(domain: string): string | null {946  if (!domain || domain.length > MAX_DECLINE_DOMAIN_LENGTH) return null;947  try {948    const url = new URL(`https://${domain}`);949    return isValidRpId(url.hostname, url.origin) ? url.hostname : null;950  } catch {951    return null;952  }953}954955function declineKey(domain: string): string {956  return `${FIREFOX_DECLINE_PREFIX}${domain}`;957}958959export async function recordPasskeyOfferDecline(domain: string): Promise<void> {960  const validDomain = normaliseDeclineDomain(domain);961  if (!validDomain) return;962  await chrome.storage.local.set({ [declineKey(validDomain)]: Date.now() });963}964965export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {966  const validDomain = normaliseDeclineDomain(domain);967  if (!validDomain) return false;968  const key = declineKey(validDomain);969970  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;971  const at = stored[key];972  if (typeof at !== "number") return false;973  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {974    await chrome.storage.local.remove(key);975    return false;976  }977  return true;978}979980// ── Firefox content-script proxy path (v2.12.0) ────────────────────────981//982// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces983// navigator.credentials.create / .get on every Firefox page. It builds a984// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),985// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,986// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.987//988// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag989// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates990// to false and these handlers short-circuit. The full removal checklist is991// in that file — the deletion is purely subtractive.992993interface FirefoxAllowCredential {994  id: string;995  type?: string;996}997998interface FirefoxPubKeyCredParam {999  type?: string;1000  alg: number;1001}10021003export interface FirefoxPasskeyHasCredentialsPayload {1004  rpId: string;1005  allowCredentials: FirefoxAllowCredential[];1006}10071008export interface FirefoxPasskeyCreatePayload {1009  rpId: string;1010  rpName?: string;1011  userId: string;1012  userName: string;1013  userDisplayName?: string;1014  challenge: string;1015  clientDataJSON: number[];1016  origin: string;1017  pubKeyCredParams?: FirefoxPubKeyCredParam[];1018  authenticatorSelection?: Record<string, unknown>;1019  timeout?: number;1020  attestation?: string;1021}10221023export interface FirefoxPasskeyGetPayload {1024  rpId: string;1025  challenge: string;1026  clientDataJSON: number[];1027  origin: string;1028  allowCredentials: FirefoxAllowCredential[];1029  userVerification?: string;1030}10311032interface FirefoxCreateCredentialResponse {1033  type: "create";1034  id: string;1035  clientDataJSON: number[];1036  attestationObject: string;1037  authenticatorData: string;1038  publicKey: string;1039  algorithm: -7 | -8;1040}10411042interface FirefoxGetCredentialResponse {1043  type: "get";1044  id: string;1045  clientDataJSON: number[];1046  authenticatorData: string;1047  signature: string;1048  userHandle: string;1049}10501051type FirefoxPasskeyResult<T> =1052  | { ok: true; credential: T }1053  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10541055function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1056  return vault.getEntries().filter(1057    (entry): entry is VaultEntry & { passkey: PasskeyData } =>1058      entry.passkey !== undefined && !entry.deletedAt1059  );1060}10611062/** Decode the `origin` field out of a WebAuthn clientDataJSON byte array. */1063function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1064  if (!Array.isArray(bytes)) return null;1065  try {1066    const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1067    return typeof obj.origin === "string" ? obj.origin : null;1068  } catch {1069    return null;1070  }1071}10721073/**1074 * Enforce the WebAuthn rpId<->origin relationship on the Firefox proxy path,1075 * where Peach is the authenticator. The ONLY trustworthy origin is the one the1076 * browser attested for the calling page (`callerOrigin`, derived from the1077 * message sender in messages.ts) — NEVER `payload.origin`, which the page1078 * controls and can set to match any rpId (SEV-1-A / audit CRITICAL). Also1079 * refuse to sign a clientDataJSON whose embedded origin disagrees with the1080 * attested origin, so a forged ceremony can't smuggle a foreign origin into1081 * the signed bytes the RP verifies.1082 */1083function assertOriginOwnsRpId(1084  rpId: string,1085  callerOrigin: string | undefined,1086  clientDataJSON?: number[],1087): void {1088  if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1089    throw new DOMException(1090      `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1091        `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1092      "SecurityError",1093    );1094  }1095  const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1096  if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1097    throw new DOMException(1098      `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1099        `attested calling origin "${callerOrigin}".`,1100      "SecurityError",1101    );1102  }1103}11041105export async function handlePasskeyHasCredentials(1106  payload: FirefoxPasskeyHasCredentialsPayload,1107  callerOrigin?: string,1108): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1109  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY || (await vault.ensureSettingsLoaded()).passkeyProviderEnabled === false) {1110    return { ok: false, error: "not_applicable" };1111  }1112  // Scope the existence check to the caller's own origin so an arbitrary page1113  // cannot enumerate which sites the user holds passkeys for (audit MEDIUM).1114  // A false answer is indistinguishable from "no credential" to the page.1115  if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1116    return { ok: true, hasCredentials: false };1117  }1118  if (!vault.isUnlocked()) {1119    // A locked vault cannot answer whether a credential exists until unlock.1120    return { ok: false, error: "vault_locked" };1121  }1122  const passkeys = listPasskeyEntries().filter(1123    (entry) => entry.passkey.rpId === payload.rpId1124  );1125  if (passkeys.length === 0) return { ok: true, hasCredentials: false };11261127  const allow = payload.allowCredentials ?? [];1128  if (allow.length === 0) return { ok: true, hasCredentials: true };11291130  const hasMatch = passkeys.some((entry) =>1131    allow.some((ac) => ac.id === entry.passkey.credentialId)1132  );1133  return { ok: true, hasCredentials: hasMatch };1134}11351136/**1137 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing1138 * pending-passkey channel (badge + popup + 60s timeout) — the same approval1139 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need1140 * any new code path. The page-side proxy never sees the request id; the1141 * background mints one and discards it after resolution.1142 */1143async function requestFirefoxApproval<T>(args: {1144  type: "create" | "get";1145  rpId: string;1146  rpName?: string;1147  userName: string;1148  userDisplayName?: string;1149}, operation: () => Promise<FirefoxPasskeyResult<T>>): Promise<FirefoxPasskeyResult<T>> {1150  const requestId = randomRequestId();1151  let finish!: () => void;1152  firefoxCeremonies.set(requestId, new Promise<void>((resolve) => { finish = resolve; }));1153  try {1154    const approved = await promptUserApproval({1155      requestId,1156      type: args.type,1157      rpId: args.rpId,1158      rpName: args.rpName ?? args.rpId,1159      userName: args.userName,1160      userDisplayName: args.userDisplayName,1161    });1162    if (!approved) return { ok: false, error: "user_cancelled" };1163    return await operation();1164  } finally {1165    finish();1166    firefoxCeremonies.delete(requestId);1167  }1168}11691170export async function handleFirefoxPasskeyCreate(1171  payload: FirefoxPasskeyCreatePayload,1172  callerOrigin?: string,1173): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1174  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY || (await vault.ensureSettingsLoaded()).passkeyProviderEnabled === false) {1175    return { ok: false, error: "not_applicable" };1176  }1177  // WebAuthn §7.1 step 6 — the requested rpId must be owned by the page's1178  // browser-ATTESTED origin (threaded in as callerOrigin from the message1179  // sender), NOT the page-supplied payload.origin which an attacker controls.1180  // On the Firefox proxy path Peach is the authenticator and must enforce this1181  // before minting anything (audit CRITICAL — origin spoofing).1182  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1183  if (!(await isProUser())) {1184    broadcastPasskeyProGate();1185    return { ok: false, error: "pro_required" };1186  }1187  if (!vault.isUnlocked()) {1188    await openApprovalPopup();1189    return { ok: false, error: "vault_locked" };1190  }11911192  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1193  if (!algorithm) {1194    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1195  }11961197  return requestFirefoxApproval<FirefoxCreateCredentialResponse>({1198    type: "create",1199    rpId: payload.rpId,1200    rpName: payload.rpName,1201    userName: payload.userName,1202    userDisplayName: payload.userDisplayName,1203  }, async () => {12041205    const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1206    const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);12071208    // 0x4d = UP|UV|BE|AT. Peach passkeys are synced, so BE is true on both1209    // registration paths and on later assertions.1210    const authenticatorData = await buildAuthenticatorData({1211      rpId: payload.rpId,1212      flags: 0x4d,1213      signCount: 0,1214      credentialId: credentialIdBytes,1215      publicKeyCose,1216    });1217    const attestationObject = createAttestationObject(authenticatorData);12181219    const passkey: PasskeyData = {1220      rpId: payload.rpId,1221      rpName: payload.rpName ?? payload.rpId,1222      credentialId,1223      publicKeyCose: base64urlEncode(publicKeyCose),1224      privateKeyJwk,1225      userHandle: payload.userId,1226      userName: payload.userName,1227      userDisplayName: payload.userDisplayName ?? payload.userName,1228      algorithm,1229      signCount: 0,1230      aaguid: base64urlEncode(new Uint8Array(16)),1231      backupEligible: true,1232      backupState: false,1233      browser: "firefox",1234      transports: ["internal"],1235      createdAt: Date.now(),1236    };12371238    const target = findAttachTarget(payload.rpId);1239    if (target) {1240      await vault.updateEntry(target.id, { passkey });1241    } else {1242      await vault.addEntry({1243        type: "login",1244        title: payload.rpName || payload.rpId,1245        url: `https://${payload.rpId}`,1246        username: payload.userName,1247        passkey,1248      });1249    }1250    await vault.consolidatePasskeyEntries();12511252    return {1253      ok: true,1254      credential: {1255        type: "create",1256        id: credentialId,1257        // The content script already encoded clientDataJSON with the page1258        // origin; round-trip the exact bytes back so the RP sees the same1259        // hash it computes locally.1260        clientDataJSON: payload.clientDataJSON,1261        attestationObject: base64urlEncode(attestationObject),1262        authenticatorData: base64urlEncode(authenticatorData),1263        publicKey: base64urlEncode(publicKeySpki),1264        algorithm,1265      },1266    };1267  });1268}12691270export async function handleFirefoxPasskeyGet(1271  payload: FirefoxPasskeyGetPayload,1272  callerOrigin?: string,1273): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1274  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY || (await vault.ensureSettingsLoaded()).passkeyProviderEnabled === false) {1275    return { ok: false, error: "not_applicable" };1276  }1277  // WebAuthn §7.2 step 8 — the rpId must be owned by the page's browser-attested1278  // origin (callerOrigin from the message sender), NOT the page-supplied1279  // payload.origin. Both rpId and payload.origin are attacker-controlled, so1280  // validating one against the other is no protection; a cross-origin page must1281  // not be able to coax Peach into signing an assertion scoped to another site1282  // (audit CRITICAL — assertion forgery).1283  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1284  if (!(await isProUser())) {1285    broadcastPasskeyProGate();1286    return { ok: false, error: "pro_required" };1287  }1288  if (!vault.isUnlocked()) {1289    const requestId = randomRequestId();1290    // Register before opening the popup so an immediate unlock cannot be missed.1291    const unlocked = new Promise<boolean>((resolve) => {1292      const timeoutId = setTimeout(() => {1293        unlockWaiters.delete(requestId);1294        resolve(false);1295      }, APPROVAL_TIMEOUT_MS);1296      unlockWaiters.set(requestId, { resolve, timeoutId });1297    });1298    await openApprovalPopup();1299    if (!(await unlocked) || !vault.isUnlocked()) {1300      return { ok: false, error: "user_cancelled" };1301    }1302  }13031304  const allow = payload.allowCredentials ?? [];1305  const candidates = listPasskeyEntries().filter((entry) => {1306    if (entry.passkey.rpId !== payload.rpId) return false;1307    if (allow.length === 0) return true;1308    return allow.some((ac) => ac.id === entry.passkey.credentialId);1309  });13101311  if (candidates.length === 0) return { ok: false, error: "no_credentials" };13121313  // Most-recently-used wins when several credentials match. A future1314  // multi-account selector goes here; for now this matches the Chrome path's1315  // behavior for the common one-passkey-per-rp case.1316  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1317  const match = candidates[0]!;1318  const credential = match.passkey;13191320  const privateKeyJwk = credential.privateKeyJwk;1321  if (!privateKeyJwk) {1322    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1323    // — no Peach-held private key to sign with. The MAIN-world proxy treats1324    // no_credentials as a fall-through, which routes to the platform UI.1325    return { ok: false, error: "no_credentials" };1326  }13271328  return requestFirefoxApproval<FirefoxGetCredentialResponse>({1329    type: "get",1330    rpId: credential.rpId,1331    rpName: credential.rpName,1332    userName: credential.userName,1333    userDisplayName: credential.userDisplayName,1334  }, async () => {13351336    const nextSignCount = credential.signCount + 1;1337    // Legacy Chrome registrations used BE=1 even when the stored flag was false.1338    // Legacy Firefox registrations used BE=0, so retain their recorded value.1339    const backupEligible = credential.browser === "chrome" ? true : credential.backupEligible;1340    const flags = 0x05 | (backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);1341    const authenticatorData = await buildAuthenticatorData({1342      rpId: credential.rpId,1343      flags,1344      signCount: nextSignCount,1345    });13461347    // The content script supplied clientDataJSON bytes built with the page1348    // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1349    // diverges from what the RP computes.1350    const clientDataBytes = new Uint8Array(payload.clientDataJSON);1351    const clientDataHash = new Uint8Array(1352      await crypto.subtle.digest("SHA-256", clientDataBytes)1353    );1354    const signature = await signPasskeyAssertion(1355      privateKeyJwk,1356      credential.algorithm,1357      authenticatorData,1358      clientDataHash1359    );13601361    await vault.updateEntry(match.id, {1362      passkey: {1363        ...credential,1364        backupEligible,1365        signCount: nextSignCount,1366        lastUsedAt: Date.now(),1367      },1368    });13691370    return {1371      ok: true,1372      credential: {1373        type: "get",1374        id: credential.credentialId,1375        clientDataJSON: payload.clientDataJSON,1376        authenticatorData: base64urlEncode(authenticatorData),1377        signature: base64urlEncode(signature),1378        userHandle: credential.userHandle,1379      },1380    };1381  });1382}13831384export async function setupPasskeyProxy(): Promise<void> {1385  if (!isPasskeySupported()) {1386    return;1387  }13881389  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1390  // popup-context credential creator is the entry point; nothing to wire here.1391  if (detectSharedBrowser() === "firefox") {1392    await refreshPasskeyProxy();1393    return;1394  }13951396  if (!listenersRegistered) {1397    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1398      // Peach is a user-verifying platform authenticator whenever the proxy1399      // is attached. The lock state is irrelevant here — the create/get1400      // handlers prompt for unlock when needed. Returning false while locked1401      // makes Google (and any RP that checks isUvpaa as a precondition) show1402      // "A passkey can't be created on this device" before the user even1403      // gets a chance to start the ceremony.1404      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1405        requestId: requestInfo.requestId,1406        isUvpaa: true,1407      });1408    });1409    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1410      void handleCreateRequest(requestInfo).catch(async (error) => {1411        // "Invalid requestId" means Chrome already cancelled — handlers will1412        // short-circuit, but if it propagates out from a still-in-flight branch,1413        // don't double-complete.1414        if (isInvalidRequestIdError(error)) {1415          return;1416        }1417        console.error("Peach passkey create failed", error);1418        await rejectCreate(1419          requestInfo.requestId,1420          "UnknownError",1421          error instanceof Error ? error.message : "Passkey creation failed."1422        );1423      });1424    });1425    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1426      void handleGetRequest(requestInfo).catch(async (error) => {1427        if (isInvalidRequestIdError(error)) {1428          return;1429        }1430        console.error("Peach passkey get failed", error);1431        await rejectGet(1432          requestInfo.requestId,1433          "UnknownError",1434          error instanceof Error ? error.message : "Passkey authentication failed."1435        );1436      });1437    });1438    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1439      // Release any unlock waiter parked for this request.1440      const waiter = unlockWaiters.get(requestId);1441      if (waiter) {1442        clearTimeout(waiter.timeoutId);1443        waiter.resolve(false);1444        unlockWaiters.delete(requestId);1445      }1446      void resolvePendingPasskeyRequest(requestId, false);1447    });1448    listenersRegistered = true;1449  }14501451  await refreshPasskeyProxy();1452}