passkey.ts

47.7 KB · raw

1import { isProUser } from "./license";2import * as vault from "./vault";3import {4  base64urlEncode,5  buildAuthenticatorData,6  buildClientDataJSON,7  createAttestationObject,8  generateCredentialId,9  generatePasskeyKeypair,10  pickPasskeyAlgorithm,11  signPasskeyAssertion,12} from "./passkey-crypto";13import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";14import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";15import { isValidRpId } from "../shared/passkey-utils";16import type {17  PasskeyData,18  PasskeyProviderState,19  PendingPasskeyRequest,20  VaultEntry,21} from "../shared/types";2223declare const __PEACH_E2E_PASSKEYS__: boolean;2425const PENDING_PREFIX = "passkey_pending_";26const APPROVAL_TIMEOUT_MS = 60_000;2728let listenersRegistered = false;29let attached = false;30let conflict: string | undefined;31const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;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  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);734  const authData = await buildAuthenticatorData({735    rpId: credential.rpId,736    flags,737    signCount: nextSignCount,738  });739  const clientDataJSON = buildClientDataJSON(740    "webauthn.get",741    options.challenge,742    await resolvePageOrigin(credential.rpId)743  );744  const clientDataHash = new Uint8Array(745    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))746  );747  const signature = await signPasskeyAssertion(748    credential.privateKeyJwk,749    credential.algorithm,750    authData,751    clientDataHash752  );753754  await vault.updateEntry(match.id, {755    passkey: {756      ...credential,757      signCount: nextSignCount,758      lastUsedAt: Date.now(),759    },760  });761762  const responseJson = JSON.stringify({763    id: credential.credentialId,764    rawId: credential.credentialId,765    type: "public-key",766    response: {767      clientDataJSON: base64urlEncode(clientDataJSON),768      authenticatorData: base64urlEncode(authData),769      signature: base64urlEncode(signature),770      userHandle: credential.userHandle,771    },772    authenticatorAttachment: "platform",773    clientExtensionResults: {},774  });775776  try {777    await chrome.webAuthenticationProxy.completeGetRequest({778      requestId: request.requestId,779      responseJson,780    });781  } catch (err) {782    if (isInvalidRequestIdError(err)) {783      return;784    }785    throw err;786  }787}788789// ── Firefox path ────────────────────────────────────────────────────────790//791// Firefox 150+ permits extensions to call navigator.credentials.create/get792// from the popup context with their host-permitted RP IDs. Peach uses that793// surface to register passkeys on behalf of the user. The page's own794// navigator.credentials call is observed via the MAIN-world detector795// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's796// flow is parallel and explicit (the user must engage with Peach's button).797798const FIREFOX_PENDING_KEY = "passkey_pending_firefox";799const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";800const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days801802export async function initiateFirefoxPasskeyCreate(args: {803  rpId: string;804  rpName: string;805  userName: string;806  userDisplayName?: string;807}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {808  // The rpId must belong to the calling page's registrable domain; without809  // this, a page could initiate a ceremony for a foreign site and the810  // approval popup would display an attacker-chosen rpId. Origin is the811  // browser-attested sender origin from messages.ts, never page payload.812  // (Codex Security 2026-08-04, passkey-ceremony-approval sibling.)813  if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {814    return { ok: false, error: "rpid_origin_mismatch" };815  }816  if (!(await isProUser())) {817    broadcastPasskeyProGate();818    return { ok: false, error: "passkey creation requires Peach Pro" };819  }820  if (!vault.isUnlocked()) {821    await openApprovalPopup();822    return { ok: false, error: "vault_locked" };823  }824  if (!chrome.storage.session) {825    return { ok: false, error: "session storage unavailable" };826  }827828  const requestId = randomRequestId();829  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));830  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));831832  const pending: PendingPasskeyRequest = {833    requestId,834    type: "create",835    rpId: args.rpId,836    rpName: args.rpName,837    userName: args.userName,838    userDisplayName: args.userDisplayName,839    browser: "firefox",840    challenge,841    userHandle,842  };843844  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });845  await openApprovalPopup();846  return { ok: true };847}848849export async function completeFirefoxPasskeyCreate(payload: {850  requestId: number;851  rpId: string;852  rpName: string;853  userName: string;854  userDisplayName?: string;855  userHandle: string;856  credentialId: string;857  publicKeyCose: string;858  algorithm: -7 | -8;859  aaguid?: string;860  transports?: string[];861  backupEligible?: boolean;862  backupState?: boolean;863}): Promise<{ ok: boolean; error?: string }> {864  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };865866  // Bind completion to the pending ceremony: requestId, rpId, challenge, and867  // userHandle are minted by the background at INITIATE time and only ever868  // leave extension context through the approval popup, so a page (or anything869  // replaying relayed messages) cannot synthesize a matching completion for an870  // rpId it does not own. (Codex Security 2026-08-04 MEDIUM,871  // broken-access-control.passkey-ceremony-approval.)872  const pending = chrome.storage.session873    ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as874        | PendingPasskeyRequest875        | undefined)876    : undefined;877  if (878    !pending ||879    pending.requestId !== payload.requestId ||880    pending.rpId !== payload.rpId ||881    pending.userHandle !== payload.userHandle882  ) {883    return { ok: false, error: "no_pending_request" };884  }885886  const passkey: PasskeyData = {887    rpId: payload.rpId,888    rpName: payload.rpName,889    credentialId: payload.credentialId,890    publicKeyCose: payload.publicKeyCose,891    userHandle: payload.userHandle,892    userName: payload.userName,893    userDisplayName: payload.userDisplayName ?? payload.userName,894    algorithm: payload.algorithm,895    signCount: 0,896    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),897    backupEligible: payload.backupEligible ?? false,898    backupState: payload.backupState ?? false,899    browser: "firefox",900    transports: payload.transports ?? ["internal"],901    createdAt: Date.now(),902  };903904  // Same attach-or-create flow as the Chrome path: prefer attaching to an905  // existing login entry for the same domain so the user ends up with one906  // entry per site, not two (a login + a passkey-only).907  const target = findAttachTarget(payload.rpId);908  if (target) {909    await vault.updateEntry(target.id, { passkey });910  } else {911    await vault.addEntry({912      type: "login",913      title: payload.rpName || payload.rpId,914      url: `https://${payload.rpId}`,915      username: payload.userName,916      passkey,917    });918  }919  await vault.consolidatePasskeyEntries();920921  if (chrome.storage.session) {922    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);923  }924  return { ok: true };925}926927const MAX_DECLINE_DOMAIN_LENGTH = 253;928929function normaliseDeclineDomain(domain: string): string | null {930  if (!domain || domain.length > MAX_DECLINE_DOMAIN_LENGTH) return null;931  try {932    const url = new URL(`https://${domain}`);933    return isValidRpId(url.hostname, url.origin) ? url.hostname : null;934  } catch {935    return null;936  }937}938939function declineKey(domain: string): string {940  return `${FIREFOX_DECLINE_PREFIX}${domain}`;941}942943export async function recordPasskeyOfferDecline(domain: string): Promise<void> {944  const validDomain = normaliseDeclineDomain(domain);945  if (!validDomain) return;946  await chrome.storage.local.set({ [declineKey(validDomain)]: Date.now() });947}948949export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {950  const validDomain = normaliseDeclineDomain(domain);951  if (!validDomain) return false;952  const key = declineKey(validDomain);953954  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;955  const at = stored[key];956  if (typeof at !== "number") return false;957  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {958    await chrome.storage.local.remove(key);959    return false;960  }961  return true;962}963964// ── Firefox content-script proxy path (v2.12.0) ────────────────────────965//966// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces967// navigator.credentials.create / .get on every Firefox page. It builds a968// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),969// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,970// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.971//972// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag973// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates974// to false and these handlers short-circuit. The full removal checklist is975// in that file — the deletion is purely subtractive.976977interface FirefoxAllowCredential {978  id: string;979  type?: string;980}981982interface FirefoxPubKeyCredParam {983  type?: string;984  alg: number;985}986987export interface FirefoxPasskeyHasCredentialsPayload {988  rpId: string;989  allowCredentials: FirefoxAllowCredential[];990}991992export interface FirefoxPasskeyCreatePayload {993  rpId: string;994  rpName?: string;995  userId: string;996  userName: string;997  userDisplayName?: string;998  challenge: string;999  clientDataJSON: number[];1000  origin: string;1001  pubKeyCredParams?: FirefoxPubKeyCredParam[];1002  authenticatorSelection?: Record<string, unknown>;1003  timeout?: number;1004  attestation?: string;1005}10061007export interface FirefoxPasskeyGetPayload {1008  rpId: string;1009  challenge: string;1010  clientDataJSON: number[];1011  origin: string;1012  allowCredentials: FirefoxAllowCredential[];1013  userVerification?: string;1014}10151016interface FirefoxCreateCredentialResponse {1017  type: "create";1018  id: string;1019  clientDataJSON: number[];1020  attestationObject: string;1021  authenticatorData: string;1022  publicKey: string;1023  algorithm: -7 | -8;1024}10251026interface FirefoxGetCredentialResponse {1027  type: "get";1028  id: string;1029  clientDataJSON: number[];1030  authenticatorData: string;1031  signature: string;1032  userHandle: string;1033}10341035type FirefoxPasskeyResult<T> =1036  | { ok: true; credential: T }1037  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10381039function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1040  return vault.getEntries().filter(1041    (entry): entry is VaultEntry & { passkey: PasskeyData } =>1042      entry.passkey !== undefined && !entry.deletedAt1043  );1044}10451046/** Decode the `origin` field out of a WebAuthn clientDataJSON byte array. */1047function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1048  if (!Array.isArray(bytes)) return null;1049  try {1050    const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1051    return typeof obj.origin === "string" ? obj.origin : null;1052  } catch {1053    return null;1054  }1055}10561057/**1058 * Enforce the WebAuthn rpId<->origin relationship on the Firefox proxy path,1059 * where Peach is the authenticator. The ONLY trustworthy origin is the one the1060 * browser attested for the calling page (`callerOrigin`, derived from the1061 * message sender in messages.ts) — NEVER `payload.origin`, which the page1062 * controls and can set to match any rpId (SEV-1-A / audit CRITICAL). Also1063 * refuse to sign a clientDataJSON whose embedded origin disagrees with the1064 * attested origin, so a forged ceremony can't smuggle a foreign origin into1065 * the signed bytes the RP verifies.1066 */1067function assertOriginOwnsRpId(1068  rpId: string,1069  callerOrigin: string | undefined,1070  clientDataJSON?: number[],1071): void {1072  if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1073    throw new DOMException(1074      `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1075        `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1076      "SecurityError",1077    );1078  }1079  const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1080  if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1081    throw new DOMException(1082      `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1083        `attested calling origin "${callerOrigin}".`,1084      "SecurityError",1085    );1086  }1087}10881089export async function handlePasskeyHasCredentials(1090  payload: FirefoxPasskeyHasCredentialsPayload,1091  callerOrigin?: string,1092): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1093  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1094    return { ok: false, error: "not_applicable" };1095  }1096  // Scope the existence check to the caller's own origin so an arbitrary page1097  // cannot enumerate which sites the user holds passkeys for (audit MEDIUM).1098  // A false answer is indistinguishable from "no credential" to the page.1099  if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1100    return { ok: true, hasCredentials: false };1101  }1102  if (!vault.isUnlocked()) {1103    // Cannot enumerate without the vault key. The MAIN-world proxy treats a1104    // false answer as "fall through to the platform" — exactly the right1105    // outcome when Peach is locked: the user gets the native UI instead.1106    return { ok: true, hasCredentials: false };1107  }1108  const passkeys = listPasskeyEntries().filter(1109    (entry) => entry.passkey.rpId === payload.rpId1110  );1111  if (passkeys.length === 0) return { ok: true, hasCredentials: false };11121113  const allow = payload.allowCredentials ?? [];1114  if (allow.length === 0) return { ok: true, hasCredentials: true };11151116  const hasMatch = passkeys.some((entry) =>1117    allow.some((ac) => ac.id === entry.passkey.credentialId)1118  );1119  return { ok: true, hasCredentials: hasMatch };1120}11211122/**1123 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing1124 * pending-passkey channel (badge + popup + 60s timeout) — the same approval1125 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need1126 * any new code path. The page-side proxy never sees the request id; the1127 * background mints one and discards it after resolution.1128 */1129async function requestFirefoxApproval(args: {1130  type: "create" | "get";1131  rpId: string;1132  rpName?: string;1133  userName: string;1134  userDisplayName?: string;1135}): Promise<boolean> {1136  // Synthetic request id — the Firefox proxy path doesn't have a1137  // chrome.webAuthenticationProxy request to tie back to.1138  const requestId = randomRequestId();1139  return promptUserApproval({1140    requestId,1141    type: args.type,1142    rpId: args.rpId,1143    rpName: args.rpName ?? args.rpId,1144    userName: args.userName,1145    userDisplayName: args.userDisplayName,1146  });1147}11481149export async function handleFirefoxPasskeyCreate(1150  payload: FirefoxPasskeyCreatePayload,1151  callerOrigin?: string,1152): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1153  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1154    return { ok: false, error: "not_applicable" };1155  }1156  // WebAuthn §7.1 step 6 — the requested rpId must be owned by the page's1157  // browser-ATTESTED origin (threaded in as callerOrigin from the message1158  // sender), NOT the page-supplied payload.origin which an attacker controls.1159  // On the Firefox proxy path Peach is the authenticator and must enforce this1160  // before minting anything (audit CRITICAL — origin spoofing).1161  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1162  if (!(await isProUser())) {1163    broadcastPasskeyProGate();1164    return { ok: false, error: "pro_required" };1165  }1166  if (!vault.isUnlocked()) {1167    await openApprovalPopup();1168    return { ok: false, error: "vault_locked" };1169  }11701171  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1172  if (!algorithm) {1173    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1174  }11751176  const approved = await requestFirefoxApproval({1177    type: "create",1178    rpId: payload.rpId,1179    rpName: payload.rpName,1180    userName: payload.userName,1181    userDisplayName: payload.userDisplayName,1182  });1183  if (!approved) return { ok: false, error: "user_cancelled" };11841185  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1186  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);11871188  // 0x4d = UP|UV|BE|AT. Peach passkeys are synced, so BE is true on both1189  // registration paths and on later assertions.1190  const authenticatorData = await buildAuthenticatorData({1191    rpId: payload.rpId,1192    flags: 0x4d,1193    signCount: 0,1194    credentialId: credentialIdBytes,1195    publicKeyCose,1196  });1197  const attestationObject = createAttestationObject(authenticatorData);11981199  const passkey: PasskeyData = {1200    rpId: payload.rpId,1201    rpName: payload.rpName ?? payload.rpId,1202    credentialId,1203    publicKeyCose: base64urlEncode(publicKeyCose),1204    privateKeyJwk,1205    userHandle: payload.userId,1206    userName: payload.userName,1207    userDisplayName: payload.userDisplayName ?? payload.userName,1208    algorithm,1209    signCount: 0,1210    aaguid: base64urlEncode(new Uint8Array(16)),1211    backupEligible: true,1212    backupState: false,1213    browser: "firefox",1214    transports: ["internal"],1215    createdAt: Date.now(),1216  };12171218  const target = findAttachTarget(payload.rpId);1219  if (target) {1220    await vault.updateEntry(target.id, { passkey });1221  } else {1222    await vault.addEntry({1223      type: "login",1224      title: payload.rpName || payload.rpId,1225      url: `https://${payload.rpId}`,1226      username: payload.userName,1227      passkey,1228    });1229  }1230  await vault.consolidatePasskeyEntries();12311232  return {1233    ok: true,1234    credential: {1235      type: "create",1236      id: credentialId,1237      // The content script already encoded clientDataJSON with the page1238      // origin; round-trip the exact bytes back so the RP sees the same1239      // hash it computes locally.1240      clientDataJSON: payload.clientDataJSON,1241      attestationObject: base64urlEncode(attestationObject),1242      authenticatorData: base64urlEncode(authenticatorData),1243      publicKey: base64urlEncode(publicKeySpki),1244      algorithm,1245    },1246  };1247}12481249export async function handleFirefoxPasskeyGet(1250  payload: FirefoxPasskeyGetPayload,1251  callerOrigin?: string,1252): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1253  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1254    return { ok: false, error: "not_applicable" };1255  }1256  // WebAuthn §7.2 step 8 — the rpId must be owned by the page's browser-attested1257  // origin (callerOrigin from the message sender), NOT the page-supplied1258  // payload.origin. Both rpId and payload.origin are attacker-controlled, so1259  // validating one against the other is no protection; a cross-origin page must1260  // not be able to coax Peach into signing an assertion scoped to another site1261  // (audit CRITICAL — assertion forgery).1262  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1263  if (!(await isProUser())) {1264    broadcastPasskeyProGate();1265    return { ok: false, error: "pro_required" };1266  }1267  if (!vault.isUnlocked()) {1268    await openApprovalPopup();1269    return { ok: false, error: "vault_locked" };1270  }12711272  const allow = payload.allowCredentials ?? [];1273  const candidates = listPasskeyEntries().filter((entry) => {1274    if (entry.passkey.rpId !== payload.rpId) return false;1275    if (allow.length === 0) return true;1276    return allow.some((ac) => ac.id === entry.passkey.credentialId);1277  });12781279  if (candidates.length === 0) return { ok: false, error: "no_credentials" };12801281  // Most-recently-used wins when several credentials match. A future1282  // multi-account selector goes here; for now this matches the Chrome path's1283  // behavior for the common one-passkey-per-rp case.1284  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1285  const match = candidates[0]!;1286  const credential = match.passkey;12871288  if (!credential.privateKeyJwk) {1289    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1290    // — no Peach-held private key to sign with. The MAIN-world proxy treats1291    // no_credentials as a fall-through, which routes to the platform UI.1292    return { ok: false, error: "no_credentials" };1293  }12941295  const approved = await requestFirefoxApproval({1296    type: "get",1297    rpId: credential.rpId,1298    rpName: credential.rpName,1299    userName: credential.userName,1300    userDisplayName: credential.userDisplayName,1301  });1302  if (!approved) return { ok: false, error: "user_cancelled" };13031304  const nextSignCount = credential.signCount + 1;1305  // 0x05 = UP|UV. backup bits track whatever was set at registration time1306  // (consistent with the Chrome path).1307  const flags =1308    0x05 |1309    (credential.backupEligible ? 0x08 : 0) |1310    (credential.backupState ? 0x10 : 0);1311  const authenticatorData = await buildAuthenticatorData({1312    rpId: credential.rpId,1313    flags,1314    signCount: nextSignCount,1315  });13161317  // The content script supplied clientDataJSON bytes built with the page1318  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1319  // diverges from what the RP computes.1320  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1321  const clientDataHash = new Uint8Array(1322    await crypto.subtle.digest("SHA-256", clientDataBytes)1323  );1324  const signature = await signPasskeyAssertion(1325    credential.privateKeyJwk,1326    credential.algorithm,1327    authenticatorData,1328    clientDataHash1329  );13301331  await vault.updateEntry(match.id, {1332    passkey: {1333      ...credential,1334      signCount: nextSignCount,1335      lastUsedAt: Date.now(),1336    },1337  });13381339  return {1340    ok: true,1341    credential: {1342      type: "get",1343      id: credential.credentialId,1344      clientDataJSON: payload.clientDataJSON,1345      authenticatorData: base64urlEncode(authenticatorData),1346      signature: base64urlEncode(signature),1347      userHandle: credential.userHandle,1348    },1349  };1350}13511352export async function setupPasskeyProxy(): Promise<void> {1353  if (!isPasskeySupported()) {1354    return;1355  }13561357  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1358  // popup-context credential creator is the entry point; nothing to wire here.1359  if (detectSharedBrowser() === "firefox") {1360    await refreshPasskeyProxy();1361    return;1362  }13631364  if (!listenersRegistered) {1365    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1366      // Peach is a user-verifying platform authenticator whenever the proxy1367      // is attached. The lock state is irrelevant here — the create/get1368      // handlers prompt for unlock when needed. Returning false while locked1369      // makes Google (and any RP that checks isUvpaa as a precondition) show1370      // "A passkey can't be created on this device" before the user even1371      // gets a chance to start the ceremony.1372      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1373        requestId: requestInfo.requestId,1374        isUvpaa: true,1375      });1376    });1377    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1378      void handleCreateRequest(requestInfo).catch(async (error) => {1379        // "Invalid requestId" means Chrome already cancelled — handlers will1380        // short-circuit, but if it propagates out from a still-in-flight branch,1381        // don't double-complete.1382        if (isInvalidRequestIdError(error)) {1383          return;1384        }1385        console.error("Peach passkey create failed", error);1386        await rejectCreate(1387          requestInfo.requestId,1388          "UnknownError",1389          error instanceof Error ? error.message : "Passkey creation failed."1390        );1391      });1392    });1393    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1394      void handleGetRequest(requestInfo).catch(async (error) => {1395        if (isInvalidRequestIdError(error)) {1396          return;1397        }1398        console.error("Peach passkey get failed", error);1399        await rejectGet(1400          requestInfo.requestId,1401          "UnknownError",1402          error instanceof Error ? error.message : "Passkey authentication failed."1403        );1404      });1405    });1406    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1407      // Release any unlock waiter parked for this request.1408      const waiter = unlockWaiters.get(requestId);1409      if (waiter) {1410        clearTimeout(waiter.timeoutId);1411        waiter.resolve(false);1412        unlockWaiters.delete(requestId);1413      }1414      void resolvePendingPasskeyRequest(requestId, false);1415    });1416    listenersRegistered = true;1417  }14181419  await refreshPasskeyProxy();1420}