passkey.ts

47.8 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  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {432    console.warn("[Peach passkey] reject: user is not Pro");433    await rejectCreate(434      request.requestId,435      "NotAllowedError",436      "Passkey creation requires Peach Pro."437    );438    // Show the inline passkey gate so the user knows what happened and can upgrade.439    broadcastPasskeyProGate();440    return;441  }442443  if (!vault.isUnlocked()) {444    console.log("[Peach passkey] vault locked, summoning inline unlock");445    // Same wait-for-unlock pattern handleGetRequest uses: summon the inline446    // unlock prompt on the active tab so the user can unlock without leaving447    // the page, then hold the create request open until the vault unlocks or448    // the timeout fires. Immediately rejecting here means Google shows449    // "Something went wrong" the instant the user picks Peach.450    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>451      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)452    );453    const activeTab = tabs[0];454    console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });455    if (activeTab?.id) {456      chrome.tabs.sendMessage(activeTab.id, {457        type: 'SUMMON_AUTOFILL',458        source: 'passkey-unlock',459      }).catch((err) => {460        console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);461        void openApprovalPopup();462      });463    } else {464      await openApprovalPopup();465    }466467    const unlocked = await new Promise<boolean>((resolve) => {468      const timeoutId = setTimeout(() => {469        unlockWaiters.delete(request.requestId);470        resolve(false);471      }, APPROVAL_TIMEOUT_MS);472      unlockWaiters.set(request.requestId, { resolve, timeoutId });473    });474475    console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });476    if (!unlocked || !vault.isUnlocked()) {477      console.warn("[Peach passkey] reject: unlock timeout");478      await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");479      return;480    }481    // Vault is now unlocked — fall through to approval.482  }483484  console.log("[Peach passkey] requesting approval via popup");485  const approved = await promptUserApproval({486    requestId: request.requestId,487    type: "create",488    rpId,489    rpName,490    userName,491    userDisplayName,492  });493  console.log("[Peach passkey] approval resolved", { approved });494  if (!approved) {495    console.warn("[Peach passkey] reject: user denied");496    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");497    return;498  }499500  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);501  if (!algorithm) {502    console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);503    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");504    return;505  }506  console.log("[Peach passkey] algorithm selected", algorithm);507508  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));509  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(510    algorithm511  );512  const authData = await buildAuthenticatorData({513    rpId,514    flags: 0x4d,515    signCount: 0,516    credentialId: credentialIdBytes,517    publicKeyCose,518  });519  const attestationObject = createAttestationObject(authData);520  const clientDataJSON = buildClientDataJSON(521    "webauthn.create",522    options.challenge,523    await resolvePageOrigin(rpId)524  );525526  const passkey: PasskeyData = {527    rpId,528    rpName,529    credentialId: base64urlEncode(credentialIdBytes),530    publicKeyCose: base64urlEncode(publicKeyCose),531    privateKeyJwk,532    userHandle: options.user.id,533    userName,534    userDisplayName,535    algorithm,536    signCount: 0,537    aaguid: base64urlEncode(new Uint8Array(16)),538    backupEligible: false,539    backupState: false,540    browser: "chrome",541    transports: ["internal"],542    createdAt: Date.now(),543  };544545  // Attach to an existing login (same domain) when one exists; otherwise546  // create a fresh login entry that exists solely to hold the passkey.547  const target = findAttachTarget(rpId);548  if (target) {549    await vault.updateEntry(target.id, { passkey });550  } else {551    await vault.addEntry({552      type: "login",553      title: rpName,554      url: `https://${rpId}`,555      username: userName,556      passkey,557    });558  }559560  try {561    await chrome.webAuthenticationProxy.completeCreateRequest({562      requestId: request.requestId,563      responseJson: JSON.stringify({564        id: passkey.credentialId,565        rawId: passkey.credentialId,566        type: "public-key",567        response: {568          clientDataJSON: base64urlEncode(clientDataJSON),569          authenticatorData: base64urlEncode(authData),570          attestationObject: base64urlEncode(attestationObject),571          transports: ["internal"],572          publicKey: base64urlEncode(publicKeySpki),573          publicKeyAlgorithm: algorithm,574        },575        authenticatorAttachment: "platform",576        clientExtensionResults: {},577      }),578    });579    console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });580  } catch (err) {581    if (isInvalidRequestIdError(err)) {582      console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });583      return;584    }585    throw err;586  }587}588589function broadcastPasskeyProGate(): void {590  chrome.tabs.query({}, (tabs) => {591    for (const tab of tabs) {592      if (tab.id !== undefined) {593        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});594      }595    }596  });597}598599async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {600  if (chrome.storage.session) {601    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });602  }603604  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {605    chrome.tabs.query({ active: true, currentWindow: true }, resolve);606  });607  const tab = tabs[0];608609  if (tab?.id) {610    chrome.tabs.sendMessage(tab.id, {611      type: 'SHOW_PASSKEY_TOAST',612      payload: {613        site: request.rpId,614        displayName: request.userDisplayName ?? request.userName,615        username: request.userName,616        requestId: String(request.requestId),617      },618    }).catch(() => {});619  } else {620    await openApprovalPopup();621  }622623  return new Promise<boolean>((resolve) => {624    const timeoutId = setTimeout(async () => {625      approvals.delete(request.requestId);626      await clearPendingRequest(request.requestId);627      resolve(false);628    }, APPROVAL_TIMEOUT_MS);629630    approvals.set(request.requestId, { resolve, timeoutId });631  });632}633634async function handleGetRequest(635  request: chrome.webAuthenticationProxy.GetRequest636): Promise<void> {637  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;638  if (!vault.isUnlocked()) {639    // Summon the inline unlock prompt on the active tab so the user can unlock640    // without leaving the page. Fall back to the popup if the content script641    // is unavailable (e.g. restricted URL, script not yet injected).642    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>643      chrome.tabs.query({ active: true, currentWindow: true }, resolve)644    );645    const activeTab = tabs[0];646    if (activeTab?.id) {647      chrome.tabs.sendMessage(activeTab.id, {648        type: 'SUMMON_AUTOFILL',649        source: 'passkey-unlock',650      }).catch(() => { void openApprovalPopup(); });651    } else {652      await openApprovalPopup();653    }654655    // Hold the request open until the vault unlocks or the timeout fires.656    const unlocked = await new Promise<boolean>((resolve) => {657      const timeoutId = setTimeout(() => {658        unlockWaiters.delete(request.requestId);659        resolve(false);660      }, APPROVAL_TIMEOUT_MS);661      unlockWaiters.set(request.requestId, { resolve, timeoutId });662    });663664    if (!unlocked || !vault.isUnlocked()) {665      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");666      return;667    }668    // Vault is now unlocked — fall through to authenticate.669  }670671  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {672    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");673    broadcastPasskeyProGate();674    return;675  }676677  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>678    entry.passkey !== undefined && !entry.deletedAt679  );680  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);681682  const match = passkeyBearing.find((entry) => {683    if (allowedCredentialIds.length > 0) {684      return allowedCredentialIds.includes(entry.passkey.credentialId);685    }686    if (!options.rpId) return false;687    return entry.passkey.rpId === options.rpId;688  });689690  if (!match) {691    // No Peach passkey for this RP — pass through to the platform authenticator.692    // completeGetRequest with no error and no responseJson is the webAuthenticationProxy693    // signal for "I'm not handling this"; Chrome routes the request to the next694    // authenticator in the chain (the built-in browser passkey manager etc.).695    // Rejecting with NotAllowedError would propagate as an error to the page and696    // break RPs (e.g. Google) that issue a GET as part of their create-passkey flow.697    console.log("[Peach passkey] no matching credential — passing through to platform", { requestId: request.requestId });698    try {699      await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });700    } catch (err) {701      if (isInvalidRequestIdError(err)) return;702      if (isMissingResponseJsonError(err)) return;703      throw err;704    }705    return;706  }707708  const credential = match.passkey;709  // Chrome's webAuthenticationProxy path requires Peach-held private key material.710  // Passkeys created on Firefox have no privateKeyJwk — pass through so Chrome711  // can route to a capable authenticator instead of surfacing an error to the page.712  if (!credential.privateKeyJwk) {713    console.log("[Peach passkey] no private key (Firefox passkey) — passing through to platform", { requestId: request.requestId });714    try {715      await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId });716    } catch (err) {717      if (isInvalidRequestIdError(err)) return;718      if (isMissingResponseJsonError(err)) return;719      throw err;720    }721    return;722  }723724  const approved = await promptViaToast({725    requestId: request.requestId,726    type: "get",727    rpId: credential.rpId,728    rpName: credential.rpName,729    userName: credential.userName,730    userDisplayName: credential.userDisplayName,731  });732  if (!approved) {733    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");734    return;735  }736737  const nextSignCount = credential.signCount + 1;738  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);739  const authData = await buildAuthenticatorData({740    rpId: credential.rpId,741    flags,742    signCount: nextSignCount,743  });744  const clientDataJSON = buildClientDataJSON(745    "webauthn.get",746    options.challenge,747    await resolvePageOrigin(credential.rpId)748  );749  const clientDataHash = new Uint8Array(750    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))751  );752  const signature = await signPasskeyAssertion(753    credential.privateKeyJwk,754    credential.algorithm,755    authData,756    clientDataHash757  );758759  await vault.updateEntry(match.id, {760    passkey: {761      ...credential,762      signCount: nextSignCount,763      lastUsedAt: Date.now(),764    },765  });766767  const responseJson = JSON.stringify({768    id: credential.credentialId,769    rawId: credential.credentialId,770    type: "public-key",771    response: {772      clientDataJSON: base64urlEncode(clientDataJSON),773      authenticatorData: base64urlEncode(authData),774      signature: base64urlEncode(signature),775      userHandle: credential.userHandle,776    },777    authenticatorAttachment: "platform",778    clientExtensionResults: {},779  });780781  try {782    await chrome.webAuthenticationProxy.completeGetRequest({783      requestId: request.requestId,784      responseJson,785    });786    console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });787  } catch (err) {788    if (isInvalidRequestIdError(err)) {789      console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });790      return;791    }792    throw err;793  }794}795796// ── Firefox path ────────────────────────────────────────────────────────797//798// Firefox 150+ permits extensions to call navigator.credentials.create/get799// from the popup context with their host-permitted RP IDs. Peach uses that800// surface to register passkeys on behalf of the user. The page's own801// navigator.credentials call is observed via the MAIN-world detector802// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's803// flow is parallel and explicit (the user must engage with Peach's button).804805const FIREFOX_PENDING_KEY = "passkey_pending_firefox";806const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";807const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days808809export async function initiateFirefoxPasskeyCreate(args: {810  rpId: string;811  rpName: string;812  userName: string;813  userDisplayName?: string;814}): Promise<{ ok: boolean; error?: string }> {815  if (!(await isProUser())) {816    broadcastPasskeyProGate();817    return { ok: false, error: "passkey creation requires Peach Pro" };818  }819  if (!vault.isUnlocked()) {820    await openApprovalPopup();821    return { ok: false, error: "vault_locked" };822  }823  if (!chrome.storage.session) {824    return { ok: false, error: "session storage unavailable" };825  }826827  // Date.now() alone collides if two Firefox passkey requests land in the828  // same millisecond. Mix in 20 random bits to match the synthetic-id pattern829  // at requestFirefoxApproval (M9/COR-010).830  const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);831  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));832  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));833834  const pending: PendingPasskeyRequest = {835    requestId,836    type: "create",837    rpId: args.rpId,838    rpName: args.rpName,839    userName: args.userName,840    userDisplayName: args.userDisplayName,841    browser: "firefox",842    challenge,843    userHandle,844  };845846  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });847  await openApprovalPopup();848  return { ok: true };849}850851export async function completeFirefoxPasskeyCreate(payload: {852  requestId: number;853  rpId: string;854  rpName: string;855  userName: string;856  userDisplayName?: string;857  userHandle: string;858  credentialId: string;859  publicKeyCose: string;860  algorithm: -7 | -8;861  aaguid?: string;862  transports?: string[];863  backupEligible?: boolean;864  backupState?: boolean;865}): Promise<{ ok: boolean; error?: string }> {866  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };867868  const passkey: PasskeyData = {869    rpId: payload.rpId,870    rpName: payload.rpName,871    credentialId: payload.credentialId,872    publicKeyCose: payload.publicKeyCose,873    userHandle: payload.userHandle,874    userName: payload.userName,875    userDisplayName: payload.userDisplayName ?? payload.userName,876    algorithm: payload.algorithm,877    signCount: 0,878    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),879    backupEligible: payload.backupEligible ?? false,880    backupState: payload.backupState ?? false,881    browser: "firefox",882    transports: payload.transports ?? ["internal"],883    createdAt: Date.now(),884  };885886  // Same attach-or-create flow as the Chrome path: prefer attaching to an887  // existing login entry for the same domain so the user ends up with one888  // entry per site, not two (a login + a passkey-only).889  const target = findAttachTarget(payload.rpId);890  if (target) {891    await vault.updateEntry(target.id, { passkey });892  } else {893    await vault.addEntry({894      type: "login",895      title: payload.rpName || payload.rpId,896      url: `https://${payload.rpId}`,897      username: payload.userName,898      passkey,899    });900  }901902  if (chrome.storage.session) {903    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);904  }905  return { ok: true };906}907908export async function cancelFirefoxPasskeyCreate(): Promise<void> {909  if (chrome.storage.session) {910    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);911  }912}913914function declineKey(domain: string): string {915  return `${FIREFOX_DECLINE_PREFIX}${domain}`;916}917918export async function recordPasskeyOfferDecline(domain: string): Promise<void> {919  if (!domain) return;920  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });921}922923export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {924  if (!domain) return false;925  const key = declineKey(domain);926  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;927  const at = stored[key];928  if (typeof at !== "number") return false;929  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {930    await chrome.storage.local.remove(key);931    return false;932  }933  return true;934}935936// ── Firefox content-script proxy path (v2.12.0) ────────────────────────937//938// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces939// navigator.credentials.create / .get on every Firefox page. It builds a940// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),941// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,942// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.943//944// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag945// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates946// to false and these handlers short-circuit. The full removal checklist is947// in that file — the deletion is purely subtractive.948949interface FirefoxAllowCredential {950  id: string;951  type?: string;952}953954interface FirefoxPubKeyCredParam {955  type?: string;956  alg: number;957}958959export interface FirefoxPasskeyHasCredentialsPayload {960  rpId: string;961  allowCredentials: FirefoxAllowCredential[];962}963964export interface FirefoxPasskeyCreatePayload {965  rpId: string;966  rpName?: string;967  userId: string;968  userName: string;969  userDisplayName?: string;970  challenge: string;971  clientDataJSON: number[];972  origin: string;973  pubKeyCredParams?: FirefoxPubKeyCredParam[];974  authenticatorSelection?: Record<string, unknown>;975  timeout?: number;976  attestation?: string;977}978979export interface FirefoxPasskeyGetPayload {980  rpId: string;981  challenge: string;982  clientDataJSON: number[];983  origin: string;984  allowCredentials: FirefoxAllowCredential[];985  userVerification?: string;986}987988interface FirefoxCreateCredentialResponse {989  type: "create";990  id: string;991  clientDataJSON: number[];992  attestationObject: string;993  authenticatorData: string;994  publicKey: string;995  algorithm: -7 | -8;996}997998interface FirefoxGetCredentialResponse {999  type: "get";1000  id: string;1001  clientDataJSON: number[];1002  authenticatorData: string;1003  signature: string;1004  userHandle: string;1005}10061007type FirefoxPasskeyResult<T> =1008  | { ok: true; credential: T }1009  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };10101011function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {1012  return vault.getEntries().filter(1013    (entry): entry is VaultEntry & { passkey: PasskeyData } =>1014      entry.passkey !== undefined && !entry.deletedAt1015  );1016}10171018/** Decode the `origin` field out of a WebAuthn clientDataJSON byte array. */1019function clientDataJsonOrigin(bytes: number[] | undefined): string | null {1020  if (!Array.isArray(bytes)) return null;1021  try {1022    const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };1023    return typeof obj.origin === "string" ? obj.origin : null;1024  } catch {1025    return null;1026  }1027}10281029/**1030 * Enforce the WebAuthn rpId<->origin relationship on the Firefox proxy path,1031 * where Peach is the authenticator. The ONLY trustworthy origin is the one the1032 * browser attested for the calling page (`callerOrigin`, derived from the1033 * message sender in messages.ts) — NEVER `payload.origin`, which the page1034 * controls and can set to match any rpId (SEV-1-A / audit CRITICAL). Also1035 * refuse to sign a clientDataJSON whose embedded origin disagrees with the1036 * attested origin, so a forged ceremony can't smuggle a foreign origin into1037 * the signed bytes the RP verifies.1038 */1039function assertOriginOwnsRpId(1040  rpId: string,1041  callerOrigin: string | undefined,1042  clientDataJSON?: number[],1043): void {1044  if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1045    throw new DOMException(1046      `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1047        `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1048      "SecurityError",1049    );1050  }1051  const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1052  if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1053    throw new DOMException(1054      `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1055        `attested calling origin "${callerOrigin}".`,1056      "SecurityError",1057    );1058  }1059}10601061export async function handlePasskeyHasCredentials(1062  payload: FirefoxPasskeyHasCredentialsPayload,1063  callerOrigin?: string,1064): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1065  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1066    return { ok: false, error: "not_applicable" };1067  }1068  // Scope the existence check to the caller's own origin so an arbitrary page1069  // cannot enumerate which sites the user holds passkeys for (audit MEDIUM).1070  // A false answer is indistinguishable from "no credential" to the page.1071  if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1072    return { ok: true, hasCredentials: false };1073  }1074  if (!vault.isUnlocked()) {1075    // Cannot enumerate without the vault key. The MAIN-world proxy treats a1076    // false answer as "fall through to the platform" — exactly the right1077    // outcome when Peach is locked: the user gets the native UI instead.1078    return { ok: true, hasCredentials: false };1079  }1080  const passkeys = listPasskeyEntries().filter(1081    (entry) => entry.passkey.rpId === payload.rpId1082  );1083  if (passkeys.length === 0) return { ok: true, hasCredentials: false };10841085  const allow = payload.allowCredentials ?? [];1086  if (allow.length === 0) return { ok: true, hasCredentials: true };10871088  const hasMatch = passkeys.some((entry) =>1089    allow.some((ac) => ac.id === entry.passkey.credentialId)1090  );1091  return { ok: true, hasCredentials: hasMatch };1092}10931094/**1095 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing1096 * pending-passkey channel (badge + popup + 60s timeout) — the same approval1097 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need1098 * any new code path. The page-side proxy never sees the request id; the1099 * background mints one and discards it after resolution.1100 */1101async function requestFirefoxApproval(args: {1102  type: "create" | "get";1103  rpId: string;1104  rpName?: string;1105  userName: string;1106  userDisplayName?: string;1107}): Promise<boolean> {1108  // Synthetic request id — the Firefox proxy path doesn't have a1109  // chrome.webAuthenticationProxy request to tie back to.1110  const requestId = Date.now() + Math.floor(Math.random() * 1000);1111  return promptUserApproval({1112    requestId,1113    type: args.type,1114    rpId: args.rpId,1115    rpName: args.rpName ?? args.rpId,1116    userName: args.userName,1117    userDisplayName: args.userDisplayName,1118  });1119}11201121export async function handleFirefoxPasskeyCreate(1122  payload: FirefoxPasskeyCreatePayload,1123  callerOrigin?: string,1124): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1125  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1126    return { ok: false, error: "not_applicable" };1127  }1128  // WebAuthn §7.1 step 6 — the requested rpId must be owned by the page's1129  // browser-ATTESTED origin (threaded in as callerOrigin from the message1130  // sender), NOT the page-supplied payload.origin which an attacker controls.1131  // On the Firefox proxy path Peach is the authenticator and must enforce this1132  // before minting anything (audit CRITICAL — origin spoofing).1133  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1134  if (!(await isProUser())) {1135    broadcastPasskeyProGate();1136    return { ok: false, error: "pro_required" };1137  }1138  if (!vault.isUnlocked()) {1139    await openApprovalPopup();1140    return { ok: false, error: "vault_locked" };1141  }11421143  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1144  if (!algorithm) {1145    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1146  }11471148  const approved = await requestFirefoxApproval({1149    type: "create",1150    rpId: payload.rpId,1151    rpName: payload.rpName,1152    userName: payload.userName,1153    userDisplayName: payload.userDisplayName,1154  });1155  if (!approved) return { ok: false, error: "user_cancelled" };11561157  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1158  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);11591160  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).1161  // Matches Chrome's create flag set in handleCreateRequest above.1162  const authenticatorData = await buildAuthenticatorData({1163    rpId: payload.rpId,1164    flags: 0x45,1165    signCount: 0,1166    credentialId: credentialIdBytes,1167    publicKeyCose,1168  });1169  const attestationObject = createAttestationObject(authenticatorData);11701171  const passkey: PasskeyData = {1172    rpId: payload.rpId,1173    rpName: payload.rpName ?? payload.rpId,1174    credentialId,1175    publicKeyCose: base64urlEncode(publicKeyCose),1176    privateKeyJwk,1177    userHandle: payload.userId,1178    userName: payload.userName,1179    userDisplayName: payload.userDisplayName ?? payload.userName,1180    algorithm,1181    signCount: 0,1182    aaguid: base64urlEncode(new Uint8Array(16)),1183    backupEligible: false,1184    backupState: false,1185    browser: "firefox",1186    transports: ["internal"],1187    createdAt: Date.now(),1188  };11891190  const target = findAttachTarget(payload.rpId);1191  if (target) {1192    await vault.updateEntry(target.id, { passkey });1193  } else {1194    await vault.addEntry({1195      type: "login",1196      title: payload.rpName || payload.rpId,1197      url: `https://${payload.rpId}`,1198      username: payload.userName,1199      passkey,1200    });1201  }12021203  return {1204    ok: true,1205    credential: {1206      type: "create",1207      id: credentialId,1208      // The content script already encoded clientDataJSON with the page1209      // origin; round-trip the exact bytes back so the RP sees the same1210      // hash it computes locally.1211      clientDataJSON: payload.clientDataJSON,1212      attestationObject: base64urlEncode(attestationObject),1213      authenticatorData: base64urlEncode(authenticatorData),1214      publicKey: base64urlEncode(publicKeySpki),1215      algorithm,1216    },1217  };1218}12191220export async function handleFirefoxPasskeyGet(1221  payload: FirefoxPasskeyGetPayload,1222  callerOrigin?: string,1223): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1224  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1225    return { ok: false, error: "not_applicable" };1226  }1227  // WebAuthn §7.2 step 8 — the rpId must be owned by the page's browser-attested1228  // origin (callerOrigin from the message sender), NOT the page-supplied1229  // payload.origin. Both rpId and payload.origin are attacker-controlled, so1230  // validating one against the other is no protection; a cross-origin page must1231  // not be able to coax Peach into signing an assertion scoped to another site1232  // (audit CRITICAL — assertion forgery).1233  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1234  if (!(await isProUser())) {1235    broadcastPasskeyProGate();1236    return { ok: false, error: "pro_required" };1237  }1238  if (!vault.isUnlocked()) {1239    await openApprovalPopup();1240    return { ok: false, error: "vault_locked" };1241  }12421243  const allow = payload.allowCredentials ?? [];1244  const candidates = listPasskeyEntries().filter((entry) => {1245    if (entry.passkey.rpId !== payload.rpId) return false;1246    if (allow.length === 0) return true;1247    return allow.some((ac) => ac.id === entry.passkey.credentialId);1248  });12491250  if (candidates.length === 0) return { ok: false, error: "no_credentials" };12511252  // Most-recently-used wins when several credentials match. A future1253  // multi-account selector goes here; for now this matches the Chrome path's1254  // behavior for the common one-passkey-per-rp case.1255  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1256  const match = candidates[0]!;1257  const credential = match.passkey;12581259  if (!credential.privateKeyJwk) {1260    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1261    // — no Peach-held private key to sign with. The MAIN-world proxy treats1262    // no_credentials as a fall-through, which routes to the platform UI.1263    return { ok: false, error: "no_credentials" };1264  }12651266  const approved = await requestFirefoxApproval({1267    type: "get",1268    rpId: credential.rpId,1269    rpName: credential.rpName,1270    userName: credential.userName,1271    userDisplayName: credential.userDisplayName,1272  });1273  if (!approved) return { ok: false, error: "user_cancelled" };12741275  const nextSignCount = credential.signCount + 1;1276  // 0x05 = UP|UV. backup bits track whatever was set at registration time1277  // (consistent with the Chrome path).1278  const flags =1279    0x05 |1280    (credential.backupEligible ? 0x08 : 0) |1281    (credential.backupState ? 0x10 : 0);1282  const authenticatorData = await buildAuthenticatorData({1283    rpId: credential.rpId,1284    flags,1285    signCount: nextSignCount,1286  });12871288  // The content script supplied clientDataJSON bytes built with the page1289  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1290  // diverges from what the RP computes.1291  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1292  const clientDataHash = new Uint8Array(1293    await crypto.subtle.digest("SHA-256", clientDataBytes)1294  );1295  const signature = await signPasskeyAssertion(1296    credential.privateKeyJwk,1297    credential.algorithm,1298    authenticatorData,1299    clientDataHash1300  );13011302  await vault.updateEntry(match.id, {1303    passkey: {1304      ...credential,1305      signCount: nextSignCount,1306      lastUsedAt: Date.now(),1307    },1308  });13091310  return {1311    ok: true,1312    credential: {1313      type: "get",1314      id: credential.credentialId,1315      clientDataJSON: payload.clientDataJSON,1316      authenticatorData: base64urlEncode(authenticatorData),1317      signature: base64urlEncode(signature),1318      userHandle: credential.userHandle,1319    },1320  };1321}13221323export async function setupPasskeyProxy(): Promise<void> {1324  if (!isPasskeySupported()) {1325    console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1326    return;1327  }13281329  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1330  // popup-context credential creator is the entry point; nothing to wire here.1331  if (detectSharedBrowser() === "firefox") {1332    await refreshPasskeyProxy();1333    return;1334  }13351336  if (!listenersRegistered) {1337    console.log("[Peach passkey] registering webAuthenticationProxy listeners");1338    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1339      // Peach is a user-verifying platform authenticator whenever the proxy1340      // is attached. The lock state is irrelevant here — the create/get1341      // handlers prompt for unlock when needed. Returning false while locked1342      // makes Google (and any RP that checks isUvpaa as a precondition) show1343      // "A passkey can't be created on this device" before the user even1344      // gets a chance to start the ceremony.1345      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1346        requestId: requestInfo.requestId,1347        isUvpaa: true,1348      });1349    });1350    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1351      console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1352      void handleCreateRequest(requestInfo).catch(async (error) => {1353        // "Invalid requestId" means Chrome already cancelled — handlers will1354        // log it themselves and short-circuit, but if it propagates out1355        // (e.g., from a still-in-flight branch) don't double-complete.1356        if (isInvalidRequestIdError(error)) {1357          console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1358          return;1359        }1360        console.error("Peach passkey create failed", error);1361        await rejectCreate(1362          requestInfo.requestId,1363          "UnknownError",1364          error instanceof Error ? error.message : "Passkey creation failed."1365        );1366      });1367    });1368    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1369      console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1370      void handleGetRequest(requestInfo).catch(async (error) => {1371        if (isInvalidRequestIdError(error)) {1372          console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1373          return;1374        }1375        console.error("Peach passkey get failed", error);1376        await rejectGet(1377          requestInfo.requestId,1378          "UnknownError",1379          error instanceof Error ? error.message : "Passkey authentication failed."1380        );1381      });1382    });1383    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1384      console.log("[Peach passkey] onRequestCanceled", { requestId });1385      markCancelled(requestId);1386      // Release any unlock waiter parked for this request.1387      const waiter = unlockWaiters.get(requestId);1388      if (waiter) {1389        clearTimeout(waiter.timeoutId);1390        waiter.resolve(false);1391        unlockWaiters.delete(requestId);1392      }1393      void resolvePendingPasskeyRequest(requestId, false);1394    });1395    listenersRegistered = true;1396  }13971398  await refreshPasskeyProxy();1399}