passkey.ts

36.4 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}103104/**105 * The clientDataJSON sent back to the RP must contain the page's actual106 * origin — not a synthesized one. `chrome.webAuthenticationProxy` does not107 * surface the requesting origin in CreateRequest/GetRequest, so we read it108 * from the active tab and validate that its hostname is a registrable109 * suffix match for the rpId. WebAuthn requires this relationship anyway, so110 * a mismatch indicates either a stale active tab or a hostile environment;111 * we fall back to https://<rpId> in that case to avoid silently signing the112 * wrong origin.113 *114 * Without this, a passkey created on https://myaccount.google.com gets115 * clientDataJSON.origin = "https://google.com", and Google's RP rejects the116 * attestation with "Something went wrong" because the origin hash doesn't117 * match what its own JS captured at create time.118 */119async function resolvePageOrigin(rpId: string): Promise<string> {120  try {121    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>122      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)123    );124    const url = tabs[0]?.url;125    if (url) {126      const parsed = new URL(url);127      const host = parsed.hostname.toLowerCase();128      const rp = rpId.toLowerCase();129      if (host === rp || host.endsWith(`.${rp}`)) {130        return parsed.origin;131      }132    }133  } catch {134    // fall through to rpId-derived origin135  }136  return originFromRpId(rpId);137}138139async function setBadge(text: string): Promise<void> {140  if (!chrome.action?.setBadgeText) return;141  await chrome.action.setBadgeText({ text });142  if (text) {143    await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });144  }145}146147async function clearPendingRequest(requestId: number): Promise<void> {148  if (chrome.storage.session) {149    await chrome.storage.session.remove(pendingStorageKey(requestId));150  }151  await setBadge("");152}153154async function openApprovalPopup(): Promise<void> {155  try {156    await chrome.action.openPopup();157  } catch {158    await setBadge("PK");159  }160}161162async function rejectCreate(163  requestId: number,164  name: string,165  message = ""166): Promise<void> {167  await chrome.webAuthenticationProxy.completeCreateRequest({168    requestId,169    error: { name, message },170  });171}172173async function rejectGet(174  requestId: number,175  name: string,176  message = ""177): Promise<void> {178  await chrome.webAuthenticationProxy.completeGetRequest({179    requestId,180    error: { name, message },181  });182}183184export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {185  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts186  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;187  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));188  if (!key) return null;189  const pending = stored[key];190  return pending && typeof pending === "object"191    ? (pending as PendingPasskeyRequest)192    : null;193}194195export async function resolvePendingPasskeyRequest(196  requestId: number,197  approved: boolean198): Promise<void> {199  const pending = approvals.get(requestId);200  if (!pending) {201    // Either the request expired (Chrome) or this is a Firefox-driven202    // pending where the popup performs the navigator.credentials call203    // directly. Clear both possible session keys so getPendingPasskeyRequest()204    // doesn't surface stale state on the next popup open.205    await clearPendingRequest(requestId);206    if (chrome.storage.session) {207      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);208    }209    return;210  }211212  clearTimeout(pending.timeoutId);213  approvals.delete(requestId);214  await clearPendingRequest(requestId);215  pending.resolve(approved);216}217218async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {219  if (chrome.storage.session) {220    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });221  }222  await openApprovalPopup();223224  return new Promise<boolean>((resolve) => {225    const timeoutId = setTimeout(async () => {226      approvals.delete(request.requestId);227      await clearPendingRequest(request.requestId);228      resolve(false);229    }, APPROVAL_TIMEOUT_MS);230231    approvals.set(request.requestId, { resolve, timeoutId });232  });233}234235export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {236  const settings = await vault.ensureSettingsLoaded();237  const supported = isPasskeySupported();238  const enabled = settings.passkeyProviderEnabled === true;239  return {240    supported,241    enabled,242    attached: supported && attached,243    browserLabel: getBrowserLabel(),244    statusLabel: !supported245      ? "Interception unavailable"246      : attached247        ? "Active"248        : conflict249          ? "Another provider is active"250          : enabled251            ? "Inactive"252            : "Disabled",253    conflict,254  };255}256257async function attachPasskeyProxy(): Promise<void> {258  if (!isPasskeySupported()) {259    attached = false;260    conflict = "This browser does not expose chrome.webAuthenticationProxy.";261    return;262  }263264  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context265  // navigator.credentials creator is always ready once the user enables the266  // provider. Treat enabled = attached for state reporting purposes.267  if (detectSharedBrowser() === "firefox") {268    attached = true;269    conflict = undefined;270    return;271  }272273  if (attached) return;274275  try {276    await chrome.webAuthenticationProxy.attach();277    attached = true;278    conflict = undefined;279  } catch (error) {280    attached = false;281    conflict = error instanceof Error ? error.message : "Another provider is active.";282  }283}284285async function detachPasskeyProxy(): Promise<void> {286  if (!isPasskeySupported()) {287    attached = false;288    return;289  }290291  if (detectSharedBrowser() === "firefox") {292    attached = false;293    conflict = undefined;294    return;295  }296297  try {298    await chrome.webAuthenticationProxy.detach();299  } catch {300    // Ignore detach failures; the extension may already be detached.301  }302303  attached = false;304  conflict = undefined;305}306307export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {308  await vault.updateSettings({ passkeyProviderEnabled: enabled });309  if (enabled) {310    await attachPasskeyProxy();311  } else {312    await detachPasskeyProxy();313  }314  return getPasskeyProviderState();315}316317async function refreshPasskeyProxy(): Promise<void> {318  const settings = await vault.ensureSettingsLoaded();319  if (settings.passkeyProviderEnabled === true) {320    await attachPasskeyProxy();321    return;322  }323  await detachPasskeyProxy();324}325326/**327 * Find the entry that already holds a passkey for the given RP, or the most328 * recently-modified login entry whose URL/additionalUrls match the RP — that329 * entry is the natural attach target when the user adds a passkey to a site330 * they already have a password for. Returns null when there's no candidate;331 * the caller creates a new entry in that case.332 */333function findAttachTarget(rpId: string): VaultEntry | null {334  const all = vault.getEntries();335  const passkeyMatch = all.find(336    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt337  );338  if (passkeyMatch) return passkeyMatch;339  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);340  if (loginMatches.length === 0) return null;341  // Pick the most recently modified candidate to be deterministic.342  return loginMatches.reduce((best, e) =>343    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best344  );345}346347async function handleCreateRequest(348  request: chrome.webAuthenticationProxy.CreateRequest349): Promise<void> {350  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;351  const rpId = options.rp.id;352  const rpName = options.rp.name ?? options.rp.id;353  const userName = options.user.name;354  const userDisplayName = options.user.displayName ?? options.user.name;355356  if (!rpId) {357    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");358    return;359  }360361  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {362    await rejectCreate(363      request.requestId,364      "NotAllowedError",365      "Passkey creation requires Peach Pro."366    );367    // Show the inline passkey gate so the user knows what happened and can upgrade.368    broadcastPasskeyProGate();369    return;370  }371372  if (!vault.isUnlocked()) {373    await openApprovalPopup();374    await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");375    return;376  }377378  const approved = await promptUserApproval({379    requestId: request.requestId,380    type: "create",381    rpId,382    rpName,383    userName,384    userDisplayName,385  });386  if (!approved) {387    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");388    return;389  }390391  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);392  if (!algorithm) {393    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");394    return;395  }396397  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));398  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(399    algorithm400  );401  const authData = await buildAuthenticatorData({402    rpId,403    flags: 0x4d,404    signCount: 0,405    credentialId: credentialIdBytes,406    publicKeyCose,407  });408  const attestationObject = createAttestationObject(authData);409  const clientDataJSON = buildClientDataJSON(410    "webauthn.create",411    options.challenge,412    await resolvePageOrigin(rpId)413  );414415  const passkey: PasskeyData = {416    rpId,417    rpName,418    credentialId: base64urlEncode(credentialIdBytes),419    publicKeyCose: base64urlEncode(publicKeyCose),420    privateKeyJwk,421    userHandle: options.user.id,422    userName,423    userDisplayName,424    algorithm,425    signCount: 0,426    aaguid: base64urlEncode(new Uint8Array(16)),427    backupEligible: false,428    backupState: false,429    browser: "chrome",430    transports: ["internal"],431    createdAt: Date.now(),432  };433434  // Attach to an existing login (same domain) when one exists; otherwise435  // create a fresh login entry that exists solely to hold the passkey.436  const target = findAttachTarget(rpId);437  if (target) {438    await vault.updateEntry(target.id, { passkey });439  } else {440    await vault.addEntry({441      type: "login",442      title: rpName,443      url: `https://${rpId}`,444      username: userName,445      passkey,446    });447  }448449  await chrome.webAuthenticationProxy.completeCreateRequest({450    requestId: request.requestId,451    responseJson: JSON.stringify({452      id: passkey.credentialId,453      rawId: passkey.credentialId,454      type: "public-key",455      response: {456        clientDataJSON: base64urlEncode(clientDataJSON),457        authenticatorData: base64urlEncode(authData),458        attestationObject: base64urlEncode(attestationObject),459        transports: ["internal"],460        publicKey: base64urlEncode(publicKeySpki),461        publicKeyAlgorithm: algorithm,462      },463      authenticatorAttachment: "platform",464      clientExtensionResults: {},465    }),466  });467}468469function broadcastPasskeyProGate(): void {470  chrome.tabs.query({}, (tabs) => {471    for (const tab of tabs) {472      if (tab.id !== undefined) {473        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});474      }475    }476  });477}478479async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {480  if (chrome.storage.session) {481    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });482  }483484  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {485    chrome.tabs.query({ active: true, currentWindow: true }, resolve);486  });487  const tab = tabs[0];488489  if (tab?.id) {490    chrome.tabs.sendMessage(tab.id, {491      type: 'SHOW_PASSKEY_TOAST',492      payload: {493        site: request.rpId,494        displayName: request.userDisplayName ?? request.userName,495        username: request.userName,496        requestId: String(request.requestId),497      },498    }).catch(() => {});499  } else {500    await openApprovalPopup();501  }502503  return new Promise<boolean>((resolve) => {504    const timeoutId = setTimeout(async () => {505      approvals.delete(request.requestId);506      await clearPendingRequest(request.requestId);507      resolve(false);508    }, APPROVAL_TIMEOUT_MS);509510    approvals.set(request.requestId, { resolve, timeoutId });511  });512}513514async function handleGetRequest(515  request: chrome.webAuthenticationProxy.GetRequest516): Promise<void> {517  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;518  if (!vault.isUnlocked()) {519    // Summon the inline unlock prompt on the active tab so the user can unlock520    // without leaving the page. Fall back to the popup if the content script521    // is unavailable (e.g. restricted URL, script not yet injected).522    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>523      chrome.tabs.query({ active: true, currentWindow: true }, resolve)524    );525    const activeTab = tabs[0];526    if (activeTab?.id) {527      chrome.tabs.sendMessage(activeTab.id, {528        type: 'SUMMON_AUTOFILL',529        source: 'passkey-unlock',530      }).catch(() => { void openApprovalPopup(); });531    } else {532      await openApprovalPopup();533    }534535    // Hold the request open until the vault unlocks or the timeout fires.536    const unlocked = await new Promise<boolean>((resolve) => {537      const timeoutId = setTimeout(() => {538        unlockWaiters.delete(request.requestId);539        resolve(false);540      }, APPROVAL_TIMEOUT_MS);541      unlockWaiters.set(request.requestId, { resolve, timeoutId });542    });543544    if (!unlocked || !vault.isUnlocked()) {545      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");546      return;547    }548    // Vault is now unlocked — fall through to authenticate.549  }550551  if (!(await isProUser())) {552    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");553    broadcastPasskeyProGate();554    return;555  }556557  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>558    entry.passkey !== undefined && !entry.deletedAt559  );560  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);561562  const match = passkeyBearing.find((entry) => {563    if (allowedCredentialIds.length > 0) {564      return allowedCredentialIds.includes(entry.passkey.credentialId);565    }566    if (!options.rpId) return false;567    return entry.passkey.rpId === options.rpId;568  });569570  if (!match) {571    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");572    return;573  }574575  const credential = match.passkey;576  // Chrome's webAuthenticationProxy path requires Peach-held private key577  // material to sign the assertion. Firefox-created passkeys delegate to the578  // platform authenticator — the browser will route to the platform UI579  // automatically when we reject, which is the right outcome.580  if (!credential.privateKeyJwk) {581    await rejectGet(582      request.requestId,583      "NotAllowedError",584      "This passkey was created on Firefox; sign in via the platform authenticator."585    );586    return;587  }588589  const approved = await promptViaToast({590    requestId: request.requestId,591    type: "get",592    rpId: credential.rpId,593    rpName: credential.rpName,594    userName: credential.userName,595    userDisplayName: credential.userDisplayName,596  });597  if (!approved) {598    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");599    return;600  }601602  const nextSignCount = credential.signCount + 1;603  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);604  const authData = await buildAuthenticatorData({605    rpId: credential.rpId,606    flags,607    signCount: nextSignCount,608  });609  const clientDataJSON = buildClientDataJSON(610    "webauthn.get",611    options.challenge,612    await resolvePageOrigin(credential.rpId)613  );614  const clientDataHash = new Uint8Array(615    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))616  );617  const signature = await signPasskeyAssertion(618    credential.privateKeyJwk,619    credential.algorithm,620    authData,621    clientDataHash622  );623624  await vault.updateEntry(match.id, {625    passkey: {626      ...credential,627      signCount: nextSignCount,628      lastUsedAt: Date.now(),629    },630  });631632  const responseJson = JSON.stringify({633    id: credential.credentialId,634    rawId: credential.credentialId,635    type: "public-key",636    response: {637      clientDataJSON: base64urlEncode(clientDataJSON),638      authenticatorData: base64urlEncode(authData),639      signature: base64urlEncode(signature),640      userHandle: credential.userHandle,641    },642    authenticatorAttachment: "platform",643    clientExtensionResults: {},644  });645646  await chrome.webAuthenticationProxy.completeGetRequest({647    requestId: request.requestId,648    responseJson,649  });650}651652// ── Firefox path ────────────────────────────────────────────────────────653//654// Firefox 150+ permits extensions to call navigator.credentials.create/get655// from the popup context with their host-permitted RP IDs. Peach uses that656// surface to register passkeys on behalf of the user. The page's own657// navigator.credentials call is observed via the MAIN-world detector658// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's659// flow is parallel and explicit (the user must engage with Peach's button).660661const FIREFOX_PENDING_KEY = "passkey_pending_firefox";662const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";663const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days664665export async function initiateFirefoxPasskeyCreate(args: {666  rpId: string;667  rpName: string;668  userName: string;669  userDisplayName?: string;670}): Promise<{ ok: boolean; error?: string }> {671  if (!(await isProUser())) {672    broadcastPasskeyProGate();673    return { ok: false, error: "passkey creation requires Peach Pro" };674  }675  if (!vault.isUnlocked()) {676    await openApprovalPopup();677    return { ok: false, error: "vault_locked" };678  }679  if (!chrome.storage.session) {680    return { ok: false, error: "session storage unavailable" };681  }682683  // Date.now() alone collides if two Firefox passkey requests land in the684  // same millisecond. Mix in 20 random bits to match the synthetic-id pattern685  // at requestFirefoxApproval (M9/COR-010).686  const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);687  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));688  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));689690  const pending: PendingPasskeyRequest = {691    requestId,692    type: "create",693    rpId: args.rpId,694    rpName: args.rpName,695    userName: args.userName,696    userDisplayName: args.userDisplayName,697    browser: "firefox",698    challenge,699    userHandle,700  };701702  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });703  await openApprovalPopup();704  return { ok: true };705}706707export async function completeFirefoxPasskeyCreate(payload: {708  requestId: number;709  rpId: string;710  rpName: string;711  userName: string;712  userDisplayName?: string;713  userHandle: string;714  credentialId: string;715  publicKeyCose: string;716  algorithm: -7 | -8;717  aaguid?: string;718  transports?: string[];719  backupEligible?: boolean;720  backupState?: boolean;721}): Promise<{ ok: boolean; error?: string }> {722  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };723724  const passkey: PasskeyData = {725    rpId: payload.rpId,726    rpName: payload.rpName,727    credentialId: payload.credentialId,728    publicKeyCose: payload.publicKeyCose,729    userHandle: payload.userHandle,730    userName: payload.userName,731    userDisplayName: payload.userDisplayName ?? payload.userName,732    algorithm: payload.algorithm,733    signCount: 0,734    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),735    backupEligible: payload.backupEligible ?? false,736    backupState: payload.backupState ?? false,737    browser: "firefox",738    transports: payload.transports ?? ["internal"],739    createdAt: Date.now(),740  };741742  // Same attach-or-create flow as the Chrome path: prefer attaching to an743  // existing login entry for the same domain so the user ends up with one744  // entry per site, not two (a login + a passkey-only).745  const target = findAttachTarget(payload.rpId);746  if (target) {747    await vault.updateEntry(target.id, { passkey });748  } else {749    await vault.addEntry({750      type: "login",751      title: payload.rpName || payload.rpId,752      url: `https://${payload.rpId}`,753      username: payload.userName,754      passkey,755    });756  }757758  if (chrome.storage.session) {759    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);760  }761  return { ok: true };762}763764export async function cancelFirefoxPasskeyCreate(): Promise<void> {765  if (chrome.storage.session) {766    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);767  }768}769770function declineKey(domain: string): string {771  return `${FIREFOX_DECLINE_PREFIX}${domain}`;772}773774export async function recordPasskeyOfferDecline(domain: string): Promise<void> {775  if (!domain) return;776  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });777}778779export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {780  if (!domain) return false;781  const key = declineKey(domain);782  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;783  const at = stored[key];784  if (typeof at !== "number") return false;785  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {786    await chrome.storage.local.remove(key);787    return false;788  }789  return true;790}791792// ── Firefox content-script proxy path (v2.12.0) ────────────────────────793//794// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces795// navigator.credentials.create / .get on every Firefox page. It builds a796// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),797// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,798// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.799//800// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag801// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates802// to false and these handlers short-circuit. The full removal checklist is803// in that file — the deletion is purely subtractive.804805interface FirefoxAllowCredential {806  id: string;807  type?: string;808}809810interface FirefoxPubKeyCredParam {811  type?: string;812  alg: number;813}814815export interface FirefoxPasskeyHasCredentialsPayload {816  rpId: string;817  allowCredentials: FirefoxAllowCredential[];818}819820export interface FirefoxPasskeyCreatePayload {821  rpId: string;822  rpName?: string;823  userId: string;824  userName: string;825  userDisplayName?: string;826  challenge: string;827  clientDataJSON: number[];828  origin: string;829  pubKeyCredParams?: FirefoxPubKeyCredParam[];830  authenticatorSelection?: Record<string, unknown>;831  timeout?: number;832  attestation?: string;833}834835export interface FirefoxPasskeyGetPayload {836  rpId: string;837  challenge: string;838  clientDataJSON: number[];839  origin: string;840  allowCredentials: FirefoxAllowCredential[];841  userVerification?: string;842}843844interface FirefoxCreateCredentialResponse {845  type: "create";846  id: string;847  clientDataJSON: number[];848  attestationObject: string;849  authenticatorData: string;850  publicKey: string;851  algorithm: -7 | -8;852}853854interface FirefoxGetCredentialResponse {855  type: "get";856  id: string;857  clientDataJSON: number[];858  authenticatorData: string;859  signature: string;860  userHandle: string;861}862863type FirefoxPasskeyResult<T> =864  | { ok: true; credential: T }865  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };866867function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {868  return vault.getEntries().filter(869    (entry): entry is VaultEntry & { passkey: PasskeyData } =>870      entry.passkey !== undefined && !entry.deletedAt871  );872}873874export async function handlePasskeyHasCredentials(875  payload: FirefoxPasskeyHasCredentialsPayload876): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {877  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {878    return { ok: false, error: "not_applicable" };879  }880  if (!vault.isUnlocked()) {881    // Cannot enumerate without the vault key. The MAIN-world proxy treats a882    // false answer as "fall through to the platform" — exactly the right883    // outcome when Peach is locked: the user gets the native UI instead.884    return { ok: true, hasCredentials: false };885  }886  const passkeys = listPasskeyEntries().filter(887    (entry) => entry.passkey.rpId === payload.rpId888  );889  if (passkeys.length === 0) return { ok: true, hasCredentials: false };890891  const allow = payload.allowCredentials ?? [];892  if (allow.length === 0) return { ok: true, hasCredentials: true };893894  const hasMatch = passkeys.some((entry) =>895    allow.some((ac) => ac.id === entry.passkey.credentialId)896  );897  return { ok: true, hasCredentials: hasMatch };898}899900/**901 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing902 * pending-passkey channel (badge + popup + 60s timeout) — the same approval903 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need904 * any new code path. The page-side proxy never sees the request id; the905 * background mints one and discards it after resolution.906 */907async function requestFirefoxApproval(args: {908  type: "create" | "get";909  rpId: string;910  rpName?: string;911  userName: string;912  userDisplayName?: string;913}): Promise<boolean> {914  // Synthetic request id — the Firefox proxy path doesn't have a915  // chrome.webAuthenticationProxy request to tie back to.916  const requestId = Date.now() + Math.floor(Math.random() * 1000);917  return promptUserApproval({918    requestId,919    type: args.type,920    rpId: args.rpId,921    rpName: args.rpName ?? args.rpId,922    userName: args.userName,923    userDisplayName: args.userDisplayName,924  });925}926927export async function handleFirefoxPasskeyCreate(928  payload: FirefoxPasskeyCreatePayload929): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {930  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {931    return { ok: false, error: "not_applicable" };932  }933  if (!(await isProUser())) {934    broadcastPasskeyProGate();935    return { ok: false, error: "pro_required" };936  }937  if (!vault.isUnlocked()) {938    await openApprovalPopup();939    return { ok: false, error: "vault_locked" };940  }941942  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);943  if (!algorithm) {944    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };945  }946947  const approved = await requestFirefoxApproval({948    type: "create",949    rpId: payload.rpId,950    rpName: payload.rpName,951    userName: payload.userName,952    userDisplayName: payload.userDisplayName,953  });954  if (!approved) return { ok: false, error: "user_cancelled" };955956  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();957  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);958959  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).960  // Matches Chrome's create flag set in handleCreateRequest above.961  const authenticatorData = await buildAuthenticatorData({962    rpId: payload.rpId,963    flags: 0x45,964    signCount: 0,965    credentialId: credentialIdBytes,966    publicKeyCose,967  });968  const attestationObject = createAttestationObject(authenticatorData);969970  const passkey: PasskeyData = {971    rpId: payload.rpId,972    rpName: payload.rpName ?? payload.rpId,973    credentialId,974    publicKeyCose: base64urlEncode(publicKeyCose),975    privateKeyJwk,976    userHandle: payload.userId,977    userName: payload.userName,978    userDisplayName: payload.userDisplayName ?? payload.userName,979    algorithm,980    signCount: 0,981    aaguid: base64urlEncode(new Uint8Array(16)),982    backupEligible: false,983    backupState: false,984    browser: "firefox",985    transports: ["internal"],986    createdAt: Date.now(),987  };988989  const target = findAttachTarget(payload.rpId);990  if (target) {991    await vault.updateEntry(target.id, { passkey });992  } else {993    await vault.addEntry({994      type: "login",995      title: payload.rpName || payload.rpId,996      url: `https://${payload.rpId}`,997      username: payload.userName,998      passkey,999    });1000  }10011002  return {1003    ok: true,1004    credential: {1005      type: "create",1006      id: credentialId,1007      // The content script already encoded clientDataJSON with the page1008      // origin; round-trip the exact bytes back so the RP sees the same1009      // hash it computes locally.1010      clientDataJSON: payload.clientDataJSON,1011      attestationObject: base64urlEncode(attestationObject),1012      authenticatorData: base64urlEncode(authenticatorData),1013      publicKey: base64urlEncode(publicKeySpki),1014      algorithm,1015    },1016  };1017}10181019export async function handleFirefoxPasskeyGet(1020  payload: FirefoxPasskeyGetPayload1021): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1022  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1023    return { ok: false, error: "not_applicable" };1024  }1025  if (!(await isProUser())) {1026    broadcastPasskeyProGate();1027    return { ok: false, error: "pro_required" };1028  }1029  if (!vault.isUnlocked()) {1030    await openApprovalPopup();1031    return { ok: false, error: "vault_locked" };1032  }10331034  const allow = payload.allowCredentials ?? [];1035  const candidates = listPasskeyEntries().filter((entry) => {1036    if (entry.passkey.rpId !== payload.rpId) return false;1037    if (allow.length === 0) return true;1038    return allow.some((ac) => ac.id === entry.passkey.credentialId);1039  });10401041  if (candidates.length === 0) return { ok: false, error: "no_credentials" };10421043  // Most-recently-used wins when several credentials match. A future1044  // multi-account selector goes here; for now this matches the Chrome path's1045  // behavior for the common one-passkey-per-rp case.1046  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1047  const match = candidates[0]!;1048  const credential = match.passkey;10491050  if (!credential.privateKeyJwk) {1051    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1052    // — no Peach-held private key to sign with. The MAIN-world proxy treats1053    // no_credentials as a fall-through, which routes to the platform UI.1054    return { ok: false, error: "no_credentials" };1055  }10561057  const approved = await requestFirefoxApproval({1058    type: "get",1059    rpId: credential.rpId,1060    rpName: credential.rpName,1061    userName: credential.userName,1062    userDisplayName: credential.userDisplayName,1063  });1064  if (!approved) return { ok: false, error: "user_cancelled" };10651066  const nextSignCount = credential.signCount + 1;1067  // 0x05 = UP|UV. backup bits track whatever was set at registration time1068  // (consistent with the Chrome path).1069  const flags =1070    0x05 |1071    (credential.backupEligible ? 0x08 : 0) |1072    (credential.backupState ? 0x10 : 0);1073  const authenticatorData = await buildAuthenticatorData({1074    rpId: credential.rpId,1075    flags,1076    signCount: nextSignCount,1077  });10781079  // The content script supplied clientDataJSON bytes built with the page1080  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1081  // diverges from what the RP computes.1082  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1083  const clientDataHash = new Uint8Array(1084    await crypto.subtle.digest("SHA-256", clientDataBytes)1085  );1086  const signature = await signPasskeyAssertion(1087    credential.privateKeyJwk,1088    credential.algorithm,1089    authenticatorData,1090    clientDataHash1091  );10921093  await vault.updateEntry(match.id, {1094    passkey: {1095      ...credential,1096      signCount: nextSignCount,1097      lastUsedAt: Date.now(),1098    },1099  });11001101  return {1102    ok: true,1103    credential: {1104      type: "get",1105      id: credential.credentialId,1106      clientDataJSON: payload.clientDataJSON,1107      authenticatorData: base64urlEncode(authenticatorData),1108      signature: base64urlEncode(signature),1109      userHandle: credential.userHandle,1110    },1111  };1112}11131114export async function setupPasskeyProxy(): Promise<void> {1115  if (!isPasskeySupported()) return;11161117  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1118  // popup-context credential creator is the entry point; nothing to wire here.1119  if (detectSharedBrowser() === "firefox") {1120    await refreshPasskeyProxy();1121    return;1122  }11231124  if (!listenersRegistered) {1125    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1126      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1127        requestId: requestInfo.requestId,1128        isUvpaa: vault.isUnlocked(),1129      });1130    });1131    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1132      void handleCreateRequest(requestInfo).catch(async (error) => {1133        console.error("Peach passkey create failed", error);1134        await rejectCreate(1135          requestInfo.requestId,1136          "UnknownError",1137          error instanceof Error ? error.message : "Passkey creation failed."1138        );1139      });1140    });1141    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1142      void handleGetRequest(requestInfo).catch(async (error) => {1143        console.error("Peach passkey get failed", error);1144        await rejectGet(1145          requestInfo.requestId,1146          "UnknownError",1147          error instanceof Error ? error.message : "Passkey authentication failed."1148        );1149      });1150    });1151    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1152      void resolvePendingPasskeyRequest(requestId, false);1153    });1154    listenersRegistered = true;1155  }11561157  await refreshPasskeyProxy();1158}