passkey.ts

39.6 KB · raw

1import { isProUser } from "./license";2import * as vault from "./vault";3import {4  base64urlEncode,5  buildAuthenticatorData,6  buildClientDataJSON,7  createAttestationObject,8  generateCredentialId,9  generatePasskeyKeypair,10  pickPasskeyAlgorithm,11  signPasskeyAssertion,12} from "./passkey-crypto";13import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";14import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";15import 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  if (approvals.size === 0 && unlockWaiters.size === 0) return;57  console.log("[Peach passkey] denyAllPendingPasskeyRequests (popup disconnected)", {58    approvals: approvals.size,59    unlockWaiters: unlockWaiters.size,60  });61  for (const [rid, { resolve, timeoutId }] of approvals.entries()) {62    clearTimeout(timeoutId);63    resolve(false);64    approvals.delete(rid);65    await clearPendingRequest(rid).catch(() => {});66  }67  for (const { resolve, timeoutId } of unlockWaiters.values()) {68    clearTimeout(timeoutId);69    resolve(false);70  }71  unlockWaiters.clear();72}7374function isPasskeySupported(): boolean {75  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;76  // Firefox 150+ exposes navigator.credentials.create/get from the popup77  // context with extension host permissions. Cannot intercept page-side78  // calls (no equivalent of webAuthenticationProxy), but registration via79  // Peach's own popup-driven flow works.80  if (detectSharedBrowser() === "firefox") {81    const ver = firefoxVersion();82    if (Number.isFinite(ver) && ver >= 150) return true;83  }84  return false;85}8687export const detectBrowser = detectSharedBrowser;8889function getBrowserLabel(): string {90  const ua = navigator.userAgent;91  if (/Firefox\/(\d+)/.test(ua)) {92    const version = ua.match(/Firefox\/(\d+)/)?.[1];93    return version ? `Firefox ${version}` : "Firefox";94  }95  const version = ua.match(/Chrome\/(\d+)/)?.[1];96  return version ? `Chrome ${version}` : "Chromium";97}9899function pendingStorageKey(requestId: number): string {100  return `${PENDING_PREFIX}${requestId}`;101}102103function originFromRpId(rpId: string): string {104  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)105    ? `http://${rpId}`106    : `https://${rpId}`;107}108109/**110 * The clientDataJSON sent back to the RP must contain the page's actual111 * origin — not a synthesized one. `chrome.webAuthenticationProxy` does not112 * surface the requesting origin in CreateRequest/GetRequest, so we read it113 * from the active tab and validate that its hostname is a registrable114 * suffix match for the rpId. WebAuthn requires this relationship anyway, so115 * a mismatch indicates either a stale active tab or a hostile environment;116 * we fall back to https://<rpId> in that case to avoid silently signing the117 * wrong origin.118 *119 * Without this, a passkey created on https://myaccount.google.com gets120 * clientDataJSON.origin = "https://google.com", and Google's RP rejects the121 * attestation with "Something went wrong" because the origin hash doesn't122 * match what its own JS captured at create time.123 */124async function resolvePageOrigin(rpId: string): Promise<string> {125  try {126    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>127      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)128    );129    const url = tabs[0]?.url;130    if (url) {131      const parsed = new URL(url);132      const host = parsed.hostname.toLowerCase();133      const rp = rpId.toLowerCase();134      if (host === rp || host.endsWith(`.${rp}`)) {135        return parsed.origin;136      }137    }138  } catch {139    // fall through to rpId-derived origin140  }141  return originFromRpId(rpId);142}143144async function setBadge(text: string): Promise<void> {145  if (!chrome.action?.setBadgeText) return;146  await chrome.action.setBadgeText({ text });147  if (text) {148    await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });149  }150}151152async function clearPendingRequest(requestId: number): Promise<void> {153  if (chrome.storage.session) {154    await chrome.storage.session.remove(pendingStorageKey(requestId));155  }156  await setBadge("");157}158159async function openApprovalPopup(): Promise<void> {160  try {161    await chrome.action.openPopup();162  } catch {163    await setBadge("PK");164  }165}166167async function rejectCreate(168  requestId: number,169  name: string,170  message = ""171): Promise<void> {172  await chrome.webAuthenticationProxy.completeCreateRequest({173    requestId,174    error: { name, message },175  });176}177178async function rejectGet(179  requestId: number,180  name: string,181  message = ""182): Promise<void> {183  await chrome.webAuthenticationProxy.completeGetRequest({184    requestId,185    error: { name, message },186  });187}188189export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {190  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts191  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;192  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));193  if (!key) return null;194  const pending = stored[key];195  return pending && typeof pending === "object"196    ? (pending as PendingPasskeyRequest)197    : null;198}199200export async function resolvePendingPasskeyRequest(201  requestId: number,202  approved: boolean203): Promise<void> {204  const pending = approvals.get(requestId);205  if (!pending) {206    // Either the request expired (Chrome) or this is a Firefox-driven207    // pending where the popup performs the navigator.credentials call208    // directly. Clear both possible session keys so getPendingPasskeyRequest()209    // doesn't surface stale state on the next popup open.210    await clearPendingRequest(requestId);211    if (chrome.storage.session) {212      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);213    }214    return;215  }216217  clearTimeout(pending.timeoutId);218  approvals.delete(requestId);219  await clearPendingRequest(requestId);220  pending.resolve(approved);221}222223async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {224  if (chrome.storage.session) {225    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });226  }227  await openApprovalPopup();228229  return new Promise<boolean>((resolve) => {230    const timeoutId = setTimeout(async () => {231      approvals.delete(request.requestId);232      await clearPendingRequest(request.requestId);233      resolve(false);234    }, APPROVAL_TIMEOUT_MS);235236    approvals.set(request.requestId, { resolve, timeoutId });237  });238}239240export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {241  const settings = await vault.ensureSettingsLoaded();242  const supported = isPasskeySupported();243  const enabled = settings.passkeyProviderEnabled === true;244  return {245    supported,246    enabled,247    attached: supported && attached,248    browserLabel: getBrowserLabel(),249    statusLabel: !supported250      ? "Interception unavailable"251      : attached252        ? "Active"253        : conflict254          ? "Another provider is active"255          : enabled256            ? "Inactive"257            : "Disabled",258    conflict,259  };260}261262async function attachPasskeyProxy(): Promise<void> {263  if (!isPasskeySupported()) {264    attached = false;265    conflict = "This browser does not expose chrome.webAuthenticationProxy.";266    return;267  }268269  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context270  // navigator.credentials creator is always ready once the user enables the271  // provider. Treat enabled = attached for state reporting purposes.272  if (detectSharedBrowser() === "firefox") {273    attached = true;274    conflict = undefined;275    return;276  }277278  if (attached) return;279280  try {281    await chrome.webAuthenticationProxy.attach();282    attached = true;283    conflict = undefined;284    console.log("[Peach passkey] proxy attached successfully");285  } catch (error) {286    attached = false;287    conflict = error instanceof Error ? error.message : "Another provider is active.";288    console.warn("[Peach passkey] proxy attach failed", { conflict });289  }290}291292async function detachPasskeyProxy(): Promise<void> {293  if (!isPasskeySupported()) {294    attached = false;295    return;296  }297298  if (detectSharedBrowser() === "firefox") {299    attached = false;300    conflict = undefined;301    return;302  }303304  try {305    await chrome.webAuthenticationProxy.detach();306  } catch {307    // Ignore detach failures; the extension may already be detached.308  }309310  attached = false;311  conflict = undefined;312}313314export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {315  await vault.updateSettings({ passkeyProviderEnabled: enabled });316  if (enabled) {317    await attachPasskeyProxy();318  } else {319    await detachPasskeyProxy();320  }321  return getPasskeyProviderState();322}323324async function refreshPasskeyProxy(): Promise<void> {325  const settings = await vault.ensureSettingsLoaded();326  if (settings.passkeyProviderEnabled === true) {327    await attachPasskeyProxy();328    return;329  }330  await detachPasskeyProxy();331}332333/**334 * Find the entry that already holds a passkey for the given RP, or the most335 * recently-modified login entry whose URL/additionalUrls match the RP — that336 * entry is the natural attach target when the user adds a passkey to a site337 * they already have a password for. Returns null when there's no candidate;338 * the caller creates a new entry in that case.339 */340function findAttachTarget(rpId: string): VaultEntry | null {341  const all = vault.getEntries();342  const passkeyMatch = all.find(343    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt344  );345  if (passkeyMatch) return passkeyMatch;346  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);347  if (loginMatches.length === 0) return null;348  // Pick the most recently modified candidate to be deterministic.349  return loginMatches.reduce((best, e) =>350    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best351  );352}353354async function handleCreateRequest(355  request: chrome.webAuthenticationProxy.CreateRequest356): Promise<void> {357  console.log("[Peach passkey] create event received", {358    requestId: request.requestId,359    detailsJson: request.requestDetailsJson,360  });361  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;362  const rpId = options.rp.id;363  const rpName = options.rp.name ?? options.rp.id;364  const userName = options.user.name;365  const userDisplayName = options.user.displayName ?? options.user.name;366  console.log("[Peach passkey] create parsed", { rpId, rpName, userName, unlocked: vault.isUnlocked() });367368  if (!rpId) {369    console.warn("[Peach passkey] reject: missing rp.id");370    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");371    return;372  }373374  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {375    console.warn("[Peach passkey] reject: user is not Pro");376    await rejectCreate(377      request.requestId,378      "NotAllowedError",379      "Passkey creation requires Peach Pro."380    );381    // Show the inline passkey gate so the user knows what happened and can upgrade.382    broadcastPasskeyProGate();383    return;384  }385386  if (!vault.isUnlocked()) {387    console.log("[Peach passkey] vault locked, summoning inline unlock");388    // Same wait-for-unlock pattern handleGetRequest uses: summon the inline389    // unlock prompt on the active tab so the user can unlock without leaving390    // the page, then hold the create request open until the vault unlocks or391    // the timeout fires. Immediately rejecting here means Google shows392    // "Something went wrong" the instant the user picks Peach.393    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>394      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)395    );396    const activeTab = tabs[0];397    console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });398    if (activeTab?.id) {399      chrome.tabs.sendMessage(activeTab.id, {400        type: 'SUMMON_AUTOFILL',401        source: 'passkey-unlock',402      }).catch((err) => {403        console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);404        void openApprovalPopup();405      });406    } else {407      await openApprovalPopup();408    }409410    const unlocked = await new Promise<boolean>((resolve) => {411      const timeoutId = setTimeout(() => {412        unlockWaiters.delete(request.requestId);413        resolve(false);414      }, APPROVAL_TIMEOUT_MS);415      unlockWaiters.set(request.requestId, { resolve, timeoutId });416    });417418    console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });419    if (!unlocked || !vault.isUnlocked()) {420      console.warn("[Peach passkey] reject: unlock timeout");421      await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");422      return;423    }424    // Vault is now unlocked — fall through to approval.425  }426427  console.log("[Peach passkey] requesting approval via popup");428  const approved = await promptUserApproval({429    requestId: request.requestId,430    type: "create",431    rpId,432    rpName,433    userName,434    userDisplayName,435  });436  console.log("[Peach passkey] approval resolved", { approved });437  if (!approved) {438    console.warn("[Peach passkey] reject: user denied");439    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");440    return;441  }442443  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);444  if (!algorithm) {445    console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);446    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");447    return;448  }449  console.log("[Peach passkey] algorithm selected", algorithm);450451  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));452  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(453    algorithm454  );455  const authData = await buildAuthenticatorData({456    rpId,457    flags: 0x4d,458    signCount: 0,459    credentialId: credentialIdBytes,460    publicKeyCose,461  });462  const attestationObject = createAttestationObject(authData);463  const clientDataJSON = buildClientDataJSON(464    "webauthn.create",465    options.challenge,466    await resolvePageOrigin(rpId)467  );468469  const passkey: PasskeyData = {470    rpId,471    rpName,472    credentialId: base64urlEncode(credentialIdBytes),473    publicKeyCose: base64urlEncode(publicKeyCose),474    privateKeyJwk,475    userHandle: options.user.id,476    userName,477    userDisplayName,478    algorithm,479    signCount: 0,480    aaguid: base64urlEncode(new Uint8Array(16)),481    backupEligible: false,482    backupState: false,483    browser: "chrome",484    transports: ["internal"],485    createdAt: Date.now(),486  };487488  // Attach to an existing login (same domain) when one exists; otherwise489  // create a fresh login entry that exists solely to hold the passkey.490  const target = findAttachTarget(rpId);491  if (target) {492    await vault.updateEntry(target.id, { passkey });493  } else {494    await vault.addEntry({495      type: "login",496      title: rpName,497      url: `https://${rpId}`,498      username: userName,499      passkey,500    });501  }502503  await chrome.webAuthenticationProxy.completeCreateRequest({504    requestId: request.requestId,505    responseJson: JSON.stringify({506      id: passkey.credentialId,507      rawId: passkey.credentialId,508      type: "public-key",509      response: {510        clientDataJSON: base64urlEncode(clientDataJSON),511        authenticatorData: base64urlEncode(authData),512        attestationObject: base64urlEncode(attestationObject),513        transports: ["internal"],514        publicKey: base64urlEncode(publicKeySpki),515        publicKeyAlgorithm: algorithm,516      },517      authenticatorAttachment: "platform",518      clientExtensionResults: {},519    }),520  });521}522523function broadcastPasskeyProGate(): void {524  chrome.tabs.query({}, (tabs) => {525    for (const tab of tabs) {526      if (tab.id !== undefined) {527        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});528      }529    }530  });531}532533async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {534  if (chrome.storage.session) {535    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });536  }537538  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {539    chrome.tabs.query({ active: true, currentWindow: true }, resolve);540  });541  const tab = tabs[0];542543  if (tab?.id) {544    chrome.tabs.sendMessage(tab.id, {545      type: 'SHOW_PASSKEY_TOAST',546      payload: {547        site: request.rpId,548        displayName: request.userDisplayName ?? request.userName,549        username: request.userName,550        requestId: String(request.requestId),551      },552    }).catch(() => {});553  } else {554    await openApprovalPopup();555  }556557  return new Promise<boolean>((resolve) => {558    const timeoutId = setTimeout(async () => {559      approvals.delete(request.requestId);560      await clearPendingRequest(request.requestId);561      resolve(false);562    }, APPROVAL_TIMEOUT_MS);563564    approvals.set(request.requestId, { resolve, timeoutId });565  });566}567568async function handleGetRequest(569  request: chrome.webAuthenticationProxy.GetRequest570): Promise<void> {571  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;572  if (!vault.isUnlocked()) {573    // Summon the inline unlock prompt on the active tab so the user can unlock574    // without leaving the page. Fall back to the popup if the content script575    // is unavailable (e.g. restricted URL, script not yet injected).576    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>577      chrome.tabs.query({ active: true, currentWindow: true }, resolve)578    );579    const activeTab = tabs[0];580    if (activeTab?.id) {581      chrome.tabs.sendMessage(activeTab.id, {582        type: 'SUMMON_AUTOFILL',583        source: 'passkey-unlock',584      }).catch(() => { void openApprovalPopup(); });585    } else {586      await openApprovalPopup();587    }588589    // Hold the request open until the vault unlocks or the timeout fires.590    const unlocked = await new Promise<boolean>((resolve) => {591      const timeoutId = setTimeout(() => {592        unlockWaiters.delete(request.requestId);593        resolve(false);594      }, APPROVAL_TIMEOUT_MS);595      unlockWaiters.set(request.requestId, { resolve, timeoutId });596    });597598    if (!unlocked || !vault.isUnlocked()) {599      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");600      return;601    }602    // Vault is now unlocked — fall through to authenticate.603  }604605  if (!(await isProUser())) {606    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");607    broadcastPasskeyProGate();608    return;609  }610611  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>612    entry.passkey !== undefined && !entry.deletedAt613  );614  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);615616  const match = passkeyBearing.find((entry) => {617    if (allowedCredentialIds.length > 0) {618      return allowedCredentialIds.includes(entry.passkey.credentialId);619    }620    if (!options.rpId) return false;621    return entry.passkey.rpId === options.rpId;622  });623624  if (!match) {625    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");626    return;627  }628629  const credential = match.passkey;630  // Chrome's webAuthenticationProxy path requires Peach-held private key631  // material to sign the assertion. Firefox-created passkeys delegate to the632  // platform authenticator — the browser will route to the platform UI633  // automatically when we reject, which is the right outcome.634  if (!credential.privateKeyJwk) {635    await rejectGet(636      request.requestId,637      "NotAllowedError",638      "This passkey was created on Firefox; sign in via the platform authenticator."639    );640    return;641  }642643  const approved = await promptViaToast({644    requestId: request.requestId,645    type: "get",646    rpId: credential.rpId,647    rpName: credential.rpName,648    userName: credential.userName,649    userDisplayName: credential.userDisplayName,650  });651  if (!approved) {652    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");653    return;654  }655656  const nextSignCount = credential.signCount + 1;657  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);658  const authData = await buildAuthenticatorData({659    rpId: credential.rpId,660    flags,661    signCount: nextSignCount,662  });663  const clientDataJSON = buildClientDataJSON(664    "webauthn.get",665    options.challenge,666    await resolvePageOrigin(credential.rpId)667  );668  const clientDataHash = new Uint8Array(669    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))670  );671  const signature = await signPasskeyAssertion(672    credential.privateKeyJwk,673    credential.algorithm,674    authData,675    clientDataHash676  );677678  await vault.updateEntry(match.id, {679    passkey: {680      ...credential,681      signCount: nextSignCount,682      lastUsedAt: Date.now(),683    },684  });685686  const responseJson = JSON.stringify({687    id: credential.credentialId,688    rawId: credential.credentialId,689    type: "public-key",690    response: {691      clientDataJSON: base64urlEncode(clientDataJSON),692      authenticatorData: base64urlEncode(authData),693      signature: base64urlEncode(signature),694      userHandle: credential.userHandle,695    },696    authenticatorAttachment: "platform",697    clientExtensionResults: {},698  });699700  await chrome.webAuthenticationProxy.completeGetRequest({701    requestId: request.requestId,702    responseJson,703  });704}705706// ── Firefox path ────────────────────────────────────────────────────────707//708// Firefox 150+ permits extensions to call navigator.credentials.create/get709// from the popup context with their host-permitted RP IDs. Peach uses that710// surface to register passkeys on behalf of the user. The page's own711// navigator.credentials call is observed via the MAIN-world detector712// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's713// flow is parallel and explicit (the user must engage with Peach's button).714715const FIREFOX_PENDING_KEY = "passkey_pending_firefox";716const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";717const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days718719export async function initiateFirefoxPasskeyCreate(args: {720  rpId: string;721  rpName: string;722  userName: string;723  userDisplayName?: string;724}): Promise<{ ok: boolean; error?: string }> {725  if (!(await isProUser())) {726    broadcastPasskeyProGate();727    return { ok: false, error: "passkey creation requires Peach Pro" };728  }729  if (!vault.isUnlocked()) {730    await openApprovalPopup();731    return { ok: false, error: "vault_locked" };732  }733  if (!chrome.storage.session) {734    return { ok: false, error: "session storage unavailable" };735  }736737  // Date.now() alone collides if two Firefox passkey requests land in the738  // same millisecond. Mix in 20 random bits to match the synthetic-id pattern739  // at requestFirefoxApproval (M9/COR-010).740  const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);741  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));742  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));743744  const pending: PendingPasskeyRequest = {745    requestId,746    type: "create",747    rpId: args.rpId,748    rpName: args.rpName,749    userName: args.userName,750    userDisplayName: args.userDisplayName,751    browser: "firefox",752    challenge,753    userHandle,754  };755756  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });757  await openApprovalPopup();758  return { ok: true };759}760761export async function completeFirefoxPasskeyCreate(payload: {762  requestId: number;763  rpId: string;764  rpName: string;765  userName: string;766  userDisplayName?: string;767  userHandle: string;768  credentialId: string;769  publicKeyCose: string;770  algorithm: -7 | -8;771  aaguid?: string;772  transports?: string[];773  backupEligible?: boolean;774  backupState?: boolean;775}): Promise<{ ok: boolean; error?: string }> {776  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };777778  const passkey: PasskeyData = {779    rpId: payload.rpId,780    rpName: payload.rpName,781    credentialId: payload.credentialId,782    publicKeyCose: payload.publicKeyCose,783    userHandle: payload.userHandle,784    userName: payload.userName,785    userDisplayName: payload.userDisplayName ?? payload.userName,786    algorithm: payload.algorithm,787    signCount: 0,788    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),789    backupEligible: payload.backupEligible ?? false,790    backupState: payload.backupState ?? false,791    browser: "firefox",792    transports: payload.transports ?? ["internal"],793    createdAt: Date.now(),794  };795796  // Same attach-or-create flow as the Chrome path: prefer attaching to an797  // existing login entry for the same domain so the user ends up with one798  // entry per site, not two (a login + a passkey-only).799  const target = findAttachTarget(payload.rpId);800  if (target) {801    await vault.updateEntry(target.id, { passkey });802  } else {803    await vault.addEntry({804      type: "login",805      title: payload.rpName || payload.rpId,806      url: `https://${payload.rpId}`,807      username: payload.userName,808      passkey,809    });810  }811812  if (chrome.storage.session) {813    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);814  }815  return { ok: true };816}817818export async function cancelFirefoxPasskeyCreate(): Promise<void> {819  if (chrome.storage.session) {820    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);821  }822}823824function declineKey(domain: string): string {825  return `${FIREFOX_DECLINE_PREFIX}${domain}`;826}827828export async function recordPasskeyOfferDecline(domain: string): Promise<void> {829  if (!domain) return;830  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });831}832833export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {834  if (!domain) return false;835  const key = declineKey(domain);836  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;837  const at = stored[key];838  if (typeof at !== "number") return false;839  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {840    await chrome.storage.local.remove(key);841    return false;842  }843  return true;844}845846// ── Firefox content-script proxy path (v2.12.0) ────────────────────────847//848// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces849// navigator.credentials.create / .get on every Firefox page. It builds a850// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),851// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,852// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.853//854// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag855// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates856// to false and these handlers short-circuit. The full removal checklist is857// in that file — the deletion is purely subtractive.858859interface FirefoxAllowCredential {860  id: string;861  type?: string;862}863864interface FirefoxPubKeyCredParam {865  type?: string;866  alg: number;867}868869export interface FirefoxPasskeyHasCredentialsPayload {870  rpId: string;871  allowCredentials: FirefoxAllowCredential[];872}873874export interface FirefoxPasskeyCreatePayload {875  rpId: string;876  rpName?: string;877  userId: string;878  userName: string;879  userDisplayName?: string;880  challenge: string;881  clientDataJSON: number[];882  origin: string;883  pubKeyCredParams?: FirefoxPubKeyCredParam[];884  authenticatorSelection?: Record<string, unknown>;885  timeout?: number;886  attestation?: string;887}888889export interface FirefoxPasskeyGetPayload {890  rpId: string;891  challenge: string;892  clientDataJSON: number[];893  origin: string;894  allowCredentials: FirefoxAllowCredential[];895  userVerification?: string;896}897898interface FirefoxCreateCredentialResponse {899  type: "create";900  id: string;901  clientDataJSON: number[];902  attestationObject: string;903  authenticatorData: string;904  publicKey: string;905  algorithm: -7 | -8;906}907908interface FirefoxGetCredentialResponse {909  type: "get";910  id: string;911  clientDataJSON: number[];912  authenticatorData: string;913  signature: string;914  userHandle: string;915}916917type FirefoxPasskeyResult<T> =918  | { ok: true; credential: T }919  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };920921function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {922  return vault.getEntries().filter(923    (entry): entry is VaultEntry & { passkey: PasskeyData } =>924      entry.passkey !== undefined && !entry.deletedAt925  );926}927928export async function handlePasskeyHasCredentials(929  payload: FirefoxPasskeyHasCredentialsPayload930): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {931  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {932    return { ok: false, error: "not_applicable" };933  }934  if (!vault.isUnlocked()) {935    // Cannot enumerate without the vault key. The MAIN-world proxy treats a936    // false answer as "fall through to the platform" — exactly the right937    // outcome when Peach is locked: the user gets the native UI instead.938    return { ok: true, hasCredentials: false };939  }940  const passkeys = listPasskeyEntries().filter(941    (entry) => entry.passkey.rpId === payload.rpId942  );943  if (passkeys.length === 0) return { ok: true, hasCredentials: false };944945  const allow = payload.allowCredentials ?? [];946  if (allow.length === 0) return { ok: true, hasCredentials: true };947948  const hasMatch = passkeys.some((entry) =>949    allow.some((ac) => ac.id === entry.passkey.credentialId)950  );951  return { ok: true, hasCredentials: hasMatch };952}953954/**955 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing956 * pending-passkey channel (badge + popup + 60s timeout) — the same approval957 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need958 * any new code path. The page-side proxy never sees the request id; the959 * background mints one and discards it after resolution.960 */961async function requestFirefoxApproval(args: {962  type: "create" | "get";963  rpId: string;964  rpName?: string;965  userName: string;966  userDisplayName?: string;967}): Promise<boolean> {968  // Synthetic request id — the Firefox proxy path doesn't have a969  // chrome.webAuthenticationProxy request to tie back to.970  const requestId = Date.now() + Math.floor(Math.random() * 1000);971  return promptUserApproval({972    requestId,973    type: args.type,974    rpId: args.rpId,975    rpName: args.rpName ?? args.rpId,976    userName: args.userName,977    userDisplayName: args.userDisplayName,978  });979}980981export async function handleFirefoxPasskeyCreate(982  payload: FirefoxPasskeyCreatePayload983): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {984  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {985    return { ok: false, error: "not_applicable" };986  }987  if (!(await isProUser())) {988    broadcastPasskeyProGate();989    return { ok: false, error: "pro_required" };990  }991  if (!vault.isUnlocked()) {992    await openApprovalPopup();993    return { ok: false, error: "vault_locked" };994  }995996  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);997  if (!algorithm) {998    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };999  }10001001  const approved = await requestFirefoxApproval({1002    type: "create",1003    rpId: payload.rpId,1004    rpName: payload.rpName,1005    userName: payload.userName,1006    userDisplayName: payload.userDisplayName,1007  });1008  if (!approved) return { ok: false, error: "user_cancelled" };10091010  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1011  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);10121013  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).1014  // Matches Chrome's create flag set in handleCreateRequest above.1015  const authenticatorData = await buildAuthenticatorData({1016    rpId: payload.rpId,1017    flags: 0x45,1018    signCount: 0,1019    credentialId: credentialIdBytes,1020    publicKeyCose,1021  });1022  const attestationObject = createAttestationObject(authenticatorData);10231024  const passkey: PasskeyData = {1025    rpId: payload.rpId,1026    rpName: payload.rpName ?? payload.rpId,1027    credentialId,1028    publicKeyCose: base64urlEncode(publicKeyCose),1029    privateKeyJwk,1030    userHandle: payload.userId,1031    userName: payload.userName,1032    userDisplayName: payload.userDisplayName ?? payload.userName,1033    algorithm,1034    signCount: 0,1035    aaguid: base64urlEncode(new Uint8Array(16)),1036    backupEligible: false,1037    backupState: false,1038    browser: "firefox",1039    transports: ["internal"],1040    createdAt: Date.now(),1041  };10421043  const target = findAttachTarget(payload.rpId);1044  if (target) {1045    await vault.updateEntry(target.id, { passkey });1046  } else {1047    await vault.addEntry({1048      type: "login",1049      title: payload.rpName || payload.rpId,1050      url: `https://${payload.rpId}`,1051      username: payload.userName,1052      passkey,1053    });1054  }10551056  return {1057    ok: true,1058    credential: {1059      type: "create",1060      id: credentialId,1061      // The content script already encoded clientDataJSON with the page1062      // origin; round-trip the exact bytes back so the RP sees the same1063      // hash it computes locally.1064      clientDataJSON: payload.clientDataJSON,1065      attestationObject: base64urlEncode(attestationObject),1066      authenticatorData: base64urlEncode(authenticatorData),1067      publicKey: base64urlEncode(publicKeySpki),1068      algorithm,1069    },1070  };1071}10721073export async function handleFirefoxPasskeyGet(1074  payload: FirefoxPasskeyGetPayload1075): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1076  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1077    return { ok: false, error: "not_applicable" };1078  }1079  if (!(await isProUser())) {1080    broadcastPasskeyProGate();1081    return { ok: false, error: "pro_required" };1082  }1083  if (!vault.isUnlocked()) {1084    await openApprovalPopup();1085    return { ok: false, error: "vault_locked" };1086  }10871088  const allow = payload.allowCredentials ?? [];1089  const candidates = listPasskeyEntries().filter((entry) => {1090    if (entry.passkey.rpId !== payload.rpId) return false;1091    if (allow.length === 0) return true;1092    return allow.some((ac) => ac.id === entry.passkey.credentialId);1093  });10941095  if (candidates.length === 0) return { ok: false, error: "no_credentials" };10961097  // Most-recently-used wins when several credentials match. A future1098  // multi-account selector goes here; for now this matches the Chrome path's1099  // behavior for the common one-passkey-per-rp case.1100  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1101  const match = candidates[0]!;1102  const credential = match.passkey;11031104  if (!credential.privateKeyJwk) {1105    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1106    // — no Peach-held private key to sign with. The MAIN-world proxy treats1107    // no_credentials as a fall-through, which routes to the platform UI.1108    return { ok: false, error: "no_credentials" };1109  }11101111  const approved = await requestFirefoxApproval({1112    type: "get",1113    rpId: credential.rpId,1114    rpName: credential.rpName,1115    userName: credential.userName,1116    userDisplayName: credential.userDisplayName,1117  });1118  if (!approved) return { ok: false, error: "user_cancelled" };11191120  const nextSignCount = credential.signCount + 1;1121  // 0x05 = UP|UV. backup bits track whatever was set at registration time1122  // (consistent with the Chrome path).1123  const flags =1124    0x05 |1125    (credential.backupEligible ? 0x08 : 0) |1126    (credential.backupState ? 0x10 : 0);1127  const authenticatorData = await buildAuthenticatorData({1128    rpId: credential.rpId,1129    flags,1130    signCount: nextSignCount,1131  });11321133  // The content script supplied clientDataJSON bytes built with the page1134  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1135  // diverges from what the RP computes.1136  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1137  const clientDataHash = new Uint8Array(1138    await crypto.subtle.digest("SHA-256", clientDataBytes)1139  );1140  const signature = await signPasskeyAssertion(1141    credential.privateKeyJwk,1142    credential.algorithm,1143    authenticatorData,1144    clientDataHash1145  );11461147  await vault.updateEntry(match.id, {1148    passkey: {1149      ...credential,1150      signCount: nextSignCount,1151      lastUsedAt: Date.now(),1152    },1153  });11541155  return {1156    ok: true,1157    credential: {1158      type: "get",1159      id: credential.credentialId,1160      clientDataJSON: payload.clientDataJSON,1161      authenticatorData: base64urlEncode(authenticatorData),1162      signature: base64urlEncode(signature),1163      userHandle: credential.userHandle,1164    },1165  };1166}11671168export async function setupPasskeyProxy(): Promise<void> {1169  if (!isPasskeySupported()) {1170    console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1171    return;1172  }11731174  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1175  // popup-context credential creator is the entry point; nothing to wire here.1176  if (detectSharedBrowser() === "firefox") {1177    await refreshPasskeyProxy();1178    return;1179  }11801181  if (!listenersRegistered) {1182    console.log("[Peach passkey] registering webAuthenticationProxy listeners");1183    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1184      // Peach is a user-verifying platform authenticator whenever the proxy1185      // is attached. The lock state is irrelevant here — the create/get1186      // handlers prompt for unlock when needed. Returning false while locked1187      // makes Google (and any RP that checks isUvpaa as a precondition) show1188      // "A passkey can't be created on this device" before the user even1189      // gets a chance to start the ceremony.1190      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1191        requestId: requestInfo.requestId,1192        isUvpaa: true,1193      });1194    });1195    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1196      void handleCreateRequest(requestInfo).catch(async (error) => {1197        console.error("Peach passkey create failed", error);1198        await rejectCreate(1199          requestInfo.requestId,1200          "UnknownError",1201          error instanceof Error ? error.message : "Passkey creation failed."1202        );1203      });1204    });1205    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1206      void handleGetRequest(requestInfo).catch(async (error) => {1207        console.error("Peach passkey get failed", error);1208        await rejectGet(1209          requestInfo.requestId,1210          "UnknownError",1211          error instanceof Error ? error.message : "Passkey authentication failed."1212        );1213      });1214    });1215    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1216      void resolvePendingPasskeyRequest(requestId, false);1217    });1218    listenersRegistered = true;1219  }12201221  await refreshPasskeyProxy();1222}