passkey.ts

48.4 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  for (const [rid, { resolve, timeoutId }] of approvals.entries()) {74    clearTimeout(timeoutId);75    resolve(false);76    approvals.delete(rid);77    await clearPendingRequest(rid).catch(() => {});78  }79  for (const { resolve, timeoutId } of unlockWaiters.values()) {80    clearTimeout(timeoutId);81    resolve(false);82  }83  unlockWaiters.clear();84}8586function isPasskeySupported(): boolean {87  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;88  // Firefox 150+ exposes navigator.credentials.create/get from the popup89  // context with extension host permissions. Cannot intercept page-side90  // calls (no equivalent of webAuthenticationProxy), but registration via91  // Peach's own popup-driven flow works.92  if (detectSharedBrowser() === "firefox") {93    const ver = firefoxVersion();94    if (Number.isFinite(ver) && ver >= 150) return true;95  }96  return false;97}9899export const detectBrowser = detectSharedBrowser;100101function getBrowserLabel(): string {102  const ua = navigator.userAgent;103  if (/Firefox\/(\d+)/.test(ua)) {104    const version = ua.match(/Firefox\/(\d+)/)?.[1];105    return version ? `Firefox ${version}` : "Firefox";106  }107  const version = ua.match(/Chrome\/(\d+)/)?.[1];108  return version ? `Chrome ${version}` : "Chromium";109}110111function pendingStorageKey(requestId: number): string {112  return `${PENDING_PREFIX}${requestId}`;113}114115function randomRequestId(): number {116  // 53-bit crypto-random integer — unbrute-forceable, unlike the old117  // Date.now()+Math.random synthetic id (~10-20 bits). Fits a JS safe118  // integer so it round-trips through String/Number for the toast path.119  // (Audit 2026-08-20 #1/#10.)120  const u = crypto.getRandomValues(new Uint32Array(2));121  return u[0] * 0x200000 + (u[1] >>> 11);122}123124function originFromRpId(rpId: string): string {125  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)126    ? `http://${rpId}`127    : `https://${rpId}`;128}129130/**131 * The clientDataJSON sent back to the RP must contain the page's actual132 * origin — not a synthesized one. `chrome.webAuthenticationProxy` does not133 * surface the requesting origin in CreateRequest/GetRequest, so we read it134 * from the active tab and validate that its hostname is a registrable135 * suffix match for the rpId. WebAuthn requires this relationship anyway, so136 * a mismatch indicates either a stale active tab or a hostile environment;137 * we fall back to https://<rpId> in that case to avoid silently signing the138 * wrong origin.139 *140 * Without this, a passkey created on https://myaccount.google.com gets141 * clientDataJSON.origin = "https://google.com", and Google's RP rejects the142 * attestation with "Something went wrong" because the origin hash doesn't143 * match what its own JS captured at create time.144 */145async function resolvePageOrigin(rpId: string): Promise<string> {146  try {147    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>148      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)149    );150    const url = tabs[0]?.url;151    if (url) {152      const parsed = new URL(url);153      const host = parsed.hostname.toLowerCase();154      const rp = rpId.toLowerCase();155      if (host === rp || host.endsWith(`.${rp}`)) {156        return parsed.origin;157      }158    }159  } catch {160    // fall through to rpId-derived origin161  }162  return originFromRpId(rpId);163}164165async function setBadge(text: string): Promise<void> {166  if (!chrome.action?.setBadgeText) return;167  await chrome.action.setBadgeText({ text });168  if (text) {169    await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });170  }171}172173async function clearPendingRequest(requestId: number): Promise<void> {174  if (chrome.storage.session) {175    await chrome.storage.session.remove(pendingStorageKey(requestId));176  }177  await setBadge("");178}179180async function openApprovalPopup(): Promise<void> {181  try {182    await chrome.action.openPopup();183  } catch {184    await setBadge("PK");185  }186}187188/**189 * Some ceremonies — especially conditional-UI `navigator.credentials.get`190 * calls — get cancelled by Chrome before we finish processing. Calling191 * complete*Request on a cancelled requestId rejects with "Invalid192 * requestId". That is expected, not exceptional: swallow it. Re-throwing193 * would surface an uncaught promise rejection in the SW and cascade through194 * the catch wrappers in setupPasskeyProxy.195 */196function isInvalidRequestIdError(err: unknown): boolean {197  return err instanceof Error && /invalid requestid/i.test(err.message);198}199200/**201 * Chrome throws "Missing GetResponseDetails.responseJson" when202 * completeGetRequest is called with no responseJson and no error (the203 * pass-through signal). The throw is a Chrome API quirk — the request IS204 * routed to the platform authenticator — but if we let it propagate, the205 * outer catch calls rejectGet, which sends a second completeGetRequest with206 * an error that cancels the pass-through and shows "Something went wrong" on207 * the page. Swallow it exactly as we swallow "Invalid requestId".208 */209function isMissingResponseJsonError(err: unknown): boolean {210  return err instanceof Error && /missing getresponsedetails/i.test(err.message);211}212213async function rejectCreate(214  requestId: number,215  name: string,216  message = ""217): Promise<void> {218  try {219    await chrome.webAuthenticationProxy.completeCreateRequest({220      requestId,221      error: { name, message },222    });223  } catch (err) {224    if (isInvalidRequestIdError(err)) {225      return;226    }227    throw err;228  }229}230231async function rejectGet(232  requestId: number,233  name: string,234  message = ""235): Promise<void> {236  try {237    await chrome.webAuthenticationProxy.completeGetRequest({238      requestId,239      error: { name, message },240    });241  } catch (err) {242    if (isInvalidRequestIdError(err)) {243      return;244    }245    throw err;246  }247}248249export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {250  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts251  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;252  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));253  if (!key) return null;254  const pending = stored[key];255  return pending && typeof pending === "object"256    ? (pending as PendingPasskeyRequest)257    : null;258}259260export async function resolvePendingPasskeyRequest(261  requestId: number,262  approved: boolean263): Promise<void> {264  const pending = approvals.get(requestId);265  if (!pending) {266    // Either the request expired (Chrome) or this is a Firefox-driven267    // pending where the popup performs the navigator.credentials call268    // directly. Clear both possible session keys so getPendingPasskeyRequest()269    // doesn't surface stale state on the next popup open.270    await clearPendingRequest(requestId);271    if (chrome.storage.session) {272      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);273    }274    return;275  }276277  clearTimeout(pending.timeoutId);278  approvals.delete(requestId);279  await clearPendingRequest(requestId);280  pending.resolve(approved);281}282283async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {284  if (chrome.storage.session) {285    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });286  }287  await openApprovalPopup();288289  return new Promise<boolean>((resolve) => {290    const timeoutId = setTimeout(async () => {291      approvals.delete(request.requestId);292      await clearPendingRequest(request.requestId);293      resolve(false);294    }, APPROVAL_TIMEOUT_MS);295296    approvals.set(request.requestId, { resolve, timeoutId });297  });298}299300export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {301  const settings = await vault.ensureSettingsLoaded();302  const supported = isPasskeySupported();303  const enabled = settings.passkeyProviderEnabled === true;304  return {305    supported,306    enabled,307    attached: supported && attached,308    browserLabel: getBrowserLabel(),309    statusLabel: !supported310      ? "Interception unavailable"311      : attached312        ? "Active"313        : conflict314          ? "Another provider is active"315          : enabled316            ? "Inactive"317            : "Disabled",318    conflict,319  };320}321322async function attachPasskeyProxy(): Promise<void> {323  if (!isPasskeySupported()) {324    attached = false;325    conflict = "This browser does not expose chrome.webAuthenticationProxy.";326    return;327  }328329  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context330  // navigator.credentials creator is always ready once the user enables the331  // provider. Treat enabled = attached for state reporting purposes.332  if (detectSharedBrowser() === "firefox") {333    attached = true;334    conflict = undefined;335    return;336  }337338  if (attached) return;339340  try {341    await chrome.webAuthenticationProxy.attach();342    attached = true;343    conflict = undefined;344  } catch (error) {345    attached = false;346    conflict = error instanceof Error ? error.message : "Another provider is active.";347    console.warn("[Peach passkey] proxy attach failed", { conflict });348  }349}350351async function detachPasskeyProxy(): Promise<void> {352  if (!isPasskeySupported()) {353    attached = false;354    return;355  }356357  if (detectSharedBrowser() === "firefox") {358    attached = false;359    conflict = undefined;360    return;361  }362363  try {364    await chrome.webAuthenticationProxy.detach();365  } catch {366    // Ignore detach failures; the extension may already be detached.367  }368369  attached = false;370  conflict = undefined;371}372373export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {374  await vault.updateSettings({ passkeyProviderEnabled: enabled });375  if (enabled) {376    await attachPasskeyProxy();377  } else {378    await detachPasskeyProxy();379  }380  return getPasskeyProviderState();381}382383async function refreshPasskeyProxy(): Promise<void> {384  const settings = await vault.ensureSettingsLoaded();385  if (settings.passkeyProviderEnabled === true) {386    await attachPasskeyProxy();387    return;388  }389  await detachPasskeyProxy();390}391392/**393 * Find the entry that already holds a passkey for the given RP, or the most394 * recently-modified login entry whose URL/additionalUrls match the RP — that395 * entry is the natural attach target when the user adds a passkey to a site396 * they already have a password for. Returns null when there's no candidate;397 * the caller creates a new entry in that case.398 */399function findAttachTarget(rpId: string): VaultEntry | null {400  const all = vault.getEntries();401  const passkeyMatch = all.find(402    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt403  );404  if (passkeyMatch) return passkeyMatch;405  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);406  if (loginMatches.length === 0) return null;407  // Pick the most recently modified candidate to be deterministic.408  return loginMatches.reduce((best, e) =>409    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best410  );411}412413async function handleCreateRequest(414  request: chrome.webAuthenticationProxy.CreateRequest415): Promise<void> {416  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;417  const rpId = options.rp.id;418  const rpName = options.rp.name ?? options.rp.id;419  const userName = options.user.name;420  const userDisplayName = options.user.displayName ?? options.user.name;421  if (!rpId) {422    console.warn("[Peach passkey] reject: missing rp.id");423    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");424    return;425  }426427  // Capture the page origin NOW — before any popup opens. Once the extension428  // action popup fires, it can steal lastFocusedWindow so the tabs query429  // returns no results and falls back to https://<rpId>. For sites where the430  // page origin is a subdomain of the rpId (e.g. myaccount.google.com vs431  // rpId google.com) that fallback produces the wrong origin and the RP432  // rejects the attestation with "Something went wrong".433  const pageOrigin = await resolvePageOrigin(rpId);434435  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {436    console.warn("[Peach passkey] reject: user is not Pro");437    await rejectCreate(438      request.requestId,439      "NotAllowedError",440      "Passkey creation requires Peach Pro."441    );442    // Show the inline passkey gate so the user knows what happened and can upgrade.443    broadcastPasskeyProGate();444    return;445  }446447  if (!vault.isUnlocked()) {448    // Same wait-for-unlock pattern handleGetRequest uses: summon the inline449    // unlock prompt on the active tab so the user can unlock without leaving450    // the page, then hold the create request open until the vault unlocks or451    // the timeout fires. Immediately rejecting here means Google shows452    // "Something went wrong" the instant the user picks Peach.453    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>454      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)455    );456    const activeTab = tabs[0];457    if (activeTab?.id) {458      chrome.tabs.sendMessage(activeTab.id, {459        type: 'SUMMON_AUTOFILL',460        source: 'passkey-unlock',461      }).catch((err) => {462        console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);463        void openApprovalPopup();464      });465    } else {466      await openApprovalPopup();467    }468469    const unlocked = await new Promise<boolean>((resolve) => {470      const timeoutId = setTimeout(() => {471        unlockWaiters.delete(request.requestId);472        resolve(false);473      }, APPROVAL_TIMEOUT_MS);474      unlockWaiters.set(request.requestId, { resolve, timeoutId });475    });476477    if (!unlocked || !vault.isUnlocked()) {478      console.warn("[Peach passkey] reject: unlock timeout");479      await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");480      return;481    }482    // Vault is now unlocked — fall through to approval.483  }484485  const approved = await promptUserApproval({486    requestId: request.requestId,487    type: "create",488    rpId,489    rpName,490    userName,491    userDisplayName,492  });493  if (!approved) {494    console.warn("[Peach passkey] reject: user denied");495    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");496    return;497  }498499  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);500  if (!algorithm) {501    console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);502    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");503    return;504  }505  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));506  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(507    algorithm508  );509  const authData = await buildAuthenticatorData({510    rpId,511    flags: 0x4d,512    signCount: 0,513    credentialId: credentialIdBytes,514    publicKeyCose,515  });516  const attestationObject = createAttestationObject(authData);517  const clientDataJSON = buildClientDataJSON(518    "webauthn.create",519    options.challenge,520    pageOrigin521  );522523  const passkey: PasskeyData = {524    rpId,525    rpName,526    credentialId: base64urlEncode(credentialIdBytes),527    publicKeyCose: base64urlEncode(publicKeyCose),528    privateKeyJwk,529    userHandle: options.user.id,530    userName,531    userDisplayName,532    algorithm,533    signCount: 0,534    aaguid: base64urlEncode(new Uint8Array(16)),535    backupEligible: true,536    backupState: false,537    browser: "chrome",538    transports: ["internal"],539    createdAt: Date.now(),540  };541542  // Attach to an existing login (same domain) when one exists; otherwise543  // create a fresh login entry that exists solely to hold the passkey.544  const target = findAttachTarget(rpId);545  if (target) {546    await vault.updateEntry(target.id, { passkey });547  } else {548    await vault.addEntry({549      type: "login",550      title: rpName,551      url: `https://${rpId}`,552      username: userName,553      passkey,554    });555  }556  // Merge any same-domain TOTP-only entries into the passkey entry right now557  // so the user sees one consolidated entry rather than waiting until next558  // unlock for the decodePayload merge pass to run.559  await vault.consolidatePasskeyEntries();560561  try {562    await chrome.webAuthenticationProxy.completeCreateRequest({563      requestId: request.requestId,564      responseJson: JSON.stringify({565        id: passkey.credentialId,566        rawId: passkey.credentialId,567        type: "public-key",568        response: {569          clientDataJSON: base64urlEncode(clientDataJSON),570          authenticatorData: base64urlEncode(authData),571          attestationObject: base64urlEncode(attestationObject),572          transports: ["internal"],573          publicKey: base64urlEncode(publicKeySpki),574          publicKeyAlgorithm: algorithm,575        },576        authenticatorAttachment: "platform",577        clientExtensionResults: {},578      }),579    });580  } catch (err) {581    if (isInvalidRequestIdError(err)) {582      return;583    }584    throw err;585  }586}587588function broadcastPasskeyProGate(): void {589  chrome.tabs.query({}, (tabs) => {590    for (const tab of tabs) {591      if (tab.id !== undefined) {592        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});593      }594    }595  });596}597598async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {599  if (chrome.storage.session) {600    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });601  }602603  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {604    chrome.tabs.query({ active: true, currentWindow: true }, resolve);605  });606  const tab = tabs[0];607608  if (tab?.id) {609    chrome.tabs.sendMessage(tab.id, {610      type: 'SHOW_PASSKEY_TOAST',611      payload: {612        toastToken: crypto.randomUUID(),613        site: request.rpId,614        displayName: request.userDisplayName ?? request.userName,615        username: request.userName,616        requestId: String(request.requestId),617      },618    }).catch(() => {});619  } else {620    await openApprovalPopup();621  }622623  return new Promise<boolean>((resolve) => {624    const timeoutId = setTimeout(async () => {625      approvals.delete(request.requestId);626      await clearPendingRequest(request.requestId);627      resolve(false);628    }, APPROVAL_TIMEOUT_MS);629630    approvals.set(request.requestId, { resolve, timeoutId });631  });632}633634async function handleGetRequest(635  request: chrome.webAuthenticationProxy.GetRequest636): Promise<void> {637  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;638  if (!vault.isUnlocked()) {639    // Summon the inline unlock prompt on the active tab so the user can unlock640    // without leaving the page. Fall back to the popup if the content script641    // is unavailable (e.g. restricted URL, script not yet injected).642    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>643      chrome.tabs.query({ active: true, currentWindow: true }, resolve)644    );645    const activeTab = tabs[0];646    if (activeTab?.id) {647      chrome.tabs.sendMessage(activeTab.id, {648        type: 'SUMMON_AUTOFILL',649        source: 'passkey-unlock',650      }).catch(() => { void openApprovalPopup(); });651    } else {652      await openApprovalPopup();653    }654655    // Hold the request open until the vault unlocks or the timeout fires.656    const unlocked = await new Promise<boolean>((resolve) => {657      const timeoutId = setTimeout(() => {658        unlockWaiters.delete(request.requestId);659        resolve(false);660      }, APPROVAL_TIMEOUT_MS);661      unlockWaiters.set(request.requestId, { resolve, timeoutId });662    });663664    if (!unlocked || !vault.isUnlocked()) {665      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");666      return;667    }668    // Vault is now unlocked — fall through to authenticate.669  }670671  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {672    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");673    broadcastPasskeyProGate();674    return;675  }676677  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>678    entry.passkey !== undefined && !entry.deletedAt679  );680  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);681682  const match = passkeyBearing.find((entry) => {683    // PB-26-015: a credential-ID match must still belong to the requesting684    // RP — the assertion later signs with the stored rpId, so a foreign-RP685    // request must never select a credential from a different rpId.686    if (options.rpId && entry.passkey.rpId !== options.rpId) return false;687    if (allowedCredentialIds.length > 0) {688      return allowedCredentialIds.includes(entry.passkey.credentialId);689    }690    if (!options.rpId) return false;691    return entry.passkey.rpId === options.rpId;692  });693694  if (!match) {695    // No Peach passkey for this RP — pass through to the platform authenticator.696    // completeGetRequest with no error and no responseJson is the webAuthenticationProxy697    // signal for "I'm not handling this"; Chrome routes the request to the next698    // authenticator in the chain (the built-in browser passkey manager etc.).699    // Rejecting with NotAllowedError would propagate as an error to the page and700    // break RPs (e.g. Google) that issue a GET as part of their create-passkey flow.701    try {702      await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });703    } catch (err) {704      if (isInvalidRequestIdError(err)) return;705      if (isMissingResponseJsonError(err)) return;706      throw err;707    }708    return;709  }710711  const credential = match.passkey;712  // Chrome's webAuthenticationProxy path requires Peach-held private key material.713  // Passkeys created on Firefox have no privateKeyJwk — pass through so Chrome714  // can route to a capable authenticator instead of surfacing an error to the page.715  if (!credential.privateKeyJwk) {716    try {717      await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });718    } catch (err) {719      if (isInvalidRequestIdError(err)) return;720      if (isMissingResponseJsonError(err)) return;721      throw err;722    }723    return;724  }725726  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {727    chrome.tabs.query({ active: true, currentWindow: true }, resolve);728  });729  const approvalBinding = {730    tabId: tabs[0]?.id,731    origin: await resolvePageOrigin(credential.rpId),732  };733  const approved = await promptViaToast({734    requestId: request.requestId,735    type: "get",736    rpId: credential.rpId,737    rpName: credential.rpName,738    userName: credential.userName,739    userDisplayName: credential.userDisplayName,740    ...approvalBinding,741  });742  if (!approved) {743    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");744    return;745  }746747  const nextSignCount = credential.signCount + 1;748  const flags = 0x05 | (credential.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      signCount: nextSignCount,773      lastUsedAt: Date.now(),774    },775  });776777  const responseJson = JSON.stringify({778    id: credential.credentialId,779    rawId: credential.credentialId,780    type: "public-key",781    response: {782      clientDataJSON: base64urlEncode(clientDataJSON),783      authenticatorData: base64urlEncode(authData),784      signature: base64urlEncode(signature),785      userHandle: credential.userHandle,786    },787    authenticatorAttachment: "platform",788    clientExtensionResults: {},789  });790791  try {792    await chrome.webAuthenticationProxy.completeGetRequest({793      requestId: request.requestId,794      responseJson,795    });796  } catch (err) {797    if (isInvalidRequestIdError(err)) {798      return;799    }800    throw err;801  }802}803804// ── Firefox path ────────────────────────────────────────────────────────805//806// Firefox 150+ permits extensions to call navigator.credentials.create/get807// from the popup context with their host-permitted RP IDs. Peach uses that808// surface to register passkeys on behalf of the user. The page's own809// navigator.credentials call is observed via the MAIN-world detector810// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's811// flow is parallel and explicit (the user must engage with Peach's button).812813const FIREFOX_PENDING_KEY = "passkey_pending_firefox";814const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";815const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days816817export async function initiateFirefoxPasskeyCreate(args: {818  rpId: string;819  rpName: string;820  userName: string;821  userDisplayName?: string;822}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {823  // The rpId must belong to the calling page's registrable domain; without824  // this, a page could initiate a ceremony for a foreign site and the825  // approval popup would display an attacker-chosen rpId. Origin is the826  // browser-attested sender origin from messages.ts, never page payload.827  // (Codex Security 2026-08-04, passkey-ceremony-approval sibling.)828  if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {829    return { ok: false, error: "rpid_origin_mismatch" };830  }831  if (!(await isProUser())) {832    broadcastPasskeyProGate();833    return { ok: false, error: "passkey creation requires Peach Pro" };834  }835  if (!vault.isUnlocked()) {836    await openApprovalPopup();837    return { ok: false, error: "vault_locked" };838  }839  if (!chrome.storage.session) {840    return { ok: false, error: "session storage unavailable" };841  }842843  const requestId = randomRequestId();844  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));845  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));846847  const pending: PendingPasskeyRequest = {848    requestId,849    type: "create",850    rpId: args.rpId,851    rpName: args.rpName,852    userName: args.userName,853    userDisplayName: args.userDisplayName,854    browser: "firefox",855    challenge,856    userHandle,857  };858859  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });860  await openApprovalPopup();861  return { ok: true };862}863864export async function completeFirefoxPasskeyCreate(payload: {865  requestId: number;866  rpId: string;867  rpName: string;868  userName: string;869  userDisplayName?: string;870  userHandle: string;871  credentialId: string;872  publicKeyCose: string;873  algorithm: -7 | -8;874  aaguid?: string;875  transports?: string[];876  backupEligible?: boolean;877  backupState?: boolean;878}): Promise<{ ok: boolean; error?: string }> {879  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };880881  // Bind completion to the pending ceremony: requestId, rpId, challenge, and882  // userHandle are minted by the background at INITIATE time and only ever883  // leave extension context through the approval popup, so a page (or anything884  // replaying relayed messages) cannot synthesize a matching completion for an885  // rpId it does not own. (Codex Security 2026-08-04 MEDIUM,886  // broken-access-control.passkey-ceremony-approval.)887  const pending = chrome.storage.session888    ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as889        | PendingPasskeyRequest890        | undefined)891    : undefined;892  if (893    !pending ||894    pending.requestId !== payload.requestId ||895    pending.rpId !== payload.rpId ||896    pending.userHandle !== payload.userHandle897  ) {898    return { ok: false, error: "no_pending_request" };899  }900901  const passkey: PasskeyData = {902    rpId: payload.rpId,903    rpName: payload.rpName,904    credentialId: payload.credentialId,905    publicKeyCose: payload.publicKeyCose,906    userHandle: payload.userHandle,907    userName: payload.userName,908    userDisplayName: payload.userDisplayName ?? payload.userName,909    algorithm: payload.algorithm,910    signCount: 0,911    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),912    backupEligible: payload.backupEligible ?? false,913    backupState: payload.backupState ?? false,914    browser: "firefox",915    transports: payload.transports ?? ["internal"],916    createdAt: Date.now(),917  };918919  // Same attach-or-create flow as the Chrome path: prefer attaching to an920  // existing login entry for the same domain so the user ends up with one921  // entry per site, not two (a login + a passkey-only).922  const target = findAttachTarget(payload.rpId);923  if (target) {924    await vault.updateEntry(target.id, { passkey });925  } else {926    await vault.addEntry({927      type: "login",928      title: payload.rpName || payload.rpId,929      url: `https://${payload.rpId}`,930      username: payload.userName,931      passkey,932    });933  }934  await vault.consolidatePasskeyEntries();935936  if (chrome.storage.session) {937    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);938  }939  return { ok: true };940}941942const MAX_DECLINE_DOMAIN_LENGTH = 253;943944function normaliseDeclineDomain(domain: string): string | null {945  if (!domain || domain.length > MAX_DECLINE_DOMAIN_LENGTH) return null;946  try {947    const url = new URL(`https://${domain}`);948    return isValidRpId(url.hostname, url.origin) ? url.hostname : null;949  } catch {950    return null;951  }952}953954function declineKey(domain: string): string {955  return `${FIREFOX_DECLINE_PREFIX}${domain}`;956}957958export async function recordPasskeyOfferDecline(domain: string): Promise<void> {959  const validDomain = normaliseDeclineDomain(domain);960  if (!validDomain) return;961  await chrome.storage.local.set({ [declineKey(validDomain)]: Date.now() });962}963964export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {965  const validDomain = normaliseDeclineDomain(domain);966  if (!validDomain) return false;967  const key = declineKey(validDomain);968969  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;970  const at = stored[key];971  if (typeof at !== "number") return false;972  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {973    await chrome.storage.local.remove(key);974    return false;975  }976  return true;977}978979// ── Firefox content-script proxy path (v2.12.0) ────────────────────────980//981// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces982// navigator.credentials.create / .get on every Firefox page. It builds a983// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),984// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,985// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.986//987// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag988// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates989// to false and these handlers short-circuit. The full removal checklist is990// in that file — the deletion is purely subtractive.991992interface FirefoxAllowCredential {993  id: string;994  type?: string;995}996997interface FirefoxPubKeyCredParam {998  type?: string;999  alg: number;1000}10011002export interface FirefoxPasskeyHasCredentialsPayload {1003  rpId: string;1004  allowCredentials: FirefoxAllowCredential[];1005}10061007export interface FirefoxPasskeyCreatePayload {1008  rpId: string;1009  rpName?: string;1010  userId: string;1011  userName: string;1012  userDisplayName?: string;1013  challenge: string;1014  clientDataJSON: number[];1015  origin: string;1016  pubKeyCredParams?: FirefoxPubKeyCredParam[];1017  authenticatorSelection?: Record<string, unknown>;1018  timeout?: number;1019  attestation?: string;1020}10211022export interface FirefoxPasskeyGetPayload {1023  rpId: string;1024  challenge: string;1025  clientDataJSON: number[];1026  origin: string;1027  allowCredentials: FirefoxAllowCredential[];1028  userVerification?: string;1029}10301031interface FirefoxCreateCredentialResponse {1032  type: "create";1033  id: string;1034  clientDataJSON: number[];1035  attestationObject: string;1036  authenticatorData: string;1037  publicKey: string;1038  algorithm: -7 | -8;1039}10401041interface FirefoxGetCredentialResponse {1042  type: "get";1043  id: string;1044  clientDataJSON: number[];1045  authenticatorData: string;1046  signature: string;1047  userHandle: string;1048}10491050type FirefoxPasskeyResult<T> =1051  | { ok: true; credential: T }1052  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10531054function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1055  return vault.getEntries().filter(1056    (entry): entry is VaultEntry & { passkey: PasskeyData } =>1057      entry.passkey !== undefined && !entry.deletedAt1058  );1059}10601061/** Decode the `origin` field out of a WebAuthn clientDataJSON byte array. */1062function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1063  if (!Array.isArray(bytes)) return null;1064  try {1065    const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1066    return typeof obj.origin === "string" ? obj.origin : null;1067  } catch {1068    return null;1069  }1070}10711072/**1073 * Enforce the WebAuthn rpId<->origin relationship on the Firefox proxy path,1074 * where Peach is the authenticator. The ONLY trustworthy origin is the one the1075 * browser attested for the calling page (`callerOrigin`, derived from the1076 * message sender in messages.ts) — NEVER `payload.origin`, which the page1077 * controls and can set to match any rpId (SEV-1-A / audit CRITICAL). Also1078 * refuse to sign a clientDataJSON whose embedded origin disagrees with the1079 * attested origin, so a forged ceremony can't smuggle a foreign origin into1080 * the signed bytes the RP verifies.1081 */1082function assertOriginOwnsRpId(1083  rpId: string,1084  callerOrigin: string | undefined,1085  clientDataJSON?: number[],1086): void {1087  if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1088    throw new DOMException(1089      `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1090        `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1091      "SecurityError",1092    );1093  }1094  const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1095  if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1096    throw new DOMException(1097      `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1098        `attested calling origin "${callerOrigin}".`,1099      "SecurityError",1100    );1101  }1102}11031104export async function handlePasskeyHasCredentials(1105  payload: FirefoxPasskeyHasCredentialsPayload,1106  callerOrigin?: string,1107): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1108  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1109    return { ok: false, error: "not_applicable" };1110  }1111  // Scope the existence check to the caller's own origin so an arbitrary page1112  // cannot enumerate which sites the user holds passkeys for (audit MEDIUM).1113  // A false answer is indistinguishable from "no credential" to the page.1114  if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1115    return { ok: true, hasCredentials: false };1116  }1117  if (!vault.isUnlocked()) {1118    // Cannot enumerate without the vault key. The MAIN-world proxy treats a1119    // false answer as "fall through to the platform" — exactly the right1120    // outcome when Peach is locked: the user gets the native UI instead.1121    return { ok: true, hasCredentials: false };1122  }1123  const passkeys = listPasskeyEntries().filter(1124    (entry) => entry.passkey.rpId === payload.rpId1125  );1126  if (passkeys.length === 0) return { ok: true, hasCredentials: false };11271128  const allow = payload.allowCredentials ?? [];1129  if (allow.length === 0) return { ok: true, hasCredentials: true };11301131  const hasMatch = passkeys.some((entry) =>1132    allow.some((ac) => ac.id === entry.passkey.credentialId)1133  );1134  return { ok: true, hasCredentials: hasMatch };1135}11361137/**1138 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing1139 * pending-passkey channel (badge + popup + 60s timeout) — the same approval1140 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need1141 * any new code path. The page-side proxy never sees the request id; the1142 * background mints one and discards it after resolution.1143 */1144async function requestFirefoxApproval(args: {1145  type: "create" | "get";1146  rpId: string;1147  rpName?: string;1148  userName: string;1149  userDisplayName?: string;1150}): Promise<boolean> {1151  // Synthetic request id — the Firefox proxy path doesn't have a1152  // chrome.webAuthenticationProxy request to tie back to.1153  const requestId = randomRequestId();1154  return 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}11631164export async function handleFirefoxPasskeyCreate(1165  payload: FirefoxPasskeyCreatePayload,1166  callerOrigin?: string,1167): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1168  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1169    return { ok: false, error: "not_applicable" };1170  }1171  // WebAuthn §7.1 step 6 — the requested rpId must be owned by the page's1172  // browser-ATTESTED origin (threaded in as callerOrigin from the message1173  // sender), NOT the page-supplied payload.origin which an attacker controls.1174  // On the Firefox proxy path Peach is the authenticator and must enforce this1175  // before minting anything (audit CRITICAL — origin spoofing).1176  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1177  if (!(await isProUser())) {1178    broadcastPasskeyProGate();1179    return { ok: false, error: "pro_required" };1180  }1181  if (!vault.isUnlocked()) {1182    await openApprovalPopup();1183    return { ok: false, error: "vault_locked" };1184  }11851186  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1187  if (!algorithm) {1188    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1189  }11901191  const approved = await requestFirefoxApproval({1192    type: "create",1193    rpId: payload.rpId,1194    rpName: payload.rpName,1195    userName: payload.userName,1196    userDisplayName: payload.userDisplayName,1197  });1198  if (!approved) return { ok: false, error: "user_cancelled" };11991200  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1201  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);12021203  // 0x4d = UP|UV|BE|AT. Peach passkeys are synced, so BE is true on both1204  // registration paths and on later assertions.1205  const authenticatorData = await buildAuthenticatorData({1206    rpId: payload.rpId,1207    flags: 0x4d,1208    signCount: 0,1209    credentialId: credentialIdBytes,1210    publicKeyCose,1211  });1212  const attestationObject = createAttestationObject(authenticatorData);12131214  const passkey: PasskeyData = {1215    rpId: payload.rpId,1216    rpName: payload.rpName ?? payload.rpId,1217    credentialId,1218    publicKeyCose: base64urlEncode(publicKeyCose),1219    privateKeyJwk,1220    userHandle: payload.userId,1221    userName: payload.userName,1222    userDisplayName: payload.userDisplayName ?? payload.userName,1223    algorithm,1224    signCount: 0,1225    aaguid: base64urlEncode(new Uint8Array(16)),1226    backupEligible: true,1227    backupState: false,1228    browser: "firefox",1229    transports: ["internal"],1230    createdAt: Date.now(),1231  };12321233  const target = findAttachTarget(payload.rpId);1234  if (target) {1235    await vault.updateEntry(target.id, { passkey });1236  } else {1237    await vault.addEntry({1238      type: "login",1239      title: payload.rpName || payload.rpId,1240      url: `https://${payload.rpId}`,1241      username: payload.userName,1242      passkey,1243    });1244  }1245  await vault.consolidatePasskeyEntries();12461247  return {1248    ok: true,1249    credential: {1250      type: "create",1251      id: credentialId,1252      // The content script already encoded clientDataJSON with the page1253      // origin; round-trip the exact bytes back so the RP sees the same1254      // hash it computes locally.1255      clientDataJSON: payload.clientDataJSON,1256      attestationObject: base64urlEncode(attestationObject),1257      authenticatorData: base64urlEncode(authenticatorData),1258      publicKey: base64urlEncode(publicKeySpki),1259      algorithm,1260    },1261  };1262}12631264export async function handleFirefoxPasskeyGet(1265  payload: FirefoxPasskeyGetPayload,1266  callerOrigin?: string,1267): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1268  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1269    return { ok: false, error: "not_applicable" };1270  }1271  // WebAuthn §7.2 step 8 — the rpId must be owned by the page's browser-attested1272  // origin (callerOrigin from the message sender), NOT the page-supplied1273  // payload.origin. Both rpId and payload.origin are attacker-controlled, so1274  // validating one against the other is no protection; a cross-origin page must1275  // not be able to coax Peach into signing an assertion scoped to another site1276  // (audit CRITICAL — assertion forgery).1277  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1278  if (!(await isProUser())) {1279    broadcastPasskeyProGate();1280    return { ok: false, error: "pro_required" };1281  }1282  if (!vault.isUnlocked()) {1283    await openApprovalPopup();1284    return { ok: false, error: "vault_locked" };1285  }12861287  const allow = payload.allowCredentials ?? [];1288  const candidates = listPasskeyEntries().filter((entry) => {1289    if (entry.passkey.rpId !== payload.rpId) return false;1290    if (allow.length === 0) return true;1291    return allow.some((ac) => ac.id === entry.passkey.credentialId);1292  });12931294  if (candidates.length === 0) return { ok: false, error: "no_credentials" };12951296  // Most-recently-used wins when several credentials match. A future1297  // multi-account selector goes here; for now this matches the Chrome path's1298  // behavior for the common one-passkey-per-rp case.1299  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1300  const match = candidates[0]!;1301  const credential = match.passkey;13021303  if (!credential.privateKeyJwk) {1304    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1305    // — no Peach-held private key to sign with. The MAIN-world proxy treats1306    // no_credentials as a fall-through, which routes to the platform UI.1307    return { ok: false, error: "no_credentials" };1308  }13091310  const approved = await requestFirefoxApproval({1311    type: "get",1312    rpId: credential.rpId,1313    rpName: credential.rpName,1314    userName: credential.userName,1315    userDisplayName: credential.userDisplayName,1316  });1317  if (!approved) return { ok: false, error: "user_cancelled" };13181319  const nextSignCount = credential.signCount + 1;1320  // 0x05 = UP|UV. backup bits track whatever was set at registration time1321  // (consistent with the Chrome path).1322  const flags =1323    0x05 |1324    (credential.backupEligible ? 0x08 : 0) |1325    (credential.backupState ? 0x10 : 0);1326  const authenticatorData = await buildAuthenticatorData({1327    rpId: credential.rpId,1328    flags,1329    signCount: nextSignCount,1330  });13311332  // The content script supplied clientDataJSON bytes built with the page1333  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1334  // diverges from what the RP computes.1335  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1336  const clientDataHash = new Uint8Array(1337    await crypto.subtle.digest("SHA-256", clientDataBytes)1338  );1339  const signature = await signPasskeyAssertion(1340    credential.privateKeyJwk,1341    credential.algorithm,1342    authenticatorData,1343    clientDataHash1344  );13451346  await vault.updateEntry(match.id, {1347    passkey: {1348      ...credential,1349      signCount: nextSignCount,1350      lastUsedAt: Date.now(),1351    },1352  });13531354  return {1355    ok: true,1356    credential: {1357      type: "get",1358      id: credential.credentialId,1359      clientDataJSON: payload.clientDataJSON,1360      authenticatorData: base64urlEncode(authenticatorData),1361      signature: base64urlEncode(signature),1362      userHandle: credential.userHandle,1363    },1364  };1365}13661367export async function setupPasskeyProxy(): Promise<void> {1368  if (!isPasskeySupported()) {1369    return;1370  }13711372  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1373  // popup-context credential creator is the entry point; nothing to wire here.1374  if (detectSharedBrowser() === "firefox") {1375    await refreshPasskeyProxy();1376    return;1377  }13781379  if (!listenersRegistered) {1380    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1381      // Peach is a user-verifying platform authenticator whenever the proxy1382      // is attached. The lock state is irrelevant here — the create/get1383      // handlers prompt for unlock when needed. Returning false while locked1384      // makes Google (and any RP that checks isUvpaa as a precondition) show1385      // "A passkey can't be created on this device" before the user even1386      // gets a chance to start the ceremony.1387      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1388        requestId: requestInfo.requestId,1389        isUvpaa: true,1390      });1391    });1392    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1393      void handleCreateRequest(requestInfo).catch(async (error) => {1394        // "Invalid requestId" means Chrome already cancelled — handlers will1395        // short-circuit, but if it propagates out from a still-in-flight branch,1396        // don't double-complete.1397        if (isInvalidRequestIdError(error)) {1398          return;1399        }1400        console.error("Peach passkey create failed", error);1401        await rejectCreate(1402          requestInfo.requestId,1403          "UnknownError",1404          error instanceof Error ? error.message : "Passkey creation failed."1405        );1406      });1407    });1408    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1409      void handleGetRequest(requestInfo).catch(async (error) => {1410        if (isInvalidRequestIdError(error)) {1411          return;1412        }1413        console.error("Peach passkey get failed", error);1414        await rejectGet(1415          requestInfo.requestId,1416          "UnknownError",1417          error instanceof Error ? error.message : "Passkey authentication failed."1418        );1419      });1420    });1421    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1422      markCancelled(requestId);1423      // Release any unlock waiter parked for this request.1424      const waiter = unlockWaiters.get(requestId);1425      if (waiter) {1426        clearTimeout(waiter.timeoutId);1427        waiter.resolve(false);1428        unlockWaiters.delete(requestId);1429      }1430      void resolvePendingPasskeyRequest(requestId, false);1431    });1432    listenersRegistered = true;1433  }14341435  await refreshPasskeyProxy();1436}