passkey.ts

32.7 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>();3738function isPasskeySupported(): boolean {39  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;40  // Firefox 150+ exposes navigator.credentials.create/get from the popup41  // context with extension host permissions. Cannot intercept page-side42  // calls (no equivalent of webAuthenticationProxy), but registration via43  // Peach's own popup-driven flow works.44  if (detectSharedBrowser() === "firefox") {45    const ver = firefoxVersion();46    if (Number.isFinite(ver) && ver >= 150) return true;47  }48  return false;49}5051export const detectBrowser = detectSharedBrowser;5253function getBrowserLabel(): string {54  const ua = navigator.userAgent;55  if (/Firefox\/(\d+)/.test(ua)) {56    const version = ua.match(/Firefox\/(\d+)/)?.[1];57    return version ? `Firefox ${version}` : "Firefox";58  }59  const version = ua.match(/Chrome\/(\d+)/)?.[1];60  return version ? `Chrome ${version}` : "Chromium";61}6263function pendingStorageKey(requestId: number): string {64  return `${PENDING_PREFIX}${requestId}`;65}6667function originFromRpId(rpId: string): string {68  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)69    ? `http://${rpId}`70    : `https://${rpId}`;71}7273async function setBadge(text: string): Promise<void> {74  if (!chrome.action?.setBadgeText) return;75  await chrome.action.setBadgeText({ text });76  if (text) {77    await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });78  }79}8081async function clearPendingRequest(requestId: number): Promise<void> {82  if (chrome.storage.session) {83    await chrome.storage.session.remove(pendingStorageKey(requestId));84  }85  await setBadge("");86}8788async function openApprovalPopup(): Promise<void> {89  try {90    await chrome.action.openPopup();91  } catch {92    await setBadge("PK");93  }94}9596async function rejectCreate(97  requestId: number,98  name: string,99  message = ""100): Promise<void> {101  await chrome.webAuthenticationProxy.completeCreateRequest({102    requestId,103    error: { name, message },104  });105}106107async function rejectGet(108  requestId: number,109  name: string,110  message = ""111): Promise<void> {112  await chrome.webAuthenticationProxy.completeGetRequest({113    requestId,114    error: { name, message },115  });116}117118export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {119  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts120  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;121  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));122  if (!key) return null;123  const pending = stored[key];124  return pending && typeof pending === "object"125    ? (pending as PendingPasskeyRequest)126    : null;127}128129export async function resolvePendingPasskeyRequest(130  requestId: number,131  approved: boolean132): Promise<void> {133  const pending = approvals.get(requestId);134  if (!pending) {135    // Either the request expired (Chrome) or this is a Firefox-driven136    // pending where the popup performs the navigator.credentials call137    // directly. Clear both possible session keys so getPendingPasskeyRequest()138    // doesn't surface stale state on the next popup open.139    await clearPendingRequest(requestId);140    if (chrome.storage.session) {141      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);142    }143    return;144  }145146  clearTimeout(pending.timeoutId);147  approvals.delete(requestId);148  await clearPendingRequest(requestId);149  pending.resolve(approved);150}151152async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {153  if (chrome.storage.session) {154    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });155  }156  await openApprovalPopup();157158  return new Promise<boolean>((resolve) => {159    const timeoutId = setTimeout(async () => {160      approvals.delete(request.requestId);161      await clearPendingRequest(request.requestId);162      resolve(false);163    }, APPROVAL_TIMEOUT_MS);164165    approvals.set(request.requestId, { resolve, timeoutId });166  });167}168169export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {170  const settings = await vault.ensureSettingsLoaded();171  const supported = isPasskeySupported();172  const enabled = settings.passkeyProviderEnabled === true;173  return {174    supported,175    enabled,176    attached: supported && attached,177    browserLabel: getBrowserLabel(),178    statusLabel: !supported179      ? "Interception unavailable"180      : attached181        ? "Active"182        : conflict183          ? "Another provider is active"184          : enabled185            ? "Inactive"186            : "Disabled",187    conflict,188  };189}190191async function attachPasskeyProxy(): Promise<void> {192  if (!isPasskeySupported()) {193    attached = false;194    conflict = "This browser does not expose chrome.webAuthenticationProxy.";195    return;196  }197198  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context199  // navigator.credentials creator is always ready once the user enables the200  // provider. Treat enabled = attached for state reporting purposes.201  if (detectSharedBrowser() === "firefox") {202    attached = true;203    conflict = undefined;204    return;205  }206207  if (attached) return;208209  try {210    await chrome.webAuthenticationProxy.attach();211    attached = true;212    conflict = undefined;213  } catch (error) {214    attached = false;215    conflict = error instanceof Error ? error.message : "Another provider is active.";216  }217}218219async function detachPasskeyProxy(): Promise<void> {220  if (!isPasskeySupported()) {221    attached = false;222    return;223  }224225  if (detectSharedBrowser() === "firefox") {226    attached = false;227    conflict = undefined;228    return;229  }230231  try {232    await chrome.webAuthenticationProxy.detach();233  } catch {234    // Ignore detach failures; the extension may already be detached.235  }236237  attached = false;238  conflict = undefined;239}240241export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {242  await vault.updateSettings({ passkeyProviderEnabled: enabled });243  if (enabled) {244    await attachPasskeyProxy();245  } else {246    await detachPasskeyProxy();247  }248  return getPasskeyProviderState();249}250251async function refreshPasskeyProxy(): Promise<void> {252  const settings = await vault.ensureSettingsLoaded();253  if (settings.passkeyProviderEnabled === true) {254    await attachPasskeyProxy();255    return;256  }257  await detachPasskeyProxy();258}259260/**261 * Find the entry that already holds a passkey for the given RP, or the most262 * recently-modified login entry whose URL/additionalUrls match the RP — that263 * entry is the natural attach target when the user adds a passkey to a site264 * they already have a password for. Returns null when there's no candidate;265 * the caller creates a new entry in that case.266 */267function findAttachTarget(rpId: string): VaultEntry | null {268  const all = vault.getEntries();269  const passkeyMatch = all.find(270    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt271  );272  if (passkeyMatch) return passkeyMatch;273  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);274  if (loginMatches.length === 0) return null;275  // Pick the most recently modified candidate to be deterministic.276  return loginMatches.reduce((best, e) =>277    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best278  );279}280281async function handleCreateRequest(282  request: chrome.webAuthenticationProxy.CreateRequest283): Promise<void> {284  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;285  const rpId = options.rp.id;286  const rpName = options.rp.name ?? options.rp.id;287  const userName = options.user.name;288  const userDisplayName = options.user.displayName ?? options.user.name;289290  if (!rpId) {291    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");292    return;293  }294295  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {296    await rejectCreate(297      request.requestId,298      "NotAllowedError",299      "Passkey creation requires Peach Pro."300    );301    // Show the inline passkey gate so the user knows what happened and can upgrade.302    broadcastPasskeyProGate();303    return;304  }305306  if (!vault.isUnlocked()) {307    await openApprovalPopup();308    await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");309    return;310  }311312  const approved = await promptUserApproval({313    requestId: request.requestId,314    type: "create",315    rpId,316    rpName,317    userName,318    userDisplayName,319  });320  if (!approved) {321    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");322    return;323  }324325  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);326  if (!algorithm) {327    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");328    return;329  }330331  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));332  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(333    algorithm334  );335  const authData = await buildAuthenticatorData({336    rpId,337    flags: 0x4d,338    signCount: 0,339    credentialId: credentialIdBytes,340    publicKeyCose,341  });342  const attestationObject = createAttestationObject(authData);343  const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));344345  const passkey: PasskeyData = {346    rpId,347    rpName,348    credentialId: base64urlEncode(credentialIdBytes),349    publicKeyCose: base64urlEncode(publicKeyCose),350    privateKeyJwk,351    userHandle: options.user.id,352    userName,353    userDisplayName,354    algorithm,355    signCount: 0,356    aaguid: base64urlEncode(new Uint8Array(16)),357    backupEligible: false,358    backupState: false,359    browser: "chrome",360    transports: ["internal"],361    createdAt: Date.now(),362  };363364  // Attach to an existing login (same domain) when one exists; otherwise365  // create a fresh login entry that exists solely to hold the passkey.366  const target = findAttachTarget(rpId);367  if (target) {368    await vault.updateEntry(target.id, { passkey });369  } else {370    await vault.addEntry({371      type: "login",372      title: rpName,373      url: `https://${rpId}`,374      username: userName,375      passkey,376    });377  }378379  await chrome.webAuthenticationProxy.completeCreateRequest({380    requestId: request.requestId,381    responseJson: JSON.stringify({382      id: passkey.credentialId,383      rawId: passkey.credentialId,384      type: "public-key",385      response: {386        clientDataJSON: base64urlEncode(clientDataJSON),387        authenticatorData: base64urlEncode(authData),388        attestationObject: base64urlEncode(attestationObject),389        transports: ["internal"],390        publicKey: base64urlEncode(publicKeySpki),391        publicKeyAlgorithm: algorithm,392      },393      authenticatorAttachment: "platform",394      clientExtensionResults: {},395    }),396  });397}398399function broadcastPasskeyProGate(): void {400  chrome.tabs.query({}, (tabs) => {401    for (const tab of tabs) {402      if (tab.id !== undefined) {403        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});404      }405    }406  });407}408409async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {410  if (chrome.storage.session) {411    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });412  }413414  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {415    chrome.tabs.query({ active: true, currentWindow: true }, resolve);416  });417  const tab = tabs[0];418419  if (tab?.id) {420    chrome.tabs.sendMessage(tab.id, {421      type: 'SHOW_PASSKEY_TOAST',422      payload: {423        site: request.rpId,424        displayName: request.userDisplayName ?? request.userName,425        username: request.userName,426        requestId: String(request.requestId),427      },428    }).catch(() => {429      // Content script not loaded on this tab — fall back to popup.430      void openApprovalPopup();431    });432  } else {433    await openApprovalPopup();434  }435436  return new Promise<boolean>((resolve) => {437    const timeoutId = setTimeout(async () => {438      approvals.delete(request.requestId);439      await clearPendingRequest(request.requestId);440      resolve(false);441    }, APPROVAL_TIMEOUT_MS);442443    approvals.set(request.requestId, { resolve, timeoutId });444  });445}446447async function handleGetRequest(448  request: chrome.webAuthenticationProxy.GetRequest449): Promise<void> {450  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;451  if (!vault.isUnlocked()) {452    await openApprovalPopup();453    await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");454    return;455  }456457  if (!(await isProUser())) {458    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");459    broadcastPasskeyProGate();460    return;461  }462463  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>464    entry.passkey !== undefined && !entry.deletedAt465  );466  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);467468  const match = passkeyBearing.find((entry) => {469    if (allowedCredentialIds.length > 0) {470      return allowedCredentialIds.includes(entry.passkey.credentialId);471    }472    if (!options.rpId) return false;473    return entry.passkey.rpId === options.rpId;474  });475476  if (!match) {477    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");478    return;479  }480481  const credential = match.passkey;482  // Chrome's webAuthenticationProxy path requires Peach-held private key483  // material to sign the assertion. Firefox-created passkeys delegate to the484  // platform authenticator — the browser will route to the platform UI485  // automatically when we reject, which is the right outcome.486  if (!credential.privateKeyJwk) {487    await rejectGet(488      request.requestId,489      "NotAllowedError",490      "This passkey was created on Firefox; sign in via the platform authenticator."491    );492    return;493  }494495  const approved = await promptViaToast({496    requestId: request.requestId,497    type: "get",498    rpId: credential.rpId,499    rpName: credential.rpName,500    userName: credential.userName,501    userDisplayName: credential.userDisplayName,502  });503  if (!approved) {504    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");505    return;506  }507508  const nextSignCount = credential.signCount + 1;509  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);510  const authData = await buildAuthenticatorData({511    rpId: credential.rpId,512    flags,513    signCount: nextSignCount,514  });515  const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));516  const clientDataHash = new Uint8Array(517    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))518  );519  const signature = await signPasskeyAssertion(520    credential.privateKeyJwk,521    credential.algorithm,522    authData,523    clientDataHash524  );525526  await vault.updateEntry(match.id, {527    passkey: {528      ...credential,529      signCount: nextSignCount,530      lastUsedAt: Date.now(),531    },532  });533534  const responseJson = JSON.stringify({535    id: credential.credentialId,536    rawId: credential.credentialId,537    type: "public-key",538    response: {539      clientDataJSON: base64urlEncode(clientDataJSON),540      authenticatorData: base64urlEncode(authData),541      signature: base64urlEncode(signature),542      userHandle: credential.userHandle,543    },544    authenticatorAttachment: "platform",545    clientExtensionResults: {},546  });547548  await chrome.webAuthenticationProxy.completeGetRequest({549    requestId: request.requestId,550    responseJson,551  });552}553554// ── Firefox path ────────────────────────────────────────────────────────555//556// Firefox 150+ permits extensions to call navigator.credentials.create/get557// from the popup context with their host-permitted RP IDs. Peach uses that558// surface to register passkeys on behalf of the user. The page's own559// navigator.credentials call is observed via the MAIN-world detector560// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's561// flow is parallel and explicit (the user must engage with Peach's button).562563const FIREFOX_PENDING_KEY = "passkey_pending_firefox";564const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";565const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days566567export async function initiateFirefoxPasskeyCreate(args: {568  rpId: string;569  rpName: string;570  userName: string;571  userDisplayName?: string;572}): Promise<{ ok: boolean; error?: string }> {573  if (!(await isProUser())) {574    broadcastPasskeyProGate();575    return { ok: false, error: "passkey creation requires Peach Pro" };576  }577  if (!vault.isUnlocked()) {578    await openApprovalPopup();579    return { ok: false, error: "vault locked" };580  }581  if (!chrome.storage.session) {582    return { ok: false, error: "session storage unavailable" };583  }584585  const requestId = Date.now();586  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));587  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));588589  const pending: PendingPasskeyRequest = {590    requestId,591    type: "create",592    rpId: args.rpId,593    rpName: args.rpName,594    userName: args.userName,595    userDisplayName: args.userDisplayName,596    browser: "firefox",597    challenge,598    userHandle,599  };600601  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });602  await openApprovalPopup();603  return { ok: true };604}605606export async function completeFirefoxPasskeyCreate(payload: {607  requestId: number;608  rpId: string;609  rpName: string;610  userName: string;611  userDisplayName?: string;612  userHandle: string;613  credentialId: string;614  publicKeyCose: string;615  algorithm: -7 | -8;616  aaguid?: string;617  transports?: string[];618  backupEligible?: boolean;619  backupState?: boolean;620}): Promise<{ ok: boolean; error?: string }> {621  if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };622623  const passkey: PasskeyData = {624    rpId: payload.rpId,625    rpName: payload.rpName,626    credentialId: payload.credentialId,627    publicKeyCose: payload.publicKeyCose,628    userHandle: payload.userHandle,629    userName: payload.userName,630    userDisplayName: payload.userDisplayName ?? payload.userName,631    algorithm: payload.algorithm,632    signCount: 0,633    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),634    backupEligible: payload.backupEligible ?? false,635    backupState: payload.backupState ?? false,636    browser: "firefox",637    transports: payload.transports ?? ["internal"],638    createdAt: Date.now(),639  };640641  // Same attach-or-create flow as the Chrome path: prefer attaching to an642  // existing login entry for the same domain so the user ends up with one643  // entry per site, not two (a login + a passkey-only).644  const target = findAttachTarget(payload.rpId);645  if (target) {646    await vault.updateEntry(target.id, { passkey });647  } else {648    await vault.addEntry({649      type: "login",650      title: payload.rpName || payload.rpId,651      url: `https://${payload.rpId}`,652      username: payload.userName,653      passkey,654    });655  }656657  if (chrome.storage.session) {658    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);659  }660  return { ok: true };661}662663export async function cancelFirefoxPasskeyCreate(): Promise<void> {664  if (chrome.storage.session) {665    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);666  }667}668669function declineKey(domain: string): string {670  return `${FIREFOX_DECLINE_PREFIX}${domain}`;671}672673export async function recordPasskeyOfferDecline(domain: string): Promise<void> {674  if (!domain) return;675  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });676}677678export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {679  if (!domain) return false;680  const key = declineKey(domain);681  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;682  const at = stored[key];683  if (typeof at !== "number") return false;684  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {685    await chrome.storage.local.remove(key);686    return false;687  }688  return true;689}690691// ── Firefox content-script proxy path (v2.12.0) ────────────────────────692//693// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces694// navigator.credentials.create / .get on every Firefox page. It builds a695// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),696// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,697// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.698//699// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag700// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates701// to false and these handlers short-circuit. The full removal checklist is702// in that file — the deletion is purely subtractive.703704interface FirefoxAllowCredential {705  id: string;706  type?: string;707}708709interface FirefoxPubKeyCredParam {710  type?: string;711  alg: number;712}713714export interface FirefoxPasskeyHasCredentialsPayload {715  rpId: string;716  allowCredentials: FirefoxAllowCredential[];717}718719export interface FirefoxPasskeyCreatePayload {720  rpId: string;721  rpName?: string;722  userId: string;723  userName: string;724  userDisplayName?: string;725  challenge: string;726  clientDataJSON: number[];727  origin: string;728  pubKeyCredParams?: FirefoxPubKeyCredParam[];729  authenticatorSelection?: Record<string, unknown>;730  timeout?: number;731  attestation?: string;732}733734export interface FirefoxPasskeyGetPayload {735  rpId: string;736  challenge: string;737  clientDataJSON: number[];738  origin: string;739  allowCredentials: FirefoxAllowCredential[];740  userVerification?: string;741}742743interface FirefoxCreateCredentialResponse {744  type: "create";745  id: string;746  clientDataJSON: number[];747  attestationObject: string;748  authenticatorData: string;749  publicKey: string;750  algorithm: -7 | -8;751}752753interface FirefoxGetCredentialResponse {754  type: "get";755  id: string;756  clientDataJSON: number[];757  authenticatorData: string;758  signature: string;759  userHandle: string;760}761762type FirefoxPasskeyResult<T> =763  | { ok: true; credential: T }764  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };765766function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {767  return vault.getEntries().filter(768    (entry): entry is VaultEntry & { passkey: PasskeyData } =>769      entry.passkey !== undefined && !entry.deletedAt770  );771}772773export async function handlePasskeyHasCredentials(774  payload: FirefoxPasskeyHasCredentialsPayload775): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {776  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {777    return { ok: false, error: "not_applicable" };778  }779  if (!vault.isUnlocked()) {780    // Cannot enumerate without the vault key. The MAIN-world proxy treats a781    // false answer as "fall through to the platform" — exactly the right782    // outcome when Peach is locked: the user gets the native UI instead.783    return { ok: true, hasCredentials: false };784  }785  const passkeys = listPasskeyEntries().filter(786    (entry) => entry.passkey.rpId === payload.rpId787  );788  if (passkeys.length === 0) return { ok: true, hasCredentials: false };789790  const allow = payload.allowCredentials ?? [];791  if (allow.length === 0) return { ok: true, hasCredentials: true };792793  const hasMatch = passkeys.some((entry) =>794    allow.some((ac) => ac.id === entry.passkey.credentialId)795  );796  return { ok: true, hasCredentials: hasMatch };797}798799/**800 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing801 * pending-passkey channel (badge + popup + 60s timeout) — the same approval802 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need803 * any new code path. The page-side proxy never sees the request id; the804 * background mints one and discards it after resolution.805 */806async function requestFirefoxApproval(args: {807  type: "create" | "get";808  rpId: string;809  rpName?: string;810  userName: string;811  userDisplayName?: string;812}): Promise<boolean> {813  // Synthetic request id — the Firefox proxy path doesn't have a814  // chrome.webAuthenticationProxy request to tie back to.815  const requestId = Date.now() + Math.floor(Math.random() * 1000);816  return promptUserApproval({817    requestId,818    type: args.type,819    rpId: args.rpId,820    rpName: args.rpName ?? args.rpId,821    userName: args.userName,822    userDisplayName: args.userDisplayName,823  });824}825826export async function handleFirefoxPasskeyCreate(827  payload: FirefoxPasskeyCreatePayload828): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {829  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {830    return { ok: false, error: "not_applicable" };831  }832  if (!(await isProUser())) {833    broadcastPasskeyProGate();834    return { ok: false, error: "pro_required" };835  }836  if (!vault.isUnlocked()) {837    await openApprovalPopup();838    return { ok: false, error: "vault_locked" };839  }840841  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);842  if (!algorithm) {843    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };844  }845846  const approved = await requestFirefoxApproval({847    type: "create",848    rpId: payload.rpId,849    rpName: payload.rpName,850    userName: payload.userName,851    userDisplayName: payload.userDisplayName,852  });853  if (!approved) return { ok: false, error: "user_cancelled" };854855  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();856  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);857858  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).859  // Matches Chrome's create flag set in handleCreateRequest above.860  const authenticatorData = await buildAuthenticatorData({861    rpId: payload.rpId,862    flags: 0x45,863    signCount: 0,864    credentialId: credentialIdBytes,865    publicKeyCose,866  });867  const attestationObject = createAttestationObject(authenticatorData);868869  const passkey: PasskeyData = {870    rpId: payload.rpId,871    rpName: payload.rpName ?? payload.rpId,872    credentialId,873    publicKeyCose: base64urlEncode(publicKeyCose),874    privateKeyJwk,875    userHandle: payload.userId,876    userName: payload.userName,877    userDisplayName: payload.userDisplayName ?? payload.userName,878    algorithm,879    signCount: 0,880    aaguid: base64urlEncode(new Uint8Array(16)),881    backupEligible: false,882    backupState: false,883    browser: "firefox",884    transports: ["internal"],885    createdAt: Date.now(),886  };887888  const target = findAttachTarget(payload.rpId);889  if (target) {890    await vault.updateEntry(target.id, { passkey });891  } else {892    await vault.addEntry({893      type: "login",894      title: payload.rpName || payload.rpId,895      url: `https://${payload.rpId}`,896      username: payload.userName,897      passkey,898    });899  }900901  return {902    ok: true,903    credential: {904      type: "create",905      id: credentialId,906      // The content script already encoded clientDataJSON with the page907      // origin; round-trip the exact bytes back so the RP sees the same908      // hash it computes locally.909      clientDataJSON: payload.clientDataJSON,910      attestationObject: base64urlEncode(attestationObject),911      authenticatorData: base64urlEncode(authenticatorData),912      publicKey: base64urlEncode(publicKeySpki),913      algorithm,914    },915  };916}917918export async function handleFirefoxPasskeyGet(919  payload: FirefoxPasskeyGetPayload920): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {921  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {922    return { ok: false, error: "not_applicable" };923  }924  if (!(await isProUser())) {925    broadcastPasskeyProGate();926    return { ok: false, error: "pro_required" };927  }928  if (!vault.isUnlocked()) {929    await openApprovalPopup();930    return { ok: false, error: "vault_locked" };931  }932933  const allow = payload.allowCredentials ?? [];934  const candidates = listPasskeyEntries().filter((entry) => {935    if (entry.passkey.rpId !== payload.rpId) return false;936    if (allow.length === 0) return true;937    return allow.some((ac) => ac.id === entry.passkey.credentialId);938  });939940  if (candidates.length === 0) return { ok: false, error: "no_credentials" };941942  // Most-recently-used wins when several credentials match. A future943  // multi-account selector goes here; for now this matches the Chrome path's944  // behavior for the common one-passkey-per-rp case.945  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));946  const match = candidates[0]!;947  const credential = match.passkey;948949  if (!credential.privateKeyJwk) {950    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator951    // — no Peach-held private key to sign with. The MAIN-world proxy treats952    // no_credentials as a fall-through, which routes to the platform UI.953    return { ok: false, error: "no_credentials" };954  }955956  const approved = await requestFirefoxApproval({957    type: "get",958    rpId: credential.rpId,959    rpName: credential.rpName,960    userName: credential.userName,961    userDisplayName: credential.userDisplayName,962  });963  if (!approved) return { ok: false, error: "user_cancelled" };964965  const nextSignCount = credential.signCount + 1;966  // 0x05 = UP|UV. backup bits track whatever was set at registration time967  // (consistent with the Chrome path).968  const flags =969    0x05 |970    (credential.backupEligible ? 0x08 : 0) |971    (credential.backupState ? 0x10 : 0);972  const authenticatorData = await buildAuthenticatorData({973    rpId: credential.rpId,974    flags,975    signCount: nextSignCount,976  });977978  // The content script supplied clientDataJSON bytes built with the page979  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash980  // diverges from what the RP computes.981  const clientDataBytes = new Uint8Array(payload.clientDataJSON);982  const clientDataHash = new Uint8Array(983    await crypto.subtle.digest("SHA-256", clientDataBytes)984  );985  const signature = await signPasskeyAssertion(986    credential.privateKeyJwk,987    credential.algorithm,988    authenticatorData,989    clientDataHash990  );991992  await vault.updateEntry(match.id, {993    passkey: {994      ...credential,995      signCount: nextSignCount,996      lastUsedAt: Date.now(),997    },998  });9991000  return {1001    ok: true,1002    credential: {1003      type: "get",1004      id: credential.credentialId,1005      clientDataJSON: payload.clientDataJSON,1006      authenticatorData: base64urlEncode(authenticatorData),1007      signature: base64urlEncode(signature),1008      userHandle: credential.userHandle,1009    },1010  };1011}10121013export async function setupPasskeyProxy(): Promise<void> {1014  if (!isPasskeySupported()) return;10151016  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1017  // popup-context credential creator is the entry point; nothing to wire here.1018  if (detectSharedBrowser() === "firefox") {1019    await refreshPasskeyProxy();1020    return;1021  }10221023  if (!listenersRegistered) {1024    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1025      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1026        requestId: requestInfo.requestId,1027        isUvpaa: vault.isUnlocked(),1028      });1029    });1030    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1031      void handleCreateRequest(requestInfo).catch(async (error) => {1032        console.error("Peach passkey create failed", error);1033        await rejectCreate(1034          requestInfo.requestId,1035          "UnknownError",1036          error instanceof Error ? error.message : "Passkey creation failed."1037        );1038      });1039    });1040    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1041      void handleGetRequest(requestInfo).catch(async (error) => {1042        console.error("Peach passkey get failed", error);1043        await rejectGet(1044          requestInfo.requestId,1045          "UnknownError",1046          error instanceof Error ? error.message : "Passkey authentication failed."1047        );1048      });1049    });1050    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1051      void resolvePendingPasskeyRequest(requestId, false);1052    });1053    listenersRegistered = true;1054  }10551056  await refreshPasskeyProxy();1057}