passkey.ts

48.6 KB · raw

1import { isProUser } from "./license";2import * as vault from "./vault";3import {4  base64urlEncode,5  buildAuthenticatorData,6  buildClientDataJSON,7  createAttestationObject,8  generateCredentialId,9  generatePasskeyKeypair,10  pickPasskeyAlgorithm,11  signPasskeyAssertion,12} from "./passkey-crypto";13import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";14import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";15import { isValidRpId } from "../shared/passkey-utils";16import type {17  PasskeyData,18  PasskeyProviderState,19  PendingPasskeyRequest,20  VaultEntry,21} from "../shared/types";2223declare const __PEACH_E2E_PASSKEYS__: boolean;2425const PENDING_PREFIX = "passkey_pending_";26const APPROVAL_TIMEOUT_MS = 60_000;2728let listenersRegistered = false;29let attached = false;30let conflict: string | undefined;31const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3233const approvals = new Map<34  number,35  { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }36>();3738// Resolvers for GET requests that arrived while the vault was locked and are39// waiting for the user to unlock before the passkey flow can continue.40const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4142// Tracks requestIds that Chrome cancelled on us (via onRequestCanceled) while43// we were still mid-handler. The complete*Request calls would otherwise reject44// with "Invalid requestId"; the handler checks this set before any work.45const cancelledRequests = new Set<number>();46function markCancelled(requestId: number): void {47  cancelledRequests.add(requestId);48  // Bounded GC — never grow forever. Old IDs that haven't been polled in a49  // long time can be forgotten without harm because the Chrome-side request50  // is already gone.51  if (cancelledRequests.size > 256) {52    const iter = cancelledRequests.values();53    for (let i = 0; i < 128; i += 1) cancelledRequests.delete(iter.next().value as number);54  }55}5657export function onVaultUnlocked(): void {58  for (const { resolve, timeoutId } of unlockWaiters.values()) {59    clearTimeout(timeoutId);60    resolve(true);61  }62  unlockWaiters.clear();63}6465/**66 * Resolve every still-pending passkey request as denied and clear unlock67 * waiters. Called from the popup-disconnect path so a closed popup releases68 * the page-side caller in <200ms instead of leaving it stuck for the full69 * APPROVAL_TIMEOUT_MS (60s) (M8/COR-009).70 */71export async function denyAllPendingPasskeyRequests(): Promise<void> {72  if (approvals.size === 0 && unlockWaiters.size === 0) return;73  console.log("[Peach passkey] denyAllPendingPasskeyRequests (popup disconnected)", {74    approvals: approvals.size,75    unlockWaiters: unlockWaiters.size,76  });77  for (const [rid, { resolve, timeoutId }] of approvals.entries()) {78    clearTimeout(timeoutId);79    resolve(false);80    approvals.delete(rid);81    await clearPendingRequest(rid).catch(() => {});82  }83  for (const { resolve, timeoutId } of unlockWaiters.values()) {84    clearTimeout(timeoutId);85    resolve(false);86  }87  unlockWaiters.clear();88}8990function isPasskeySupported(): boolean {91  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;92  // Firefox 150+ exposes navigator.credentials.create/get from the popup93  // context with extension host permissions. Cannot intercept page-side94  // calls (no equivalent of webAuthenticationProxy), but registration via95  // Peach's own popup-driven flow works.96  if (detectSharedBrowser() === "firefox") {97    const ver = firefoxVersion();98    if (Number.isFinite(ver) && ver >= 150) return true;99  }100  return false;101}102103export const detectBrowser = detectSharedBrowser;104105function getBrowserLabel(): string {106  const ua = navigator.userAgent;107  if (/Firefox\/(\d+)/.test(ua)) {108    const version = ua.match(/Firefox\/(\d+)/)?.[1];109    return version ? `Firefox ${version}` : "Firefox";110  }111  const version = ua.match(/Chrome\/(\d+)/)?.[1];112  return version ? `Chrome ${version}` : "Chromium";113}114115function pendingStorageKey(requestId: number): string {116  return `${PENDING_PREFIX}${requestId}`;117}118119function originFromRpId(rpId: string): string {120  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)121    ? `http://${rpId}`122    : `https://${rpId}`;123}124125/**126 * The clientDataJSON sent back to the RP must contain the page's actual127 * origin — not a synthesized one. `chrome.webAuthenticationProxy` does not128 * surface the requesting origin in CreateRequest/GetRequest, so we read it129 * from the active tab and validate that its hostname is a registrable130 * suffix match for the rpId. WebAuthn requires this relationship anyway, so131 * a mismatch indicates either a stale active tab or a hostile environment;132 * we fall back to https://<rpId> in that case to avoid silently signing the133 * wrong origin.134 *135 * Without this, a passkey created on https://myaccount.google.com gets136 * clientDataJSON.origin = "https://google.com", and Google's RP rejects the137 * attestation with "Something went wrong" because the origin hash doesn't138 * match what its own JS captured at create time.139 */140async function resolvePageOrigin(rpId: string): Promise<string> {141  try {142    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>143      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)144    );145    const url = tabs[0]?.url;146    if (url) {147      const parsed = new URL(url);148      const host = parsed.hostname.toLowerCase();149      const rp = rpId.toLowerCase();150      if (host === rp || host.endsWith(`.${rp}`)) {151        return parsed.origin;152      }153    }154  } catch {155    // fall through to rpId-derived origin156  }157  return originFromRpId(rpId);158}159160async function setBadge(text: string): Promise<void> {161  if (!chrome.action?.setBadgeText) return;162  await chrome.action.setBadgeText({ text });163  if (text) {164    await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });165  }166}167168async function clearPendingRequest(requestId: number): Promise<void> {169  if (chrome.storage.session) {170    await chrome.storage.session.remove(pendingStorageKey(requestId));171  }172  await setBadge("");173}174175async function openApprovalPopup(): Promise<void> {176  try {177    await chrome.action.openPopup();178  } catch {179    await setBadge("PK");180  }181}182183/**184 * Some ceremonies — especially conditional-UI `navigator.credentials.get`185 * calls — get cancelled by Chrome before we finish processing. Calling186 * complete*Request on a cancelled requestId rejects with "Invalid187 * requestId". That is expected, not exceptional: swallow it. Re-throwing188 * would surface an uncaught promise rejection in the SW and cascade through189 * the catch wrappers in setupPasskeyProxy.190 */191function isInvalidRequestIdError(err: unknown): boolean {192  return err instanceof Error && /invalid requestid/i.test(err.message);193}194195/**196 * Chrome throws "Missing GetResponseDetails.responseJson" when197 * completeGetRequest is called with no responseJson and no error (the198 * pass-through signal). The throw is a Chrome API quirk — the request IS199 * routed to the platform authenticator — but if we let it propagate, the200 * outer catch calls rejectGet, which sends a second completeGetRequest with201 * an error that cancels the pass-through and shows "Something went wrong" on202 * the page. Swallow it exactly as we swallow "Invalid requestId".203 */204function isMissingResponseJsonError(err: unknown): boolean {205  return err instanceof Error && /missing getresponsedetails/i.test(err.message);206}207208async function rejectCreate(209  requestId: number,210  name: string,211  message = ""212): Promise<void> {213  try {214    await chrome.webAuthenticationProxy.completeCreateRequest({215      requestId,216      error: { name, message },217    });218  } catch (err) {219    if (isInvalidRequestIdError(err)) {220      console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId });221      return;222    }223    throw err;224  }225}226227async function rejectGet(228  requestId: number,229  name: string,230  message = ""231): Promise<void> {232  try {233    await chrome.webAuthenticationProxy.completeGetRequest({234      requestId,235      error: { name, message },236    });237  } catch (err) {238    if (isInvalidRequestIdError(err)) {239      console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId });240      return;241    }242    throw err;243  }244}245246export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {247  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts248  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;249  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));250  if (!key) return null;251  const pending = stored[key];252  return pending && typeof pending === "object"253    ? (pending as PendingPasskeyRequest)254    : null;255}256257export async function resolvePendingPasskeyRequest(258  requestId: number,259  approved: boolean260): Promise<void> {261  const pending = approvals.get(requestId);262  if (!pending) {263    // Either the request expired (Chrome) or this is a Firefox-driven264    // pending where the popup performs the navigator.credentials call265    // directly. Clear both possible session keys so getPendingPasskeyRequest()266    // doesn't surface stale state on the next popup open.267    await clearPendingRequest(requestId);268    if (chrome.storage.session) {269      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);270    }271    return;272  }273274  clearTimeout(pending.timeoutId);275  approvals.delete(requestId);276  await clearPendingRequest(requestId);277  pending.resolve(approved);278}279280async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {281  if (chrome.storage.session) {282    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });283  }284  await openApprovalPopup();285286  return new Promise<boolean>((resolve) => {287    const timeoutId = setTimeout(async () => {288      approvals.delete(request.requestId);289      await clearPendingRequest(request.requestId);290      resolve(false);291    }, APPROVAL_TIMEOUT_MS);292293    approvals.set(request.requestId, { resolve, timeoutId });294  });295}296297export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {298  const settings = await vault.ensureSettingsLoaded();299  const supported = isPasskeySupported();300  const enabled = settings.passkeyProviderEnabled === true;301  return {302    supported,303    enabled,304    attached: supported && attached,305    browserLabel: getBrowserLabel(),306    statusLabel: !supported307      ? "Interception unavailable"308      : attached309        ? "Active"310        : conflict311          ? "Another provider is active"312          : enabled313            ? "Inactive"314            : "Disabled",315    conflict,316  };317}318319async function attachPasskeyProxy(): Promise<void> {320  if (!isPasskeySupported()) {321    attached = false;322    conflict = "This browser does not expose chrome.webAuthenticationProxy.";323    return;324  }325326  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context327  // navigator.credentials creator is always ready once the user enables the328  // provider. Treat enabled = attached for state reporting purposes.329  if (detectSharedBrowser() === "firefox") {330    attached = true;331    conflict = undefined;332    return;333  }334335  if (attached) return;336337  try {338    await chrome.webAuthenticationProxy.attach();339    attached = true;340    conflict = undefined;341    console.log("[Peach passkey] proxy attached successfully");342  } catch (error) {343    attached = false;344    conflict = error instanceof Error ? error.message : "Another provider is active.";345    console.warn("[Peach passkey] proxy attach failed", { conflict });346  }347}348349async function detachPasskeyProxy(): Promise<void> {350  if (!isPasskeySupported()) {351    attached = false;352    return;353  }354355  if (detectSharedBrowser() === "firefox") {356    attached = false;357    conflict = undefined;358    return;359  }360361  try {362    await chrome.webAuthenticationProxy.detach();363  } catch {364    // Ignore detach failures; the extension may already be detached.365  }366367  attached = false;368  conflict = undefined;369}370371export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {372  await vault.updateSettings({ passkeyProviderEnabled: enabled });373  if (enabled) {374    await attachPasskeyProxy();375  } else {376    await detachPasskeyProxy();377  }378  return getPasskeyProviderState();379}380381async function refreshPasskeyProxy(): Promise<void> {382  const settings = await vault.ensureSettingsLoaded();383  if (settings.passkeyProviderEnabled === true) {384    await attachPasskeyProxy();385    return;386  }387  await detachPasskeyProxy();388}389390/**391 * Find the entry that already holds a passkey for the given RP, or the most392 * recently-modified login entry whose URL/additionalUrls match the RP — that393 * entry is the natural attach target when the user adds a passkey to a site394 * they already have a password for. Returns null when there's no candidate;395 * the caller creates a new entry in that case.396 */397function findAttachTarget(rpId: string): VaultEntry | null {398  const all = vault.getEntries();399  const passkeyMatch = all.find(400    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt401  );402  if (passkeyMatch) return passkeyMatch;403  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);404  if (loginMatches.length === 0) return null;405  // Pick the most recently modified candidate to be deterministic.406  return loginMatches.reduce((best, e) =>407    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best408  );409}410411async function handleCreateRequest(412  request: chrome.webAuthenticationProxy.CreateRequest413): Promise<void> {414  console.log("[Peach passkey] create event received", {415    requestId: request.requestId,416    detailsJson: request.requestDetailsJson,417  });418  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;419  const rpId = options.rp.id;420  const rpName = options.rp.name ?? options.rp.id;421  const userName = options.user.name;422  const userDisplayName = options.user.displayName ?? options.user.name;423  console.log("[Peach passkey] create parsed", { rpId, rpName, userName, unlocked: vault.isUnlocked() });424425  if (!rpId) {426    console.warn("[Peach passkey] reject: missing rp.id");427    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");428    return;429  }430431  // Capture the page origin NOW — before any popup opens. Once the extension432  // action popup fires, it can steal lastFocusedWindow so the tabs query433  // returns no results and falls back to https://<rpId>. For sites where the434  // page origin is a subdomain of the rpId (e.g. myaccount.google.com vs435  // rpId google.com) that fallback produces the wrong origin and the RP436  // rejects the attestation with "Something went wrong".437  const pageOrigin = await resolvePageOrigin(rpId);438439  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {440    console.warn("[Peach passkey] reject: user is not Pro");441    await rejectCreate(442      request.requestId,443      "NotAllowedError",444      "Passkey creation requires Peach Pro."445    );446    // Show the inline passkey gate so the user knows what happened and can upgrade.447    broadcastPasskeyProGate();448    return;449  }450451  if (!vault.isUnlocked()) {452    console.log("[Peach passkey] vault locked, summoning inline unlock");453    // Same wait-for-unlock pattern handleGetRequest uses: summon the inline454    // unlock prompt on the active tab so the user can unlock without leaving455    // the page, then hold the create request open until the vault unlocks or456    // the timeout fires. Immediately rejecting here means Google shows457    // "Something went wrong" the instant the user picks Peach.458    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>459      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)460    );461    const activeTab = tabs[0];462    console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });463    if (activeTab?.id) {464      chrome.tabs.sendMessage(activeTab.id, {465        type: 'SUMMON_AUTOFILL',466        source: 'passkey-unlock',467      }).catch((err) => {468        console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);469        void openApprovalPopup();470      });471    } else {472      await openApprovalPopup();473    }474475    const unlocked = await new Promise<boolean>((resolve) => {476      const timeoutId = setTimeout(() => {477        unlockWaiters.delete(request.requestId);478        resolve(false);479      }, APPROVAL_TIMEOUT_MS);480      unlockWaiters.set(request.requestId, { resolve, timeoutId });481    });482483    console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });484    if (!unlocked || !vault.isUnlocked()) {485      console.warn("[Peach passkey] reject: unlock timeout");486      await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");487      return;488    }489    // Vault is now unlocked — fall through to approval.490  }491492  console.log("[Peach passkey] requesting approval via popup");493  const approved = await promptUserApproval({494    requestId: request.requestId,495    type: "create",496    rpId,497    rpName,498    userName,499    userDisplayName,500  });501  console.log("[Peach passkey] approval resolved", { approved });502  if (!approved) {503    console.warn("[Peach passkey] reject: user denied");504    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");505    return;506  }507508  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);509  if (!algorithm) {510    console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);511    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");512    return;513  }514  console.log("[Peach passkey] algorithm selected", algorithm);515516  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));517  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(518    algorithm519  );520  const authData = await buildAuthenticatorData({521    rpId,522    flags: 0x4d,523    signCount: 0,524    credentialId: credentialIdBytes,525    publicKeyCose,526  });527  const attestationObject = createAttestationObject(authData);528  const clientDataJSON = buildClientDataJSON(529    "webauthn.create",530    options.challenge,531    pageOrigin532  );533534  const passkey: PasskeyData = {535    rpId,536    rpName,537    credentialId: base64urlEncode(credentialIdBytes),538    publicKeyCose: base64urlEncode(publicKeyCose),539    privateKeyJwk,540    userHandle: options.user.id,541    userName,542    userDisplayName,543    algorithm,544    signCount: 0,545    aaguid: base64urlEncode(new Uint8Array(16)),546    backupEligible: false,547    backupState: false,548    browser: "chrome",549    transports: ["internal"],550    createdAt: Date.now(),551  };552553  // Attach to an existing login (same domain) when one exists; otherwise554  // create a fresh login entry that exists solely to hold the passkey.555  const target = findAttachTarget(rpId);556  if (target) {557    await vault.updateEntry(target.id, { passkey });558  } else {559    await vault.addEntry({560      type: "login",561      title: rpName,562      url: `https://${rpId}`,563      username: userName,564      passkey,565    });566  }567  // Merge any same-domain TOTP-only entries into the passkey entry right now568  // so the user sees one consolidated entry rather than waiting until next569  // unlock for the decodePayload merge pass to run.570  await vault.consolidatePasskeyEntries();571572  try {573    await chrome.webAuthenticationProxy.completeCreateRequest({574      requestId: request.requestId,575      responseJson: JSON.stringify({576        id: passkey.credentialId,577        rawId: passkey.credentialId,578        type: "public-key",579        response: {580          clientDataJSON: base64urlEncode(clientDataJSON),581          authenticatorData: base64urlEncode(authData),582          attestationObject: base64urlEncode(attestationObject),583          transports: ["internal"],584          publicKey: base64urlEncode(publicKeySpki),585          publicKeyAlgorithm: algorithm,586        },587        authenticatorAttachment: "platform",588        clientExtensionResults: {},589      }),590    });591    console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });592  } catch (err) {593    if (isInvalidRequestIdError(err)) {594      console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });595      return;596    }597    throw err;598  }599}600601function broadcastPasskeyProGate(): void {602  chrome.tabs.query({}, (tabs) => {603    for (const tab of tabs) {604      if (tab.id !== undefined) {605        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});606      }607    }608  });609}610611async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {612  if (chrome.storage.session) {613    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });614  }615616  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {617    chrome.tabs.query({ active: true, currentWindow: true }, resolve);618  });619  const tab = tabs[0];620621  if (tab?.id) {622    chrome.tabs.sendMessage(tab.id, {623      type: 'SHOW_PASSKEY_TOAST',624      payload: {625        site: request.rpId,626        displayName: request.userDisplayName ?? request.userName,627        username: request.userName,628        requestId: String(request.requestId),629      },630    }).catch(() => {});631  } else {632    await openApprovalPopup();633  }634635  return new Promise<boolean>((resolve) => {636    const timeoutId = setTimeout(async () => {637      approvals.delete(request.requestId);638      await clearPendingRequest(request.requestId);639      resolve(false);640    }, APPROVAL_TIMEOUT_MS);641642    approvals.set(request.requestId, { resolve, timeoutId });643  });644}645646async function handleGetRequest(647  request: chrome.webAuthenticationProxy.GetRequest648): Promise<void> {649  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;650  if (!vault.isUnlocked()) {651    // Summon the inline unlock prompt on the active tab so the user can unlock652    // without leaving the page. Fall back to the popup if the content script653    // is unavailable (e.g. restricted URL, script not yet injected).654    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>655      chrome.tabs.query({ active: true, currentWindow: true }, resolve)656    );657    const activeTab = tabs[0];658    if (activeTab?.id) {659      chrome.tabs.sendMessage(activeTab.id, {660        type: 'SUMMON_AUTOFILL',661        source: 'passkey-unlock',662      }).catch(() => { void openApprovalPopup(); });663    } else {664      await openApprovalPopup();665    }666667    // Hold the request open until the vault unlocks or the timeout fires.668    const unlocked = await new Promise<boolean>((resolve) => {669      const timeoutId = setTimeout(() => {670        unlockWaiters.delete(request.requestId);671        resolve(false);672      }, APPROVAL_TIMEOUT_MS);673      unlockWaiters.set(request.requestId, { resolve, timeoutId });674    });675676    if (!unlocked || !vault.isUnlocked()) {677      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");678      return;679    }680    // Vault is now unlocked — fall through to authenticate.681  }682683  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {684    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");685    broadcastPasskeyProGate();686    return;687  }688689  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>690    entry.passkey !== undefined && !entry.deletedAt691  );692  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);693694  const match = passkeyBearing.find((entry) => {695    if (allowedCredentialIds.length > 0) {696      return allowedCredentialIds.includes(entry.passkey.credentialId);697    }698    if (!options.rpId) return false;699    return entry.passkey.rpId === options.rpId;700  });701702  if (!match) {703    // No Peach passkey for this RP — pass through to the platform authenticator.704    // completeGetRequest with no error and no responseJson is the webAuthenticationProxy705    // signal for "I'm not handling this"; Chrome routes the request to the next706    // authenticator in the chain (the built-in browser passkey manager etc.).707    // Rejecting with NotAllowedError would propagate as an error to the page and708    // break RPs (e.g. Google) that issue a GET as part of their create-passkey flow.709    console.log("[Peach passkey] no matching credential — passing through to platform", { requestId: request.requestId });710    try {711      await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });712    } catch (err) {713      if (isInvalidRequestIdError(err)) return;714      if (isMissingResponseJsonError(err)) return;715      throw err;716    }717    return;718  }719720  const credential = match.passkey;721  // Chrome's webAuthenticationProxy path requires Peach-held private key material.722  // Passkeys created on Firefox have no privateKeyJwk — pass through so Chrome723  // can route to a capable authenticator instead of surfacing an error to the page.724  if (!credential.privateKeyJwk) {725    console.log("[Peach passkey] no private key (Firefox passkey) — passing through to platform", { requestId: request.requestId });726    try {727      await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });728    } catch (err) {729      if (isInvalidRequestIdError(err)) return;730      if (isMissingResponseJsonError(err)) return;731      throw err;732    }733    return;734  }735736  const approved = await promptViaToast({737    requestId: request.requestId,738    type: "get",739    rpId: credential.rpId,740    rpName: credential.rpName,741    userName: credential.userName,742    userDisplayName: credential.userDisplayName,743  });744  if (!approved) {745    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");746    return;747  }748749  const nextSignCount = credential.signCount + 1;750  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);751  const authData = await buildAuthenticatorData({752    rpId: credential.rpId,753    flags,754    signCount: nextSignCount,755  });756  const clientDataJSON = buildClientDataJSON(757    "webauthn.get",758    options.challenge,759    await resolvePageOrigin(credential.rpId)760  );761  const clientDataHash = new Uint8Array(762    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))763  );764  const signature = await signPasskeyAssertion(765    credential.privateKeyJwk,766    credential.algorithm,767    authData,768    clientDataHash769  );770771  await vault.updateEntry(match.id, {772    passkey: {773      ...credential,774      signCount: nextSignCount,775      lastUsedAt: Date.now(),776    },777  });778779  const responseJson = JSON.stringify({780    id: credential.credentialId,781    rawId: credential.credentialId,782    type: "public-key",783    response: {784      clientDataJSON: base64urlEncode(clientDataJSON),785      authenticatorData: base64urlEncode(authData),786      signature: base64urlEncode(signature),787      userHandle: credential.userHandle,788    },789    authenticatorAttachment: "platform",790    clientExtensionResults: {},791  });792793  try {794    await chrome.webAuthenticationProxy.completeGetRequest({795      requestId: request.requestId,796      responseJson,797    });798    console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });799  } catch (err) {800    if (isInvalidRequestIdError(err)) {801      console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });802      return;803    }804    throw err;805  }806}807808// ── Firefox path ────────────────────────────────────────────────────────809//810// Firefox 150+ permits extensions to call navigator.credentials.create/get811// from the popup context with their host-permitted RP IDs. Peach uses that812// surface to register passkeys on behalf of the user. The page's own813// navigator.credentials call is observed via the MAIN-world detector814// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's815// flow is parallel and explicit (the user must engage with Peach's button).816817const FIREFOX_PENDING_KEY = "passkey_pending_firefox";818const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";819const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days820821export async function initiateFirefoxPasskeyCreate(args: {822  rpId: string;823  rpName: string;824  userName: string;825  userDisplayName?: string;826}): Promise<{ ok: boolean; error?: string }> {827  if (!(await isProUser())) {828    broadcastPasskeyProGate();829    return { ok: false, error: "passkey creation requires Peach Pro" };830  }831  if (!vault.isUnlocked()) {832    await openApprovalPopup();833    return { ok: false, error: "vault_locked" };834  }835  if (!chrome.storage.session) {836    return { ok: false, error: "session storage unavailable" };837  }838839  // Date.now() alone collides if two Firefox passkey requests land in the840  // same millisecond. Mix in 20 random bits to match the synthetic-id pattern841  // at requestFirefoxApproval (M9/COR-010).842  const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);843  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));844  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));845846  const pending: PendingPasskeyRequest = {847    requestId,848    type: "create",849    rpId: args.rpId,850    rpName: args.rpName,851    userName: args.userName,852    userDisplayName: args.userDisplayName,853    browser: "firefox",854    challenge,855    userHandle,856  };857858  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });859  await openApprovalPopup();860  return { ok: true };861}862863export async function completeFirefoxPasskeyCreate(payload: {864  requestId: number;865  rpId: string;866  rpName: string;867  userName: string;868  userDisplayName?: string;869  userHandle: string;870  credentialId: string;871  publicKeyCose: string;872  algorithm: -7 | -8;873  aaguid?: string;874  transports?: string[];875  backupEligible?: boolean;876  backupState?: boolean;877}): Promise<{ ok: boolean; error?: string }> {878  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };879880  const passkey: PasskeyData = {881    rpId: payload.rpId,882    rpName: payload.rpName,883    credentialId: payload.credentialId,884    publicKeyCose: payload.publicKeyCose,885    userHandle: payload.userHandle,886    userName: payload.userName,887    userDisplayName: payload.userDisplayName ?? payload.userName,888    algorithm: payload.algorithm,889    signCount: 0,890    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),891    backupEligible: payload.backupEligible ?? false,892    backupState: payload.backupState ?? false,893    browser: "firefox",894    transports: payload.transports ?? ["internal"],895    createdAt: Date.now(),896  };897898  // Same attach-or-create flow as the Chrome path: prefer attaching to an899  // existing login entry for the same domain so the user ends up with one900  // entry per site, not two (a login + a passkey-only).901  const target = findAttachTarget(payload.rpId);902  if (target) {903    await vault.updateEntry(target.id, { passkey });904  } else {905    await vault.addEntry({906      type: "login",907      title: payload.rpName || payload.rpId,908      url: `https://${payload.rpId}`,909      username: payload.userName,910      passkey,911    });912  }913  await vault.consolidatePasskeyEntries();914915  if (chrome.storage.session) {916    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);917  }918  return { ok: true };919}920921export async function cancelFirefoxPasskeyCreate(): Promise<void> {922  if (chrome.storage.session) {923    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);924  }925}926927function declineKey(domain: string): string {928  return `${FIREFOX_DECLINE_PREFIX}${domain}`;929}930931export async function recordPasskeyOfferDecline(domain: string): Promise<void> {932  if (!domain) return;933  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });934}935936export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {937  if (!domain) return false;938  const key = declineKey(domain);939  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;940  const at = stored[key];941  if (typeof at !== "number") return false;942  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {943    await chrome.storage.local.remove(key);944    return false;945  }946  return true;947}948949// ── Firefox content-script proxy path (v2.12.0) ────────────────────────950//951// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces952// navigator.credentials.create / .get on every Firefox page. It builds a953// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),954// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,955// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.956//957// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag958// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates959// to false and these handlers short-circuit. The full removal checklist is960// in that file — the deletion is purely subtractive.961962interface FirefoxAllowCredential {963  id: string;964  type?: string;965}966967interface FirefoxPubKeyCredParam {968  type?: string;969  alg: number;970}971972export interface FirefoxPasskeyHasCredentialsPayload {973  rpId: string;974  allowCredentials: FirefoxAllowCredential[];975}976977export interface FirefoxPasskeyCreatePayload {978  rpId: string;979  rpName?: string;980  userId: string;981  userName: string;982  userDisplayName?: string;983  challenge: string;984  clientDataJSON: number[];985  origin: string;986  pubKeyCredParams?: FirefoxPubKeyCredParam[];987  authenticatorSelection?: Record<string, unknown>;988  timeout?: number;989  attestation?: string;990}991992export interface FirefoxPasskeyGetPayload {993  rpId: string;994  challenge: string;995  clientDataJSON: number[];996  origin: string;997  allowCredentials: FirefoxAllowCredential[];998  userVerification?: string;999}10001001interface FirefoxCreateCredentialResponse {1002  type: "create";1003  id: string;1004  clientDataJSON: number[];1005  attestationObject: string;1006  authenticatorData: string;1007  publicKey: string;1008  algorithm: -7 | -8;1009}10101011interface FirefoxGetCredentialResponse {1012  type: "get";1013  id: string;1014  clientDataJSON: number[];1015  authenticatorData: string;1016  signature: string;1017  userHandle: string;1018}10191020type FirefoxPasskeyResult<T> =1021  | { ok: true; credential: T }1022  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10231024function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1025  return vault.getEntries().filter(1026    (entry): entry is VaultEntry & { passkey: PasskeyData } =>1027      entry.passkey !== undefined && !entry.deletedAt1028  );1029}10301031/** Decode the `origin` field out of a WebAuthn clientDataJSON byte array. */1032function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1033  if (!Array.isArray(bytes)) return null;1034  try {1035    const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1036    return typeof obj.origin === "string" ? obj.origin : null;1037  } catch {1038    return null;1039  }1040}10411042/**1043 * Enforce the WebAuthn rpId<->origin relationship on the Firefox proxy path,1044 * where Peach is the authenticator. The ONLY trustworthy origin is the one the1045 * browser attested for the calling page (`callerOrigin`, derived from the1046 * message sender in messages.ts) — NEVER `payload.origin`, which the page1047 * controls and can set to match any rpId (SEV-1-A / audit CRITICAL). Also1048 * refuse to sign a clientDataJSON whose embedded origin disagrees with the1049 * attested origin, so a forged ceremony can't smuggle a foreign origin into1050 * the signed bytes the RP verifies.1051 */1052function assertOriginOwnsRpId(1053  rpId: string,1054  callerOrigin: string | undefined,1055  clientDataJSON?: number[],1056): void {1057  if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1058    throw new DOMException(1059      `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1060        `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1061      "SecurityError",1062    );1063  }1064  const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1065  if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1066    throw new DOMException(1067      `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1068        `attested calling origin "${callerOrigin}".`,1069      "SecurityError",1070    );1071  }1072}10731074export async function handlePasskeyHasCredentials(1075  payload: FirefoxPasskeyHasCredentialsPayload,1076  callerOrigin?: string,1077): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1078  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1079    return { ok: false, error: "not_applicable" };1080  }1081  // Scope the existence check to the caller's own origin so an arbitrary page1082  // cannot enumerate which sites the user holds passkeys for (audit MEDIUM).1083  // A false answer is indistinguishable from "no credential" to the page.1084  if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1085    return { ok: true, hasCredentials: false };1086  }1087  if (!vault.isUnlocked()) {1088    // Cannot enumerate without the vault key. The MAIN-world proxy treats a1089    // false answer as "fall through to the platform" — exactly the right1090    // outcome when Peach is locked: the user gets the native UI instead.1091    return { ok: true, hasCredentials: false };1092  }1093  const passkeys = listPasskeyEntries().filter(1094    (entry) => entry.passkey.rpId === payload.rpId1095  );1096  if (passkeys.length === 0) return { ok: true, hasCredentials: false };10971098  const allow = payload.allowCredentials ?? [];1099  if (allow.length === 0) return { ok: true, hasCredentials: true };11001101  const hasMatch = passkeys.some((entry) =>1102    allow.some((ac) => ac.id === entry.passkey.credentialId)1103  );1104  return { ok: true, hasCredentials: hasMatch };1105}11061107/**1108 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing1109 * pending-passkey channel (badge + popup + 60s timeout) — the same approval1110 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need1111 * any new code path. The page-side proxy never sees the request id; the1112 * background mints one and discards it after resolution.1113 */1114async function requestFirefoxApproval(args: {1115  type: "create" | "get";1116  rpId: string;1117  rpName?: string;1118  userName: string;1119  userDisplayName?: string;1120}): Promise<boolean> {1121  // Synthetic request id — the Firefox proxy path doesn't have a1122  // chrome.webAuthenticationProxy request to tie back to.1123  const requestId = Date.now() + Math.floor(Math.random() * 1000);1124  return promptUserApproval({1125    requestId,1126    type: args.type,1127    rpId: args.rpId,1128    rpName: args.rpName ?? args.rpId,1129    userName: args.userName,1130    userDisplayName: args.userDisplayName,1131  });1132}11331134export async function handleFirefoxPasskeyCreate(1135  payload: FirefoxPasskeyCreatePayload,1136  callerOrigin?: string,1137): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1138  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1139    return { ok: false, error: "not_applicable" };1140  }1141  // WebAuthn §7.1 step 6 — the requested rpId must be owned by the page's1142  // browser-ATTESTED origin (threaded in as callerOrigin from the message1143  // sender), NOT the page-supplied payload.origin which an attacker controls.1144  // On the Firefox proxy path Peach is the authenticator and must enforce this1145  // before minting anything (audit CRITICAL — origin spoofing).1146  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1147  if (!(await isProUser())) {1148    broadcastPasskeyProGate();1149    return { ok: false, error: "pro_required" };1150  }1151  if (!vault.isUnlocked()) {1152    await openApprovalPopup();1153    return { ok: false, error: "vault_locked" };1154  }11551156  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1157  if (!algorithm) {1158    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1159  }11601161  const approved = await requestFirefoxApproval({1162    type: "create",1163    rpId: payload.rpId,1164    rpName: payload.rpName,1165    userName: payload.userName,1166    userDisplayName: payload.userDisplayName,1167  });1168  if (!approved) return { ok: false, error: "user_cancelled" };11691170  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1171  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);11721173  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).1174  // Matches Chrome's create flag set in handleCreateRequest above.1175  const authenticatorData = await buildAuthenticatorData({1176    rpId: payload.rpId,1177    flags: 0x45,1178    signCount: 0,1179    credentialId: credentialIdBytes,1180    publicKeyCose,1181  });1182  const attestationObject = createAttestationObject(authenticatorData);11831184  const passkey: PasskeyData = {1185    rpId: payload.rpId,1186    rpName: payload.rpName ?? payload.rpId,1187    credentialId,1188    publicKeyCose: base64urlEncode(publicKeyCose),1189    privateKeyJwk,1190    userHandle: payload.userId,1191    userName: payload.userName,1192    userDisplayName: payload.userDisplayName ?? payload.userName,1193    algorithm,1194    signCount: 0,1195    aaguid: base64urlEncode(new Uint8Array(16)),1196    backupEligible: false,1197    backupState: false,1198    browser: "firefox",1199    transports: ["internal"],1200    createdAt: Date.now(),1201  };12021203  const target = findAttachTarget(payload.rpId);1204  if (target) {1205    await vault.updateEntry(target.id, { passkey });1206  } else {1207    await vault.addEntry({1208      type: "login",1209      title: payload.rpName || payload.rpId,1210      url: `https://${payload.rpId}`,1211      username: payload.userName,1212      passkey,1213    });1214  }1215  await vault.consolidatePasskeyEntries();12161217  return {1218    ok: true,1219    credential: {1220      type: "create",1221      id: credentialId,1222      // The content script already encoded clientDataJSON with the page1223      // origin; round-trip the exact bytes back so the RP sees the same1224      // hash it computes locally.1225      clientDataJSON: payload.clientDataJSON,1226      attestationObject: base64urlEncode(attestationObject),1227      authenticatorData: base64urlEncode(authenticatorData),1228      publicKey: base64urlEncode(publicKeySpki),1229      algorithm,1230    },1231  };1232}12331234export async function handleFirefoxPasskeyGet(1235  payload: FirefoxPasskeyGetPayload,1236  callerOrigin?: string,1237): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1238  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1239    return { ok: false, error: "not_applicable" };1240  }1241  // WebAuthn §7.2 step 8 — the rpId must be owned by the page's browser-attested1242  // origin (callerOrigin from the message sender), NOT the page-supplied1243  // payload.origin. Both rpId and payload.origin are attacker-controlled, so1244  // validating one against the other is no protection; a cross-origin page must1245  // not be able to coax Peach into signing an assertion scoped to another site1246  // (audit CRITICAL — assertion forgery).1247  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1248  if (!(await isProUser())) {1249    broadcastPasskeyProGate();1250    return { ok: false, error: "pro_required" };1251  }1252  if (!vault.isUnlocked()) {1253    await openApprovalPopup();1254    return { ok: false, error: "vault_locked" };1255  }12561257  const allow = payload.allowCredentials ?? [];1258  const candidates = listPasskeyEntries().filter((entry) => {1259    if (entry.passkey.rpId !== payload.rpId) return false;1260    if (allow.length === 0) return true;1261    return allow.some((ac) => ac.id === entry.passkey.credentialId);1262  });12631264  if (candidates.length === 0) return { ok: false, error: "no_credentials" };12651266  // Most-recently-used wins when several credentials match. A future1267  // multi-account selector goes here; for now this matches the Chrome path's1268  // behavior for the common one-passkey-per-rp case.1269  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1270  const match = candidates[0]!;1271  const credential = match.passkey;12721273  if (!credential.privateKeyJwk) {1274    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1275    // — no Peach-held private key to sign with. The MAIN-world proxy treats1276    // no_credentials as a fall-through, which routes to the platform UI.1277    return { ok: false, error: "no_credentials" };1278  }12791280  const approved = await requestFirefoxApproval({1281    type: "get",1282    rpId: credential.rpId,1283    rpName: credential.rpName,1284    userName: credential.userName,1285    userDisplayName: credential.userDisplayName,1286  });1287  if (!approved) return { ok: false, error: "user_cancelled" };12881289  const nextSignCount = credential.signCount + 1;1290  // 0x05 = UP|UV. backup bits track whatever was set at registration time1291  // (consistent with the Chrome path).1292  const flags =1293    0x05 |1294    (credential.backupEligible ? 0x08 : 0) |1295    (credential.backupState ? 0x10 : 0);1296  const authenticatorData = await buildAuthenticatorData({1297    rpId: credential.rpId,1298    flags,1299    signCount: nextSignCount,1300  });13011302  // The content script supplied clientDataJSON bytes built with the page1303  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1304  // diverges from what the RP computes.1305  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1306  const clientDataHash = new Uint8Array(1307    await crypto.subtle.digest("SHA-256", clientDataBytes)1308  );1309  const signature = await signPasskeyAssertion(1310    credential.privateKeyJwk,1311    credential.algorithm,1312    authenticatorData,1313    clientDataHash1314  );13151316  await vault.updateEntry(match.id, {1317    passkey: {1318      ...credential,1319      signCount: nextSignCount,1320      lastUsedAt: Date.now(),1321    },1322  });13231324  return {1325    ok: true,1326    credential: {1327      type: "get",1328      id: credential.credentialId,1329      clientDataJSON: payload.clientDataJSON,1330      authenticatorData: base64urlEncode(authenticatorData),1331      signature: base64urlEncode(signature),1332      userHandle: credential.userHandle,1333    },1334  };1335}13361337export async function setupPasskeyProxy(): Promise<void> {1338  if (!isPasskeySupported()) {1339    console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1340    return;1341  }13421343  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1344  // popup-context credential creator is the entry point; nothing to wire here.1345  if (detectSharedBrowser() === "firefox") {1346    await refreshPasskeyProxy();1347    return;1348  }13491350  if (!listenersRegistered) {1351    console.log("[Peach passkey] registering webAuthenticationProxy listeners");1352    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1353      // Peach is a user-verifying platform authenticator whenever the proxy1354      // is attached. The lock state is irrelevant here — the create/get1355      // handlers prompt for unlock when needed. Returning false while locked1356      // makes Google (and any RP that checks isUvpaa as a precondition) show1357      // "A passkey can't be created on this device" before the user even1358      // gets a chance to start the ceremony.1359      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1360        requestId: requestInfo.requestId,1361        isUvpaa: true,1362      });1363    });1364    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1365      console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1366      void handleCreateRequest(requestInfo).catch(async (error) => {1367        // "Invalid requestId" means Chrome already cancelled — handlers will1368        // log it themselves and short-circuit, but if it propagates out1369        // (e.g., from a still-in-flight branch) don't double-complete.1370        if (isInvalidRequestIdError(error)) {1371          console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1372          return;1373        }1374        console.error("Peach passkey create failed", error);1375        await rejectCreate(1376          requestInfo.requestId,1377          "UnknownError",1378          error instanceof Error ? error.message : "Passkey creation failed."1379        );1380      });1381    });1382    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1383      console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1384      void handleGetRequest(requestInfo).catch(async (error) => {1385        if (isInvalidRequestIdError(error)) {1386          console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1387          return;1388        }1389        console.error("Peach passkey get failed", error);1390        await rejectGet(1391          requestInfo.requestId,1392          "UnknownError",1393          error instanceof Error ? error.message : "Passkey authentication failed."1394        );1395      });1396    });1397    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1398      console.log("[Peach passkey] onRequestCanceled", { requestId });1399      markCancelled(requestId);1400      // Release any unlock waiter parked for this request.1401      const waiter = unlockWaiters.get(requestId);1402      if (waiter) {1403        clearTimeout(waiter.timeoutId);1404        waiter.resolve(false);1405        unlockWaiters.delete(requestId);1406      }1407      void resolvePendingPasskeyRequest(requestId, false);1408    });1409    listenersRegistered = true;1410  }14111412  await refreshPasskeyProxy();1413}