passkey.ts

34.1 KB · raw

1import { isProUser } from "./license";2import * as vault from "./vault";3import {4  base64urlDecode,5  base64urlEncode,6  buildAuthenticatorData,7  buildClientDataJSON,8  createAttestationObject,9  generateCredentialId,10  generatePasskeyKeypair,11  pickPasskeyAlgorithm,12  signPasskeyAssertion,13} from "./passkey-crypto";14import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";15import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";16import type {17  PasskeyData,18  PasskeyProviderState,19  PendingPasskeyRequest,20  VaultEntry,21} from "../shared/types";2223declare const __PEACH_E2E_PASSKEYS__: boolean;2425const PENDING_PREFIX = "passkey_pending_";26const APPROVAL_TIMEOUT_MS = 60_000;2728let listenersRegistered = false;29let attached = false;30let conflict: string | undefined;31const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3233const approvals = new Map<34  number,35  { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }36>();3738// Resolvers for GET requests that arrived while the vault was locked and are39// waiting for the user to unlock before the passkey flow can continue.40const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4142export function onVaultUnlocked(): void {43  for (const { resolve, timeoutId } of unlockWaiters.values()) {44    clearTimeout(timeoutId);45    resolve(true);46  }47  unlockWaiters.clear();48}4950function isPasskeySupported(): boolean {51  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;52  // Firefox 150+ exposes navigator.credentials.create/get from the popup53  // context with extension host permissions. Cannot intercept page-side54  // calls (no equivalent of webAuthenticationProxy), but registration via55  // Peach's own popup-driven flow works.56  if (detectSharedBrowser() === "firefox") {57    const ver = firefoxVersion();58    if (Number.isFinite(ver) && ver >= 150) return true;59  }60  return false;61}6263export const detectBrowser = detectSharedBrowser;6465function getBrowserLabel(): string {66  const ua = navigator.userAgent;67  if (/Firefox\/(\d+)/.test(ua)) {68    const version = ua.match(/Firefox\/(\d+)/)?.[1];69    return version ? `Firefox ${version}` : "Firefox";70  }71  const version = ua.match(/Chrome\/(\d+)/)?.[1];72  return version ? `Chrome ${version}` : "Chromium";73}7475function pendingStorageKey(requestId: number): string {76  return `${PENDING_PREFIX}${requestId}`;77}7879function originFromRpId(rpId: string): string {80  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)81    ? `http://${rpId}`82    : `https://${rpId}`;83}8485async function setBadge(text: string): Promise<void> {86  if (!chrome.action?.setBadgeText) return;87  await chrome.action.setBadgeText({ text });88  if (text) {89    await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });90  }91}9293async function clearPendingRequest(requestId: number): Promise<void> {94  if (chrome.storage.session) {95    await chrome.storage.session.remove(pendingStorageKey(requestId));96  }97  await setBadge("");98}99100async function openApprovalPopup(): Promise<void> {101  try {102    await chrome.action.openPopup();103  } catch {104    await setBadge("PK");105  }106}107108async function rejectCreate(109  requestId: number,110  name: string,111  message = ""112): Promise<void> {113  await chrome.webAuthenticationProxy.completeCreateRequest({114    requestId,115    error: { name, message },116  });117}118119async function rejectGet(120  requestId: number,121  name: string,122  message = ""123): Promise<void> {124  await chrome.webAuthenticationProxy.completeGetRequest({125    requestId,126    error: { name, message },127  });128}129130export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {131  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts132  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;133  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));134  if (!key) return null;135  const pending = stored[key];136  return pending && typeof pending === "object"137    ? (pending as PendingPasskeyRequest)138    : null;139}140141export async function resolvePendingPasskeyRequest(142  requestId: number,143  approved: boolean144): Promise<void> {145  const pending = approvals.get(requestId);146  if (!pending) {147    // Either the request expired (Chrome) or this is a Firefox-driven148    // pending where the popup performs the navigator.credentials call149    // directly. Clear both possible session keys so getPendingPasskeyRequest()150    // doesn't surface stale state on the next popup open.151    await clearPendingRequest(requestId);152    if (chrome.storage.session) {153      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);154    }155    return;156  }157158  clearTimeout(pending.timeoutId);159  approvals.delete(requestId);160  await clearPendingRequest(requestId);161  pending.resolve(approved);162}163164async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {165  if (chrome.storage.session) {166    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });167  }168  await openApprovalPopup();169170  return new Promise<boolean>((resolve) => {171    const timeoutId = setTimeout(async () => {172      approvals.delete(request.requestId);173      await clearPendingRequest(request.requestId);174      resolve(false);175    }, APPROVAL_TIMEOUT_MS);176177    approvals.set(request.requestId, { resolve, timeoutId });178  });179}180181export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {182  const settings = await vault.ensureSettingsLoaded();183  const supported = isPasskeySupported();184  const enabled = settings.passkeyProviderEnabled === true;185  return {186    supported,187    enabled,188    attached: supported && attached,189    browserLabel: getBrowserLabel(),190    statusLabel: !supported191      ? "Interception unavailable"192      : attached193        ? "Active"194        : conflict195          ? "Another provider is active"196          : enabled197            ? "Inactive"198            : "Disabled",199    conflict,200  };201}202203async function attachPasskeyProxy(): Promise<void> {204  if (!isPasskeySupported()) {205    attached = false;206    conflict = "This browser does not expose chrome.webAuthenticationProxy.";207    return;208  }209210  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context211  // navigator.credentials creator is always ready once the user enables the212  // provider. Treat enabled = attached for state reporting purposes.213  if (detectSharedBrowser() === "firefox") {214    attached = true;215    conflict = undefined;216    return;217  }218219  if (attached) return;220221  try {222    await chrome.webAuthenticationProxy.attach();223    attached = true;224    conflict = undefined;225  } catch (error) {226    attached = false;227    conflict = error instanceof Error ? error.message : "Another provider is active.";228  }229}230231async function detachPasskeyProxy(): Promise<void> {232  if (!isPasskeySupported()) {233    attached = false;234    return;235  }236237  if (detectSharedBrowser() === "firefox") {238    attached = false;239    conflict = undefined;240    return;241  }242243  try {244    await chrome.webAuthenticationProxy.detach();245  } catch {246    // Ignore detach failures; the extension may already be detached.247  }248249  attached = false;250  conflict = undefined;251}252253export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {254  await vault.updateSettings({ passkeyProviderEnabled: enabled });255  if (enabled) {256    await attachPasskeyProxy();257  } else {258    await detachPasskeyProxy();259  }260  return getPasskeyProviderState();261}262263async function refreshPasskeyProxy(): Promise<void> {264  const settings = await vault.ensureSettingsLoaded();265  if (settings.passkeyProviderEnabled === true) {266    await attachPasskeyProxy();267    return;268  }269  await detachPasskeyProxy();270}271272/**273 * Find the entry that already holds a passkey for the given RP, or the most274 * recently-modified login entry whose URL/additionalUrls match the RP — that275 * entry is the natural attach target when the user adds a passkey to a site276 * they already have a password for. Returns null when there's no candidate;277 * the caller creates a new entry in that case.278 */279function findAttachTarget(rpId: string): VaultEntry | null {280  const all = vault.getEntries();281  const passkeyMatch = all.find(282    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt283  );284  if (passkeyMatch) return passkeyMatch;285  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);286  if (loginMatches.length === 0) return null;287  // Pick the most recently modified candidate to be deterministic.288  return loginMatches.reduce((best, e) =>289    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best290  );291}292293async function handleCreateRequest(294  request: chrome.webAuthenticationProxy.CreateRequest295): Promise<void> {296  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;297  const rpId = options.rp.id;298  const rpName = options.rp.name ?? options.rp.id;299  const userName = options.user.name;300  const userDisplayName = options.user.displayName ?? options.user.name;301302  if (!rpId) {303    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");304    return;305  }306307  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {308    await rejectCreate(309      request.requestId,310      "NotAllowedError",311      "Passkey creation requires Peach Pro."312    );313    // Show the inline passkey gate so the user knows what happened and can upgrade.314    broadcastPasskeyProGate();315    return;316  }317318  if (!vault.isUnlocked()) {319    await openApprovalPopup();320    await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");321    return;322  }323324  const approved = await promptUserApproval({325    requestId: request.requestId,326    type: "create",327    rpId,328    rpName,329    userName,330    userDisplayName,331  });332  if (!approved) {333    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");334    return;335  }336337  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);338  if (!algorithm) {339    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");340    return;341  }342343  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));344  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(345    algorithm346  );347  const authData = await buildAuthenticatorData({348    rpId,349    flags: 0x4d,350    signCount: 0,351    credentialId: credentialIdBytes,352    publicKeyCose,353  });354  const attestationObject = createAttestationObject(authData);355  const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));356357  const passkey: PasskeyData = {358    rpId,359    rpName,360    credentialId: base64urlEncode(credentialIdBytes),361    publicKeyCose: base64urlEncode(publicKeyCose),362    privateKeyJwk,363    userHandle: options.user.id,364    userName,365    userDisplayName,366    algorithm,367    signCount: 0,368    aaguid: base64urlEncode(new Uint8Array(16)),369    backupEligible: false,370    backupState: false,371    browser: "chrome",372    transports: ["internal"],373    createdAt: Date.now(),374  };375376  // Attach to an existing login (same domain) when one exists; otherwise377  // create a fresh login entry that exists solely to hold the passkey.378  const target = findAttachTarget(rpId);379  if (target) {380    await vault.updateEntry(target.id, { passkey });381  } else {382    await vault.addEntry({383      type: "login",384      title: rpName,385      url: `https://${rpId}`,386      username: userName,387      passkey,388    });389  }390391  await chrome.webAuthenticationProxy.completeCreateRequest({392    requestId: request.requestId,393    responseJson: JSON.stringify({394      id: passkey.credentialId,395      rawId: passkey.credentialId,396      type: "public-key",397      response: {398        clientDataJSON: base64urlEncode(clientDataJSON),399        authenticatorData: base64urlEncode(authData),400        attestationObject: base64urlEncode(attestationObject),401        transports: ["internal"],402        publicKey: base64urlEncode(publicKeySpki),403        publicKeyAlgorithm: algorithm,404      },405      authenticatorAttachment: "platform",406      clientExtensionResults: {},407    }),408  });409}410411function broadcastPasskeyProGate(): void {412  chrome.tabs.query({}, (tabs) => {413    for (const tab of tabs) {414      if (tab.id !== undefined) {415        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});416      }417    }418  });419}420421async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {422  if (chrome.storage.session) {423    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });424  }425426  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {427    chrome.tabs.query({ active: true, currentWindow: true }, resolve);428  });429  const tab = tabs[0];430431  if (tab?.id) {432    chrome.tabs.sendMessage(tab.id, {433      type: 'SHOW_PASSKEY_TOAST',434      payload: {435        site: request.rpId,436        displayName: request.userDisplayName ?? request.userName,437        username: request.userName,438        requestId: String(request.requestId),439      },440    }).catch(() => {});441  } else {442    await openApprovalPopup();443  }444445  return new Promise<boolean>((resolve) => {446    const timeoutId = setTimeout(async () => {447      approvals.delete(request.requestId);448      await clearPendingRequest(request.requestId);449      resolve(false);450    }, APPROVAL_TIMEOUT_MS);451452    approvals.set(request.requestId, { resolve, timeoutId });453  });454}455456async function handleGetRequest(457  request: chrome.webAuthenticationProxy.GetRequest458): Promise<void> {459  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;460  if (!vault.isUnlocked()) {461    // Summon the inline unlock prompt on the active tab so the user can unlock462    // without leaving the page. Fall back to the popup if the content script463    // is unavailable (e.g. restricted URL, script not yet injected).464    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>465      chrome.tabs.query({ active: true, currentWindow: true }, resolve)466    );467    const activeTab = tabs[0];468    if (activeTab?.id) {469      chrome.tabs.sendMessage(activeTab.id, {470        type: 'SUMMON_AUTOFILL',471        source: 'passkey-unlock',472      }).catch(() => { void openApprovalPopup(); });473    } else {474      await openApprovalPopup();475    }476477    // Hold the request open until the vault unlocks or the timeout fires.478    const unlocked = await new Promise<boolean>((resolve) => {479      const timeoutId = setTimeout(() => {480        unlockWaiters.delete(request.requestId);481        resolve(false);482      }, APPROVAL_TIMEOUT_MS);483      unlockWaiters.set(request.requestId, { resolve, timeoutId });484    });485486    if (!unlocked || !vault.isUnlocked()) {487      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");488      return;489    }490    // Vault is now unlocked — fall through to authenticate.491  }492493  if (!(await isProUser())) {494    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");495    broadcastPasskeyProGate();496    return;497  }498499  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>500    entry.passkey !== undefined && !entry.deletedAt501  );502  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);503504  const match = passkeyBearing.find((entry) => {505    if (allowedCredentialIds.length > 0) {506      return allowedCredentialIds.includes(entry.passkey.credentialId);507    }508    if (!options.rpId) return false;509    return entry.passkey.rpId === options.rpId;510  });511512  if (!match) {513    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");514    return;515  }516517  const credential = match.passkey;518  // Chrome's webAuthenticationProxy path requires Peach-held private key519  // material to sign the assertion. Firefox-created passkeys delegate to the520  // platform authenticator — the browser will route to the platform UI521  // automatically when we reject, which is the right outcome.522  if (!credential.privateKeyJwk) {523    await rejectGet(524      request.requestId,525      "NotAllowedError",526      "This passkey was created on Firefox; sign in via the platform authenticator."527    );528    return;529  }530531  const approved = await promptViaToast({532    requestId: request.requestId,533    type: "get",534    rpId: credential.rpId,535    rpName: credential.rpName,536    userName: credential.userName,537    userDisplayName: credential.userDisplayName,538  });539  if (!approved) {540    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");541    return;542  }543544  const nextSignCount = credential.signCount + 1;545  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);546  const authData = await buildAuthenticatorData({547    rpId: credential.rpId,548    flags,549    signCount: nextSignCount,550  });551  const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));552  const clientDataHash = new Uint8Array(553    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))554  );555  const signature = await signPasskeyAssertion(556    credential.privateKeyJwk,557    credential.algorithm,558    authData,559    clientDataHash560  );561562  await vault.updateEntry(match.id, {563    passkey: {564      ...credential,565      signCount: nextSignCount,566      lastUsedAt: Date.now(),567    },568  });569570  const responseJson = JSON.stringify({571    id: credential.credentialId,572    rawId: credential.credentialId,573    type: "public-key",574    response: {575      clientDataJSON: base64urlEncode(clientDataJSON),576      authenticatorData: base64urlEncode(authData),577      signature: base64urlEncode(signature),578      userHandle: credential.userHandle,579    },580    authenticatorAttachment: "platform",581    clientExtensionResults: {},582  });583584  await chrome.webAuthenticationProxy.completeGetRequest({585    requestId: request.requestId,586    responseJson,587  });588}589590// ── Firefox path ────────────────────────────────────────────────────────591//592// Firefox 150+ permits extensions to call navigator.credentials.create/get593// from the popup context with their host-permitted RP IDs. Peach uses that594// surface to register passkeys on behalf of the user. The page's own595// navigator.credentials call is observed via the MAIN-world detector596// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's597// flow is parallel and explicit (the user must engage with Peach's button).598599const FIREFOX_PENDING_KEY = "passkey_pending_firefox";600const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";601const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days602603export async function initiateFirefoxPasskeyCreate(args: {604  rpId: string;605  rpName: string;606  userName: string;607  userDisplayName?: string;608}): Promise<{ ok: boolean; error?: string }> {609  if (!(await isProUser())) {610    broadcastPasskeyProGate();611    return { ok: false, error: "passkey creation requires Peach Pro" };612  }613  if (!vault.isUnlocked()) {614    await openApprovalPopup();615    return { ok: false, error: "vault locked" };616  }617  if (!chrome.storage.session) {618    return { ok: false, error: "session storage unavailable" };619  }620621  const requestId = Date.now();622  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));623  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));624625  const pending: PendingPasskeyRequest = {626    requestId,627    type: "create",628    rpId: args.rpId,629    rpName: args.rpName,630    userName: args.userName,631    userDisplayName: args.userDisplayName,632    browser: "firefox",633    challenge,634    userHandle,635  };636637  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });638  await openApprovalPopup();639  return { ok: true };640}641642export async function completeFirefoxPasskeyCreate(payload: {643  requestId: number;644  rpId: string;645  rpName: string;646  userName: string;647  userDisplayName?: string;648  userHandle: string;649  credentialId: string;650  publicKeyCose: string;651  algorithm: -7 | -8;652  aaguid?: string;653  transports?: string[];654  backupEligible?: boolean;655  backupState?: boolean;656}): Promise<{ ok: boolean; error?: string }> {657  if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };658659  const passkey: PasskeyData = {660    rpId: payload.rpId,661    rpName: payload.rpName,662    credentialId: payload.credentialId,663    publicKeyCose: payload.publicKeyCose,664    userHandle: payload.userHandle,665    userName: payload.userName,666    userDisplayName: payload.userDisplayName ?? payload.userName,667    algorithm: payload.algorithm,668    signCount: 0,669    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),670    backupEligible: payload.backupEligible ?? false,671    backupState: payload.backupState ?? false,672    browser: "firefox",673    transports: payload.transports ?? ["internal"],674    createdAt: Date.now(),675  };676677  // Same attach-or-create flow as the Chrome path: prefer attaching to an678  // existing login entry for the same domain so the user ends up with one679  // entry per site, not two (a login + a passkey-only).680  const target = findAttachTarget(payload.rpId);681  if (target) {682    await vault.updateEntry(target.id, { passkey });683  } else {684    await vault.addEntry({685      type: "login",686      title: payload.rpName || payload.rpId,687      url: `https://${payload.rpId}`,688      username: payload.userName,689      passkey,690    });691  }692693  if (chrome.storage.session) {694    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);695  }696  return { ok: true };697}698699export async function cancelFirefoxPasskeyCreate(): Promise<void> {700  if (chrome.storage.session) {701    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);702  }703}704705function declineKey(domain: string): string {706  return `${FIREFOX_DECLINE_PREFIX}${domain}`;707}708709export async function recordPasskeyOfferDecline(domain: string): Promise<void> {710  if (!domain) return;711  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });712}713714export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {715  if (!domain) return false;716  const key = declineKey(domain);717  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;718  const at = stored[key];719  if (typeof at !== "number") return false;720  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {721    await chrome.storage.local.remove(key);722    return false;723  }724  return true;725}726727// ── Firefox content-script proxy path (v2.12.0) ────────────────────────728//729// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces730// navigator.credentials.create / .get on every Firefox page. It builds a731// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),732// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,733// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.734//735// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag736// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates737// to false and these handlers short-circuit. The full removal checklist is738// in that file — the deletion is purely subtractive.739740interface FirefoxAllowCredential {741  id: string;742  type?: string;743}744745interface FirefoxPubKeyCredParam {746  type?: string;747  alg: number;748}749750export interface FirefoxPasskeyHasCredentialsPayload {751  rpId: string;752  allowCredentials: FirefoxAllowCredential[];753}754755export interface FirefoxPasskeyCreatePayload {756  rpId: string;757  rpName?: string;758  userId: string;759  userName: string;760  userDisplayName?: string;761  challenge: string;762  clientDataJSON: number[];763  origin: string;764  pubKeyCredParams?: FirefoxPubKeyCredParam[];765  authenticatorSelection?: Record<string, unknown>;766  timeout?: number;767  attestation?: string;768}769770export interface FirefoxPasskeyGetPayload {771  rpId: string;772  challenge: string;773  clientDataJSON: number[];774  origin: string;775  allowCredentials: FirefoxAllowCredential[];776  userVerification?: string;777}778779interface FirefoxCreateCredentialResponse {780  type: "create";781  id: string;782  clientDataJSON: number[];783  attestationObject: string;784  authenticatorData: string;785  publicKey: string;786  algorithm: -7 | -8;787}788789interface FirefoxGetCredentialResponse {790  type: "get";791  id: string;792  clientDataJSON: number[];793  authenticatorData: string;794  signature: string;795  userHandle: string;796}797798type FirefoxPasskeyResult<T> =799  | { ok: true; credential: T }800  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };801802function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {803  return vault.getEntries().filter(804    (entry): entry is VaultEntry & { passkey: PasskeyData } =>805      entry.passkey !== undefined && !entry.deletedAt806  );807}808809export async function handlePasskeyHasCredentials(810  payload: FirefoxPasskeyHasCredentialsPayload811): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {812  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {813    return { ok: false, error: "not_applicable" };814  }815  if (!vault.isUnlocked()) {816    // Cannot enumerate without the vault key. The MAIN-world proxy treats a817    // false answer as "fall through to the platform" — exactly the right818    // outcome when Peach is locked: the user gets the native UI instead.819    return { ok: true, hasCredentials: false };820  }821  const passkeys = listPasskeyEntries().filter(822    (entry) => entry.passkey.rpId === payload.rpId823  );824  if (passkeys.length === 0) return { ok: true, hasCredentials: false };825826  const allow = payload.allowCredentials ?? [];827  if (allow.length === 0) return { ok: true, hasCredentials: true };828829  const hasMatch = passkeys.some((entry) =>830    allow.some((ac) => ac.id === entry.passkey.credentialId)831  );832  return { ok: true, hasCredentials: hasMatch };833}834835/**836 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing837 * pending-passkey channel (badge + popup + 60s timeout) — the same approval838 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need839 * any new code path. The page-side proxy never sees the request id; the840 * background mints one and discards it after resolution.841 */842async function requestFirefoxApproval(args: {843  type: "create" | "get";844  rpId: string;845  rpName?: string;846  userName: string;847  userDisplayName?: string;848}): Promise<boolean> {849  // Synthetic request id — the Firefox proxy path doesn't have a850  // chrome.webAuthenticationProxy request to tie back to.851  const requestId = Date.now() + Math.floor(Math.random() * 1000);852  return promptUserApproval({853    requestId,854    type: args.type,855    rpId: args.rpId,856    rpName: args.rpName ?? args.rpId,857    userName: args.userName,858    userDisplayName: args.userDisplayName,859  });860}861862export async function handleFirefoxPasskeyCreate(863  payload: FirefoxPasskeyCreatePayload864): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {865  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {866    return { ok: false, error: "not_applicable" };867  }868  if (!(await isProUser())) {869    broadcastPasskeyProGate();870    return { ok: false, error: "pro_required" };871  }872  if (!vault.isUnlocked()) {873    await openApprovalPopup();874    return { ok: false, error: "vault_locked" };875  }876877  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);878  if (!algorithm) {879    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };880  }881882  const approved = await requestFirefoxApproval({883    type: "create",884    rpId: payload.rpId,885    rpName: payload.rpName,886    userName: payload.userName,887    userDisplayName: payload.userDisplayName,888  });889  if (!approved) return { ok: false, error: "user_cancelled" };890891  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();892  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);893894  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).895  // Matches Chrome's create flag set in handleCreateRequest above.896  const authenticatorData = await buildAuthenticatorData({897    rpId: payload.rpId,898    flags: 0x45,899    signCount: 0,900    credentialId: credentialIdBytes,901    publicKeyCose,902  });903  const attestationObject = createAttestationObject(authenticatorData);904905  const passkey: PasskeyData = {906    rpId: payload.rpId,907    rpName: payload.rpName ?? payload.rpId,908    credentialId,909    publicKeyCose: base64urlEncode(publicKeyCose),910    privateKeyJwk,911    userHandle: payload.userId,912    userName: payload.userName,913    userDisplayName: payload.userDisplayName ?? payload.userName,914    algorithm,915    signCount: 0,916    aaguid: base64urlEncode(new Uint8Array(16)),917    backupEligible: false,918    backupState: false,919    browser: "firefox",920    transports: ["internal"],921    createdAt: Date.now(),922  };923924  const target = findAttachTarget(payload.rpId);925  if (target) {926    await vault.updateEntry(target.id, { passkey });927  } else {928    await vault.addEntry({929      type: "login",930      title: payload.rpName || payload.rpId,931      url: `https://${payload.rpId}`,932      username: payload.userName,933      passkey,934    });935  }936937  return {938    ok: true,939    credential: {940      type: "create",941      id: credentialId,942      // The content script already encoded clientDataJSON with the page943      // origin; round-trip the exact bytes back so the RP sees the same944      // hash it computes locally.945      clientDataJSON: payload.clientDataJSON,946      attestationObject: base64urlEncode(attestationObject),947      authenticatorData: base64urlEncode(authenticatorData),948      publicKey: base64urlEncode(publicKeySpki),949      algorithm,950    },951  };952}953954export async function handleFirefoxPasskeyGet(955  payload: FirefoxPasskeyGetPayload956): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {957  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {958    return { ok: false, error: "not_applicable" };959  }960  if (!(await isProUser())) {961    broadcastPasskeyProGate();962    return { ok: false, error: "pro_required" };963  }964  if (!vault.isUnlocked()) {965    await openApprovalPopup();966    return { ok: false, error: "vault_locked" };967  }968969  const allow = payload.allowCredentials ?? [];970  const candidates = listPasskeyEntries().filter((entry) => {971    if (entry.passkey.rpId !== payload.rpId) return false;972    if (allow.length === 0) return true;973    return allow.some((ac) => ac.id === entry.passkey.credentialId);974  });975976  if (candidates.length === 0) return { ok: false, error: "no_credentials" };977978  // Most-recently-used wins when several credentials match. A future979  // multi-account selector goes here; for now this matches the Chrome path's980  // behavior for the common one-passkey-per-rp case.981  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));982  const match = candidates[0]!;983  const credential = match.passkey;984985  if (!credential.privateKeyJwk) {986    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator987    // — no Peach-held private key to sign with. The MAIN-world proxy treats988    // no_credentials as a fall-through, which routes to the platform UI.989    return { ok: false, error: "no_credentials" };990  }991992  const approved = await requestFirefoxApproval({993    type: "get",994    rpId: credential.rpId,995    rpName: credential.rpName,996    userName: credential.userName,997    userDisplayName: credential.userDisplayName,998  });999  if (!approved) return { ok: false, error: "user_cancelled" };10001001  const nextSignCount = credential.signCount + 1;1002  // 0x05 = UP|UV. backup bits track whatever was set at registration time1003  // (consistent with the Chrome path).1004  const flags =1005    0x05 |1006    (credential.backupEligible ? 0x08 : 0) |1007    (credential.backupState ? 0x10 : 0);1008  const authenticatorData = await buildAuthenticatorData({1009    rpId: credential.rpId,1010    flags,1011    signCount: nextSignCount,1012  });10131014  // The content script supplied clientDataJSON bytes built with the page1015  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1016  // diverges from what the RP computes.1017  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1018  const clientDataHash = new Uint8Array(1019    await crypto.subtle.digest("SHA-256", clientDataBytes)1020  );1021  const signature = await signPasskeyAssertion(1022    credential.privateKeyJwk,1023    credential.algorithm,1024    authenticatorData,1025    clientDataHash1026  );10271028  await vault.updateEntry(match.id, {1029    passkey: {1030      ...credential,1031      signCount: nextSignCount,1032      lastUsedAt: Date.now(),1033    },1034  });10351036  return {1037    ok: true,1038    credential: {1039      type: "get",1040      id: credential.credentialId,1041      clientDataJSON: payload.clientDataJSON,1042      authenticatorData: base64urlEncode(authenticatorData),1043      signature: base64urlEncode(signature),1044      userHandle: credential.userHandle,1045    },1046  };1047}10481049export async function setupPasskeyProxy(): Promise<void> {1050  if (!isPasskeySupported()) return;10511052  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1053  // popup-context credential creator is the entry point; nothing to wire here.1054  if (detectSharedBrowser() === "firefox") {1055    await refreshPasskeyProxy();1056    return;1057  }10581059  if (!listenersRegistered) {1060    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1061      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1062        requestId: requestInfo.requestId,1063        isUvpaa: vault.isUnlocked(),1064      });1065    });1066    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1067      void handleCreateRequest(requestInfo).catch(async (error) => {1068        console.error("Peach passkey create failed", error);1069        await rejectCreate(1070          requestInfo.requestId,1071          "UnknownError",1072          error instanceof Error ? error.message : "Passkey creation failed."1073        );1074      });1075    });1076    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1077      void handleGetRequest(requestInfo).catch(async (error) => {1078        console.error("Peach passkey get failed", error);1079        await rejectGet(1080          requestInfo.requestId,1081          "UnknownError",1082          error instanceof Error ? error.message : "Passkey authentication failed."1083        );1084      });1085    });1086    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1087      void resolvePendingPasskeyRequest(requestId, false);1088    });1089    listenersRegistered = true;1090  }10911092  await refreshPasskeyProxy();1093}