passkey.ts

43.1 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> }>();4041// Tracks requestIds that Chrome cancelled on us (via onRequestCanceled) while42// we were still mid-handler. The complete*Request calls would otherwise reject43// with "Invalid requestId"; the handler checks this set before any work.44const cancelledRequests = new Set<number>();45function markCancelled(requestId: number): void {46  cancelledRequests.add(requestId);47  // Bounded GC — never grow forever. Old IDs that haven't been polled in a48  // long time can be forgotten without harm because the Chrome-side request49  // is already gone.50  if (cancelledRequests.size > 256) {51    const iter = cancelledRequests.values();52    for (let i = 0; i < 128; i += 1) cancelledRequests.delete(iter.next().value as number);53  }54}5556export function onVaultUnlocked(): void {57  for (const { resolve, timeoutId } of unlockWaiters.values()) {58    clearTimeout(timeoutId);59    resolve(true);60  }61  unlockWaiters.clear();62}6364/**65 * Resolve every still-pending passkey request as denied and clear unlock66 * waiters. Called from the popup-disconnect path so a closed popup releases67 * the page-side caller in <200ms instead of leaving it stuck for the full68 * APPROVAL_TIMEOUT_MS (60s) (M8/COR-009).69 */70export async function denyAllPendingPasskeyRequests(): Promise<void> {71  if (approvals.size === 0 && unlockWaiters.size === 0) return;72  console.log("[Peach passkey] denyAllPendingPasskeyRequests (popup disconnected)", {73    approvals: approvals.size,74    unlockWaiters: unlockWaiters.size,75  });76  for (const [rid, { resolve, timeoutId }] of approvals.entries()) {77    clearTimeout(timeoutId);78    resolve(false);79    approvals.delete(rid);80    await clearPendingRequest(rid).catch(() => {});81  }82  for (const { resolve, timeoutId } of unlockWaiters.values()) {83    clearTimeout(timeoutId);84    resolve(false);85  }86  unlockWaiters.clear();87}8889function isPasskeySupported(): boolean {90  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;91  // Firefox 150+ exposes navigator.credentials.create/get from the popup92  // context with extension host permissions. Cannot intercept page-side93  // calls (no equivalent of webAuthenticationProxy), but registration via94  // Peach's own popup-driven flow works.95  if (detectSharedBrowser() === "firefox") {96    const ver = firefoxVersion();97    if (Number.isFinite(ver) && ver >= 150) return true;98  }99  return false;100}101102export const detectBrowser = detectSharedBrowser;103104function getBrowserLabel(): string {105  const ua = navigator.userAgent;106  if (/Firefox\/(\d+)/.test(ua)) {107    const version = ua.match(/Firefox\/(\d+)/)?.[1];108    return version ? `Firefox ${version}` : "Firefox";109  }110  const version = ua.match(/Chrome\/(\d+)/)?.[1];111  return version ? `Chrome ${version}` : "Chromium";112}113114function pendingStorageKey(requestId: number): string {115  return `${PENDING_PREFIX}${requestId}`;116}117118function originFromRpId(rpId: string): string {119  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)120    ? `http://${rpId}`121    : `https://${rpId}`;122}123124/**125 * The clientDataJSON sent back to the RP must contain the page's actual126 * origin — not a synthesized one. `chrome.webAuthenticationProxy` does not127 * surface the requesting origin in CreateRequest/GetRequest, so we read it128 * from the active tab and validate that its hostname is a registrable129 * suffix match for the rpId. WebAuthn requires this relationship anyway, so130 * a mismatch indicates either a stale active tab or a hostile environment;131 * we fall back to https://<rpId> in that case to avoid silently signing the132 * wrong origin.133 *134 * Without this, a passkey created on https://myaccount.google.com gets135 * clientDataJSON.origin = "https://google.com", and Google's RP rejects the136 * attestation with "Something went wrong" because the origin hash doesn't137 * match what its own JS captured at create time.138 */139async function resolvePageOrigin(rpId: string): Promise<string> {140  try {141    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>142      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)143    );144    const url = tabs[0]?.url;145    if (url) {146      const parsed = new URL(url);147      const host = parsed.hostname.toLowerCase();148      const rp = rpId.toLowerCase();149      if (host === rp || host.endsWith(`.${rp}`)) {150        return parsed.origin;151      }152    }153  } catch {154    // fall through to rpId-derived origin155  }156  return originFromRpId(rpId);157}158159async function setBadge(text: string): Promise<void> {160  if (!chrome.action?.setBadgeText) return;161  await chrome.action.setBadgeText({ text });162  if (text) {163    await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });164  }165}166167async function clearPendingRequest(requestId: number): Promise<void> {168  if (chrome.storage.session) {169    await chrome.storage.session.remove(pendingStorageKey(requestId));170  }171  await setBadge("");172}173174async function openApprovalPopup(): Promise<void> {175  try {176    await chrome.action.openPopup();177  } catch {178    await setBadge("PK");179  }180}181182/**183 * Some ceremonies — especially conditional-UI `navigator.credentials.get`184 * calls — get cancelled by Chrome before we finish processing. Calling185 * complete*Request on a cancelled requestId rejects with "Invalid186 * requestId". That is expected, not exceptional: swallow it. Re-throwing187 * would surface an uncaught promise rejection in the SW and cascade through188 * the catch wrappers in setupPasskeyProxy.189 */190function isInvalidRequestIdError(err: unknown): boolean {191  return err instanceof Error && /invalid requestid/i.test(err.message);192}193194async function rejectCreate(195  requestId: number,196  name: string,197  message = ""198): Promise<void> {199  try {200    await chrome.webAuthenticationProxy.completeCreateRequest({201      requestId,202      error: { name, message },203    });204  } catch (err) {205    if (isInvalidRequestIdError(err)) {206      console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId });207      return;208    }209    throw err;210  }211}212213async function rejectGet(214  requestId: number,215  name: string,216  message = ""217): Promise<void> {218  try {219    await chrome.webAuthenticationProxy.completeGetRequest({220      requestId,221      error: { name, message },222    });223  } catch (err) {224    if (isInvalidRequestIdError(err)) {225      console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId });226      return;227    }228    throw err;229  }230}231232export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {233  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts234  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;235  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));236  if (!key) return null;237  const pending = stored[key];238  return pending && typeof pending === "object"239    ? (pending as PendingPasskeyRequest)240    : null;241}242243export async function resolvePendingPasskeyRequest(244  requestId: number,245  approved: boolean246): Promise<void> {247  const pending = approvals.get(requestId);248  if (!pending) {249    // Either the request expired (Chrome) or this is a Firefox-driven250    // pending where the popup performs the navigator.credentials call251    // directly. Clear both possible session keys so getPendingPasskeyRequest()252    // doesn't surface stale state on the next popup open.253    await clearPendingRequest(requestId);254    if (chrome.storage.session) {255      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);256    }257    return;258  }259260  clearTimeout(pending.timeoutId);261  approvals.delete(requestId);262  await clearPendingRequest(requestId);263  pending.resolve(approved);264}265266async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {267  if (chrome.storage.session) {268    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });269  }270  await openApprovalPopup();271272  return new Promise<boolean>((resolve) => {273    const timeoutId = setTimeout(async () => {274      approvals.delete(request.requestId);275      await clearPendingRequest(request.requestId);276      resolve(false);277    }, APPROVAL_TIMEOUT_MS);278279    approvals.set(request.requestId, { resolve, timeoutId });280  });281}282283export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {284  const settings = await vault.ensureSettingsLoaded();285  const supported = isPasskeySupported();286  const enabled = settings.passkeyProviderEnabled === true;287  return {288    supported,289    enabled,290    attached: supported && attached,291    browserLabel: getBrowserLabel(),292    statusLabel: !supported293      ? "Interception unavailable"294      : attached295        ? "Active"296        : conflict297          ? "Another provider is active"298          : enabled299            ? "Inactive"300            : "Disabled",301    conflict,302  };303}304305async function attachPasskeyProxy(): Promise<void> {306  if (!isPasskeySupported()) {307    attached = false;308    conflict = "This browser does not expose chrome.webAuthenticationProxy.";309    return;310  }311312  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context313  // navigator.credentials creator is always ready once the user enables the314  // provider. Treat enabled = attached for state reporting purposes.315  if (detectSharedBrowser() === "firefox") {316    attached = true;317    conflict = undefined;318    return;319  }320321  if (attached) return;322323  try {324    await chrome.webAuthenticationProxy.attach();325    attached = true;326    conflict = undefined;327    console.log("[Peach passkey] proxy attached successfully");328  } catch (error) {329    attached = false;330    conflict = error instanceof Error ? error.message : "Another provider is active.";331    console.warn("[Peach passkey] proxy attach failed", { conflict });332  }333}334335async function detachPasskeyProxy(): Promise<void> {336  if (!isPasskeySupported()) {337    attached = false;338    return;339  }340341  if (detectSharedBrowser() === "firefox") {342    attached = false;343    conflict = undefined;344    return;345  }346347  try {348    await chrome.webAuthenticationProxy.detach();349  } catch {350    // Ignore detach failures; the extension may already be detached.351  }352353  attached = false;354  conflict = undefined;355}356357export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {358  await vault.updateSettings({ passkeyProviderEnabled: enabled });359  if (enabled) {360    await attachPasskeyProxy();361  } else {362    await detachPasskeyProxy();363  }364  return getPasskeyProviderState();365}366367async function refreshPasskeyProxy(): Promise<void> {368  const settings = await vault.ensureSettingsLoaded();369  if (settings.passkeyProviderEnabled === true) {370    await attachPasskeyProxy();371    return;372  }373  await detachPasskeyProxy();374}375376/**377 * Find the entry that already holds a passkey for the given RP, or the most378 * recently-modified login entry whose URL/additionalUrls match the RP — that379 * entry is the natural attach target when the user adds a passkey to a site380 * they already have a password for. Returns null when there's no candidate;381 * the caller creates a new entry in that case.382 */383function findAttachTarget(rpId: string): VaultEntry | null {384  const all = vault.getEntries();385  const passkeyMatch = all.find(386    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt387  );388  if (passkeyMatch) return passkeyMatch;389  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);390  if (loginMatches.length === 0) return null;391  // Pick the most recently modified candidate to be deterministic.392  return loginMatches.reduce((best, e) =>393    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best394  );395}396397async function handleCreateRequest(398  request: chrome.webAuthenticationProxy.CreateRequest399): Promise<void> {400  console.log("[Peach passkey] create event received", {401    requestId: request.requestId,402    detailsJson: request.requestDetailsJson,403  });404  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;405  const rpId = options.rp.id;406  const rpName = options.rp.name ?? options.rp.id;407  const userName = options.user.name;408  const userDisplayName = options.user.displayName ?? options.user.name;409  console.log("[Peach passkey] create parsed", { rpId, rpName, userName, unlocked: vault.isUnlocked() });410411  if (!rpId) {412    console.warn("[Peach passkey] reject: missing rp.id");413    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");414    return;415  }416417  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {418    console.warn("[Peach passkey] reject: user is not Pro");419    await rejectCreate(420      request.requestId,421      "NotAllowedError",422      "Passkey creation requires Peach Pro."423    );424    // Show the inline passkey gate so the user knows what happened and can upgrade.425    broadcastPasskeyProGate();426    return;427  }428429  if (!vault.isUnlocked()) {430    console.log("[Peach passkey] vault locked, summoning inline unlock");431    // Same wait-for-unlock pattern handleGetRequest uses: summon the inline432    // unlock prompt on the active tab so the user can unlock without leaving433    // the page, then hold the create request open until the vault unlocks or434    // the timeout fires. Immediately rejecting here means Google shows435    // "Something went wrong" the instant the user picks Peach.436    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>437      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)438    );439    const activeTab = tabs[0];440    console.log("[Peach passkey] active tab for unlock summon", { id: activeTab?.id, url: activeTab?.url });441    if (activeTab?.id) {442      chrome.tabs.sendMessage(activeTab.id, {443        type: 'SUMMON_AUTOFILL',444        source: 'passkey-unlock',445      }).catch((err) => {446        console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err);447        void openApprovalPopup();448      });449    } else {450      await openApprovalPopup();451    }452453    const unlocked = await new Promise<boolean>((resolve) => {454      const timeoutId = setTimeout(() => {455        unlockWaiters.delete(request.requestId);456        resolve(false);457      }, APPROVAL_TIMEOUT_MS);458      unlockWaiters.set(request.requestId, { resolve, timeoutId });459    });460461    console.log("[Peach passkey] unlock wait resolved", { unlocked, vaultUnlocked: vault.isUnlocked() });462    if (!unlocked || !vault.isUnlocked()) {463      console.warn("[Peach passkey] reject: unlock timeout");464      await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");465      return;466    }467    // Vault is now unlocked — fall through to approval.468  }469470  console.log("[Peach passkey] requesting approval via popup");471  const approved = await promptUserApproval({472    requestId: request.requestId,473    type: "create",474    rpId,475    rpName,476    userName,477    userDisplayName,478  });479  console.log("[Peach passkey] approval resolved", { approved });480  if (!approved) {481    console.warn("[Peach passkey] reject: user denied");482    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");483    return;484  }485486  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);487  if (!algorithm) {488    console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams);489    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");490    return;491  }492  console.log("[Peach passkey] algorithm selected", algorithm);493494  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));495  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(496    algorithm497  );498  const authData = await buildAuthenticatorData({499    rpId,500    flags: 0x4d,501    signCount: 0,502    credentialId: credentialIdBytes,503    publicKeyCose,504  });505  const attestationObject = createAttestationObject(authData);506  const clientDataJSON = buildClientDataJSON(507    "webauthn.create",508    options.challenge,509    await resolvePageOrigin(rpId)510  );511512  const passkey: PasskeyData = {513    rpId,514    rpName,515    credentialId: base64urlEncode(credentialIdBytes),516    publicKeyCose: base64urlEncode(publicKeyCose),517    privateKeyJwk,518    userHandle: options.user.id,519    userName,520    userDisplayName,521    algorithm,522    signCount: 0,523    aaguid: base64urlEncode(new Uint8Array(16)),524    backupEligible: false,525    backupState: false,526    browser: "chrome",527    transports: ["internal"],528    createdAt: Date.now(),529  };530531  // Attach to an existing login (same domain) when one exists; otherwise532  // create a fresh login entry that exists solely to hold the passkey.533  const target = findAttachTarget(rpId);534  if (target) {535    await vault.updateEntry(target.id, { passkey });536  } else {537    await vault.addEntry({538      type: "login",539      title: rpName,540      url: `https://${rpId}`,541      username: userName,542      passkey,543    });544  }545546  try {547    await chrome.webAuthenticationProxy.completeCreateRequest({548      requestId: request.requestId,549      responseJson: JSON.stringify({550        id: passkey.credentialId,551        rawId: passkey.credentialId,552        type: "public-key",553        response: {554          clientDataJSON: base64urlEncode(clientDataJSON),555          authenticatorData: base64urlEncode(authData),556          attestationObject: base64urlEncode(attestationObject),557          transports: ["internal"],558          publicKey: base64urlEncode(publicKeySpki),559          publicKeyAlgorithm: algorithm,560        },561        authenticatorAttachment: "platform",562        clientExtensionResults: {},563      }),564    });565    console.log("[Peach passkey] completeCreateRequest success", { requestId: request.requestId });566  } catch (err) {567    if (isInvalidRequestIdError(err)) {568      console.log("[Peach passkey] completeCreateRequest skipped (already cancelled)", { requestId: request.requestId });569      return;570    }571    throw err;572  }573}574575function broadcastPasskeyProGate(): void {576  chrome.tabs.query({}, (tabs) => {577    for (const tab of tabs) {578      if (tab.id !== undefined) {579        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});580      }581    }582  });583}584585async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {586  if (chrome.storage.session) {587    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });588  }589590  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {591    chrome.tabs.query({ active: true, currentWindow: true }, resolve);592  });593  const tab = tabs[0];594595  if (tab?.id) {596    chrome.tabs.sendMessage(tab.id, {597      type: 'SHOW_PASSKEY_TOAST',598      payload: {599        site: request.rpId,600        displayName: request.userDisplayName ?? request.userName,601        username: request.userName,602        requestId: String(request.requestId),603      },604    }).catch(() => {});605  } else {606    await openApprovalPopup();607  }608609  return new Promise<boolean>((resolve) => {610    const timeoutId = setTimeout(async () => {611      approvals.delete(request.requestId);612      await clearPendingRequest(request.requestId);613      resolve(false);614    }, APPROVAL_TIMEOUT_MS);615616    approvals.set(request.requestId, { resolve, timeoutId });617  });618}619620async function handleGetRequest(621  request: chrome.webAuthenticationProxy.GetRequest622): Promise<void> {623  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;624  if (!vault.isUnlocked()) {625    // Summon the inline unlock prompt on the active tab so the user can unlock626    // without leaving the page. Fall back to the popup if the content script627    // is unavailable (e.g. restricted URL, script not yet injected).628    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>629      chrome.tabs.query({ active: true, currentWindow: true }, resolve)630    );631    const activeTab = tabs[0];632    if (activeTab?.id) {633      chrome.tabs.sendMessage(activeTab.id, {634        type: 'SUMMON_AUTOFILL',635        source: 'passkey-unlock',636      }).catch(() => { void openApprovalPopup(); });637    } else {638      await openApprovalPopup();639    }640641    // Hold the request open until the vault unlocks or the timeout fires.642    const unlocked = await new Promise<boolean>((resolve) => {643      const timeoutId = setTimeout(() => {644        unlockWaiters.delete(request.requestId);645        resolve(false);646      }, APPROVAL_TIMEOUT_MS);647      unlockWaiters.set(request.requestId, { resolve, timeoutId });648    });649650    if (!unlocked || !vault.isUnlocked()) {651      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");652      return;653    }654    // Vault is now unlocked — fall through to authenticate.655  }656657  if (!(await isProUser())) {658    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");659    broadcastPasskeyProGate();660    return;661  }662663  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>664    entry.passkey !== undefined && !entry.deletedAt665  );666  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);667668  const match = passkeyBearing.find((entry) => {669    if (allowedCredentialIds.length > 0) {670      return allowedCredentialIds.includes(entry.passkey.credentialId);671    }672    if (!options.rpId) return false;673    return entry.passkey.rpId === options.rpId;674  });675676  if (!match) {677    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");678    return;679  }680681  const credential = match.passkey;682  // Chrome's webAuthenticationProxy path requires Peach-held private key683  // material to sign the assertion. Firefox-created passkeys delegate to the684  // platform authenticator — the browser will route to the platform UI685  // automatically when we reject, which is the right outcome.686  if (!credential.privateKeyJwk) {687    await rejectGet(688      request.requestId,689      "NotAllowedError",690      "This passkey was created on Firefox; sign in via the platform authenticator."691    );692    return;693  }694695  const approved = await promptViaToast({696    requestId: request.requestId,697    type: "get",698    rpId: credential.rpId,699    rpName: credential.rpName,700    userName: credential.userName,701    userDisplayName: credential.userDisplayName,702  });703  if (!approved) {704    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");705    return;706  }707708  const nextSignCount = credential.signCount + 1;709  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);710  const authData = await buildAuthenticatorData({711    rpId: credential.rpId,712    flags,713    signCount: nextSignCount,714  });715  const clientDataJSON = buildClientDataJSON(716    "webauthn.get",717    options.challenge,718    await resolvePageOrigin(credential.rpId)719  );720  const clientDataHash = new Uint8Array(721    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))722  );723  const signature = await signPasskeyAssertion(724    credential.privateKeyJwk,725    credential.algorithm,726    authData,727    clientDataHash728  );729730  await vault.updateEntry(match.id, {731    passkey: {732      ...credential,733      signCount: nextSignCount,734      lastUsedAt: Date.now(),735    },736  });737738  const responseJson = JSON.stringify({739    id: credential.credentialId,740    rawId: credential.credentialId,741    type: "public-key",742    response: {743      clientDataJSON: base64urlEncode(clientDataJSON),744      authenticatorData: base64urlEncode(authData),745      signature: base64urlEncode(signature),746      userHandle: credential.userHandle,747    },748    authenticatorAttachment: "platform",749    clientExtensionResults: {},750  });751752  try {753    await chrome.webAuthenticationProxy.completeGetRequest({754      requestId: request.requestId,755      responseJson,756    });757    console.log("[Peach passkey] completeGetRequest success", { requestId: request.requestId });758  } catch (err) {759    if (isInvalidRequestIdError(err)) {760      console.log("[Peach passkey] completeGetRequest skipped (already cancelled)", { requestId: request.requestId });761      return;762    }763    throw err;764  }765}766767// ── Firefox path ────────────────────────────────────────────────────────768//769// Firefox 150+ permits extensions to call navigator.credentials.create/get770// from the popup context with their host-permitted RP IDs. Peach uses that771// surface to register passkeys on behalf of the user. The page's own772// navigator.credentials call is observed via the MAIN-world detector773// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's774// flow is parallel and explicit (the user must engage with Peach's button).775776const FIREFOX_PENDING_KEY = "passkey_pending_firefox";777const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";778const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days779780export async function initiateFirefoxPasskeyCreate(args: {781  rpId: string;782  rpName: string;783  userName: string;784  userDisplayName?: string;785}): Promise<{ ok: boolean; error?: string }> {786  if (!(await isProUser())) {787    broadcastPasskeyProGate();788    return { ok: false, error: "passkey creation requires Peach Pro" };789  }790  if (!vault.isUnlocked()) {791    await openApprovalPopup();792    return { ok: false, error: "vault_locked" };793  }794  if (!chrome.storage.session) {795    return { ok: false, error: "session storage unavailable" };796  }797798  // Date.now() alone collides if two Firefox passkey requests land in the799  // same millisecond. Mix in 20 random bits to match the synthetic-id pattern800  // at requestFirefoxApproval (M9/COR-010).801  const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);802  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));803  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));804805  const pending: PendingPasskeyRequest = {806    requestId,807    type: "create",808    rpId: args.rpId,809    rpName: args.rpName,810    userName: args.userName,811    userDisplayName: args.userDisplayName,812    browser: "firefox",813    challenge,814    userHandle,815  };816817  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });818  await openApprovalPopup();819  return { ok: true };820}821822export async function completeFirefoxPasskeyCreate(payload: {823  requestId: number;824  rpId: string;825  rpName: string;826  userName: string;827  userDisplayName?: string;828  userHandle: string;829  credentialId: string;830  publicKeyCose: string;831  algorithm: -7 | -8;832  aaguid?: string;833  transports?: string[];834  backupEligible?: boolean;835  backupState?: boolean;836}): Promise<{ ok: boolean; error?: string }> {837  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };838839  const passkey: PasskeyData = {840    rpId: payload.rpId,841    rpName: payload.rpName,842    credentialId: payload.credentialId,843    publicKeyCose: payload.publicKeyCose,844    userHandle: payload.userHandle,845    userName: payload.userName,846    userDisplayName: payload.userDisplayName ?? payload.userName,847    algorithm: payload.algorithm,848    signCount: 0,849    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),850    backupEligible: payload.backupEligible ?? false,851    backupState: payload.backupState ?? false,852    browser: "firefox",853    transports: payload.transports ?? ["internal"],854    createdAt: Date.now(),855  };856857  // Same attach-or-create flow as the Chrome path: prefer attaching to an858  // existing login entry for the same domain so the user ends up with one859  // entry per site, not two (a login + a passkey-only).860  const target = findAttachTarget(payload.rpId);861  if (target) {862    await vault.updateEntry(target.id, { passkey });863  } else {864    await vault.addEntry({865      type: "login",866      title: payload.rpName || payload.rpId,867      url: `https://${payload.rpId}`,868      username: payload.userName,869      passkey,870    });871  }872873  if (chrome.storage.session) {874    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);875  }876  return { ok: true };877}878879export async function cancelFirefoxPasskeyCreate(): Promise<void> {880  if (chrome.storage.session) {881    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);882  }883}884885function declineKey(domain: string): string {886  return `${FIREFOX_DECLINE_PREFIX}${domain}`;887}888889export async function recordPasskeyOfferDecline(domain: string): Promise<void> {890  if (!domain) return;891  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });892}893894export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {895  if (!domain) return false;896  const key = declineKey(domain);897  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;898  const at = stored[key];899  if (typeof at !== "number") return false;900  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {901    await chrome.storage.local.remove(key);902    return false;903  }904  return true;905}906907// ── Firefox content-script proxy path (v2.12.0) ────────────────────────908//909// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces910// navigator.credentials.create / .get on every Firefox page. It builds a911// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),912// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,913// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.914//915// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag916// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates917// to false and these handlers short-circuit. The full removal checklist is918// in that file — the deletion is purely subtractive.919920interface FirefoxAllowCredential {921  id: string;922  type?: string;923}924925interface FirefoxPubKeyCredParam {926  type?: string;927  alg: number;928}929930export interface FirefoxPasskeyHasCredentialsPayload {931  rpId: string;932  allowCredentials: FirefoxAllowCredential[];933}934935export interface FirefoxPasskeyCreatePayload {936  rpId: string;937  rpName?: string;938  userId: string;939  userName: string;940  userDisplayName?: string;941  challenge: string;942  clientDataJSON: number[];943  origin: string;944  pubKeyCredParams?: FirefoxPubKeyCredParam[];945  authenticatorSelection?: Record<string, unknown>;946  timeout?: number;947  attestation?: string;948}949950export interface FirefoxPasskeyGetPayload {951  rpId: string;952  challenge: string;953  clientDataJSON: number[];954  origin: string;955  allowCredentials: FirefoxAllowCredential[];956  userVerification?: string;957}958959interface FirefoxCreateCredentialResponse {960  type: "create";961  id: string;962  clientDataJSON: number[];963  attestationObject: string;964  authenticatorData: string;965  publicKey: string;966  algorithm: -7 | -8;967}968969interface FirefoxGetCredentialResponse {970  type: "get";971  id: string;972  clientDataJSON: number[];973  authenticatorData: string;974  signature: string;975  userHandle: string;976}977978type FirefoxPasskeyResult<T> =979  | { ok: true; credential: T }980  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };981982function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {983  return vault.getEntries().filter(984    (entry): entry is VaultEntry & { passkey: PasskeyData } =>985      entry.passkey !== undefined && !entry.deletedAt986  );987}988989export async function handlePasskeyHasCredentials(990  payload: FirefoxPasskeyHasCredentialsPayload991): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {992  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {993    return { ok: false, error: "not_applicable" };994  }995  if (!vault.isUnlocked()) {996    // Cannot enumerate without the vault key. The MAIN-world proxy treats a997    // false answer as "fall through to the platform" — exactly the right998    // outcome when Peach is locked: the user gets the native UI instead.999    return { ok: true, hasCredentials: false };1000  }1001  const passkeys = listPasskeyEntries().filter(1002    (entry) => entry.passkey.rpId === payload.rpId1003  );1004  if (passkeys.length === 0) return { ok: true, hasCredentials: false };10051006  const allow = payload.allowCredentials ?? [];1007  if (allow.length === 0) return { ok: true, hasCredentials: true };10081009  const hasMatch = passkeys.some((entry) =>1010    allow.some((ac) => ac.id === entry.passkey.credentialId)1011  );1012  return { ok: true, hasCredentials: hasMatch };1013}10141015/**1016 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing1017 * pending-passkey channel (badge + popup + 60s timeout) — the same approval1018 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need1019 * any new code path. The page-side proxy never sees the request id; the1020 * background mints one and discards it after resolution.1021 */1022async function requestFirefoxApproval(args: {1023  type: "create" | "get";1024  rpId: string;1025  rpName?: string;1026  userName: string;1027  userDisplayName?: string;1028}): Promise<boolean> {1029  // Synthetic request id — the Firefox proxy path doesn't have a1030  // chrome.webAuthenticationProxy request to tie back to.1031  const requestId = Date.now() + Math.floor(Math.random() * 1000);1032  return promptUserApproval({1033    requestId,1034    type: args.type,1035    rpId: args.rpId,1036    rpName: args.rpName ?? args.rpId,1037    userName: args.userName,1038    userDisplayName: args.userDisplayName,1039  });1040}10411042export async function handleFirefoxPasskeyCreate(1043  payload: FirefoxPasskeyCreatePayload1044): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {1045  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1046    return { ok: false, error: "not_applicable" };1047  }1048  if (!(await isProUser())) {1049    broadcastPasskeyProGate();1050    return { ok: false, error: "pro_required" };1051  }1052  if (!vault.isUnlocked()) {1053    await openApprovalPopup();1054    return { ok: false, error: "vault_locked" };1055  }10561057  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);1058  if (!algorithm) {1059    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };1060  }10611062  const approved = await requestFirefoxApproval({1063    type: "create",1064    rpId: payload.rpId,1065    rpName: payload.rpName,1066    userName: payload.userName,1067    userDisplayName: payload.userDisplayName,1068  });1069  if (!approved) return { ok: false, error: "user_cancelled" };10701071  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();1072  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);10731074  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).1075  // Matches Chrome's create flag set in handleCreateRequest above.1076  const authenticatorData = await buildAuthenticatorData({1077    rpId: payload.rpId,1078    flags: 0x45,1079    signCount: 0,1080    credentialId: credentialIdBytes,1081    publicKeyCose,1082  });1083  const attestationObject = createAttestationObject(authenticatorData);10841085  const passkey: PasskeyData = {1086    rpId: payload.rpId,1087    rpName: payload.rpName ?? payload.rpId,1088    credentialId,1089    publicKeyCose: base64urlEncode(publicKeyCose),1090    privateKeyJwk,1091    userHandle: payload.userId,1092    userName: payload.userName,1093    userDisplayName: payload.userDisplayName ?? payload.userName,1094    algorithm,1095    signCount: 0,1096    aaguid: base64urlEncode(new Uint8Array(16)),1097    backupEligible: false,1098    backupState: false,1099    browser: "firefox",1100    transports: ["internal"],1101    createdAt: Date.now(),1102  };11031104  const target = findAttachTarget(payload.rpId);1105  if (target) {1106    await vault.updateEntry(target.id, { passkey });1107  } else {1108    await vault.addEntry({1109      type: "login",1110      title: payload.rpName || payload.rpId,1111      url: `https://${payload.rpId}`,1112      username: payload.userName,1113      passkey,1114    });1115  }11161117  return {1118    ok: true,1119    credential: {1120      type: "create",1121      id: credentialId,1122      // The content script already encoded clientDataJSON with the page1123      // origin; round-trip the exact bytes back so the RP sees the same1124      // hash it computes locally.1125      clientDataJSON: payload.clientDataJSON,1126      attestationObject: base64urlEncode(attestationObject),1127      authenticatorData: base64urlEncode(authenticatorData),1128      publicKey: base64urlEncode(publicKeySpki),1129      algorithm,1130    },1131  };1132}11331134export async function handleFirefoxPasskeyGet(1135  payload: FirefoxPasskeyGetPayload1136): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1137  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1138    return { ok: false, error: "not_applicable" };1139  }1140  if (!(await isProUser())) {1141    broadcastPasskeyProGate();1142    return { ok: false, error: "pro_required" };1143  }1144  if (!vault.isUnlocked()) {1145    await openApprovalPopup();1146    return { ok: false, error: "vault_locked" };1147  }11481149  const allow = payload.allowCredentials ?? [];1150  const candidates = listPasskeyEntries().filter((entry) => {1151    if (entry.passkey.rpId !== payload.rpId) return false;1152    if (allow.length === 0) return true;1153    return allow.some((ac) => ac.id === entry.passkey.credentialId);1154  });11551156  if (candidates.length === 0) return { ok: false, error: "no_credentials" };11571158  // Most-recently-used wins when several credentials match. A future1159  // multi-account selector goes here; for now this matches the Chrome path's1160  // behavior for the common one-passkey-per-rp case.1161  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1162  const match = candidates[0]!;1163  const credential = match.passkey;11641165  if (!credential.privateKeyJwk) {1166    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1167    // — no Peach-held private key to sign with. The MAIN-world proxy treats1168    // no_credentials as a fall-through, which routes to the platform UI.1169    return { ok: false, error: "no_credentials" };1170  }11711172  const approved = await requestFirefoxApproval({1173    type: "get",1174    rpId: credential.rpId,1175    rpName: credential.rpName,1176    userName: credential.userName,1177    userDisplayName: credential.userDisplayName,1178  });1179  if (!approved) return { ok: false, error: "user_cancelled" };11801181  const nextSignCount = credential.signCount + 1;1182  // 0x05 = UP|UV. backup bits track whatever was set at registration time1183  // (consistent with the Chrome path).1184  const flags =1185    0x05 |1186    (credential.backupEligible ? 0x08 : 0) |1187    (credential.backupState ? 0x10 : 0);1188  const authenticatorData = await buildAuthenticatorData({1189    rpId: credential.rpId,1190    flags,1191    signCount: nextSignCount,1192  });11931194  // The content script supplied clientDataJSON bytes built with the page1195  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1196  // diverges from what the RP computes.1197  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1198  const clientDataHash = new Uint8Array(1199    await crypto.subtle.digest("SHA-256", clientDataBytes)1200  );1201  const signature = await signPasskeyAssertion(1202    credential.privateKeyJwk,1203    credential.algorithm,1204    authenticatorData,1205    clientDataHash1206  );12071208  await vault.updateEntry(match.id, {1209    passkey: {1210      ...credential,1211      signCount: nextSignCount,1212      lastUsedAt: Date.now(),1213    },1214  });12151216  return {1217    ok: true,1218    credential: {1219      type: "get",1220      id: credential.credentialId,1221      clientDataJSON: payload.clientDataJSON,1222      authenticatorData: base64urlEncode(authenticatorData),1223      signature: base64urlEncode(signature),1224      userHandle: credential.userHandle,1225    },1226  };1227}12281229export async function setupPasskeyProxy(): Promise<void> {1230  if (!isPasskeySupported()) {1231    console.log("[Peach passkey] setupPasskeyProxy: not supported on this browser");1232    return;1233  }12341235  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1236  // popup-context credential creator is the entry point; nothing to wire here.1237  if (detectSharedBrowser() === "firefox") {1238    await refreshPasskeyProxy();1239    return;1240  }12411242  if (!listenersRegistered) {1243    console.log("[Peach passkey] registering webAuthenticationProxy listeners");1244    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1245      // Peach is a user-verifying platform authenticator whenever the proxy1246      // is attached. The lock state is irrelevant here — the create/get1247      // handlers prompt for unlock when needed. Returning false while locked1248      // makes Google (and any RP that checks isUvpaa as a precondition) show1249      // "A passkey can't be created on this device" before the user even1250      // gets a chance to start the ceremony.1251      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1252        requestId: requestInfo.requestId,1253        isUvpaa: true,1254      });1255    });1256    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1257      console.log("[Peach passkey] onCreateRequest fired", { requestId: requestInfo.requestId });1258      void handleCreateRequest(requestInfo).catch(async (error) => {1259        // "Invalid requestId" means Chrome already cancelled — handlers will1260        // log it themselves and short-circuit, but if it propagates out1261        // (e.g., from a still-in-flight branch) don't double-complete.1262        if (isInvalidRequestIdError(error)) {1263          console.log("[Peach passkey] create handler resolved after cancel", { requestId: requestInfo.requestId });1264          return;1265        }1266        console.error("Peach passkey create failed", error);1267        await rejectCreate(1268          requestInfo.requestId,1269          "UnknownError",1270          error instanceof Error ? error.message : "Passkey creation failed."1271        );1272      });1273    });1274    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1275      console.log("[Peach passkey] onGetRequest fired", { requestId: requestInfo.requestId });1276      void handleGetRequest(requestInfo).catch(async (error) => {1277        if (isInvalidRequestIdError(error)) {1278          console.log("[Peach passkey] get handler resolved after cancel", { requestId: requestInfo.requestId });1279          return;1280        }1281        console.error("Peach passkey get failed", error);1282        await rejectGet(1283          requestInfo.requestId,1284          "UnknownError",1285          error instanceof Error ? error.message : "Passkey authentication failed."1286        );1287      });1288    });1289    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1290      console.log("[Peach passkey] onRequestCanceled", { requestId });1291      markCancelled(requestId);1292      // Release any unlock waiter parked for this request.1293      const waiter = unlockWaiters.get(requestId);1294      if (waiter) {1295        clearTimeout(waiter.timeoutId);1296        waiter.resolve(false);1297        unlockWaiters.delete(requestId);1298      }1299      void resolvePendingPasskeyRequest(requestId, false);1300    });1301    listenersRegistered = true;1302  }13031304  await refreshPasskeyProxy();1305}