passkey.ts

50.1 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;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> }>();4142// Tracks requestIds that Chrome cancelled on us (via onRequestCanceled) while43// we were still mid-handler. The complete*Request calls would otherwise reject44// with "Invalid requestId"; the handler checks this set before any work.45const cancelledRequests = new Set<number>();46function markCancelled(requestId: number): void {47  cancelledRequests.add(requestId);48  // Bounded GC — never grow forever. Old IDs that haven't been polled in a49  // long time can be forgotten without harm because the Chrome-side request50  // is already gone.51  if (cancelledRequests.size > 256) {52    const iter = cancelledRequests.values();53    for (let i = 0; i < 128; i += 1) cancelledRequests.delete(iter.next().value as number);54  }55}5657export function onVaultUnlocked(): void {58  for (const { resolve, timeoutId } of unlockWaiters.values()) {59    clearTimeout(timeoutId);60    resolve(true);61  }62  unlockWaiters.clear();63}6465/**66 * Resolve every still-pending passkey request as denied and clear unlock67 * waiters. Called from the popup-disconnect path so a closed popup releases68 * the page-side caller in <200ms instead of leaving it stuck for the full69 * APPROVAL_TIMEOUT_MS (60s) (M8/COR-009).70 */71export async function denyAllPendingPasskeyRequests(): Promise<void> {72  if (approvals.size === 0 && unlockWaiters.size === 0) return;73  console.log("[Peach passkey] denyAllPendingPasskeyRequests (popup disconnected)", {74    approvals: approvals.size,75    unlockWaiters: unlockWaiters.size,76  });77  for (const [rid, { resolve, timeoutId }] of approvals.entries()) {78    clearTimeout(timeoutId);79    resolve(false);80    approvals.delete(rid);81    await clearPendingRequest(rid).catch(() => {});82  }83  for (const { resolve, timeoutId } of unlockWaiters.values()) {84    clearTimeout(timeoutId);85    resolve(false);86  }87  unlockWaiters.clear();88}8990function isPasskeySupported(): boolean {91  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;92  // Firefox 150+ exposes navigator.credentials.create/get from the popup93  // context with extension host permissions. Cannot intercept page-side94  // calls (no equivalent of webAuthenticationProxy), but registration via95  // Peach's own popup-driven flow works.96  if (detectSharedBrowser() === "firefox") {97    const ver = firefoxVersion();98    if (Number.isFinite(ver) && ver >= 150) return true;99  }100  return false;101}102103export const detectBrowser = detectSharedBrowser;104105function getBrowserLabel(): string {106  const ua = navigator.userAgent;107  if (/Firefox\/(\d+)/.test(ua)) {108    const version = ua.match(/Firefox\/(\d+)/)?.[1];109    return version ? `Firefox ${version}` : "Firefox";110  }111  const version = ua.match(/Chrome\/(\d+)/)?.[1];112  return version ? `Chrome ${version}` : "Chromium";113}114115function pendingStorageKey(requestId: number): string {116  return `${PENDING_PREFIX}${requestId}`;117}118119function randomRequestId(): number {120  // 53-bit crypto-random integer — unbrute-forceable, unlike the old121  // Date.now()+Math.random synthetic id (~10-20 bits). Fits a JS safe122  // integer so it round-trips through String/Number for the toast path.123  // (Audit 2026-08-20 #1/#10.)124  const u = crypto.getRandomValues(new Uint32Array(2));125  return u[0] * 0x200000 + (u[1] >>> 11);126}127128function originFromRpId(rpId: string): string {129  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)130    ? `http://${rpId}`131    : `https://${rpId}`;132}133134/**135 * The clientDataJSON sent back to the RP must contain the page's actual136 * origin — not a synthesized one. `chrome.webAuthenticationProxy` does not137 * surface the requesting origin in CreateRequest/GetRequest, so we read it138 * from the active tab and validate that its hostname is a registrable139 * suffix match for the rpId. WebAuthn requires this relationship anyway, so140 * a mismatch indicates either a stale active tab or a hostile environment;141 * we fall back to https://<rpId> in that case to avoid silently signing the142 * wrong origin.143 *144 * Without this, a passkey created on https://myaccount.google.com gets145 * clientDataJSON.origin = "https://google.com", and Google's RP rejects the146 * attestation with "Something went wrong" because the origin hash doesn't147 * match what its own JS captured at create time.148 */149async function resolvePageOrigin(rpId: string): Promise<string> {150  try {151    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>152      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)153    );154    const url = tabs[0]?.url;155    if (url) {156      const parsed = new URL(url);157      const host = parsed.hostname.toLowerCase();158      const rp = rpId.toLowerCase();159      if (host === rp || host.endsWith(`.${rp}`)) {160        return parsed.origin;161      }162    }163  } catch {164    // fall through to rpId-derived origin165  }166  return originFromRpId(rpId);167}168169async function setBadge(text: string): Promise<void> {170  if (!chrome.action?.setBadgeText) return;171  await chrome.action.setBadgeText({ text });172  if (text) {173    await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });174  }175}176177async function clearPendingRequest(requestId: number): Promise<void> {178  if (chrome.storage.session) {179    await chrome.storage.session.remove(pendingStorageKey(requestId));180  }181  await setBadge("");182}183184async function openApprovalPopup(): Promise<void> {185  try {186    await chrome.action.openPopup();187  } catch {188    await setBadge("PK");189  }190}191192/**193 * Some ceremonies — especially conditional-UI `navigator.credentials.get`194 * calls — get cancelled by Chrome before we finish processing. Calling195 * complete*Request on a cancelled requestId rejects with "Invalid196 * requestId". That is expected, not exceptional: swallow it. Re-throwing197 * would surface an uncaught promise rejection in the SW and cascade through198 * the catch wrappers in setupPasskeyProxy.199 */200function isInvalidRequestIdError(err: unknown): boolean {201  return err instanceof Error && /invalid requestid/i.test(err.message);202}203204/**205 * Chrome throws "Missing GetResponseDetails.responseJson" when206 * completeGetRequest is called with no responseJson and no error (the207 * pass-through signal). The throw is a Chrome API quirk — the request IS208 * routed to the platform authenticator — but if we let it propagate, the209 * outer catch calls rejectGet, which sends a second completeGetRequest with210 * an error that cancels the pass-through and shows "Something went wrong" on211 * the page. Swallow it exactly as we swallow "Invalid requestId".212 */213function isMissingResponseJsonError(err: unknown): boolean {214  return err instanceof Error && /missing getresponsedetails/i.test(err.message);215}216217async function rejectCreate(218  requestId: number,219  name: string,220  message = ""221): Promise<void> {222  try {223    await chrome.webAuthenticationProxy.completeCreateRequest({224      requestId,225      error: { name, message },226    });227  } catch (err) {228    if (isInvalidRequestIdError(err)) {229      console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId });230      return;231    }232    throw err;233  }234}235236async function rejectGet(237  requestId: number,238  name: string,239  message = ""240): Promise<void> {241  try {242    await chrome.webAuthenticationProxy.completeGetRequest({243      requestId,244      error: { name, message },245    });246  } catch (err) {247    if (isInvalidRequestIdError(err)) {248      console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId });249      return;250    }251    throw err;252  }253}254255export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {256  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts257  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;258  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));259  if (!key) return null;260  const pending = stored[key];261  return pending && typeof pending === "object"262    ? (pending as PendingPasskeyRequest)263    : null;264}265266export async function resolvePendingPasskeyRequest(267  requestId: number,268  approved: boolean269): Promise<void> {270  const pending = approvals.get(requestId);271  if (!pending) {272    // Either the request expired (Chrome) or this is a Firefox-driven273    // pending where the popup performs the navigator.credentials call274    // directly. Clear both possible session keys so getPendingPasskeyRequest()275    // doesn't surface stale state on the next popup open.276    await clearPendingRequest(requestId);277    if (chrome.storage.session) {278      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);279    }280    return;281  }282283  clearTimeout(pending.timeoutId);284  approvals.delete(requestId);285  await clearPendingRequest(requestId);286  pending.resolve(approved);287}288289async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {290  if (chrome.storage.session) {291    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });292  }293  await openApprovalPopup();294295  return new Promise<boolean>((resolve) => {296    const timeoutId = setTimeout(async () => {297      approvals.delete(request.requestId);298      await clearPendingRequest(request.requestId);299      resolve(false);300    }, APPROVAL_TIMEOUT_MS);301302    approvals.set(request.requestId, { resolve, timeoutId });303  });304}305306export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {307  const settings = await vault.ensureSettingsLoaded();308  const supported = isPasskeySupported();309  const enabled = settings.passkeyProviderEnabled === true;310  return {311    supported,312    enabled,313    attached: supported && attached,314    browserLabel: getBrowserLabel(),315    statusLabel: !supported316      ? "Interception unavailable"317      : attached318        ? "Active"319        : conflict320          ? "Another provider is active"321          : enabled322            ? "Inactive"323            : "Disabled",324    conflict,325  };326}327328async function attachPasskeyProxy(): Promise<void> {329  if (!isPasskeySupported()) {330    attached = false;331    conflict = "This browser does not expose chrome.webAuthenticationProxy.";332    return;333  }334335  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context336  // navigator.credentials creator is always ready once the user enables the337  // provider. Treat enabled = attached for state reporting purposes.338  if (detectSharedBrowser() === "firefox") {339    attached = true;340    conflict = undefined;341    return;342  }343344  if (attached) return;345346  try {347    await chrome.webAuthenticationProxy.attach();348    attached = true;349    conflict = undefined;350    console.log("[Peach passkey] proxy attached successfully");351  } catch (error) {352    attached = false;353    conflict = error instanceof Error ? error.message : "Another provider is active.";354    console.warn("[Peach passkey] proxy attach failed", { conflict });355  }356}357358async function detachPasskeyProxy(): Promise<void> {359  if (!isPasskeySupported()) {360    attached = false;361    return;362  }363364  if (detectSharedBrowser() === "firefox") {365    attached = false;366    conflict = undefined;367    return;368  }369370  try {371    await chrome.webAuthenticationProxy.detach();372  } catch {373    // Ignore detach failures; the extension may already be detached.374  }375376  attached = false;377  conflict = undefined;378}379380export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {381  await vault.updateSettings({ passkeyProviderEnabled: enabled });382  if (enabled) {383    await attachPasskeyProxy();384  } else {385    await detachPasskeyProxy();386  }387  return getPasskeyProviderState();388}389390async function refreshPasskeyProxy(): Promise<void> {391  const settings = await vault.ensureSettingsLoaded();392  if (settings.passkeyProviderEnabled === true) {393    await attachPasskeyProxy();394    return;395  }396  await detachPasskeyProxy();397}398399/**400 * Find the entry that already holds a passkey for the given RP, or the most401 * recently-modified login entry whose URL/additionalUrls match the RP — that402 * entry is the natural attach target when the user adds a passkey to a site403 * they already have a password for. Returns null when there's no candidate;404 * the caller creates a new entry in that case.405 */406function findAttachTarget(rpId: string): VaultEntry | null {407  const all = vault.getEntries();408  const passkeyMatch = all.find(409    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt410  );411  if (passkeyMatch) return passkeyMatch;412  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);413  if (loginMatches.length === 0) return null;414  // Pick the most recently modified candidate to be deterministic.415  return loginMatches.reduce((best, e) =>416    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best417  );418}419420async function handleCreateRequest(421  request: chrome.webAuthenticationProxy.CreateRequest422): Promise<void> {423  console.log("[Peach passkey] create event received", {424    requestId: request.requestId,425    detailsJson: request.requestDetailsJson,426  });427  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;428  const rpId = options.rp.id;429  const rpName = options.rp.name ?? options.rp.id;430  const userName = options.user.name;431  const userDisplayName = options.user.displayName ?? options.user.name;432  console.log("[Peach passkey] create parsed", { rpId, rpName, userName, unlocked: vault.isUnlocked() });433434  if (!rpId) {435    console.warn("[Peach passkey] reject: missing rp.id");436    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");437    return;438  }439440  // Capture the page origin NOW — before any popup opens. Once the extension441  // action popup fires, it can steal lastFocusedWindow so the tabs query442  // returns no results and falls back to https://<rpId>. For sites where the443  // page origin is a subdomain of the rpId (e.g. myaccount.google.com vs444  // rpId google.com) that fallback produces the wrong origin and the RP445  // rejects the attestation with "Something went wrong".446  const pageOrigin = await resolvePageOrigin(rpId);447448  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {449    console.warn("[Peach passkey] reject: user is not Pro");450    await rejectCreate(451      request.requestId,452      "NotAllowedError",453      "Passkey creation requires Peach Pro."454    );455    // Show the inline passkey gate so the user knows what happened and can upgrade.456    broadcastPasskeyProGate();457    return;458  }459460  if (!vault.isUnlocked()) {461    console.log("[Peach passkey] vault locked, summoning inline unlock");462    // Same wait-for-unlock pattern handleGetRequest uses: summon the inline463    // unlock prompt on the active tab so the user can unlock without leaving464    // the page, then hold the create request open until the vault unlocks or465    // the timeout fires. Immediately rejecting here means Google shows466    // "Something went wrong" the instant the user picks Peach.467    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>468      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)469    );470    const activeTab = tabs[0];471    console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });472    if (activeTab?.id) {473      chrome.tabs.sendMessage(activeTab.id, {474        type: 'SUMMON_AUTOFILL',475        source: 'passkey-unlock',476      }).catch((err) => {477        console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);478        void openApprovalPopup();479      });480    } else {481      await openApprovalPopup();482    }483484    const unlocked = await new Promise<boolean>((resolve) => {485      const timeoutId = setTimeout(() => {486        unlockWaiters.delete(request.requestId);487        resolve(false);488      }, APPROVAL_TIMEOUT_MS);489      unlockWaiters.set(request.requestId, { resolve, timeoutId });490    });491492    console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });493    if (!unlocked || !vault.isUnlocked()) {494      console.warn("[Peach passkey] reject: unlock timeout");495      await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");496      return;497    }498    // Vault is now unlocked — fall through to approval.499  }500501  console.log("[Peach passkey] requesting approval via popup");502  const approved = await promptUserApproval({503    requestId: request.requestId,504    type: "create",505    rpId,506    rpName,507    userName,508    userDisplayName,509  });510  console.log("[Peach passkey] approval resolved", { approved });511  if (!approved) {512    console.warn("[Peach passkey] reject: user denied");513    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");514    return;515  }516517  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);518  if (!algorithm) {519    console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);520    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");521    return;522  }523  console.log("[Peach passkey] algorithm selected", algorithm);524525  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));526  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(527    algorithm528  );529  const authData = await buildAuthenticatorData({530    rpId,531    flags: 0x4d,532    signCount: 0,533    credentialId: credentialIdBytes,534    publicKeyCose,535  });536  const attestationObject = createAttestationObject(authData);537  const clientDataJSON = buildClientDataJSON(538    "webauthn.create",539    options.challenge,540    pageOrigin541  );542543  const passkey: PasskeyData = {544    rpId,545    rpName,546    credentialId: base64urlEncode(credentialIdBytes),547    publicKeyCose: base64urlEncode(publicKeyCose),548    privateKeyJwk,549    userHandle: options.user.id,550    userName,551    userDisplayName,552    algorithm,553    signCount: 0,554    aaguid: base64urlEncode(new Uint8Array(16)),555    backupEligible: false,556    backupState: false,557    browser: "chrome",558    transports: ["internal"],559    createdAt: Date.now(),560  };561562  // Attach to an existing login (same domain) when one exists; otherwise563  // create a fresh login entry that exists solely to hold the passkey.564  const target = findAttachTarget(rpId);565  if (target) {566    await vault.updateEntry(target.id, { passkey });567  } else {568    await vault.addEntry({569      type: "login",570      title: rpName,571      url: `https://${rpId}`,572      username: userName,573      passkey,574    });575  }576  // Merge any same-domain TOTP-only entries into the passkey entry right now577  // so the user sees one consolidated entry rather than waiting until next578  // unlock for the decodePayload merge pass to run.579  await vault.consolidatePasskeyEntries();580581  try {582    await chrome.webAuthenticationProxy.completeCreateRequest({583      requestId: request.requestId,584      responseJson: JSON.stringify({585        id: passkey.credentialId,586        rawId: passkey.credentialId,587        type: "public-key",588        response: {589          clientDataJSON: base64urlEncode(clientDataJSON),590          authenticatorData: base64urlEncode(authData),591          attestationObject: base64urlEncode(attestationObject),592          transports: ["internal"],593          publicKey: base64urlEncode(publicKeySpki),594          publicKeyAlgorithm: algorithm,595        },596        authenticatorAttachment: "platform",597        clientExtensionResults: {},598      }),599    });600    console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });601  } catch (err) {602    if (isInvalidRequestIdError(err)) {603      console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });604      return;605    }606    throw err;607  }608}609610function broadcastPasskeyProGate(): void {611  chrome.tabs.query({}, (tabs) => {612    for (const tab of tabs) {613      if (tab.id !== undefined) {614        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});615      }616    }617  });618}619620async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {621  if (chrome.storage.session) {622    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });623  }624625  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {626    chrome.tabs.query({ active: true, currentWindow: true }, resolve);627  });628  const tab = tabs[0];629630  if (tab?.id) {631    chrome.tabs.sendMessage(tab.id, {632      type: 'SHOW_PASSKEY_TOAST',633      payload: {634        site: request.rpId,635        displayName: request.userDisplayName ?? request.userName,636        username: request.userName,637        requestId: String(request.requestId),638      },639    }).catch(() => {});640  } else {641    await openApprovalPopup();642  }643644  return new Promise<boolean>((resolve) => {645    const timeoutId = setTimeout(async () => {646      approvals.delete(request.requestId);647      await clearPendingRequest(request.requestId);648      resolve(false);649    }, APPROVAL_TIMEOUT_MS);650651    approvals.set(request.requestId, { resolve, timeoutId });652  });653}654655async function handleGetRequest(656  request: chrome.webAuthenticationProxy.GetRequest657): Promise<void> {658  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;659  if (!vault.isUnlocked()) {660    // Summon the inline unlock prompt on the active tab so the user can unlock661    // without leaving the page. Fall back to the popup if the content script662    // is unavailable (e.g. restricted URL, script not yet injected).663    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>664      chrome.tabs.query({ active: true, currentWindow: true }, resolve)665    );666    const activeTab = tabs[0];667    if (activeTab?.id) {668      chrome.tabs.sendMessage(activeTab.id, {669        type: 'SUMMON_AUTOFILL',670        source: 'passkey-unlock',671      }).catch(() => { void openApprovalPopup(); });672    } else {673      await openApprovalPopup();674    }675676    // Hold the request open until the vault unlocks or the timeout fires.677    const unlocked = await new Promise<boolean>((resolve) => {678      const timeoutId = setTimeout(() => {679        unlockWaiters.delete(request.requestId);680        resolve(false);681      }, APPROVAL_TIMEOUT_MS);682      unlockWaiters.set(request.requestId, { resolve, timeoutId });683    });684685    if (!unlocked || !vault.isUnlocked()) {686      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");687      return;688    }689    // Vault is now unlocked — fall through to authenticate.690  }691692  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {693    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");694    broadcastPasskeyProGate();695    return;696  }697698  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>699    entry.passkey !== undefined && !entry.deletedAt700  );701  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);702703  const match = passkeyBearing.find((entry) => {704    if (allowedCredentialIds.length > 0) {705      return allowedCredentialIds.includes(entry.passkey.credentialId);706    }707    if (!options.rpId) return false;708    return entry.passkey.rpId === options.rpId;709  });710711  if (!match) {712    // No Peach passkey for this RP — pass through to the platform authenticator.713    // completeGetRequest with no error and no responseJson is the webAuthenticationProxy714    // signal for "I'm not handling this"; Chrome routes the request to the next715    // authenticator in the chain (the built-in browser passkey manager etc.).716    // Rejecting with NotAllowedError would propagate as an error to the page and717    // break RPs (e.g. Google) that issue a GET as part of their create-passkey flow.718    console.log("[Peach passkey] no matching credential — passing through to platform", { requestId: request.requestId });719    try {720      await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });721    } catch (err) {722      if (isInvalidRequestIdError(err)) return;723      if (isMissingResponseJsonError(err)) return;724      throw err;725    }726    return;727  }728729  const credential = match.passkey;730  // Chrome's webAuthenticationProxy path requires Peach-held private key material.731  // Passkeys created on Firefox have no privateKeyJwk — pass through so Chrome732  // can route to a capable authenticator instead of surfacing an error to the page.733  if (!credential.privateKeyJwk) {734    console.log("[Peach passkey] no private key (Firefox passkey) — passing through to platform", { requestId: request.requestId });735    try {736      await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });737    } catch (err) {738      if (isInvalidRequestIdError(err)) return;739      if (isMissingResponseJsonError(err)) return;740      throw err;741    }742    return;743  }744745  const approved = await promptViaToast({746    requestId: request.requestId,747    type: "get",748    rpId: credential.rpId,749    rpName: credential.rpName,750    userName: credential.userName,751    userDisplayName: credential.userDisplayName,752  });753  if (!approved) {754    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");755    return;756  }757758  const nextSignCount = credential.signCount + 1;759  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);760  const authData = await buildAuthenticatorData({761    rpId: credential.rpId,762    flags,763    signCount: nextSignCount,764  });765  const clientDataJSON = buildClientDataJSON(766    "webauthn.get",767    options.challenge,768    await resolvePageOrigin(credential.rpId)769  );770  const clientDataHash = new Uint8Array(771    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))772  );773  const signature = await signPasskeyAssertion(774    credential.privateKeyJwk,775    credential.algorithm,776    authData,777    clientDataHash778  );779780  await vault.updateEntry(match.id, {781    passkey: {782      ...credential,783      signCount: nextSignCount,784      lastUsedAt: Date.now(),785    },786  });787788  const responseJson = JSON.stringify({789    id: credential.credentialId,790    rawId: credential.credentialId,791    type: "public-key",792    response: {793      clientDataJSON: base64urlEncode(clientDataJSON),794      authenticatorData: base64urlEncode(authData),795      signature: base64urlEncode(signature),796      userHandle: credential.userHandle,797    },798    authenticatorAttachment: "platform",799    clientExtensionResults: {},800  });801802  try {803    await chrome.webAuthenticationProxy.completeGetRequest({804      requestId: request.requestId,805      responseJson,806    });807    console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });808  } catch (err) {809    if (isInvalidRequestIdError(err)) {810      console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });811      return;812    }813    throw err;814  }815}816817// ── Firefox path ────────────────────────────────────────────────────────818//819// Firefox 150+ permits extensions to call navigator.credentials.create/get820// from the popup context with their host-permitted RP IDs. Peach uses that821// surface to register passkeys on behalf of the user. The page's own822// navigator.credentials call is observed via the MAIN-world detector823// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's824// flow is parallel and explicit (the user must engage with Peach's button).825826const FIREFOX_PENDING_KEY = "passkey_pending_firefox";827const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";828const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days829830export async function initiateFirefoxPasskeyCreate(args: {831  rpId: string;832  rpName: string;833  userName: string;834  userDisplayName?: string;835}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {836  // The rpId must belong to the calling page's registrable domain; without837  // this, a page could initiate a ceremony for a foreign site and the838  // approval popup would display an attacker-chosen rpId. Origin is the839  // browser-attested sender origin from messages.ts, never page payload.840  // (Codex Security 2026-08-04, passkey-ceremony-approval sibling.)841  if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {842    return { ok: false, error: "rpid_origin_mismatch" };843  }844  if (!(await isProUser())) {845    broadcastPasskeyProGate();846    return { ok: false, error: "passkey creation requires Peach Pro" };847  }848  if (!vault.isUnlocked()) {849    await openApprovalPopup();850    return { ok: false, error: "vault_locked" };851  }852  if (!chrome.storage.session) {853    return { ok: false, error: "session storage unavailable" };854  }855856  const requestId = randomRequestId();857  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));858  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));859860  const pending: PendingPasskeyRequest = {861    requestId,862    type: "create",863    rpId: args.rpId,864    rpName: args.rpName,865    userName: args.userName,866    userDisplayName: args.userDisplayName,867    browser: "firefox",868    challenge,869    userHandle,870  };871872  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });873  await openApprovalPopup();874  return { ok: true };875}876877export async function completeFirefoxPasskeyCreate(payload: {878  requestId: number;879  rpId: string;880  rpName: string;881  userName: string;882  userDisplayName?: string;883  userHandle: string;884  credentialId: string;885  publicKeyCose: string;886  algorithm: -7 | -8;887  aaguid?: string;888  transports?: string[];889  backupEligible?: boolean;890  backupState?: boolean;891}): Promise<{ ok: boolean; error?: string }> {892  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };893894  // Bind completion to the pending ceremony: requestId, rpId, challenge, and895  // userHandle are minted by the background at INITIATE time and only ever896  // leave extension context through the approval popup, so a page (or anything897  // replaying relayed messages) cannot synthesize a matching completion for an898  // rpId it does not own. (Codex Security 2026-08-04 MEDIUM,899  // broken-access-control.passkey-ceremony-approval.)900  const pending = chrome.storage.session901    ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as902        | PendingPasskeyRequest903        | undefined)904    : undefined;905  if (906    !pending ||907    pending.requestId !== payload.requestId ||908    pending.rpId !== payload.rpId ||909    pending.userHandle !== payload.userHandle910  ) {911    return { ok: false, error: "no_pending_request" };912  }913914  const passkey: PasskeyData = {915    rpId: payload.rpId,916    rpName: payload.rpName,917    credentialId: payload.credentialId,918    publicKeyCose: payload.publicKeyCose,919    userHandle: payload.userHandle,920    userName: payload.userName,921    userDisplayName: payload.userDisplayName ?? payload.userName,922    algorithm: payload.algorithm,923    signCount: 0,924    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),925    backupEligible: payload.backupEligible ?? false,926    backupState: payload.backupState ?? false,927    browser: "firefox",928    transports: payload.transports ?? ["internal"],929    createdAt: Date.now(),930  };931932  // Same attach-or-create flow as the Chrome path: prefer attaching to an933  // existing login entry for the same domain so the user ends up with one934  // entry per site, not two (a login + a passkey-only).935  const target = findAttachTarget(payload.rpId);936  if (target) {937    await vault.updateEntry(target.id, { passkey });938  } else {939    await vault.addEntry({940      type: "login",941      title: payload.rpName || payload.rpId,942      url: `https://${payload.rpId}`,943      username: payload.userName,944      passkey,945    });946  }947  await vault.consolidatePasskeyEntries();948949  if (chrome.storage.session) {950    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);951  }952  return { ok: true };953}954955export async function cancelFirefoxPasskeyCreate(): Promise<void> {956  if (chrome.storage.session) {957    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);958  }959}960961function declineKey(domain: string): string {962  return `${FIREFOX_DECLINE_PREFIX}${domain}`;963}964965export async function recordPasskeyOfferDecline(domain: string): Promise<void> {966  if (!domain) return;967  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });968}969970export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {971  if (!domain) return false;972  const key = declineKey(domain);973  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;974  const at = stored[key];975  if (typeof at !== "number") return false;976  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {977    await chrome.storage.local.remove(key);978    return false;979  }980  return true;981}982983// ── Firefox content-script proxy path (v2.12.0) ────────────────────────984//985// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces986// navigator.credentials.create / .get on every Firefox page. It builds a987// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),988// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,989// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.990//991// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag992// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates993// to false and these handlers short-circuit. The full removal checklist is994// in that file — the deletion is purely subtractive.995996interface FirefoxAllowCredential {997  id: string;998  type?: string;999}10001001interface FirefoxPubKeyCredParam {1002  type?: string;1003  alg: number;1004}10051006export interface FirefoxPasskeyHasCredentialsPayload {1007  rpId: string;1008  allowCredentials: FirefoxAllowCredential[];1009}10101011export interface FirefoxPasskeyCreatePayload {1012  rpId: string;1013  rpName?: string;1014  userId: string;1015  userName: string;1016  userDisplayName?: string;1017  challenge: string;1018  clientDataJSON: number[];1019  origin: string;1020  pubKeyCredParams?: FirefoxPubKeyCredParam[];1021  authenticatorSelection?: Record<string, unknown>;1022  timeout?: number;1023  attestation?: string;1024}10251026export interface FirefoxPasskeyGetPayload {1027  rpId: string;1028  challenge: string;1029  clientDataJSON: number[];1030  origin: string;1031  allowCredentials: FirefoxAllowCredential[];1032  userVerification?: string;1033}10341035interface FirefoxCreateCredentialResponse {1036  type: "create";1037  id: string;1038  clientDataJSON: number[];1039  attestationObject: string;1040  authenticatorData: string;1041  publicKey: string;1042  algorithm: -7 | -8;1043}10441045interface FirefoxGetCredentialResponse {1046  type: "get";1047  id: string;1048  clientDataJSON: number[];1049  authenticatorData: string;1050  signature: string;1051  userHandle: string;1052}10531054type FirefoxPasskeyResult<T> =1055  | { ok: true; credential: T }1056  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10571058function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1059  return vault.getEntries().filter(1060    (entry): entry is VaultEntry & { passkey: PasskeyData } =>1061      entry.passkey !== undefined && !entry.deletedAt1062  );1063}10641065/** Decode the `origin` field out of a WebAuthn clientDataJSON byte array. */1066function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1067  if (!Array.isArray(bytes)) return null;1068  try {1069    const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1070    return typeof obj.origin === "string" ? obj.origin : null;1071  } catch {1072    return null;1073  }1074}10751076/**1077 * Enforce the WebAuthn rpId<->origin relationship on the Firefox proxy path,1078 * where Peach is the authenticator. The ONLY trustworthy origin is the one the1079 * browser attested for the calling page (`callerOrigin`, derived from the1080 * message sender in messages.ts) — NEVER `payload.origin`, which the page1081 * controls and can set to match any rpId (SEV-1-A / audit CRITICAL). Also1082 * refuse to sign a clientDataJSON whose embedded origin disagrees with the1083 * attested origin, so a forged ceremony can't smuggle a foreign origin into1084 * the signed bytes the RP verifies.1085 */1086function assertOriginOwnsRpId(1087  rpId: string,1088  callerOrigin: string | undefined,1089  clientDataJSON?: number[],1090): void {1091  if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1092    throw new DOMException(1093      `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1094        `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1095      "SecurityError",1096    );1097  }1098  const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1099  if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1100    throw new DOMException(1101      `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1102        `attested calling origin "${callerOrigin}".`,1103      "SecurityError",1104    );1105  }1106}11071108export async function handlePasskeyHasCredentials(1109  payload: FirefoxPasskeyHasCredentialsPayload,1110  callerOrigin?: string,1111): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1112  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1113    return { ok: false, error: "not_applicable" };1114  }1115  // Scope the existence check to the caller's own origin so an arbitrary page1116  // cannot enumerate which sites the user holds passkeys for (audit MEDIUM).1117  // A false answer is indistinguishable from "no credential" to the page.1118  if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1119    return { ok: true, hasCredentials: false };1120  }1121  if (!vault.isUnlocked()) {1122    // Cannot enumerate without the vault key. The MAIN-world proxy treats a1123    // false answer as "fall through to the platform" — exactly the right1124    // outcome when Peach is locked: the user gets the native UI instead.1125    return { ok: true, hasCredentials: false };1126  }1127  const passkeys = listPasskeyEntries().filter(1128    (entry) => entry.passkey.rpId === payload.rpId1129  );1130  if (passkeys.length === 0) return { ok: true, hasCredentials: false };11311132  const allow = payload.allowCredentials ?? [];1133  if (allow.length === 0) return { ok: true, hasCredentials: true };11341135  const hasMatch = passkeys.some((entry) =>1136    allow.some((ac) => ac.id === entry.passkey.credentialId)1137  );1138  return { ok: true, hasCredentials: hasMatch };1139}11401141/**1142 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing1143 * pending-passkey channel (badge + popup + 60s timeout) — the same approval1144 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need1145 * any new code path. The page-side proxy never sees the request id; the1146 * background mints one and discards it after resolution.1147 */1148async function requestFirefoxApproval(args: {1149  type: "create" | "get";1150  rpId: string;1151  rpName?: string;1152  userName: string;1153  userDisplayName?: string;1154}): Promise<boolean> {1155  // Synthetic request id — the Firefox proxy path doesn't have a1156  // chrome.webAuthenticationProxy request to tie back to.1157  const requestId = randomRequestId();1158  return promptUserApproval({1159    requestId,1160    type: args.type,1161    rpId: args.rpId,1162    rpName: args.rpName ?? args.rpId,1163    userName: args.userName,1164    userDisplayName: args.userDisplayName,1165  });1166}11671168export async function handleFirefoxPasskeyCreate(1169  payload: FirefoxPasskeyCreatePayload,1170  callerOrigin?: string,1171): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1172  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1173    return { ok: false, error: "not_applicable" };1174  }1175  // WebAuthn §7.1 step 6 — the requested rpId must be owned by the page's1176  // browser-ATTESTED origin (threaded in as callerOrigin from the message1177  // sender), NOT the page-supplied payload.origin which an attacker controls.1178  // On the Firefox proxy path Peach is the authenticator and must enforce this1179  // before minting anything (audit CRITICAL — origin spoofing).1180  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1181  if (!(await isProUser())) {1182    broadcastPasskeyProGate();1183    return { ok: false, error: "pro_required" };1184  }1185  if (!vault.isUnlocked()) {1186    await openApprovalPopup();1187    return { ok: false, error: "vault_locked" };1188  }11891190  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1191  if (!algorithm) {1192    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1193  }11941195  const approved = await requestFirefoxApproval({1196    type: "create",1197    rpId: payload.rpId,1198    rpName: payload.rpName,1199    userName: payload.userName,1200    userDisplayName: payload.userDisplayName,1201  });1202  if (!approved) return { ok: false, error: "user_cancelled" };12031204  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1205  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);12061207  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).1208  // Matches Chrome's create flag set in handleCreateRequest above.1209  const authenticatorData = await buildAuthenticatorData({1210    rpId: payload.rpId,1211    flags: 0x45,1212    signCount: 0,1213    credentialId: credentialIdBytes,1214    publicKeyCose,1215  });1216  const attestationObject = createAttestationObject(authenticatorData);12171218  const passkey: PasskeyData = {1219    rpId: payload.rpId,1220    rpName: payload.rpName ?? payload.rpId,1221    credentialId,1222    publicKeyCose: base64urlEncode(publicKeyCose),1223    privateKeyJwk,1224    userHandle: payload.userId,1225    userName: payload.userName,1226    userDisplayName: payload.userDisplayName ?? payload.userName,1227    algorithm,1228    signCount: 0,1229    aaguid: base64urlEncode(new Uint8Array(16)),1230    backupEligible: false,1231    backupState: false,1232    browser: "firefox",1233    transports: ["internal"],1234    createdAt: Date.now(),1235  };12361237  const target = findAttachTarget(payload.rpId);1238  if (target) {1239    await vault.updateEntry(target.id, { passkey });1240  } else {1241    await vault.addEntry({1242      type: "login",1243      title: payload.rpName || payload.rpId,1244      url: `https://${payload.rpId}`,1245      username: payload.userName,1246      passkey,1247    });1248  }1249  await vault.consolidatePasskeyEntries();12501251  return {1252    ok: true,1253    credential: {1254      type: "create",1255      id: credentialId,1256      // The content script already encoded clientDataJSON with the page1257      // origin; round-trip the exact bytes back so the RP sees the same1258      // hash it computes locally.1259      clientDataJSON: payload.clientDataJSON,1260      attestationObject: base64urlEncode(attestationObject),1261      authenticatorData: base64urlEncode(authenticatorData),1262      publicKey: base64urlEncode(publicKeySpki),1263      algorithm,1264    },1265  };1266}12671268export async function handleFirefoxPasskeyGet(1269  payload: FirefoxPasskeyGetPayload,1270  callerOrigin?: string,1271): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1272  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1273    return { ok: false, error: "not_applicable" };1274  }1275  // WebAuthn §7.2 step 8 — the rpId must be owned by the page's browser-attested1276  // origin (callerOrigin from the message sender), NOT the page-supplied1277  // payload.origin. Both rpId and payload.origin are attacker-controlled, so1278  // validating one against the other is no protection; a cross-origin page must1279  // not be able to coax Peach into signing an assertion scoped to another site1280  // (audit CRITICAL — assertion forgery).1281  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1282  if (!(await isProUser())) {1283    broadcastPasskeyProGate();1284    return { ok: false, error: "pro_required" };1285  }1286  if (!vault.isUnlocked()) {1287    await openApprovalPopup();1288    return { ok: false, error: "vault_locked" };1289  }12901291  const allow = payload.allowCredentials ?? [];1292  const candidates = listPasskeyEntries().filter((entry) => {1293    if (entry.passkey.rpId !== payload.rpId) return false;1294    if (allow.length === 0) return true;1295    return allow.some((ac) => ac.id === entry.passkey.credentialId);1296  });12971298  if (candidates.length === 0) return { ok: false, error: "no_credentials" };12991300  // Most-recently-used wins when several credentials match. A future1301  // multi-account selector goes here; for now this matches the Chrome path's1302  // behavior for the common one-passkey-per-rp case.1303  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1304  const match = candidates[0]!;1305  const credential = match.passkey;13061307  if (!credential.privateKeyJwk) {1308    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1309    // — no Peach-held private key to sign with. The MAIN-world proxy treats1310    // no_credentials as a fall-through, which routes to the platform UI.1311    return { ok: false, error: "no_credentials" };1312  }13131314  const approved = await requestFirefoxApproval({1315    type: "get",1316    rpId: credential.rpId,1317    rpName: credential.rpName,1318    userName: credential.userName,1319    userDisplayName: credential.userDisplayName,1320  });1321  if (!approved) return { ok: false, error: "user_cancelled" };13221323  const nextSignCount = credential.signCount + 1;1324  // 0x05 = UP|UV. backup bits track whatever was set at registration time1325  // (consistent with the Chrome path).1326  const flags =1327    0x05 |1328    (credential.backupEligible ? 0x08 : 0) |1329    (credential.backupState ? 0x10 : 0);1330  const authenticatorData = await buildAuthenticatorData({1331    rpId: credential.rpId,1332    flags,1333    signCount: nextSignCount,1334  });13351336  // The content script supplied clientDataJSON bytes built with the page1337  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1338  // diverges from what the RP computes.1339  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1340  const clientDataHash = new Uint8Array(1341    await crypto.subtle.digest("SHA-256", clientDataBytes)1342  );1343  const signature = await signPasskeyAssertion(1344    credential.privateKeyJwk,1345    credential.algorithm,1346    authenticatorData,1347    clientDataHash1348  );13491350  await vault.updateEntry(match.id, {1351    passkey: {1352      ...credential,1353      signCount: nextSignCount,1354      lastUsedAt: Date.now(),1355    },1356  });13571358  return {1359    ok: true,1360    credential: {1361      type: "get",1362      id: credential.credentialId,1363      clientDataJSON: payload.clientDataJSON,1364      authenticatorData: base64urlEncode(authenticatorData),1365      signature: base64urlEncode(signature),1366      userHandle: credential.userHandle,1367    },1368  };1369}13701371export async function setupPasskeyProxy(): Promise<void> {1372  if (!isPasskeySupported()) {1373    console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1374    return;1375  }13761377  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1378  // popup-context credential creator is the entry point; nothing to wire here.1379  if (detectSharedBrowser() === "firefox") {1380    await refreshPasskeyProxy();1381    return;1382  }13831384  if (!listenersRegistered) {1385    console.log("[Peach passkey] registering webAuthenticationProxy listeners");1386    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1387      // Peach is a user-verifying platform authenticator whenever the proxy1388      // is attached. The lock state is irrelevant here — the create/get1389      // handlers prompt for unlock when needed. Returning false while locked1390      // makes Google (and any RP that checks isUvpaa as a precondition) show1391      // "A passkey can't be created on this device" before the user even1392      // gets a chance to start the ceremony.1393      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1394        requestId: requestInfo.requestId,1395        isUvpaa: true,1396      });1397    });1398    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1399      console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1400      void handleCreateRequest(requestInfo).catch(async (error) => {1401        // "Invalid requestId" means Chrome already cancelled — handlers will1402        // log it themselves and short-circuit, but if it propagates out1403        // (e.g., from a still-in-flight branch) don't double-complete.1404        if (isInvalidRequestIdError(error)) {1405          console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1406          return;1407        }1408        console.error("Peach passkey create failed", error);1409        await rejectCreate(1410          requestInfo.requestId,1411          "UnknownError",1412          error instanceof Error ? error.message : "Passkey creation failed."1413        );1414      });1415    });1416    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1417      console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1418      void handleGetRequest(requestInfo).catch(async (error) => {1419        if (isInvalidRequestIdError(error)) {1420          console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1421          return;1422        }1423        console.error("Peach passkey get failed", error);1424        await rejectGet(1425          requestInfo.requestId,1426          "UnknownError",1427          error instanceof Error ? error.message : "Passkey authentication failed."1428        );1429      });1430    });1431    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1432      console.log("[Peach passkey] onRequestCanceled", { requestId });1433      markCancelled(requestId);1434      // Release any unlock waiter parked for this request.1435      const waiter = unlockWaiters.get(requestId);1436      if (waiter) {1437        clearTimeout(waiter.timeoutId);1438        waiter.resolve(false);1439        unlockWaiters.delete(requestId);1440      }1441      void resolvePendingPasskeyRequest(requestId, false);1442    });1443    listenersRegistered = true;1444  }14451446  await refreshPasskeyProxy();1447}