passkey.ts

35.0 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 type {16  PasskeyData,17  PasskeyProviderState,18  PendingPasskeyRequest,19  VaultEntry,20} from "../shared/types";2122declare const __PEACH_E2E_PASSKEYS__: boolean;2324const PENDING_PREFIX = "passkey_pending_";25const APPROVAL_TIMEOUT_MS = 60_000;2627let listenersRegistered = false;28let attached = false;29let conflict: string | undefined;30const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3132const approvals = new Map<33  number,34  { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }35>();3637// Resolvers for GET requests that arrived while the vault was locked and are38// waiting for the user to unlock before the passkey flow can continue.39const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4041export function onVaultUnlocked(): void {42  for (const { resolve, timeoutId } of unlockWaiters.values()) {43    clearTimeout(timeoutId);44    resolve(true);45  }46  unlockWaiters.clear();47}4849/**50 * Resolve every still-pending passkey request as denied and clear unlock51 * waiters. Called from the popup-disconnect path so a closed popup releases52 * the page-side caller in <200ms instead of leaving it stuck for the full53 * APPROVAL_TIMEOUT_MS (60s) (M8/COR-009).54 */55export async function denyAllPendingPasskeyRequests(): Promise<void> {56  for (const [rid, { resolve, timeoutId }] of approvals.entries()) {57    clearTimeout(timeoutId);58    resolve(false);59    approvals.delete(rid);60    await clearPendingRequest(rid).catch(() => {});61  }62  for (const { resolve, timeoutId } of unlockWaiters.values()) {63    clearTimeout(timeoutId);64    resolve(false);65  }66  unlockWaiters.clear();67}6869function isPasskeySupported(): boolean {70  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;71  // Firefox 150+ exposes navigator.credentials.create/get from the popup72  // context with extension host permissions. Cannot intercept page-side73  // calls (no equivalent of webAuthenticationProxy), but registration via74  // Peach's own popup-driven flow works.75  if (detectSharedBrowser() === "firefox") {76    const ver = firefoxVersion();77    if (Number.isFinite(ver) && ver >= 150) return true;78  }79  return false;80}8182export const detectBrowser = detectSharedBrowser;8384function getBrowserLabel(): string {85  const ua = navigator.userAgent;86  if (/Firefox\/(\d+)/.test(ua)) {87    const version = ua.match(/Firefox\/(\d+)/)?.[1];88    return version ? `Firefox ${version}` : "Firefox";89  }90  const version = ua.match(/Chrome\/(\d+)/)?.[1];91  return version ? `Chrome ${version}` : "Chromium";92}9394function pendingStorageKey(requestId: number): string {95  return `${PENDING_PREFIX}${requestId}`;96}9798function originFromRpId(rpId: string): string {99  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)100    ? `http://${rpId}`101    : `https://${rpId}`;102}103104async function setBadge(text: string): Promise<void> {105  if (!chrome.action?.setBadgeText) return;106  await chrome.action.setBadgeText({ text });107  if (text) {108    await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });109  }110}111112async function clearPendingRequest(requestId: number): Promise<void> {113  if (chrome.storage.session) {114    await chrome.storage.session.remove(pendingStorageKey(requestId));115  }116  await setBadge("");117}118119async function openApprovalPopup(): Promise<void> {120  try {121    await chrome.action.openPopup();122  } catch {123    await setBadge("PK");124  }125}126127async function rejectCreate(128  requestId: number,129  name: string,130  message = ""131): Promise<void> {132  await chrome.webAuthenticationProxy.completeCreateRequest({133    requestId,134    error: { name, message },135  });136}137138async function rejectGet(139  requestId: number,140  name: string,141  message = ""142): Promise<void> {143  await chrome.webAuthenticationProxy.completeGetRequest({144    requestId,145    error: { name, message },146  });147}148149export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {150  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts151  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;152  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));153  if (!key) return null;154  const pending = stored[key];155  return pending && typeof pending === "object"156    ? (pending as PendingPasskeyRequest)157    : null;158}159160export async function resolvePendingPasskeyRequest(161  requestId: number,162  approved: boolean163): Promise<void> {164  const pending = approvals.get(requestId);165  if (!pending) {166    // Either the request expired (Chrome) or this is a Firefox-driven167    // pending where the popup performs the navigator.credentials call168    // directly. Clear both possible session keys so getPendingPasskeyRequest()169    // doesn't surface stale state on the next popup open.170    await clearPendingRequest(requestId);171    if (chrome.storage.session) {172      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);173    }174    return;175  }176177  clearTimeout(pending.timeoutId);178  approvals.delete(requestId);179  await clearPendingRequest(requestId);180  pending.resolve(approved);181}182183async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {184  if (chrome.storage.session) {185    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });186  }187  await openApprovalPopup();188189  return new Promise<boolean>((resolve) => {190    const timeoutId = setTimeout(async () => {191      approvals.delete(request.requestId);192      await clearPendingRequest(request.requestId);193      resolve(false);194    }, APPROVAL_TIMEOUT_MS);195196    approvals.set(request.requestId, { resolve, timeoutId });197  });198}199200export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {201  const settings = await vault.ensureSettingsLoaded();202  const supported = isPasskeySupported();203  const enabled = settings.passkeyProviderEnabled === true;204  return {205    supported,206    enabled,207    attached: supported && attached,208    browserLabel: getBrowserLabel(),209    statusLabel: !supported210      ? "Interception unavailable"211      : attached212        ? "Active"213        : conflict214          ? "Another provider is active"215          : enabled216            ? "Inactive"217            : "Disabled",218    conflict,219  };220}221222async function attachPasskeyProxy(): Promise<void> {223  if (!isPasskeySupported()) {224    attached = false;225    conflict = "This browser does not expose chrome.webAuthenticationProxy.";226    return;227  }228229  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context230  // navigator.credentials creator is always ready once the user enables the231  // provider. Treat enabled = attached for state reporting purposes.232  if (detectSharedBrowser() === "firefox") {233    attached = true;234    conflict = undefined;235    return;236  }237238  if (attached) return;239240  try {241    await chrome.webAuthenticationProxy.attach();242    attached = true;243    conflict = undefined;244  } catch (error) {245    attached = false;246    conflict = error instanceof Error ? error.message : "Another provider is active.";247  }248}249250async function detachPasskeyProxy(): Promise<void> {251  if (!isPasskeySupported()) {252    attached = false;253    return;254  }255256  if (detectSharedBrowser() === "firefox") {257    attached = false;258    conflict = undefined;259    return;260  }261262  try {263    await chrome.webAuthenticationProxy.detach();264  } catch {265    // Ignore detach failures; the extension may already be detached.266  }267268  attached = false;269  conflict = undefined;270}271272export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {273  await vault.updateSettings({ passkeyProviderEnabled: enabled });274  if (enabled) {275    await attachPasskeyProxy();276  } else {277    await detachPasskeyProxy();278  }279  return getPasskeyProviderState();280}281282async function refreshPasskeyProxy(): Promise<void> {283  const settings = await vault.ensureSettingsLoaded();284  if (settings.passkeyProviderEnabled === true) {285    await attachPasskeyProxy();286    return;287  }288  await detachPasskeyProxy();289}290291/**292 * Find the entry that already holds a passkey for the given RP, or the most293 * recently-modified login entry whose URL/additionalUrls match the RP — that294 * entry is the natural attach target when the user adds a passkey to a site295 * they already have a password for. Returns null when there's no candidate;296 * the caller creates a new entry in that case.297 */298function findAttachTarget(rpId: string): VaultEntry | null {299  const all = vault.getEntries();300  const passkeyMatch = all.find(301    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt302  );303  if (passkeyMatch) return passkeyMatch;304  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);305  if (loginMatches.length === 0) return null;306  // Pick the most recently modified candidate to be deterministic.307  return loginMatches.reduce((best, e) =>308    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best309  );310}311312async function handleCreateRequest(313  request: chrome.webAuthenticationProxy.CreateRequest314): Promise<void> {315  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;316  const rpId = options.rp.id;317  const rpName = options.rp.name ?? options.rp.id;318  const userName = options.user.name;319  const userDisplayName = options.user.displayName ?? options.user.name;320321  if (!rpId) {322    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");323    return;324  }325326  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {327    await rejectCreate(328      request.requestId,329      "NotAllowedError",330      "Passkey creation requires Peach Pro."331    );332    // Show the inline passkey gate so the user knows what happened and can upgrade.333    broadcastPasskeyProGate();334    return;335  }336337  if (!vault.isUnlocked()) {338    await openApprovalPopup();339    await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");340    return;341  }342343  const approved = await promptUserApproval({344    requestId: request.requestId,345    type: "create",346    rpId,347    rpName,348    userName,349    userDisplayName,350  });351  if (!approved) {352    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");353    return;354  }355356  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);357  if (!algorithm) {358    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");359    return;360  }361362  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));363  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(364    algorithm365  );366  const authData = await buildAuthenticatorData({367    rpId,368    flags: 0x4d,369    signCount: 0,370    credentialId: credentialIdBytes,371    publicKeyCose,372  });373  const attestationObject = createAttestationObject(authData);374  const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));375376  const passkey: PasskeyData = {377    rpId,378    rpName,379    credentialId: base64urlEncode(credentialIdBytes),380    publicKeyCose: base64urlEncode(publicKeyCose),381    privateKeyJwk,382    userHandle: options.user.id,383    userName,384    userDisplayName,385    algorithm,386    signCount: 0,387    aaguid: base64urlEncode(new Uint8Array(16)),388    backupEligible: false,389    backupState: false,390    browser: "chrome",391    transports: ["internal"],392    createdAt: Date.now(),393  };394395  // Attach to an existing login (same domain) when one exists; otherwise396  // create a fresh login entry that exists solely to hold the passkey.397  const target = findAttachTarget(rpId);398  if (target) {399    await vault.updateEntry(target.id, { passkey });400  } else {401    await vault.addEntry({402      type: "login",403      title: rpName,404      url: `https://${rpId}`,405      username: userName,406      passkey,407    });408  }409410  await chrome.webAuthenticationProxy.completeCreateRequest({411    requestId: request.requestId,412    responseJson: JSON.stringify({413      id: passkey.credentialId,414      rawId: passkey.credentialId,415      type: "public-key",416      response: {417        clientDataJSON: base64urlEncode(clientDataJSON),418        authenticatorData: base64urlEncode(authData),419        attestationObject: base64urlEncode(attestationObject),420        transports: ["internal"],421        publicKey: base64urlEncode(publicKeySpki),422        publicKeyAlgorithm: algorithm,423      },424      authenticatorAttachment: "platform",425      clientExtensionResults: {},426    }),427  });428}429430function broadcastPasskeyProGate(): void {431  chrome.tabs.query({}, (tabs) => {432    for (const tab of tabs) {433      if (tab.id !== undefined) {434        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});435      }436    }437  });438}439440async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {441  if (chrome.storage.session) {442    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });443  }444445  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {446    chrome.tabs.query({ active: true, currentWindow: true }, resolve);447  });448  const tab = tabs[0];449450  if (tab?.id) {451    chrome.tabs.sendMessage(tab.id, {452      type: 'SHOW_PASSKEY_TOAST',453      payload: {454        site: request.rpId,455        displayName: request.userDisplayName ?? request.userName,456        username: request.userName,457        requestId: String(request.requestId),458      },459    }).catch(() => {});460  } else {461    await openApprovalPopup();462  }463464  return new Promise<boolean>((resolve) => {465    const timeoutId = setTimeout(async () => {466      approvals.delete(request.requestId);467      await clearPendingRequest(request.requestId);468      resolve(false);469    }, APPROVAL_TIMEOUT_MS);470471    approvals.set(request.requestId, { resolve, timeoutId });472  });473}474475async function handleGetRequest(476  request: chrome.webAuthenticationProxy.GetRequest477): Promise<void> {478  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;479  if (!vault.isUnlocked()) {480    // Summon the inline unlock prompt on the active tab so the user can unlock481    // without leaving the page. Fall back to the popup if the content script482    // is unavailable (e.g. restricted URL, script not yet injected).483    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>484      chrome.tabs.query({ active: true, currentWindow: true }, resolve)485    );486    const activeTab = tabs[0];487    if (activeTab?.id) {488      chrome.tabs.sendMessage(activeTab.id, {489        type: 'SUMMON_AUTOFILL',490        source: 'passkey-unlock',491      }).catch(() => { void openApprovalPopup(); });492    } else {493      await openApprovalPopup();494    }495496    // Hold the request open until the vault unlocks or the timeout fires.497    const unlocked = await new Promise<boolean>((resolve) => {498      const timeoutId = setTimeout(() => {499        unlockWaiters.delete(request.requestId);500        resolve(false);501      }, APPROVAL_TIMEOUT_MS);502      unlockWaiters.set(request.requestId, { resolve, timeoutId });503    });504505    if (!unlocked || !vault.isUnlocked()) {506      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");507      return;508    }509    // Vault is now unlocked — fall through to authenticate.510  }511512  if (!(await isProUser())) {513    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");514    broadcastPasskeyProGate();515    return;516  }517518  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>519    entry.passkey !== undefined && !entry.deletedAt520  );521  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);522523  const match = passkeyBearing.find((entry) => {524    if (allowedCredentialIds.length > 0) {525      return allowedCredentialIds.includes(entry.passkey.credentialId);526    }527    if (!options.rpId) return false;528    return entry.passkey.rpId === options.rpId;529  });530531  if (!match) {532    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");533    return;534  }535536  const credential = match.passkey;537  // Chrome's webAuthenticationProxy path requires Peach-held private key538  // material to sign the assertion. Firefox-created passkeys delegate to the539  // platform authenticator — the browser will route to the platform UI540  // automatically when we reject, which is the right outcome.541  if (!credential.privateKeyJwk) {542    await rejectGet(543      request.requestId,544      "NotAllowedError",545      "This passkey was created on Firefox; sign in via the platform authenticator."546    );547    return;548  }549550  const approved = await promptViaToast({551    requestId: request.requestId,552    type: "get",553    rpId: credential.rpId,554    rpName: credential.rpName,555    userName: credential.userName,556    userDisplayName: credential.userDisplayName,557  });558  if (!approved) {559    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");560    return;561  }562563  const nextSignCount = credential.signCount + 1;564  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);565  const authData = await buildAuthenticatorData({566    rpId: credential.rpId,567    flags,568    signCount: nextSignCount,569  });570  const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));571  const clientDataHash = new Uint8Array(572    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))573  );574  const signature = await signPasskeyAssertion(575    credential.privateKeyJwk,576    credential.algorithm,577    authData,578    clientDataHash579  );580581  await vault.updateEntry(match.id, {582    passkey: {583      ...credential,584      signCount: nextSignCount,585      lastUsedAt: Date.now(),586    },587  });588589  const responseJson = JSON.stringify({590    id: credential.credentialId,591    rawId: credential.credentialId,592    type: "public-key",593    response: {594      clientDataJSON: base64urlEncode(clientDataJSON),595      authenticatorData: base64urlEncode(authData),596      signature: base64urlEncode(signature),597      userHandle: credential.userHandle,598    },599    authenticatorAttachment: "platform",600    clientExtensionResults: {},601  });602603  await chrome.webAuthenticationProxy.completeGetRequest({604    requestId: request.requestId,605    responseJson,606  });607}608609// ── Firefox path ────────────────────────────────────────────────────────610//611// Firefox 150+ permits extensions to call navigator.credentials.create/get612// from the popup context with their host-permitted RP IDs. Peach uses that613// surface to register passkeys on behalf of the user. The page's own614// navigator.credentials call is observed via the MAIN-world detector615// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's616// flow is parallel and explicit (the user must engage with Peach's button).617618const FIREFOX_PENDING_KEY = "passkey_pending_firefox";619const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";620const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days621622export async function initiateFirefoxPasskeyCreate(args: {623  rpId: string;624  rpName: string;625  userName: string;626  userDisplayName?: string;627}): Promise<{ ok: boolean; error?: string }> {628  if (!(await isProUser())) {629    broadcastPasskeyProGate();630    return { ok: false, error: "passkey creation requires Peach Pro" };631  }632  if (!vault.isUnlocked()) {633    await openApprovalPopup();634    return { ok: false, error: "vault_locked" };635  }636  if (!chrome.storage.session) {637    return { ok: false, error: "session storage unavailable" };638  }639640  // Date.now() alone collides if two Firefox passkey requests land in the641  // same millisecond. Mix in 20 random bits to match the synthetic-id pattern642  // at requestFirefoxApproval (M9/COR-010).643  const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);644  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));645  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));646647  const pending: PendingPasskeyRequest = {648    requestId,649    type: "create",650    rpId: args.rpId,651    rpName: args.rpName,652    userName: args.userName,653    userDisplayName: args.userDisplayName,654    browser: "firefox",655    challenge,656    userHandle,657  };658659  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });660  await openApprovalPopup();661  return { ok: true };662}663664export async function completeFirefoxPasskeyCreate(payload: {665  requestId: number;666  rpId: string;667  rpName: string;668  userName: string;669  userDisplayName?: string;670  userHandle: string;671  credentialId: string;672  publicKeyCose: string;673  algorithm: -7 | -8;674  aaguid?: string;675  transports?: string[];676  backupEligible?: boolean;677  backupState?: boolean;678}): Promise<{ ok: boolean; error?: string }> {679  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };680681  const passkey: PasskeyData = {682    rpId: payload.rpId,683    rpName: payload.rpName,684    credentialId: payload.credentialId,685    publicKeyCose: payload.publicKeyCose,686    userHandle: payload.userHandle,687    userName: payload.userName,688    userDisplayName: payload.userDisplayName ?? payload.userName,689    algorithm: payload.algorithm,690    signCount: 0,691    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),692    backupEligible: payload.backupEligible ?? false,693    backupState: payload.backupState ?? false,694    browser: "firefox",695    transports: payload.transports ?? ["internal"],696    createdAt: Date.now(),697  };698699  // Same attach-or-create flow as the Chrome path: prefer attaching to an700  // existing login entry for the same domain so the user ends up with one701  // entry per site, not two (a login + a passkey-only).702  const target = findAttachTarget(payload.rpId);703  if (target) {704    await vault.updateEntry(target.id, { passkey });705  } else {706    await vault.addEntry({707      type: "login",708      title: payload.rpName || payload.rpId,709      url: `https://${payload.rpId}`,710      username: payload.userName,711      passkey,712    });713  }714715  if (chrome.storage.session) {716    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);717  }718  return { ok: true };719}720721export async function cancelFirefoxPasskeyCreate(): Promise<void> {722  if (chrome.storage.session) {723    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);724  }725}726727function declineKey(domain: string): string {728  return `${FIREFOX_DECLINE_PREFIX}${domain}`;729}730731export async function recordPasskeyOfferDecline(domain: string): Promise<void> {732  if (!domain) return;733  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });734}735736export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {737  if (!domain) return false;738  const key = declineKey(domain);739  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;740  const at = stored[key];741  if (typeof at !== "number") return false;742  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {743    await chrome.storage.local.remove(key);744    return false;745  }746  return true;747}748749// ── Firefox content-script proxy path (v2.12.0) ────────────────────────750//751// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces752// navigator.credentials.create / .get on every Firefox page. It builds a753// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),754// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,755// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.756//757// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag758// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates759// to false and these handlers short-circuit. The full removal checklist is760// in that file — the deletion is purely subtractive.761762interface FirefoxAllowCredential {763  id: string;764  type?: string;765}766767interface FirefoxPubKeyCredParam {768  type?: string;769  alg: number;770}771772export interface FirefoxPasskeyHasCredentialsPayload {773  rpId: string;774  allowCredentials: FirefoxAllowCredential[];775}776777export interface FirefoxPasskeyCreatePayload {778  rpId: string;779  rpName?: string;780  userId: string;781  userName: string;782  userDisplayName?: string;783  challenge: string;784  clientDataJSON: number[];785  origin: string;786  pubKeyCredParams?: FirefoxPubKeyCredParam[];787  authenticatorSelection?: Record<string, unknown>;788  timeout?: number;789  attestation?: string;790}791792export interface FirefoxPasskeyGetPayload {793  rpId: string;794  challenge: string;795  clientDataJSON: number[];796  origin: string;797  allowCredentials: FirefoxAllowCredential[];798  userVerification?: string;799}800801interface FirefoxCreateCredentialResponse {802  type: "create";803  id: string;804  clientDataJSON: number[];805  attestationObject: string;806  authenticatorData: string;807  publicKey: string;808  algorithm: -7 | -8;809}810811interface FirefoxGetCredentialResponse {812  type: "get";813  id: string;814  clientDataJSON: number[];815  authenticatorData: string;816  signature: string;817  userHandle: string;818}819820type FirefoxPasskeyResult<T> =821  | { ok: true; credential: T }822  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };823824function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {825  return vault.getEntries().filter(826    (entry): entry is VaultEntry & { passkey: PasskeyData } =>827      entry.passkey !== undefined && !entry.deletedAt828  );829}830831export async function handlePasskeyHasCredentials(832  payload: FirefoxPasskeyHasCredentialsPayload833): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {834  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {835    return { ok: false, error: "not_applicable" };836  }837  if (!vault.isUnlocked()) {838    // Cannot enumerate without the vault key. The MAIN-world proxy treats a839    // false answer as "fall through to the platform" — exactly the right840    // outcome when Peach is locked: the user gets the native UI instead.841    return { ok: true, hasCredentials: false };842  }843  const passkeys = listPasskeyEntries().filter(844    (entry) => entry.passkey.rpId === payload.rpId845  );846  if (passkeys.length === 0) return { ok: true, hasCredentials: false };847848  const allow = payload.allowCredentials ?? [];849  if (allow.length === 0) return { ok: true, hasCredentials: true };850851  const hasMatch = passkeys.some((entry) =>852    allow.some((ac) => ac.id === entry.passkey.credentialId)853  );854  return { ok: true, hasCredentials: hasMatch };855}856857/**858 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing859 * pending-passkey channel (badge + popup + 60s timeout) — the same approval860 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need861 * any new code path. The page-side proxy never sees the request id; the862 * background mints one and discards it after resolution.863 */864async function requestFirefoxApproval(args: {865  type: "create" | "get";866  rpId: string;867  rpName?: string;868  userName: string;869  userDisplayName?: string;870}): Promise<boolean> {871  // Synthetic request id — the Firefox proxy path doesn't have a872  // chrome.webAuthenticationProxy request to tie back to.873  const requestId = Date.now() + Math.floor(Math.random() * 1000);874  return promptUserApproval({875    requestId,876    type: args.type,877    rpId: args.rpId,878    rpName: args.rpName ?? args.rpId,879    userName: args.userName,880    userDisplayName: args.userDisplayName,881  });882}883884export async function handleFirefoxPasskeyCreate(885  payload: FirefoxPasskeyCreatePayload886): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {887  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {888    return { ok: false, error: "not_applicable" };889  }890  if (!(await isProUser())) {891    broadcastPasskeyProGate();892    return { ok: false, error: "pro_required" };893  }894  if (!vault.isUnlocked()) {895    await openApprovalPopup();896    return { ok: false, error: "vault_locked" };897  }898899  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);900  if (!algorithm) {901    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };902  }903904  const approved = await requestFirefoxApproval({905    type: "create",906    rpId: payload.rpId,907    rpName: payload.rpName,908    userName: payload.userName,909    userDisplayName: payload.userDisplayName,910  });911  if (!approved) return { ok: false, error: "user_cancelled" };912913  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();914  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);915916  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).917  // Matches Chrome's create flag set in handleCreateRequest above.918  const authenticatorData = await buildAuthenticatorData({919    rpId: payload.rpId,920    flags: 0x45,921    signCount: 0,922    credentialId: credentialIdBytes,923    publicKeyCose,924  });925  const attestationObject = createAttestationObject(authenticatorData);926927  const passkey: PasskeyData = {928    rpId: payload.rpId,929    rpName: payload.rpName ?? payload.rpId,930    credentialId,931    publicKeyCose: base64urlEncode(publicKeyCose),932    privateKeyJwk,933    userHandle: payload.userId,934    userName: payload.userName,935    userDisplayName: payload.userDisplayName ?? payload.userName,936    algorithm,937    signCount: 0,938    aaguid: base64urlEncode(new Uint8Array(16)),939    backupEligible: false,940    backupState: false,941    browser: "firefox",942    transports: ["internal"],943    createdAt: Date.now(),944  };945946  const target = findAttachTarget(payload.rpId);947  if (target) {948    await vault.updateEntry(target.id, { passkey });949  } else {950    await vault.addEntry({951      type: "login",952      title: payload.rpName || payload.rpId,953      url: `https://${payload.rpId}`,954      username: payload.userName,955      passkey,956    });957  }958959  return {960    ok: true,961    credential: {962      type: "create",963      id: credentialId,964      // The content script already encoded clientDataJSON with the page965      // origin; round-trip the exact bytes back so the RP sees the same966      // hash it computes locally.967      clientDataJSON: payload.clientDataJSON,968      attestationObject: base64urlEncode(attestationObject),969      authenticatorData: base64urlEncode(authenticatorData),970      publicKey: base64urlEncode(publicKeySpki),971      algorithm,972    },973  };974}975976export async function handleFirefoxPasskeyGet(977  payload: FirefoxPasskeyGetPayload978): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {979  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {980    return { ok: false, error: "not_applicable" };981  }982  if (!(await isProUser())) {983    broadcastPasskeyProGate();984    return { ok: false, error: "pro_required" };985  }986  if (!vault.isUnlocked()) {987    await openApprovalPopup();988    return { ok: false, error: "vault_locked" };989  }990991  const allow = payload.allowCredentials ?? [];992  const candidates = listPasskeyEntries().filter((entry) => {993    if (entry.passkey.rpId !== payload.rpId) return false;994    if (allow.length === 0) return true;995    return allow.some((ac) => ac.id === entry.passkey.credentialId);996  });997998  if (candidates.length === 0) return { ok: false, error: "no_credentials" };9991000  // Most-recently-used wins when several credentials match. A future1001  // multi-account selector goes here; for now this matches the Chrome path's1002  // behavior for the common one-passkey-per-rp case.1003  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1004  const match = candidates[0]!;1005  const credential = match.passkey;10061007  if (!credential.privateKeyJwk) {1008    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1009    // — no Peach-held private key to sign with. The MAIN-world proxy treats1010    // no_credentials as a fall-through, which routes to the platform UI.1011    return { ok: false, error: "no_credentials" };1012  }10131014  const approved = await requestFirefoxApproval({1015    type: "get",1016    rpId: credential.rpId,1017    rpName: credential.rpName,1018    userName: credential.userName,1019    userDisplayName: credential.userDisplayName,1020  });1021  if (!approved) return { ok: false, error: "user_cancelled" };10221023  const nextSignCount = credential.signCount + 1;1024  // 0x05 = UP|UV. backup bits track whatever was set at registration time1025  // (consistent with the Chrome path).1026  const flags =1027    0x05 |1028    (credential.backupEligible ? 0x08 : 0) |1029    (credential.backupState ? 0x10 : 0);1030  const authenticatorData = await buildAuthenticatorData({1031    rpId: credential.rpId,1032    flags,1033    signCount: nextSignCount,1034  });10351036  // The content script supplied clientDataJSON bytes built with the page1037  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1038  // diverges from what the RP computes.1039  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1040  const clientDataHash = new Uint8Array(1041    await crypto.subtle.digest("SHA-256", clientDataBytes)1042  );1043  const signature = await signPasskeyAssertion(1044    credential.privateKeyJwk,1045    credential.algorithm,1046    authenticatorData,1047    clientDataHash1048  );10491050  await vault.updateEntry(match.id, {1051    passkey: {1052      ...credential,1053      signCount: nextSignCount,1054      lastUsedAt: Date.now(),1055    },1056  });10571058  return {1059    ok: true,1060    credential: {1061      type: "get",1062      id: credential.credentialId,1063      clientDataJSON: payload.clientDataJSON,1064      authenticatorData: base64urlEncode(authenticatorData),1065      signature: base64urlEncode(signature),1066      userHandle: credential.userHandle,1067    },1068  };1069}10701071export async function setupPasskeyProxy(): Promise<void> {1072  if (!isPasskeySupported()) return;10731074  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1075  // popup-context credential creator is the entry point; nothing to wire here.1076  if (detectSharedBrowser() === "firefox") {1077    await refreshPasskeyProxy();1078    return;1079  }10801081  if (!listenersRegistered) {1082    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1083      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1084        requestId: requestInfo.requestId,1085        isUvpaa: vault.isUnlocked(),1086      });1087    });1088    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1089      void handleCreateRequest(requestInfo).catch(async (error) => {1090        console.error("Peach passkey create failed", error);1091        await rejectCreate(1092          requestInfo.requestId,1093          "UnknownError",1094          error instanceof Error ? error.message : "Passkey creation failed."1095        );1096      });1097    });1098    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1099      void handleGetRequest(requestInfo).catch(async (error) => {1100        console.error("Peach passkey get failed", error);1101        await rejectGet(1102          requestInfo.requestId,1103          "UnknownError",1104          error instanceof Error ? error.message : "Passkey authentication failed."1105        );1106      });1107    });1108    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1109      void resolvePendingPasskeyRequest(requestId, false);1110    });1111    listenersRegistered = true;1112  }11131114  await refreshPasskeyProxy();1115}