passkey.ts

48.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> }>();4142export function onVaultUnlocked(): void {43  for (const { resolve, timeoutId } of unlockWaiters.values()) {44    clearTimeout(timeoutId);45    resolve(true);46  }47  unlockWaiters.clear();48}4950/**51 * Resolve every still-pending passkey request as denied and clear unlock52 * waiters. Called from the popup-disconnect path so a closed popup releases53 * the page-side caller in <200ms instead of leaving it stuck for the full54 * APPROVAL_TIMEOUT_MS (60s) (M8/COR-009).55 */56export async function denyAllPendingPasskeyRequests(): Promise<void> {57  if (approvals.size === 0 && unlockWaiters.size === 0) return;58  for (const [rid, { resolve, timeoutId }] of approvals.entries()) {59    clearTimeout(timeoutId);60    resolve(false);61    approvals.delete(rid);62    await clearPendingRequest(rid).catch(() => {});63  }64  for (const { resolve, timeoutId } of unlockWaiters.values()) {65    clearTimeout(timeoutId);66    resolve(false);67  }68  unlockWaiters.clear();69}7071function isPasskeySupported(): boolean {72  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;73  // Firefox 150+ exposes navigator.credentials.create/get from the popup74  // context with extension host permissions. Cannot intercept page-side75  // calls (no equivalent of webAuthenticationProxy), but registration via76  // Peach's own popup-driven flow works.77  if (detectSharedBrowser() === "firefox") {78    const ver = firefoxVersion();79    if (Number.isFinite(ver) && ver >= 150) return true;80  }81  return false;82}8384export const detectBrowser = detectSharedBrowser;8586function getBrowserLabel(): string {87  const ua = navigator.userAgent;88  if (/Firefox\/(\d+)/.test(ua)) {89    const version = ua.match(/Firefox\/(\d+)/)?.[1];90    return version ? `Firefox ${version}` : "Firefox";91  }92  const version = ua.match(/Chrome\/(\d+)/)?.[1];93  return version ? `Chrome ${version}` : "Chromium";94}9596function pendingStorageKey(requestId: number): string {97  return `${PENDING_PREFIX}${requestId}`;98}99100function randomRequestId(): number {101  // 53-bit crypto-random integer — unbrute-forceable, unlike the old102  // Date.now()+Math.random synthetic id (~10-20 bits). Fits a JS safe103  // integer so it round-trips through String/Number for the toast path.104  // (Audit 2026-08-20 #1/#10.)105  const u = crypto.getRandomValues(new Uint32Array(2));106  return u[0] * 0x200000 + (u[1] >>> 11);107}108109function originFromRpId(rpId: string): string {110  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)111    ? `http://${rpId}`112    : `https://${rpId}`;113}114115/**116 * The clientDataJSON sent back to the RP must contain the page's actual117 * origin — not a synthesized one. `chrome.webAuthenticationProxy` does not118 * surface the requesting origin in CreateRequest/GetRequest, so we read it119 * from the active tab and validate that its hostname is a registrable120 * suffix match for the rpId. WebAuthn requires this relationship anyway, so121 * a mismatch indicates either a stale active tab or a hostile environment;122 * we fall back to https://<rpId> in that case to avoid silently signing the123 * wrong origin.124 *125 * Without this, a passkey created on https://myaccount.google.com gets126 * clientDataJSON.origin = "https://google.com", and Google's RP rejects the127 * attestation with "Something went wrong" because the origin hash doesn't128 * match what its own JS captured at create time.129 */130async function resolvePageOrigin(rpId: string): Promise<string> {131  try {132    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>133      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)134    );135    const url = tabs[0]?.url;136    if (url) {137      const parsed = new URL(url);138      const host = parsed.hostname.toLowerCase();139      const rp = rpId.toLowerCase();140      if (host === rp || host.endsWith(`.${rp}`)) {141        return parsed.origin;142      }143    }144  } catch {145    // fall through to rpId-derived origin146  }147  return originFromRpId(rpId);148}149150async function setBadge(text: string): Promise<void> {151  if (!chrome.action?.setBadgeText) return;152  await chrome.action.setBadgeText({ text });153  if (text) {154    await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });155  }156}157158async function clearPendingRequest(requestId: number): Promise<void> {159  if (chrome.storage.session) {160    await chrome.storage.session.remove(pendingStorageKey(requestId));161  }162  await setBadge("");163}164165async function openApprovalPopup(): Promise<void> {166  try {167    await chrome.action.openPopup();168  } catch {169    await setBadge("PK");170  }171}172173/**174 * Some ceremonies — especially conditional-UI `navigator.credentials.get`175 * calls — get cancelled by Chrome before we finish processing. Calling176 * complete*Request on a cancelled requestId rejects with "Invalid177 * requestId". That is expected, not exceptional: swallow it. Re-throwing178 * would surface an uncaught promise rejection in the SW and cascade through179 * the catch wrappers in setupPasskeyProxy.180 */181function isInvalidRequestIdError(err: unknown): boolean {182  return err instanceof Error && /invalid requestid/i.test(err.message);183}184185/**186 * Chrome throws "Missing GetResponseDetails.responseJson" when187 * completeGetRequest is called with no responseJson and no error (the188 * pass-through signal). The throw is a Chrome API quirk — the request IS189 * routed to the platform authenticator — but if we let it propagate, the190 * outer catch calls rejectGet, which sends a second completeGetRequest with191 * an error that cancels the pass-through and shows "Something went wrong" on192 * the page. Swallow it exactly as we swallow "Invalid requestId".193 */194function isMissingResponseJsonError(err: unknown): boolean {195  return err instanceof Error && /missing getresponsedetails/i.test(err.message);196}197198async function rejectCreate(199  requestId: number,200  name: string,201  message = ""202): Promise<void> {203  try {204    await chrome.webAuthenticationProxy.completeCreateRequest({205      requestId,206      error: { name, message },207    });208  } catch (err) {209    if (isInvalidRequestIdError(err)) {210      return;211    }212    throw err;213  }214}215216async function rejectGet(217  requestId: number,218  name: string,219  message = ""220): Promise<void> {221  try {222    await chrome.webAuthenticationProxy.completeGetRequest({223      requestId,224      error: { name, message },225    });226  } catch (err) {227    if (isInvalidRequestIdError(err)) {228      return;229    }230    throw err;231  }232}233234export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {235  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts236  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;237  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));238  if (!key) return null;239  const pending = stored[key];240  return pending && typeof pending === "object"241    ? (pending as PendingPasskeyRequest)242    : null;243}244245export async function resolvePendingPasskeyRequest(246  requestId: number,247  approved: boolean248): Promise<void> {249  const pending = approvals.get(requestId);250  if (!pending) {251    // Either the request expired (Chrome) or this is a Firefox-driven252    // pending where the popup performs the navigator.credentials call253    // directly. Clear both possible session keys so getPendingPasskeyRequest()254    // doesn't surface stale state on the next popup open.255    await clearPendingRequest(requestId);256    if (chrome.storage.session) {257      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);258    }259    return;260  }261262  clearTimeout(pending.timeoutId);263  approvals.delete(requestId);264  await clearPendingRequest(requestId);265  pending.resolve(approved);266}267268async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {269  if (chrome.storage.session) {270    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });271  }272  await openApprovalPopup();273274  return new Promise<boolean>((resolve) => {275    const timeoutId = setTimeout(async () => {276      approvals.delete(request.requestId);277      await clearPendingRequest(request.requestId);278      resolve(false);279    }, APPROVAL_TIMEOUT_MS);280281    approvals.set(request.requestId, { resolve, timeoutId });282  });283}284285export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {286  const settings = await vault.ensureSettingsLoaded();287  const supported = isPasskeySupported();288  const enabled = settings.passkeyProviderEnabled === true;289  return {290    supported,291    enabled,292    attached: supported && attached,293    browserLabel: getBrowserLabel(),294    statusLabel: !supported295      ? "Interception unavailable"296      : attached297        ? "Active"298        : conflict299          ? "Another provider is active"300          : enabled301            ? "Inactive"302            : "Disabled",303    conflict,304  };305}306307async function attachPasskeyProxy(): Promise<void> {308  if (!isPasskeySupported()) {309    attached = false;310    conflict = "This browser does not expose chrome.webAuthenticationProxy.";311    return;312  }313314  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context315  // navigator.credentials creator is always ready once the user enables the316  // provider. Treat enabled = attached for state reporting purposes.317  if (detectSharedBrowser() === "firefox") {318    attached = true;319    conflict = undefined;320    return;321  }322323  if (attached) return;324325  try {326    await chrome.webAuthenticationProxy.attach();327    attached = true;328    conflict = undefined;329  } catch (error) {330    attached = false;331    conflict = error instanceof Error ? error.message : "Another provider is active.";332    console.warn("[Peach passkey] proxy attach failed", { conflict });333  }334}335336async function detachPasskeyProxy(): Promise<void> {337  if (!isPasskeySupported()) {338    attached = false;339    return;340  }341342  if (detectSharedBrowser() === "firefox") {343    attached = false;344    conflict = undefined;345    return;346  }347348  try {349    await chrome.webAuthenticationProxy.detach();350  } catch {351    // Ignore detach failures; the extension may already be detached.352  }353354  attached = false;355  conflict = undefined;356}357358export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {359  await vault.updateSettings({ passkeyProviderEnabled: enabled });360  if (enabled) {361    await attachPasskeyProxy();362  } else {363    await detachPasskeyProxy();364  }365  return getPasskeyProviderState();366}367368async function refreshPasskeyProxy(): Promise<void> {369  const settings = await vault.ensureSettingsLoaded();370  if (settings.passkeyProviderEnabled === true) {371    await attachPasskeyProxy();372    return;373  }374  await detachPasskeyProxy();375}376377/**378 * Find the entry that already holds a passkey for the given RP, or the most379 * recently-modified login entry whose URL/additionalUrls match the RP — that380 * entry is the natural attach target when the user adds a passkey to a site381 * they already have a password for. Returns null when there's no candidate;382 * the caller creates a new entry in that case.383 */384function findAttachTarget(rpId: string): VaultEntry | null {385  const all = vault.getEntries();386  const passkeyMatch = all.find(387    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt388  );389  if (passkeyMatch) return passkeyMatch;390  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);391  if (loginMatches.length === 0) return null;392  // Pick the most recently modified candidate to be deterministic.393  return loginMatches.reduce((best, e) =>394    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best395  );396}397398async function handleCreateRequest(399  request: chrome.webAuthenticationProxy.CreateRequest400): Promise<void> {401  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;402  const rpId = options.rp.id;403  const rpName = options.rp.name ?? options.rp.id;404  const userName = options.user.name;405  const userDisplayName = options.user.displayName ?? options.user.name;406  if (!rpId) {407    console.warn("[Peach passkey] reject: missing rp.id");408    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");409    return;410  }411412  // Capture the page origin NOW — before any popup opens. Once the extension413  // action popup fires, it can steal lastFocusedWindow so the tabs query414  // returns no results and falls back to https://<rpId>. For sites where the415  // page origin is a subdomain of the rpId (e.g. myaccount.google.com vs416  // rpId google.com) that fallback produces the wrong origin and the RP417  // rejects the attestation with "Something went wrong".418  const pageOrigin = await resolvePageOrigin(rpId);419420  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {421    console.warn("[Peach passkey] reject: user is not Pro");422    await rejectCreate(423      request.requestId,424      "NotAllowedError",425      "Passkey creation requires Peach Pro."426    );427    // Show the inline passkey gate so the user knows what happened and can upgrade.428    broadcastPasskeyProGate();429    return;430  }431432  if (!vault.isUnlocked()) {433    // Same wait-for-unlock pattern handleGetRequest uses: summon the inline434    // unlock prompt on the active tab so the user can unlock without leaving435    // the page, then hold the create request open until the vault unlocks or436    // the timeout fires. Immediately rejecting here means Google shows437    // "Something went wrong" the instant the user picks Peach.438    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>439      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)440    );441    const activeTab = tabs[0];442    if (activeTab?.id) {443      chrome.tabs.sendMessage(activeTab.id, {444        type: 'SUMMON_AUTOFILL',445        source: 'passkey-unlock',446      }).catch((err) => {447        console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);448        void openApprovalPopup();449      });450    } else {451      await openApprovalPopup();452    }453454    const unlocked = await new Promise<boolean>((resolve) => {455      const timeoutId = setTimeout(() => {456        unlockWaiters.delete(request.requestId);457        resolve(false);458      }, APPROVAL_TIMEOUT_MS);459      unlockWaiters.set(request.requestId, { resolve, timeoutId });460    });461462    if (!unlocked || !vault.isUnlocked()) {463      console.warn("[Peach passkey] reject: unlock timeout");464      await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");465      return;466    }467    // Vault is now unlocked — fall through to approval.468  }469470  const approved = await promptUserApproval({471    requestId: request.requestId,472    type: "create",473    rpId,474    rpName,475    userName,476    userDisplayName,477  });478  if (!approved) {479    console.warn("[Peach passkey] reject: user denied");480    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");481    return;482  }483484  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);485  if (!algorithm) {486    console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);487    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");488    return;489  }490  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));491  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(492    algorithm493  );494  const authData = await buildAuthenticatorData({495    rpId,496    flags: 0x4d,497    signCount: 0,498    credentialId: credentialIdBytes,499    publicKeyCose,500  });501  const attestationObject = createAttestationObject(authData);502  const clientDataJSON = buildClientDataJSON(503    "webauthn.create",504    options.challenge,505    pageOrigin506  );507508  const passkey: PasskeyData = {509    rpId,510    rpName,511    credentialId: base64urlEncode(credentialIdBytes),512    publicKeyCose: base64urlEncode(publicKeyCose),513    privateKeyJwk,514    userHandle: options.user.id,515    userName,516    userDisplayName,517    algorithm,518    signCount: 0,519    aaguid: base64urlEncode(new Uint8Array(16)),520    backupEligible: true,521    backupState: false,522    browser: "chrome",523    transports: ["internal"],524    createdAt: Date.now(),525  };526527  // Attach to an existing login (same domain) when one exists; otherwise528  // create a fresh login entry that exists solely to hold the passkey.529  const target = findAttachTarget(rpId);530  if (target) {531    await vault.updateEntry(target.id, { passkey });532  } else {533    await vault.addEntry({534      type: "login",535      title: rpName,536      url: `https://${rpId}`,537      username: userName,538      passkey,539    });540  }541  // Merge any same-domain TOTP-only entries into the passkey entry right now542  // so the user sees one consolidated entry rather than waiting until next543  // unlock for the decodePayload merge pass to run.544  await vault.consolidatePasskeyEntries();545546  try {547    await chrome.webAuthenticationProxy.completeCreateRequest({548      requestId: request.requestId,549      responseJson: JSON.stringify({550        id: passkey.credentialId,551        rawId: passkey.credentialId,552        type: "public-key",553        response: {554          clientDataJSON: base64urlEncode(clientDataJSON),555          authenticatorData: base64urlEncode(authData),556          attestationObject: base64urlEncode(attestationObject),557          transports: ["internal"],558          publicKey: base64urlEncode(publicKeySpki),559          publicKeyAlgorithm: algorithm,560        },561        authenticatorAttachment: "platform",562        clientExtensionResults: {},563      }),564    });565  } catch (err) {566    if (isInvalidRequestIdError(err)) {567      return;568    }569    throw err;570  }571}572573function broadcastPasskeyProGate(): void {574  chrome.tabs.query({}, (tabs) => {575    for (const tab of tabs) {576      if (tab.id !== undefined) {577        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});578      }579    }580  });581}582583async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {584  if (chrome.storage.session) {585    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });586  }587588  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {589    chrome.tabs.query({ active: true, currentWindow: true }, resolve);590  });591  const tab = tabs[0];592593  if (tab?.id) {594    chrome.tabs.sendMessage(tab.id, {595      type: 'SHOW_PASSKEY_TOAST',596      payload: {597        toastToken: crypto.randomUUID(),598        site: request.rpId,599        displayName: request.userDisplayName ?? request.userName,600        username: request.userName,601        requestId: String(request.requestId),602      },603    }).catch(() => {});604  } else {605    await openApprovalPopup();606  }607608  return new Promise<boolean>((resolve) => {609    const timeoutId = setTimeout(async () => {610      approvals.delete(request.requestId);611      await clearPendingRequest(request.requestId);612      resolve(false);613    }, APPROVAL_TIMEOUT_MS);614615    approvals.set(request.requestId, { resolve, timeoutId });616  });617}618619async function handleGetRequest(620  request: chrome.webAuthenticationProxy.GetRequest621): Promise<void> {622  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;623  if (!vault.isUnlocked()) {624    // Summon the inline unlock prompt on the active tab so the user can unlock625    // without leaving the page. Fall back to the popup if the content script626    // is unavailable (e.g. restricted URL, script not yet injected).627    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>628      chrome.tabs.query({ active: true, currentWindow: true }, resolve)629    );630    const activeTab = tabs[0];631    if (activeTab?.id) {632      chrome.tabs.sendMessage(activeTab.id, {633        type: 'SUMMON_AUTOFILL',634        source: 'passkey-unlock',635      }).catch(() => { void openApprovalPopup(); });636    } else {637      await openApprovalPopup();638    }639640    // Hold the request open until the vault unlocks or the timeout fires.641    const unlocked = await new Promise<boolean>((resolve) => {642      const timeoutId = setTimeout(() => {643        unlockWaiters.delete(request.requestId);644        resolve(false);645      }, APPROVAL_TIMEOUT_MS);646      unlockWaiters.set(request.requestId, { resolve, timeoutId });647    });648649    if (!unlocked || !vault.isUnlocked()) {650      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");651      return;652    }653    // Vault is now unlocked — fall through to authenticate.654  }655656  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {657    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");658    broadcastPasskeyProGate();659    return;660  }661662  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>663    entry.passkey !== undefined && !entry.deletedAt664  );665  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);666667  const match = passkeyBearing.find((entry) => {668    // PB-26-015: a credential-ID match must still belong to the requesting669    // RP — the assertion later signs with the stored rpId, so a foreign-RP670    // request must never select a credential from a different rpId.671    if (options.rpId && entry.passkey.rpId !== options.rpId) return false;672    if (allowedCredentialIds.length > 0) {673      return allowedCredentialIds.includes(entry.passkey.credentialId);674    }675    if (!options.rpId) return false;676    return entry.passkey.rpId === options.rpId;677  });678679  if (!match) {680    // No Peach passkey for this RP — pass through to the platform authenticator.681    // completeGetRequest with no error and no responseJson is the webAuthenticationProxy682    // signal for "I'm not handling this"; Chrome routes the request to the next683    // authenticator in the chain (the built-in browser passkey manager etc.).684    // Rejecting with NotAllowedError would propagate as an error to the page and685    // break RPs (e.g. Google) that issue a GET as part of their create-passkey flow.686    try {687      await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });688    } catch (err) {689      if (isInvalidRequestIdError(err)) return;690      if (isMissingResponseJsonError(err)) return;691      throw err;692    }693    return;694  }695696  const credential = match.passkey;697  // Chrome's webAuthenticationProxy path requires Peach-held private key material.698  // Passkeys created on Firefox have no privateKeyJwk — pass through so Chrome699  // can route to a capable authenticator instead of surfacing an error to the page.700  if (!credential.privateKeyJwk) {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 tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {712    chrome.tabs.query({ active: true, currentWindow: true }, resolve);713  });714  const approvalBinding = {715    tabId: tabs[0]?.id,716    origin: await resolvePageOrigin(credential.rpId),717  };718  const approved = await promptViaToast({719    requestId: request.requestId,720    type: "get",721    rpId: credential.rpId,722    rpName: credential.rpName,723    userName: credential.userName,724    userDisplayName: credential.userDisplayName,725    ...approvalBinding,726  });727  if (!approved) {728    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");729    return;730  }731732  const nextSignCount = credential.signCount + 1;733  // Chrome passkeys have always been registered by Peach with BE=1 (0x4d),734  // but versions before v5.0.87 incorrectly persisted backupEligible=false.735  // WebAuthn requires BE to match registration, so repair that legacy metadata736  // while constructing the assertion GitHub and other strict RPs verify.737  const backupEligible = credential.browser === "chrome" ? true : credential.backupEligible;738  const flags = 0x05 | (backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);739  const authData = await buildAuthenticatorData({740    rpId: credential.rpId,741    flags,742    signCount: nextSignCount,743  });744  const clientDataJSON = buildClientDataJSON(745    "webauthn.get",746    options.challenge,747    await resolvePageOrigin(credential.rpId)748  );749  const clientDataHash = new Uint8Array(750    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))751  );752  const signature = await signPasskeyAssertion(753    credential.privateKeyJwk,754    credential.algorithm,755    authData,756    clientDataHash757  );758759  await vault.updateEntry(match.id, {760    passkey: {761      ...credential,762      backupEligible,763      signCount: nextSignCount,764      lastUsedAt: Date.now(),765    },766  });767768  const responseJson = JSON.stringify({769    id: credential.credentialId,770    rawId: credential.credentialId,771    type: "public-key",772    response: {773      clientDataJSON: base64urlEncode(clientDataJSON),774      authenticatorData: base64urlEncode(authData),775      signature: base64urlEncode(signature),776      userHandle: credential.userHandle,777    },778    authenticatorAttachment: "platform",779    clientExtensionResults: {},780  });781782  try {783    await chrome.webAuthenticationProxy.completeGetRequest({784      requestId: request.requestId,785      responseJson,786    });787  } catch (err) {788    if (isInvalidRequestIdError(err)) {789      return;790    }791    throw err;792  }793}794795// ── Firefox path ────────────────────────────────────────────────────────796//797// Firefox 150+ permits extensions to call navigator.credentials.create/get798// from the popup context with their host-permitted RP IDs. Peach uses that799// surface to register passkeys on behalf of the user. The page's own800// navigator.credentials call is observed via the MAIN-world detector801// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's802// flow is parallel and explicit (the user must engage with Peach's button).803804const FIREFOX_PENDING_KEY = "passkey_pending_firefox";805const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";806const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days807808export async function initiateFirefoxPasskeyCreate(args: {809  rpId: string;810  rpName: string;811  userName: string;812  userDisplayName?: string;813}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {814  // The rpId must belong to the calling page's registrable domain; without815  // this, a page could initiate a ceremony for a foreign site and the816  // approval popup would display an attacker-chosen rpId. Origin is the817  // browser-attested sender origin from messages.ts, never page payload.818  // (Codex Security 2026-08-04, passkey-ceremony-approval sibling.)819  if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {820    return { ok: false, error: "rpid_origin_mismatch" };821  }822  if (!(await isProUser())) {823    broadcastPasskeyProGate();824    return { ok: false, error: "passkey creation requires Peach Pro" };825  }826  if (!vault.isUnlocked()) {827    await openApprovalPopup();828    return { ok: false, error: "vault_locked" };829  }830  if (!chrome.storage.session) {831    return { ok: false, error: "session storage unavailable" };832  }833834  const requestId = randomRequestId();835  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));836  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));837838  const pending: PendingPasskeyRequest = {839    requestId,840    type: "create",841    rpId: args.rpId,842    rpName: args.rpName,843    userName: args.userName,844    userDisplayName: args.userDisplayName,845    browser: "firefox",846    challenge,847    userHandle,848  };849850  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });851  await openApprovalPopup();852  return { ok: true };853}854855export async function completeFirefoxPasskeyCreate(payload: {856  requestId: number;857  rpId: string;858  rpName: string;859  userName: string;860  userDisplayName?: string;861  userHandle: string;862  credentialId: string;863  publicKeyCose: string;864  algorithm: -7 | -8;865  aaguid?: string;866  transports?: string[];867  backupEligible?: boolean;868  backupState?: boolean;869}): Promise<{ ok: boolean; error?: string }> {870  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };871872  // Bind completion to the pending ceremony: requestId, rpId, challenge, and873  // userHandle are minted by the background at INITIATE time and only ever874  // leave extension context through the approval popup, so a page (or anything875  // replaying relayed messages) cannot synthesize a matching completion for an876  // rpId it does not own. (Codex Security 2026-08-04 MEDIUM,877  // broken-access-control.passkey-ceremony-approval.)878  const pending = chrome.storage.session879    ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as880        | PendingPasskeyRequest881        | undefined)882    : undefined;883  if (884    !pending ||885    pending.requestId !== payload.requestId ||886    pending.rpId !== payload.rpId ||887    pending.userHandle !== payload.userHandle888  ) {889    return { ok: false, error: "no_pending_request" };890  }891892  const passkey: PasskeyData = {893    rpId: payload.rpId,894    rpName: payload.rpName,895    credentialId: payload.credentialId,896    publicKeyCose: payload.publicKeyCose,897    userHandle: payload.userHandle,898    userName: payload.userName,899    userDisplayName: payload.userDisplayName ?? payload.userName,900    algorithm: payload.algorithm,901    signCount: 0,902    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),903    backupEligible: payload.backupEligible ?? false,904    backupState: payload.backupState ?? false,905    browser: "firefox",906    transports: payload.transports ?? ["internal"],907    createdAt: Date.now(),908  };909910  // Same attach-or-create flow as the Chrome path: prefer attaching to an911  // existing login entry for the same domain so the user ends up with one912  // entry per site, not two (a login + a passkey-only).913  const target = findAttachTarget(payload.rpId);914  if (target) {915    await vault.updateEntry(target.id, { passkey });916  } else {917    await vault.addEntry({918      type: "login",919      title: payload.rpName || payload.rpId,920      url: `https://${payload.rpId}`,921      username: payload.userName,922      passkey,923    });924  }925  await vault.consolidatePasskeyEntries();926927  if (chrome.storage.session) {928    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);929  }930  return { ok: true };931}932933const MAX_DECLINE_DOMAIN_LENGTH = 253;934935function normaliseDeclineDomain(domain: string): string | null {936  if (!domain || domain.length > MAX_DECLINE_DOMAIN_LENGTH) return null;937  try {938    const url = new URL(`https://${domain}`);939    return isValidRpId(url.hostname, url.origin) ? url.hostname : null;940  } catch {941    return null;942  }943}944945function declineKey(domain: string): string {946  return `${FIREFOX_DECLINE_PREFIX}${domain}`;947}948949export async function recordPasskeyOfferDecline(domain: string): Promise<void> {950  const validDomain = normaliseDeclineDomain(domain);951  if (!validDomain) return;952  await chrome.storage.local.set({ [declineKey(validDomain)]: Date.now() });953}954955export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {956  const validDomain = normaliseDeclineDomain(domain);957  if (!validDomain) return false;958  const key = declineKey(validDomain);959960  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;961  const at = stored[key];962  if (typeof at !== "number") return false;963  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {964    await chrome.storage.local.remove(key);965    return false;966  }967  return true;968}969970// ── Firefox content-script proxy path (v2.12.0) ────────────────────────971//972// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces973// navigator.credentials.create / .get on every Firefox page. It builds a974// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),975// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,976// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.977//978// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag979// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates980// to false and these handlers short-circuit. The full removal checklist is981// in that file — the deletion is purely subtractive.982983interface FirefoxAllowCredential {984  id: string;985  type?: string;986}987988interface FirefoxPubKeyCredParam {989  type?: string;990  alg: number;991}992993export interface FirefoxPasskeyHasCredentialsPayload {994  rpId: string;995  allowCredentials: FirefoxAllowCredential[];996}997998export interface FirefoxPasskeyCreatePayload {999  rpId: string;1000  rpName?: string;1001  userId: string;1002  userName: string;1003  userDisplayName?: string;1004  challenge: string;1005  clientDataJSON: number[];1006  origin: string;1007  pubKeyCredParams?: FirefoxPubKeyCredParam[];1008  authenticatorSelection?: Record<string, unknown>;1009  timeout?: number;1010  attestation?: string;1011}10121013export interface FirefoxPasskeyGetPayload {1014  rpId: string;1015  challenge: string;1016  clientDataJSON: number[];1017  origin: string;1018  allowCredentials: FirefoxAllowCredential[];1019  userVerification?: string;1020}10211022interface FirefoxCreateCredentialResponse {1023  type: "create";1024  id: string;1025  clientDataJSON: number[];1026  attestationObject: string;1027  authenticatorData: string;1028  publicKey: string;1029  algorithm: -7 | -8;1030}10311032interface FirefoxGetCredentialResponse {1033  type: "get";1034  id: string;1035  clientDataJSON: number[];1036  authenticatorData: string;1037  signature: string;1038  userHandle: string;1039}10401041type FirefoxPasskeyResult<T> =1042  | { ok: true; credential: T }1043  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10441045function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1046  return vault.getEntries().filter(1047    (entry): entry is VaultEntry & { passkey: PasskeyData } =>1048      entry.passkey !== undefined && !entry.deletedAt1049  );1050}10511052/** Decode the `origin` field out of a WebAuthn clientDataJSON byte array. */1053function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1054  if (!Array.isArray(bytes)) return null;1055  try {1056    const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1057    return typeof obj.origin === "string" ? obj.origin : null;1058  } catch {1059    return null;1060  }1061}10621063/**1064 * Enforce the WebAuthn rpId<->origin relationship on the Firefox proxy path,1065 * where Peach is the authenticator. The ONLY trustworthy origin is the one the1066 * browser attested for the calling page (`callerOrigin`, derived from the1067 * message sender in messages.ts) — NEVER `payload.origin`, which the page1068 * controls and can set to match any rpId (SEV-1-A / audit CRITICAL). Also1069 * refuse to sign a clientDataJSON whose embedded origin disagrees with the1070 * attested origin, so a forged ceremony can't smuggle a foreign origin into1071 * the signed bytes the RP verifies.1072 */1073function assertOriginOwnsRpId(1074  rpId: string,1075  callerOrigin: string | undefined,1076  clientDataJSON?: number[],1077): void {1078  if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1079    throw new DOMException(1080      `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1081        `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1082      "SecurityError",1083    );1084  }1085  const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1086  if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1087    throw new DOMException(1088      `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1089        `attested calling origin "${callerOrigin}".`,1090      "SecurityError",1091    );1092  }1093}10941095export async function handlePasskeyHasCredentials(1096  payload: FirefoxPasskeyHasCredentialsPayload,1097  callerOrigin?: string,1098): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1099  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1100    return { ok: false, error: "not_applicable" };1101  }1102  // Scope the existence check to the caller's own origin so an arbitrary page1103  // cannot enumerate which sites the user holds passkeys for (audit MEDIUM).1104  // A false answer is indistinguishable from "no credential" to the page.1105  if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1106    return { ok: true, hasCredentials: false };1107  }1108  if (!vault.isUnlocked()) {1109    // Cannot enumerate without the vault key. The MAIN-world proxy treats a1110    // false answer as "fall through to the platform" — exactly the right1111    // outcome when Peach is locked: the user gets the native UI instead.1112    return { ok: true, hasCredentials: false };1113  }1114  const passkeys = listPasskeyEntries().filter(1115    (entry) => entry.passkey.rpId === payload.rpId1116  );1117  if (passkeys.length === 0) return { ok: true, hasCredentials: false };11181119  const allow = payload.allowCredentials ?? [];1120  if (allow.length === 0) return { ok: true, hasCredentials: true };11211122  const hasMatch = passkeys.some((entry) =>1123    allow.some((ac) => ac.id === entry.passkey.credentialId)1124  );1125  return { ok: true, hasCredentials: hasMatch };1126}11271128/**1129 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing1130 * pending-passkey channel (badge + popup + 60s timeout) — the same approval1131 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need1132 * any new code path. The page-side proxy never sees the request id; the1133 * background mints one and discards it after resolution.1134 */1135async function requestFirefoxApproval(args: {1136  type: "create" | "get";1137  rpId: string;1138  rpName?: string;1139  userName: string;1140  userDisplayName?: string;1141}): Promise<boolean> {1142  // Synthetic request id — the Firefox proxy path doesn't have a1143  // chrome.webAuthenticationProxy request to tie back to.1144  const requestId = randomRequestId();1145  return promptUserApproval({1146    requestId,1147    type: args.type,1148    rpId: args.rpId,1149    rpName: args.rpName ?? args.rpId,1150    userName: args.userName,1151    userDisplayName: args.userDisplayName,1152  });1153}11541155export async function handleFirefoxPasskeyCreate(1156  payload: FirefoxPasskeyCreatePayload,1157  callerOrigin?: string,1158): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1159  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1160    return { ok: false, error: "not_applicable" };1161  }1162  // WebAuthn §7.1 step 6 — the requested rpId must be owned by the page's1163  // browser-ATTESTED origin (threaded in as callerOrigin from the message1164  // sender), NOT the page-supplied payload.origin which an attacker controls.1165  // On the Firefox proxy path Peach is the authenticator and must enforce this1166  // before minting anything (audit CRITICAL — origin spoofing).1167  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1168  if (!(await isProUser())) {1169    broadcastPasskeyProGate();1170    return { ok: false, error: "pro_required" };1171  }1172  if (!vault.isUnlocked()) {1173    await openApprovalPopup();1174    return { ok: false, error: "vault_locked" };1175  }11761177  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1178  if (!algorithm) {1179    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1180  }11811182  const approved = await requestFirefoxApproval({1183    type: "create",1184    rpId: payload.rpId,1185    rpName: payload.rpName,1186    userName: payload.userName,1187    userDisplayName: payload.userDisplayName,1188  });1189  if (!approved) return { ok: false, error: "user_cancelled" };11901191  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1192  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);11931194  // 0x4d = UP|UV|BE|AT. Peach passkeys are synced, so BE is true on both1195  // registration paths and on later assertions.1196  const authenticatorData = await buildAuthenticatorData({1197    rpId: payload.rpId,1198    flags: 0x4d,1199    signCount: 0,1200    credentialId: credentialIdBytes,1201    publicKeyCose,1202  });1203  const attestationObject = createAttestationObject(authenticatorData);12041205  const passkey: PasskeyData = {1206    rpId: payload.rpId,1207    rpName: payload.rpName ?? payload.rpId,1208    credentialId,1209    publicKeyCose: base64urlEncode(publicKeyCose),1210    privateKeyJwk,1211    userHandle: payload.userId,1212    userName: payload.userName,1213    userDisplayName: payload.userDisplayName ?? payload.userName,1214    algorithm,1215    signCount: 0,1216    aaguid: base64urlEncode(new Uint8Array(16)),1217    backupEligible: true,1218    backupState: false,1219    browser: "firefox",1220    transports: ["internal"],1221    createdAt: Date.now(),1222  };12231224  const target = findAttachTarget(payload.rpId);1225  if (target) {1226    await vault.updateEntry(target.id, { passkey });1227  } else {1228    await vault.addEntry({1229      type: "login",1230      title: payload.rpName || payload.rpId,1231      url: `https://${payload.rpId}`,1232      username: payload.userName,1233      passkey,1234    });1235  }1236  await vault.consolidatePasskeyEntries();12371238  return {1239    ok: true,1240    credential: {1241      type: "create",1242      id: credentialId,1243      // The content script already encoded clientDataJSON with the page1244      // origin; round-trip the exact bytes back so the RP sees the same1245      // hash it computes locally.1246      clientDataJSON: payload.clientDataJSON,1247      attestationObject: base64urlEncode(attestationObject),1248      authenticatorData: base64urlEncode(authenticatorData),1249      publicKey: base64urlEncode(publicKeySpki),1250      algorithm,1251    },1252  };1253}12541255export async function handleFirefoxPasskeyGet(1256  payload: FirefoxPasskeyGetPayload,1257  callerOrigin?: string,1258): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1259  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1260    return { ok: false, error: "not_applicable" };1261  }1262  // WebAuthn §7.2 step 8 — the rpId must be owned by the page's browser-attested1263  // origin (callerOrigin from the message sender), NOT the page-supplied1264  // payload.origin. Both rpId and payload.origin are attacker-controlled, so1265  // validating one against the other is no protection; a cross-origin page must1266  // not be able to coax Peach into signing an assertion scoped to another site1267  // (audit CRITICAL — assertion forgery).1268  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1269  if (!(await isProUser())) {1270    broadcastPasskeyProGate();1271    return { ok: false, error: "pro_required" };1272  }1273  if (!vault.isUnlocked()) {1274    await openApprovalPopup();1275    return { ok: false, error: "vault_locked" };1276  }12771278  const allow = payload.allowCredentials ?? [];1279  const candidates = listPasskeyEntries().filter((entry) => {1280    if (entry.passkey.rpId !== payload.rpId) return false;1281    if (allow.length === 0) return true;1282    return allow.some((ac) => ac.id === entry.passkey.credentialId);1283  });12841285  if (candidates.length === 0) return { ok: false, error: "no_credentials" };12861287  // Most-recently-used wins when several credentials match. A future1288  // multi-account selector goes here; for now this matches the Chrome path's1289  // behavior for the common one-passkey-per-rp case.1290  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1291  const match = candidates[0]!;1292  const credential = match.passkey;12931294  if (!credential.privateKeyJwk) {1295    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1296    // — no Peach-held private key to sign with. The MAIN-world proxy treats1297    // no_credentials as a fall-through, which routes to the platform UI.1298    return { ok: false, error: "no_credentials" };1299  }13001301  const approved = await requestFirefoxApproval({1302    type: "get",1303    rpId: credential.rpId,1304    rpName: credential.rpName,1305    userName: credential.userName,1306    userDisplayName: credential.userDisplayName,1307  });1308  if (!approved) return { ok: false, error: "user_cancelled" };13091310  const nextSignCount = credential.signCount + 1;1311  // 0x05 = UP|UV. backup bits track whatever was set at registration time1312  // (consistent with the Chrome path).1313  const flags =1314    0x05 |1315    (credential.backupEligible ? 0x08 : 0) |1316    (credential.backupState ? 0x10 : 0);1317  const authenticatorData = await buildAuthenticatorData({1318    rpId: credential.rpId,1319    flags,1320    signCount: nextSignCount,1321  });13221323  // The content script supplied clientDataJSON bytes built with the page1324  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1325  // diverges from what the RP computes.1326  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1327  const clientDataHash = new Uint8Array(1328    await crypto.subtle.digest("SHA-256", clientDataBytes)1329  );1330  const signature = await signPasskeyAssertion(1331    credential.privateKeyJwk,1332    credential.algorithm,1333    authenticatorData,1334    clientDataHash1335  );13361337  await vault.updateEntry(match.id, {1338    passkey: {1339      ...credential,1340      signCount: nextSignCount,1341      lastUsedAt: Date.now(),1342    },1343  });13441345  return {1346    ok: true,1347    credential: {1348      type: "get",1349      id: credential.credentialId,1350      clientDataJSON: payload.clientDataJSON,1351      authenticatorData: base64urlEncode(authenticatorData),1352      signature: base64urlEncode(signature),1353      userHandle: credential.userHandle,1354    },1355  };1356}13571358export async function setupPasskeyProxy(): Promise<void> {1359  if (!isPasskeySupported()) {1360    return;1361  }13621363  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1364  // popup-context credential creator is the entry point; nothing to wire here.1365  if (detectSharedBrowser() === "firefox") {1366    await refreshPasskeyProxy();1367    return;1368  }13691370  if (!listenersRegistered) {1371    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1372      // Peach is a user-verifying platform authenticator whenever the proxy1373      // is attached. The lock state is irrelevant here — the create/get1374      // handlers prompt for unlock when needed. Returning false while locked1375      // makes Google (and any RP that checks isUvpaa as a precondition) show1376      // "A passkey can't be created on this device" before the user even1377      // gets a chance to start the ceremony.1378      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1379        requestId: requestInfo.requestId,1380        isUvpaa: true,1381      });1382    });1383    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1384      void handleCreateRequest(requestInfo).catch(async (error) => {1385        // "Invalid requestId" means Chrome already cancelled — handlers will1386        // short-circuit, but if it propagates out from a still-in-flight branch,1387        // don't double-complete.1388        if (isInvalidRequestIdError(error)) {1389          return;1390        }1391        console.error("Peach passkey create failed", error);1392        await rejectCreate(1393          requestInfo.requestId,1394          "UnknownError",1395          error instanceof Error ? error.message : "Passkey creation failed."1396        );1397      });1398    });1399    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1400      void handleGetRequest(requestInfo).catch(async (error) => {1401        if (isInvalidRequestIdError(error)) {1402          return;1403        }1404        console.error("Peach passkey get failed", error);1405        await rejectGet(1406          requestInfo.requestId,1407          "UnknownError",1408          error instanceof Error ? error.message : "Passkey authentication failed."1409        );1410      });1411    });1412    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1413      // Release any unlock waiter parked for this request.1414      const waiter = unlockWaiters.get(requestId);1415      if (waiter) {1416        clearTimeout(waiter.timeoutId);1417        waiter.resolve(false);1418        unlockWaiters.delete(requestId);1419      }1420      void resolvePendingPasskeyRequest(requestId, false);1421    });1422    listenersRegistered = true;1423  }14241425  await refreshPasskeyProxy();1426}