passkey.ts

46.2 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 { isValidRpId } from "../shared/passkey-utils";16import type {17  PasskeyData,18  PasskeyProviderState,19  PendingPasskeyRequest,20  VaultEntry,21} from "../shared/types";2223declare const __PEACH_E2E_PASSKEYS__: boolean;2425const PENDING_PREFIX = "passkey_pending_";26const APPROVAL_TIMEOUT_MS = 60_000;2728let listenersRegistered = false;29let attached = false;30let conflict: string | undefined;31const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3233const approvals = new Map<34  number,35  { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }36>();3738// Resolvers for GET requests that arrived while the vault was locked and are39// waiting for the user to unlock before the passkey flow can continue.40const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4142// Tracks requestIds that Chrome cancelled on us (via onRequestCanceled) while43// we were still mid-handler. The complete*Request calls would otherwise reject44// with "Invalid requestId"; the handler checks this set before any work.45const cancelledRequests = new Set<number>();46function markCancelled(requestId: number): void {47  cancelledRequests.add(requestId);48  // Bounded GC — never grow forever. Old IDs that haven't been polled in a49  // long time can be forgotten without harm because the Chrome-side request50  // is already gone.51  if (cancelledRequests.size > 256) {52    const iter = cancelledRequests.values();53    for (let i = 0; i < 128; i += 1) cancelledRequests.delete(iter.next().value as number);54  }55}5657export function onVaultUnlocked(): void {58  for (const { resolve, timeoutId } of unlockWaiters.values()) {59    clearTimeout(timeoutId);60    resolve(true);61  }62  unlockWaiters.clear();63}6465/**66 * Resolve every still-pending passkey request as denied and clear unlock67 * waiters. Called from the popup-disconnect path so a closed popup releases68 * the page-side caller in <200ms instead of leaving it stuck for the full69 * APPROVAL_TIMEOUT_MS (60s) (M8/COR-009).70 */71export async function denyAllPendingPasskeyRequests(): Promise<void> {72  if (approvals.size === 0 && unlockWaiters.size === 0) return;73  console.log("[Peach passkey] denyAllPendingPasskeyRequests (popup disconnected)", {74    approvals: approvals.size,75    unlockWaiters: unlockWaiters.size,76  });77  for (const [rid, { resolve, timeoutId }] of approvals.entries()) {78    clearTimeout(timeoutId);79    resolve(false);80    approvals.delete(rid);81    await clearPendingRequest(rid).catch(() => {});82  }83  for (const { resolve, timeoutId } of unlockWaiters.values()) {84    clearTimeout(timeoutId);85    resolve(false);86  }87  unlockWaiters.clear();88}8990function isPasskeySupported(): boolean {91  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;92  // Firefox 150+ exposes navigator.credentials.create/get from the popup93  // context with extension host permissions. Cannot intercept page-side94  // calls (no equivalent of webAuthenticationProxy), but registration via95  // Peach's own popup-driven flow works.96  if (detectSharedBrowser() === "firefox") {97    const ver = firefoxVersion();98    if (Number.isFinite(ver) && ver >= 150) return true;99  }100  return false;101}102103export const detectBrowser = detectSharedBrowser;104105function getBrowserLabel(): string {106  const ua = navigator.userAgent;107  if (/Firefox\/(\d+)/.test(ua)) {108    const version = ua.match(/Firefox\/(\d+)/)?.[1];109    return version ? `Firefox ${version}` : "Firefox";110  }111  const version = ua.match(/Chrome\/(\d+)/)?.[1];112  return version ? `Chrome ${version}` : "Chromium";113}114115function pendingStorageKey(requestId: number): string {116  return `${PENDING_PREFIX}${requestId}`;117}118119function originFromRpId(rpId: string): string {120  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)121    ? `http://${rpId}`122    : `https://${rpId}`;123}124125/**126 * The clientDataJSON sent back to the RP must contain the page's actual127 * origin — not a synthesized one. `chrome.webAuthenticationProxy` does not128 * surface the requesting origin in CreateRequest/GetRequest, so we read it129 * from the active tab and validate that its hostname is a registrable130 * suffix match for the rpId. WebAuthn requires this relationship anyway, so131 * a mismatch indicates either a stale active tab or a hostile environment;132 * we fall back to https://<rpId> in that case to avoid silently signing the133 * wrong origin.134 *135 * Without this, a passkey created on https://myaccount.google.com gets136 * clientDataJSON.origin = "https://google.com", and Google's RP rejects the137 * attestation with "Something went wrong" because the origin hash doesn't138 * match what its own JS captured at create time.139 */140async function resolvePageOrigin(rpId: string): Promise<string> {141  try {142    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>143      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)144    );145    const url = tabs[0]?.url;146    if (url) {147      const parsed = new URL(url);148      const host = parsed.hostname.toLowerCase();149      const rp = rpId.toLowerCase();150      if (host === rp || host.endsWith(`.${rp}`)) {151        return parsed.origin;152      }153    }154  } catch {155    // fall through to rpId-derived origin156  }157  return originFromRpId(rpId);158}159160async function setBadge(text: string): Promise<void> {161  if (!chrome.action?.setBadgeText) return;162  await chrome.action.setBadgeText({ text });163  if (text) {164    await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });165  }166}167168async function clearPendingRequest(requestId: number): Promise<void> {169  if (chrome.storage.session) {170    await chrome.storage.session.remove(pendingStorageKey(requestId));171  }172  await setBadge("");173}174175async function openApprovalPopup(): Promise<void> {176  try {177    await chrome.action.openPopup();178  } catch {179    await setBadge("PK");180  }181}182183/**184 * Some ceremonies — especially conditional-UI `navigator.credentials.get`185 * calls — get cancelled by Chrome before we finish processing. Calling186 * complete*Request on a cancelled requestId rejects with "Invalid187 * requestId". That is expected, not exceptional: swallow it. Re-throwing188 * would surface an uncaught promise rejection in the SW and cascade through189 * the catch wrappers in setupPasskeyProxy.190 */191function isInvalidRequestIdError(err: unknown): boolean {192  return err instanceof Error && /invalid requestid/i.test(err.message);193}194195async function rejectCreate(196  requestId: number,197  name: string,198  message = ""199): Promise<void> {200  try {201    await chrome.webAuthenticationProxy.completeCreateRequest({202      requestId,203      error: { name, message },204    });205  } catch (err) {206    if (isInvalidRequestIdError(err)) {207      console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId });208      return;209    }210    throw err;211  }212}213214async function rejectGet(215  requestId: number,216  name: string,217  message = ""218): Promise<void> {219  try {220    await chrome.webAuthenticationProxy.completeGetRequest({221      requestId,222      error: { name, message },223    });224  } catch (err) {225    if (isInvalidRequestIdError(err)) {226      console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId });227      return;228    }229    throw err;230  }231}232233export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {234  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts235  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;236  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));237  if (!key) return null;238  const pending = stored[key];239  return pending && typeof pending === "object"240    ? (pending as PendingPasskeyRequest)241    : null;242}243244export async function resolvePendingPasskeyRequest(245  requestId: number,246  approved: boolean247): Promise<void> {248  const pending = approvals.get(requestId);249  if (!pending) {250    // Either the request expired (Chrome) or this is a Firefox-driven251    // pending where the popup performs the navigator.credentials call252    // directly. Clear both possible session keys so getPendingPasskeyRequest()253    // doesn't surface stale state on the next popup open.254    await clearPendingRequest(requestId);255    if (chrome.storage.session) {256      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);257    }258    return;259  }260261  clearTimeout(pending.timeoutId);262  approvals.delete(requestId);263  await clearPendingRequest(requestId);264  pending.resolve(approved);265}266267async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {268  if (chrome.storage.session) {269    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });270  }271  await openApprovalPopup();272273  return new Promise<boolean>((resolve) => {274    const timeoutId = setTimeout(async () => {275      approvals.delete(request.requestId);276      await clearPendingRequest(request.requestId);277      resolve(false);278    }, APPROVAL_TIMEOUT_MS);279280    approvals.set(request.requestId, { resolve, timeoutId });281  });282}283284export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {285  const settings = await vault.ensureSettingsLoaded();286  const supported = isPasskeySupported();287  const enabled = settings.passkeyProviderEnabled === true;288  return {289    supported,290    enabled,291    attached: supported && attached,292    browserLabel: getBrowserLabel(),293    statusLabel: !supported294      ? "Interception unavailable"295      : attached296        ? "Active"297        : conflict298          ? "Another provider is active"299          : enabled300            ? "Inactive"301            : "Disabled",302    conflict,303  };304}305306async function attachPasskeyProxy(): Promise<void> {307  if (!isPasskeySupported()) {308    attached = false;309    conflict = "This browser does not expose chrome.webAuthenticationProxy.";310    return;311  }312313  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context314  // navigator.credentials creator is always ready once the user enables the315  // provider. Treat enabled = attached for state reporting purposes.316  if (detectSharedBrowser() === "firefox") {317    attached = true;318    conflict = undefined;319    return;320  }321322  if (attached) return;323324  try {325    await chrome.webAuthenticationProxy.attach();326    attached = true;327    conflict = undefined;328    console.log("[Peach passkey] proxy attached successfully");329  } catch (error) {330    attached = false;331    conflict = error instanceof Error ? error.message : "Another provider is active.";332    console.warn("[Peach passkey] proxy attach failed", { conflict });333  }334}335336async function detachPasskeyProxy(): Promise<void> {337  if (!isPasskeySupported()) {338    attached = false;339    return;340  }341342  if (detectSharedBrowser() === "firefox") {343    attached = false;344    conflict = undefined;345    return;346  }347348  try {349    await chrome.webAuthenticationProxy.detach();350  } catch {351    // Ignore detach failures; the extension may already be detached.352  }353354  attached = false;355  conflict = undefined;356}357358export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {359  await vault.updateSettings({ passkeyProviderEnabled: enabled });360  if (enabled) {361    await attachPasskeyProxy();362  } else {363    await detachPasskeyProxy();364  }365  return getPasskeyProviderState();366}367368async function refreshPasskeyProxy(): Promise<void> {369  const settings = await vault.ensureSettingsLoaded();370  if (settings.passkeyProviderEnabled === true) {371    await attachPasskeyProxy();372    return;373  }374  await detachPasskeyProxy();375}376377/**378 * Find the entry that already holds a passkey for the given RP, or the most379 * recently-modified login entry whose URL/additionalUrls match the RP — that380 * entry is the natural attach target when the user adds a passkey to a site381 * they already have a password for. Returns null when there's no candidate;382 * the caller creates a new entry in that case.383 */384function findAttachTarget(rpId: string): VaultEntry | null {385  const all = vault.getEntries();386  const passkeyMatch = all.find(387    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt388  );389  if (passkeyMatch) return passkeyMatch;390  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);391  if (loginMatches.length === 0) return null;392  // Pick the most recently modified candidate to be deterministic.393  return loginMatches.reduce((best, e) =>394    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best395  );396}397398async function handleCreateRequest(399  request: chrome.webAuthenticationProxy.CreateRequest400): Promise<void> {401  console.log("[Peach passkey] create event received", {402    requestId: request.requestId,403    detailsJson: request.requestDetailsJson,404  });405  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;406  const rpId = options.rp.id;407  const rpName = options.rp.name ?? options.rp.id;408  const userName = options.user.name;409  const userDisplayName = options.user.displayName ?? options.user.name;410  console.log("[Peach passkey] create parsed", { rpId, rpName, userName, unlocked: vault.isUnlocked() });411412  if (!rpId) {413    console.warn("[Peach passkey] reject: missing rp.id");414    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");415    return;416  }417418  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {419    console.warn("[Peach passkey] reject: user is not Pro");420    await rejectCreate(421      request.requestId,422      "NotAllowedError",423      "Passkey creation requires Peach Pro."424    );425    // Show the inline passkey gate so the user knows what happened and can upgrade.426    broadcastPasskeyProGate();427    return;428  }429430  if (!vault.isUnlocked()) {431    console.log("[Peach passkey] vault locked, summoning inline unlock");432    // Same wait-for-unlock pattern handleGetRequest uses: summon the inline433    // unlock prompt on the active tab so the user can unlock without leaving434    // the page, then hold the create request open until the vault unlocks or435    // the timeout fires. Immediately rejecting here means Google shows436    // "Something went wrong" the instant the user picks Peach.437    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>438      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)439    );440    const activeTab = tabs[0];441    console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });442    if (activeTab?.id) {443      chrome.tabs.sendMessage(activeTab.id, {444        type: 'SUMMON_AUTOFILL',445        source: 'passkey-unlock',446      }).catch((err) => {447        console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);448        void openApprovalPopup();449      });450    } else {451      await openApprovalPopup();452    }453454    const unlocked = await new Promise<boolean>((resolve) => {455      const timeoutId = setTimeout(() => {456        unlockWaiters.delete(request.requestId);457        resolve(false);458      }, APPROVAL_TIMEOUT_MS);459      unlockWaiters.set(request.requestId, { resolve, timeoutId });460    });461462    console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });463    if (!unlocked || !vault.isUnlocked()) {464      console.warn("[Peach passkey] reject: unlock timeout");465      await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");466      return;467    }468    // Vault is now unlocked — fall through to approval.469  }470471  console.log("[Peach passkey] requesting approval via popup");472  const approved = await promptUserApproval({473    requestId: request.requestId,474    type: "create",475    rpId,476    rpName,477    userName,478    userDisplayName,479  });480  console.log("[Peach passkey] approval resolved", { approved });481  if (!approved) {482    console.warn("[Peach passkey] reject: user denied");483    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");484    return;485  }486487  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);488  if (!algorithm) {489    console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);490    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");491    return;492  }493  console.log("[Peach passkey] algorithm selected", algorithm);494495  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));496  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(497    algorithm498  );499  const authData = await buildAuthenticatorData({500    rpId,501    flags: 0x4d,502    signCount: 0,503    credentialId: credentialIdBytes,504    publicKeyCose,505  });506  const attestationObject = createAttestationObject(authData);507  const clientDataJSON = buildClientDataJSON(508    "webauthn.create",509    options.challenge,510    await resolvePageOrigin(rpId)511  );512513  const passkey: PasskeyData = {514    rpId,515    rpName,516    credentialId: base64urlEncode(credentialIdBytes),517    publicKeyCose: base64urlEncode(publicKeyCose),518    privateKeyJwk,519    userHandle: options.user.id,520    userName,521    userDisplayName,522    algorithm,523    signCount: 0,524    aaguid: base64urlEncode(new Uint8Array(16)),525    backupEligible: false,526    backupState: false,527    browser: "chrome",528    transports: ["internal"],529    createdAt: Date.now(),530  };531532  // Attach to an existing login (same domain) when one exists; otherwise533  // create a fresh login entry that exists solely to hold the passkey.534  const target = findAttachTarget(rpId);535  if (target) {536    await vault.updateEntry(target.id, { passkey });537  } else {538    await vault.addEntry({539      type: "login",540      title: rpName,541      url: `https://${rpId}`,542      username: userName,543      passkey,544    });545  }546547  try {548    await chrome.webAuthenticationProxy.completeCreateRequest({549      requestId: request.requestId,550      responseJson: JSON.stringify({551        id: passkey.credentialId,552        rawId: passkey.credentialId,553        type: "public-key",554        response: {555          clientDataJSON: base64urlEncode(clientDataJSON),556          authenticatorData: base64urlEncode(authData),557          attestationObject: base64urlEncode(attestationObject),558          transports: ["internal"],559          publicKey: base64urlEncode(publicKeySpki),560          publicKeyAlgorithm: algorithm,561        },562        authenticatorAttachment: "platform",563        clientExtensionResults: {},564      }),565    });566    console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });567  } catch (err) {568    if (isInvalidRequestIdError(err)) {569      console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });570      return;571    }572    throw err;573  }574}575576function broadcastPasskeyProGate(): void {577  chrome.tabs.query({}, (tabs) => {578    for (const tab of tabs) {579      if (tab.id !== undefined) {580        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});581      }582    }583  });584}585586async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {587  if (chrome.storage.session) {588    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });589  }590591  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {592    chrome.tabs.query({ active: true, currentWindow: true }, resolve);593  });594  const tab = tabs[0];595596  if (tab?.id) {597    chrome.tabs.sendMessage(tab.id, {598      type: 'SHOW_PASSKEY_TOAST',599      payload: {600        site: request.rpId,601        displayName: request.userDisplayName ?? request.userName,602        username: request.userName,603        requestId: String(request.requestId),604      },605    }).catch(() => {});606  } else {607    await openApprovalPopup();608  }609610  return new Promise<boolean>((resolve) => {611    const timeoutId = setTimeout(async () => {612      approvals.delete(request.requestId);613      await clearPendingRequest(request.requestId);614      resolve(false);615    }, APPROVAL_TIMEOUT_MS);616617    approvals.set(request.requestId, { resolve, timeoutId });618  });619}620621async function handleGetRequest(622  request: chrome.webAuthenticationProxy.GetRequest623): Promise<void> {624  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;625  if (!vault.isUnlocked()) {626    // Summon the inline unlock prompt on the active tab so the user can unlock627    // without leaving the page. Fall back to the popup if the content script628    // is unavailable (e.g. restricted URL, script not yet injected).629    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>630      chrome.tabs.query({ active: true, currentWindow: true }, resolve)631    );632    const activeTab = tabs[0];633    if (activeTab?.id) {634      chrome.tabs.sendMessage(activeTab.id, {635        type: 'SUMMON_AUTOFILL',636        source: 'passkey-unlock',637      }).catch(() => { void openApprovalPopup(); });638    } else {639      await openApprovalPopup();640    }641642    // Hold the request open until the vault unlocks or the timeout fires.643    const unlocked = await new Promise<boolean>((resolve) => {644      const timeoutId = setTimeout(() => {645        unlockWaiters.delete(request.requestId);646        resolve(false);647      }, APPROVAL_TIMEOUT_MS);648      unlockWaiters.set(request.requestId, { resolve, timeoutId });649    });650651    if (!unlocked || !vault.isUnlocked()) {652      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");653      return;654    }655    // Vault is now unlocked — fall through to authenticate.656  }657658  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {659    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");660    broadcastPasskeyProGate();661    return;662  }663664  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>665    entry.passkey !== undefined && !entry.deletedAt666  );667  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);668669  const match = passkeyBearing.find((entry) => {670    if (allowedCredentialIds.length > 0) {671      return allowedCredentialIds.includes(entry.passkey.credentialId);672    }673    if (!options.rpId) return false;674    return entry.passkey.rpId === options.rpId;675  });676677  if (!match) {678    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");679    return;680  }681682  const credential = match.passkey;683  // Chrome's webAuthenticationProxy path requires Peach-held private key684  // material to sign the assertion. Firefox-created passkeys delegate to the685  // platform authenticator — the browser will route to the platform UI686  // automatically when we reject, which is the right outcome.687  if (!credential.privateKeyJwk) {688    await rejectGet(689      request.requestId,690      "NotAllowedError",691      "This passkey was created on Firefox; sign in via the platform authenticator."692    );693    return;694  }695696  const approved = await promptViaToast({697    requestId: request.requestId,698    type: "get",699    rpId: credential.rpId,700    rpName: credential.rpName,701    userName: credential.userName,702    userDisplayName: credential.userDisplayName,703  });704  if (!approved) {705    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");706    return;707  }708709  const nextSignCount = credential.signCount + 1;710  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);711  const authData = await buildAuthenticatorData({712    rpId: credential.rpId,713    flags,714    signCount: nextSignCount,715  });716  const clientDataJSON = buildClientDataJSON(717    "webauthn.get",718    options.challenge,719    await resolvePageOrigin(credential.rpId)720  );721  const clientDataHash = new Uint8Array(722    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))723  );724  const signature = await signPasskeyAssertion(725    credential.privateKeyJwk,726    credential.algorithm,727    authData,728    clientDataHash729  );730731  await vault.updateEntry(match.id, {732    passkey: {733      ...credential,734      signCount: nextSignCount,735      lastUsedAt: Date.now(),736    },737  });738739  const responseJson = JSON.stringify({740    id: credential.credentialId,741    rawId: credential.credentialId,742    type: "public-key",743    response: {744      clientDataJSON: base64urlEncode(clientDataJSON),745      authenticatorData: base64urlEncode(authData),746      signature: base64urlEncode(signature),747      userHandle: credential.userHandle,748    },749    authenticatorAttachment: "platform",750    clientExtensionResults: {},751  });752753  try {754    await chrome.webAuthenticationProxy.completeGetRequest({755      requestId: request.requestId,756      responseJson,757    });758    console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });759  } catch (err) {760    if (isInvalidRequestIdError(err)) {761      console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });762      return;763    }764    throw err;765  }766}767768// ── Firefox path ────────────────────────────────────────────────────────769//770// Firefox 150+ permits extensions to call navigator.credentials.create/get771// from the popup context with their host-permitted RP IDs. Peach uses that772// surface to register passkeys on behalf of the user. The page's own773// navigator.credentials call is observed via the MAIN-world detector774// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's775// flow is parallel and explicit (the user must engage with Peach's button).776777const FIREFOX_PENDING_KEY = "passkey_pending_firefox";778const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";779const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days780781export async function initiateFirefoxPasskeyCreate(args: {782  rpId: string;783  rpName: string;784  userName: string;785  userDisplayName?: string;786}): Promise<{ ok: boolean; error?: string }> {787  if (!(await isProUser())) {788    broadcastPasskeyProGate();789    return { ok: false, error: "passkey creation requires Peach Pro" };790  }791  if (!vault.isUnlocked()) {792    await openApprovalPopup();793    return { ok: false, error: "vault_locked" };794  }795  if (!chrome.storage.session) {796    return { ok: false, error: "session storage unavailable" };797  }798799  // Date.now() alone collides if two Firefox passkey requests land in the800  // same millisecond. Mix in 20 random bits to match the synthetic-id pattern801  // at requestFirefoxApproval (M9/COR-010).802  const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);803  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));804  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));805806  const pending: PendingPasskeyRequest = {807    requestId,808    type: "create",809    rpId: args.rpId,810    rpName: args.rpName,811    userName: args.userName,812    userDisplayName: args.userDisplayName,813    browser: "firefox",814    challenge,815    userHandle,816  };817818  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });819  await openApprovalPopup();820  return { ok: true };821}822823export async function completeFirefoxPasskeyCreate(payload: {824  requestId: number;825  rpId: string;826  rpName: string;827  userName: string;828  userDisplayName?: string;829  userHandle: string;830  credentialId: string;831  publicKeyCose: string;832  algorithm: -7 | -8;833  aaguid?: string;834  transports?: string[];835  backupEligible?: boolean;836  backupState?: boolean;837}): Promise<{ ok: boolean; error?: string }> {838  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };839840  const passkey: PasskeyData = {841    rpId: payload.rpId,842    rpName: payload.rpName,843    credentialId: payload.credentialId,844    publicKeyCose: payload.publicKeyCose,845    userHandle: payload.userHandle,846    userName: payload.userName,847    userDisplayName: payload.userDisplayName ?? payload.userName,848    algorithm: payload.algorithm,849    signCount: 0,850    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),851    backupEligible: payload.backupEligible ?? false,852    backupState: payload.backupState ?? false,853    browser: "firefox",854    transports: payload.transports ?? ["internal"],855    createdAt: Date.now(),856  };857858  // Same attach-or-create flow as the Chrome path: prefer attaching to an859  // existing login entry for the same domain so the user ends up with one860  // entry per site, not two (a login + a passkey-only).861  const target = findAttachTarget(payload.rpId);862  if (target) {863    await vault.updateEntry(target.id, { passkey });864  } else {865    await vault.addEntry({866      type: "login",867      title: payload.rpName || payload.rpId,868      url: `https://${payload.rpId}`,869      username: payload.userName,870      passkey,871    });872  }873874  if (chrome.storage.session) {875    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);876  }877  return { ok: true };878}879880export async function cancelFirefoxPasskeyCreate(): Promise<void> {881  if (chrome.storage.session) {882    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);883  }884}885886function declineKey(domain: string): string {887  return `${FIREFOX_DECLINE_PREFIX}${domain}`;888}889890export async function recordPasskeyOfferDecline(domain: string): Promise<void> {891  if (!domain) return;892  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });893}894895export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {896  if (!domain) return false;897  const key = declineKey(domain);898  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;899  const at = stored[key];900  if (typeof at !== "number") return false;901  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {902    await chrome.storage.local.remove(key);903    return false;904  }905  return true;906}907908// ── Firefox content-script proxy path (v2.12.0) ────────────────────────909//910// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces911// navigator.credentials.create / .get on every Firefox page. It builds a912// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),913// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,914// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.915//916// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag917// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates918// to false and these handlers short-circuit. The full removal checklist is919// in that file — the deletion is purely subtractive.920921interface FirefoxAllowCredential {922  id: string;923  type?: string;924}925926interface FirefoxPubKeyCredParam {927  type?: string;928  alg: number;929}930931export interface FirefoxPasskeyHasCredentialsPayload {932  rpId: string;933  allowCredentials: FirefoxAllowCredential[];934}935936export interface FirefoxPasskeyCreatePayload {937  rpId: string;938  rpName?: string;939  userId: string;940  userName: string;941  userDisplayName?: string;942  challenge: string;943  clientDataJSON: number[];944  origin: string;945  pubKeyCredParams?: FirefoxPubKeyCredParam[];946  authenticatorSelection?: Record<string, unknown>;947  timeout?: number;948  attestation?: string;949}950951export interface FirefoxPasskeyGetPayload {952  rpId: string;953  challenge: string;954  clientDataJSON: number[];955  origin: string;956  allowCredentials: FirefoxAllowCredential[];957  userVerification?: string;958}959960interface FirefoxCreateCredentialResponse {961  type: "create";962  id: string;963  clientDataJSON: number[];964  attestationObject: string;965  authenticatorData: string;966  publicKey: string;967  algorithm: -7 | -8;968}969970interface FirefoxGetCredentialResponse {971  type: "get";972  id: string;973  clientDataJSON: number[];974  authenticatorData: string;975  signature: string;976  userHandle: string;977}978979type FirefoxPasskeyResult<T> =980  | { ok: true; credential: T }981  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };982983function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {984  return vault.getEntries().filter(985    (entry): entry is VaultEntry & { passkey: PasskeyData } =>986      entry.passkey !== undefined && !entry.deletedAt987  );988}989990/** Decode the `origin` field out of a WebAuthn clientDataJSON byte array. */991function clientDataJsonOrigin(bytes: number[] | undefined): string | null {992  if (!Array.isArray(bytes)) return null;993  try {994    const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };995    return typeof obj.origin === "string" ? obj.origin : null;996  } catch {997    return null;998  }999}10001001/**1002 * Enforce the WebAuthn rpId<->origin relationship on the Firefox proxy path,1003 * where Peach is the authenticator. The ONLY trustworthy origin is the one the1004 * browser attested for the calling page (`callerOrigin`, derived from the1005 * message sender in messages.ts) — NEVER `payload.origin`, which the page1006 * controls and can set to match any rpId (SEV-1-A / audit CRITICAL). Also1007 * refuse to sign a clientDataJSON whose embedded origin disagrees with the1008 * attested origin, so a forged ceremony can't smuggle a foreign origin into1009 * the signed bytes the RP verifies.1010 */1011function assertOriginOwnsRpId(1012  rpId: string,1013  callerOrigin: string | undefined,1014  clientDataJSON?: number[],1015): void {1016  if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1017    throw new DOMException(1018      `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1019        `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1020      "SecurityError",1021    );1022  }1023  const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1024  if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1025    throw new DOMException(1026      `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1027        `attested calling origin "${callerOrigin}".`,1028      "SecurityError",1029    );1030  }1031}10321033export async function handlePasskeyHasCredentials(1034  payload: FirefoxPasskeyHasCredentialsPayload,1035  callerOrigin?: string,1036): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {1037  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1038    return { ok: false, error: "not_applicable" };1039  }1040  // Scope the existence check to the caller's own origin so an arbitrary page1041  // cannot enumerate which sites the user holds passkeys for (audit MEDIUM).1042  // A false answer is indistinguishable from "no credential" to the page.1043  if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1044    return { ok: true, hasCredentials: false };1045  }1046  if (!vault.isUnlocked()) {1047    // Cannot enumerate without the vault key. The MAIN-world proxy treats a1048    // false answer as "fall through to the platform" — exactly the right1049    // outcome when Peach is locked: the user gets the native UI instead.1050    return { ok: true, hasCredentials: false };1051  }1052  const passkeys = listPasskeyEntries().filter(1053    (entry) => entry.passkey.rpId === payload.rpId1054  );1055  if (passkeys.length === 0) return { ok: true, hasCredentials: false };10561057  const allow = payload.allowCredentials ?? [];1058  if (allow.length === 0) return { ok: true, hasCredentials: true };10591060  const hasMatch = passkeys.some((entry) =>1061    allow.some((ac) => ac.id === entry.passkey.credentialId)1062  );1063  return { ok: true, hasCredentials: hasMatch };1064}10651066/**1067 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing1068 * pending-passkey channel (badge + popup + 60s timeout) — the same approval1069 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need1070 * any new code path. The page-side proxy never sees the request id; the1071 * background mints one and discards it after resolution.1072 */1073async function requestFirefoxApproval(args: {1074  type: "create" | "get";1075  rpId: string;1076  rpName?: string;1077  userName: string;1078  userDisplayName?: string;1079}): Promise<boolean> {1080  // Synthetic request id — the Firefox proxy path doesn't have a1081  // chrome.webAuthenticationProxy request to tie back to.1082  const requestId = Date.now() + Math.floor(Math.random() * 1000);1083  return promptUserApproval({1084    requestId,1085    type: args.type,1086    rpId: args.rpId,1087    rpName: args.rpName ?? args.rpId,1088    userName: args.userName,1089    userDisplayName: args.userDisplayName,1090  });1091}10921093export async function handleFirefoxPasskeyCreate(1094  payload: FirefoxPasskeyCreatePayload,1095  callerOrigin?: string,1096): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1097  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1098    return { ok: false, error: "not_applicable" };1099  }1100  // WebAuthn §7.1 step 6 — the requested rpId must be owned by the page's1101  // browser-ATTESTED origin (threaded in as callerOrigin from the message1102  // sender), NOT the page-supplied payload.origin which an attacker controls.1103  // On the Firefox proxy path Peach is the authenticator and must enforce this1104  // before minting anything (audit CRITICAL — origin spoofing).1105  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1106  if (!(await isProUser())) {1107    broadcastPasskeyProGate();1108    return { ok: false, error: "pro_required" };1109  }1110  if (!vault.isUnlocked()) {1111    await openApprovalPopup();1112    return { ok: false, error: "vault_locked" };1113  }11141115  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1116  if (!algorithm) {1117    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1118  }11191120  const approved = await requestFirefoxApproval({1121    type: "create",1122    rpId: payload.rpId,1123    rpName: payload.rpName,1124    userName: payload.userName,1125    userDisplayName: payload.userDisplayName,1126  });1127  if (!approved) return { ok: false, error: "user_cancelled" };11281129  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1130  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);11311132  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).1133  // Matches Chrome's create flag set in handleCreateRequest above.1134  const authenticatorData = await buildAuthenticatorData({1135    rpId: payload.rpId,1136    flags: 0x45,1137    signCount: 0,1138    credentialId: credentialIdBytes,1139    publicKeyCose,1140  });1141  const attestationObject = createAttestationObject(authenticatorData);11421143  const passkey: PasskeyData = {1144    rpId: payload.rpId,1145    rpName: payload.rpName ?? payload.rpId,1146    credentialId,1147    publicKeyCose: base64urlEncode(publicKeyCose),1148    privateKeyJwk,1149    userHandle: payload.userId,1150    userName: payload.userName,1151    userDisplayName: payload.userDisplayName ?? payload.userName,1152    algorithm,1153    signCount: 0,1154    aaguid: base64urlEncode(new Uint8Array(16)),1155    backupEligible: false,1156    backupState: false,1157    browser: "firefox",1158    transports: ["internal"],1159    createdAt: Date.now(),1160  };11611162  const target = findAttachTarget(payload.rpId);1163  if (target) {1164    await vault.updateEntry(target.id, { passkey });1165  } else {1166    await vault.addEntry({1167      type: "login",1168      title: payload.rpName || payload.rpId,1169      url: `https://${payload.rpId}`,1170      username: payload.userName,1171      passkey,1172    });1173  }11741175  return {1176    ok: true,1177    credential: {1178      type: "create",1179      id: credentialId,1180      // The content script already encoded clientDataJSON with the page1181      // origin; round-trip the exact bytes back so the RP sees the same1182      // hash it computes locally.1183      clientDataJSON: payload.clientDataJSON,1184      attestationObject: base64urlEncode(attestationObject),1185      authenticatorData: base64urlEncode(authenticatorData),1186      publicKey: base64urlEncode(publicKeySpki),1187      algorithm,1188    },1189  };1190}11911192export async function handleFirefoxPasskeyGet(1193  payload: FirefoxPasskeyGetPayload,1194  callerOrigin?: string,1195): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1196  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1197    return { ok: false, error: "not_applicable" };1198  }1199  // WebAuthn §7.2 step 8 — the rpId must be owned by the page's browser-attested1200  // origin (callerOrigin from the message sender), NOT the page-supplied1201  // payload.origin. Both rpId and payload.origin are attacker-controlled, so1202  // validating one against the other is no protection; a cross-origin page must1203  // not be able to coax Peach into signing an assertion scoped to another site1204  // (audit CRITICAL — assertion forgery).1205  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);1206  if (!(await isProUser())) {1207    broadcastPasskeyProGate();1208    return { ok: false, error: "pro_required" };1209  }1210  if (!vault.isUnlocked()) {1211    await openApprovalPopup();1212    return { ok: false, error: "vault_locked" };1213  }12141215  const allow = payload.allowCredentials ?? [];1216  const candidates = listPasskeyEntries().filter((entry) => {1217    if (entry.passkey.rpId !== payload.rpId) return false;1218    if (allow.length === 0) return true;1219    return allow.some((ac) => ac.id === entry.passkey.credentialId);1220  });12211222  if (candidates.length === 0) return { ok: false, error: "no_credentials" };12231224  // Most-recently-used wins when several credentials match. A future1225  // multi-account selector goes here; for now this matches the Chrome path's1226  // behavior for the common one-passkey-per-rp case.1227  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1228  const match = candidates[0]!;1229  const credential = match.passkey;12301231  if (!credential.privateKeyJwk) {1232    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1233    // — no Peach-held private key to sign with. The MAIN-world proxy treats1234    // no_credentials as a fall-through, which routes to the platform UI.1235    return { ok: false, error: "no_credentials" };1236  }12371238  const approved = await requestFirefoxApproval({1239    type: "get",1240    rpId: credential.rpId,1241    rpName: credential.rpName,1242    userName: credential.userName,1243    userDisplayName: credential.userDisplayName,1244  });1245  if (!approved) return { ok: false, error: "user_cancelled" };12461247  const nextSignCount = credential.signCount + 1;1248  // 0x05 = UP|UV. backup bits track whatever was set at registration time1249  // (consistent with the Chrome path).1250  const flags =1251    0x05 |1252    (credential.backupEligible ? 0x08 : 0) |1253    (credential.backupState ? 0x10 : 0);1254  const authenticatorData = await buildAuthenticatorData({1255    rpId: credential.rpId,1256    flags,1257    signCount: nextSignCount,1258  });12591260  // The content script supplied clientDataJSON bytes built with the page1261  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1262  // diverges from what the RP computes.1263  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1264  const clientDataHash = new Uint8Array(1265    await crypto.subtle.digest("SHA-256", clientDataBytes)1266  );1267  const signature = await signPasskeyAssertion(1268    credential.privateKeyJwk,1269    credential.algorithm,1270    authenticatorData,1271    clientDataHash1272  );12731274  await vault.updateEntry(match.id, {1275    passkey: {1276      ...credential,1277      signCount: nextSignCount,1278      lastUsedAt: Date.now(),1279    },1280  });12811282  return {1283    ok: true,1284    credential: {1285      type: "get",1286      id: credential.credentialId,1287      clientDataJSON: payload.clientDataJSON,1288      authenticatorData: base64urlEncode(authenticatorData),1289      signature: base64urlEncode(signature),1290      userHandle: credential.userHandle,1291    },1292  };1293}12941295export async function setupPasskeyProxy(): Promise<void> {1296  if (!isPasskeySupported()) {1297    console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1298    return;1299  }13001301  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1302  // popup-context credential creator is the entry point; nothing to wire here.1303  if (detectSharedBrowser() === "firefox") {1304    await refreshPasskeyProxy();1305    return;1306  }13071308  if (!listenersRegistered) {1309    console.log("[Peach passkey] registering webAuthenticationProxy listeners");1310    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1311      // Peach is a user-verifying platform authenticator whenever the proxy1312      // is attached. The lock state is irrelevant here — the create/get1313      // handlers prompt for unlock when needed. Returning false while locked1314      // makes Google (and any RP that checks isUvpaa as a precondition) show1315      // "A passkey can't be created on this device" before the user even1316      // gets a chance to start the ceremony.1317      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1318        requestId: requestInfo.requestId,1319        isUvpaa: true,1320      });1321    });1322    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1323      console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1324      void handleCreateRequest(requestInfo).catch(async (error) => {1325        // "Invalid requestId" means Chrome already cancelled — handlers will1326        // log it themselves and short-circuit, but if it propagates out1327        // (e.g., from a still-in-flight branch) don't double-complete.1328        if (isInvalidRequestIdError(error)) {1329          console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1330          return;1331        }1332        console.error("Peach passkey create failed", error);1333        await rejectCreate(1334          requestInfo.requestId,1335          "UnknownError",1336          error instanceof Error ? error.message : "Passkey creation failed."1337        );1338      });1339    });1340    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1341      console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1342      void handleGetRequest(requestInfo).catch(async (error) => {1343        if (isInvalidRequestIdError(error)) {1344          console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1345          return;1346        }1347        console.error("Peach passkey get failed", error);1348        await rejectGet(1349          requestInfo.requestId,1350          "UnknownError",1351          error instanceof Error ? error.message : "Passkey authentication failed."1352        );1353      });1354    });1355    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1356      console.log("[Peach passkey] onRequestCanceled", { requestId });1357      markCancelled(requestId);1358      // Release any unlock waiter parked for this request.1359      const waiter = unlockWaiters.get(requestId);1360      if (waiter) {1361        clearTimeout(waiter.timeoutId);1362        waiter.resolve(false);1363        unlockWaiters.delete(requestId);1364      }1365      void resolvePendingPasskeyRequest(requestId, false);1366    });1367    listenersRegistered = true;1368  }13691370  await refreshPasskeyProxy();1371}