passkey.ts

38.0 KB · raw

1import { isProUser } from "./license";2import * as vault from "./vault";3import {4  base64urlEncode,5  buildAuthenticatorData,6  buildClientDataJSON,7  createAttestationObject,8  generateCredentialId,9  generatePasskeyKeypair,10  pickPasskeyAlgorithm,11  signPasskeyAssertion,12} from "./passkey-crypto";13import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";14import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";15import type {16  PasskeyData,17  PasskeyProviderState,18  PendingPasskeyRequest,19  VaultEntry,20} from "../shared/types";2122declare const __PEACH_E2E_PASSKEYS__: boolean;2324const PENDING_PREFIX = "passkey_pending_";25const APPROVAL_TIMEOUT_MS = 60_000;2627let listenersRegistered = false;28let attached = false;29let conflict: string | undefined;30const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3132const approvals = new Map<33  number,34  { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }35>();3637// Resolvers for GET requests that arrived while the vault was locked and are38// waiting for the user to unlock before the passkey flow can continue.39const unlockWaiters = new Map<number, { resolve: (ok: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }>();4041export function onVaultUnlocked(): void {42  for (const { resolve, timeoutId } of unlockWaiters.values()) {43    clearTimeout(timeoutId);44    resolve(true);45  }46  unlockWaiters.clear();47}4849/**50 * Resolve every still-pending passkey request as denied and clear unlock51 * waiters. Called from the popup-disconnect path so a closed popup releases52 * the page-side caller in <200ms instead of leaving it stuck for the full53 * APPROVAL_TIMEOUT_MS (60s) (M8/COR-009).54 */55export async function denyAllPendingPasskeyRequests(): Promise<void> {56  for (const [rid, { resolve, timeoutId }] of approvals.entries()) {57    clearTimeout(timeoutId);58    resolve(false);59    approvals.delete(rid);60    await clearPendingRequest(rid).catch(() => {});61  }62  for (const { resolve, timeoutId } of unlockWaiters.values()) {63    clearTimeout(timeoutId);64    resolve(false);65  }66  unlockWaiters.clear();67}6869function isPasskeySupported(): boolean {70  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;71  // Firefox 150+ exposes navigator.credentials.create/get from the popup72  // context with extension host permissions. Cannot intercept page-side73  // calls (no equivalent of webAuthenticationProxy), but registration via74  // Peach's own popup-driven flow works.75  if (detectSharedBrowser() === "firefox") {76    const ver = firefoxVersion();77    if (Number.isFinite(ver) && ver >= 150) return true;78  }79  return false;80}8182export const detectBrowser = detectSharedBrowser;8384function getBrowserLabel(): string {85  const ua = navigator.userAgent;86  if (/Firefox\/(\d+)/.test(ua)) {87    const version = ua.match(/Firefox\/(\d+)/)?.[1];88    return version ? `Firefox ${version}` : "Firefox";89  }90  const version = ua.match(/Chrome\/(\d+)/)?.[1];91  return version ? `Chrome ${version}` : "Chromium";92}9394function pendingStorageKey(requestId: number): string {95  return `${PENDING_PREFIX}${requestId}`;96}9798function originFromRpId(rpId: string): string {99  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)100    ? `http://${rpId}`101    : `https://${rpId}`;102}103104/**105 * The clientDataJSON sent back to the RP must contain the page's actual106 * origin — not a synthesized one. `chrome.webAuthenticationProxy` does not107 * surface the requesting origin in CreateRequest/GetRequest, so we read it108 * from the active tab and validate that its hostname is a registrable109 * suffix match for the rpId. WebAuthn requires this relationship anyway, so110 * a mismatch indicates either a stale active tab or a hostile environment;111 * we fall back to https://<rpId> in that case to avoid silently signing the112 * wrong origin.113 *114 * Without this, a passkey created on https://myaccount.google.com gets115 * clientDataJSON.origin = "https://google.com", and Google's RP rejects the116 * attestation with "Something went wrong" because the origin hash doesn't117 * match what its own JS captured at create time.118 */119async function resolvePageOrigin(rpId: string): Promise<string> {120  try {121    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>122      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)123    );124    const url = tabs[0]?.url;125    if (url) {126      const parsed = new URL(url);127      const host = parsed.hostname.toLowerCase();128      const rp = rpId.toLowerCase();129      if (host === rp || host.endsWith(`.${rp}`)) {130        return parsed.origin;131      }132    }133  } catch {134    // fall through to rpId-derived origin135  }136  return originFromRpId(rpId);137}138139async function setBadge(text: string): Promise<void> {140  if (!chrome.action?.setBadgeText) return;141  await chrome.action.setBadgeText({ text });142  if (text) {143    await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" });144  }145}146147async function clearPendingRequest(requestId: number): Promise<void> {148  if (chrome.storage.session) {149    await chrome.storage.session.remove(pendingStorageKey(requestId));150  }151  await setBadge("");152}153154async function openApprovalPopup(): Promise<void> {155  try {156    await chrome.action.openPopup();157  } catch {158    await setBadge("PK");159  }160}161162async function rejectCreate(163  requestId: number,164  name: string,165  message = ""166): Promise<void> {167  await chrome.webAuthenticationProxy.completeCreateRequest({168    requestId,169    error: { name, message },170  });171}172173async function rejectGet(174  requestId: number,175  name: string,176  message = ""177): Promise<void> {178  await chrome.webAuthenticationProxy.completeGetRequest({179    requestId,180    error: { name, message },181  });182}183184export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {185  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts186  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;187  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));188  if (!key) return null;189  const pending = stored[key];190  return pending && typeof pending === "object"191    ? (pending as PendingPasskeyRequest)192    : null;193}194195export async function resolvePendingPasskeyRequest(196  requestId: number,197  approved: boolean198): Promise<void> {199  const pending = approvals.get(requestId);200  if (!pending) {201    // Either the request expired (Chrome) or this is a Firefox-driven202    // pending where the popup performs the navigator.credentials call203    // directly. Clear both possible session keys so getPendingPasskeyRequest()204    // doesn't surface stale state on the next popup open.205    await clearPendingRequest(requestId);206    if (chrome.storage.session) {207      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);208    }209    return;210  }211212  clearTimeout(pending.timeoutId);213  approvals.delete(requestId);214  await clearPendingRequest(requestId);215  pending.resolve(approved);216}217218async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {219  if (chrome.storage.session) {220    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });221  }222  await openApprovalPopup();223224  return new Promise<boolean>((resolve) => {225    const timeoutId = setTimeout(async () => {226      approvals.delete(request.requestId);227      await clearPendingRequest(request.requestId);228      resolve(false);229    }, APPROVAL_TIMEOUT_MS);230231    approvals.set(request.requestId, { resolve, timeoutId });232  });233}234235export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {236  const settings = await vault.ensureSettingsLoaded();237  const supported = isPasskeySupported();238  const enabled = settings.passkeyProviderEnabled === true;239  return {240    supported,241    enabled,242    attached: supported && attached,243    browserLabel: getBrowserLabel(),244    statusLabel: !supported245      ? "Interception unavailable"246      : attached247        ? "Active"248        : conflict249          ? "Another provider is active"250          : enabled251            ? "Inactive"252            : "Disabled",253    conflict,254  };255}256257async function attachPasskeyProxy(): Promise<void> {258  if (!isPasskeySupported()) {259    attached = false;260    conflict = "This browser does not expose chrome.webAuthenticationProxy.";261    return;262  }263264  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context265  // navigator.credentials creator is always ready once the user enables the266  // provider. Treat enabled = attached for state reporting purposes.267  if (detectSharedBrowser() === "firefox") {268    attached = true;269    conflict = undefined;270    return;271  }272273  if (attached) return;274275  try {276    await chrome.webAuthenticationProxy.attach();277    attached = true;278    conflict = undefined;279  } catch (error) {280    attached = false;281    conflict = error instanceof Error ? error.message : "Another provider is active.";282  }283}284285async function detachPasskeyProxy(): Promise<void> {286  if (!isPasskeySupported()) {287    attached = false;288    return;289  }290291  if (detectSharedBrowser() === "firefox") {292    attached = false;293    conflict = undefined;294    return;295  }296297  try {298    await chrome.webAuthenticationProxy.detach();299  } catch {300    // Ignore detach failures; the extension may already be detached.301  }302303  attached = false;304  conflict = undefined;305}306307export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {308  await vault.updateSettings({ passkeyProviderEnabled: enabled });309  if (enabled) {310    await attachPasskeyProxy();311  } else {312    await detachPasskeyProxy();313  }314  return getPasskeyProviderState();315}316317async function refreshPasskeyProxy(): Promise<void> {318  const settings = await vault.ensureSettingsLoaded();319  if (settings.passkeyProviderEnabled === true) {320    await attachPasskeyProxy();321    return;322  }323  await detachPasskeyProxy();324}325326/**327 * Find the entry that already holds a passkey for the given RP, or the most328 * recently-modified login entry whose URL/additionalUrls match the RP — that329 * entry is the natural attach target when the user adds a passkey to a site330 * they already have a password for. Returns null when there's no candidate;331 * the caller creates a new entry in that case.332 */333function findAttachTarget(rpId: string): VaultEntry | null {334  const all = vault.getEntries();335  const passkeyMatch = all.find(336    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt337  );338  if (passkeyMatch) return passkeyMatch;339  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);340  if (loginMatches.length === 0) return null;341  // Pick the most recently modified candidate to be deterministic.342  return loginMatches.reduce((best, e) =>343    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best344  );345}346347async function handleCreateRequest(348  request: chrome.webAuthenticationProxy.CreateRequest349): Promise<void> {350  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;351  const rpId = options.rp.id;352  const rpName = options.rp.name ?? options.rp.id;353  const userName = options.user.name;354  const userDisplayName = options.user.displayName ?? options.user.name;355356  if (!rpId) {357    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");358    return;359  }360361  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {362    await rejectCreate(363      request.requestId,364      "NotAllowedError",365      "Passkey creation requires Peach Pro."366    );367    // Show the inline passkey gate so the user knows what happened and can upgrade.368    broadcastPasskeyProGate();369    return;370  }371372  if (!vault.isUnlocked()) {373    // Same wait-for-unlock pattern handleGetRequest uses: summon the inline374    // unlock prompt on the active tab so the user can unlock without leaving375    // the page, then hold the create request open until the vault unlocks or376    // the timeout fires. Immediately rejecting here means Google shows377    // "Something went wrong" the instant the user picks Peach.378    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>379      chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve)380    );381    const activeTab = tabs[0];382    if (activeTab?.id) {383      chrome.tabs.sendMessage(activeTab.id, {384        type: 'SUMMON_AUTOFILL',385        source: 'passkey-unlock',386      }).catch(() => { void openApprovalPopup(); });387    } else {388      await openApprovalPopup();389    }390391    const unlocked = await new Promise<boolean>((resolve) => {392      const timeoutId = setTimeout(() => {393        unlockWaiters.delete(request.requestId);394        resolve(false);395      }, APPROVAL_TIMEOUT_MS);396      unlockWaiters.set(request.requestId, { resolve, timeoutId });397    });398399    if (!unlocked || !vault.isUnlocked()) {400      await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time.");401      return;402    }403    // Vault is now unlocked — fall through to approval.404  }405406  const approved = await promptUserApproval({407    requestId: request.requestId,408    type: "create",409    rpId,410    rpName,411    userName,412    userDisplayName,413  });414  if (!approved) {415    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");416    return;417  }418419  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);420  if (!algorithm) {421    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");422    return;423  }424425  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));426  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(427    algorithm428  );429  const authData = await buildAuthenticatorData({430    rpId,431    flags: 0x4d,432    signCount: 0,433    credentialId: credentialIdBytes,434    publicKeyCose,435  });436  const attestationObject = createAttestationObject(authData);437  const clientDataJSON = buildClientDataJSON(438    "webauthn.create",439    options.challenge,440    await resolvePageOrigin(rpId)441  );442443  const passkey: PasskeyData = {444    rpId,445    rpName,446    credentialId: base64urlEncode(credentialIdBytes),447    publicKeyCose: base64urlEncode(publicKeyCose),448    privateKeyJwk,449    userHandle: options.user.id,450    userName,451    userDisplayName,452    algorithm,453    signCount: 0,454    aaguid: base64urlEncode(new Uint8Array(16)),455    backupEligible: false,456    backupState: false,457    browser: "chrome",458    transports: ["internal"],459    createdAt: Date.now(),460  };461462  // Attach to an existing login (same domain) when one exists; otherwise463  // create a fresh login entry that exists solely to hold the passkey.464  const target = findAttachTarget(rpId);465  if (target) {466    await vault.updateEntry(target.id, { passkey });467  } else {468    await vault.addEntry({469      type: "login",470      title: rpName,471      url: `https://${rpId}`,472      username: userName,473      passkey,474    });475  }476477  await chrome.webAuthenticationProxy.completeCreateRequest({478    requestId: request.requestId,479    responseJson: JSON.stringify({480      id: passkey.credentialId,481      rawId: passkey.credentialId,482      type: "public-key",483      response: {484        clientDataJSON: base64urlEncode(clientDataJSON),485        authenticatorData: base64urlEncode(authData),486        attestationObject: base64urlEncode(attestationObject),487        transports: ["internal"],488        publicKey: base64urlEncode(publicKeySpki),489        publicKeyAlgorithm: algorithm,490      },491      authenticatorAttachment: "platform",492      clientExtensionResults: {},493    }),494  });495}496497function broadcastPasskeyProGate(): void {498  chrome.tabs.query({}, (tabs) => {499    for (const tab of tabs) {500      if (tab.id !== undefined) {501        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});502      }503    }504  });505}506507async function promptViaToast(request: PendingPasskeyRequest): Promise<boolean> {508  if (chrome.storage.session) {509    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });510  }511512  const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {513    chrome.tabs.query({ active: true, currentWindow: true }, resolve);514  });515  const tab = tabs[0];516517  if (tab?.id) {518    chrome.tabs.sendMessage(tab.id, {519      type: 'SHOW_PASSKEY_TOAST',520      payload: {521        site: request.rpId,522        displayName: request.userDisplayName ?? request.userName,523        username: request.userName,524        requestId: String(request.requestId),525      },526    }).catch(() => {});527  } else {528    await openApprovalPopup();529  }530531  return new Promise<boolean>((resolve) => {532    const timeoutId = setTimeout(async () => {533      approvals.delete(request.requestId);534      await clearPendingRequest(request.requestId);535      resolve(false);536    }, APPROVAL_TIMEOUT_MS);537538    approvals.set(request.requestId, { resolve, timeoutId });539  });540}541542async function handleGetRequest(543  request: chrome.webAuthenticationProxy.GetRequest544): Promise<void> {545  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;546  if (!vault.isUnlocked()) {547    // Summon the inline unlock prompt on the active tab so the user can unlock548    // without leaving the page. Fall back to the popup if the content script549    // is unavailable (e.g. restricted URL, script not yet injected).550    const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) =>551      chrome.tabs.query({ active: true, currentWindow: true }, resolve)552    );553    const activeTab = tabs[0];554    if (activeTab?.id) {555      chrome.tabs.sendMessage(activeTab.id, {556        type: 'SUMMON_AUTOFILL',557        source: 'passkey-unlock',558      }).catch(() => { void openApprovalPopup(); });559    } else {560      await openApprovalPopup();561    }562563    // Hold the request open until the vault unlocks or the timeout fires.564    const unlocked = await new Promise<boolean>((resolve) => {565      const timeoutId = setTimeout(() => {566        unlockWaiters.delete(request.requestId);567        resolve(false);568      }, APPROVAL_TIMEOUT_MS);569      unlockWaiters.set(request.requestId, { resolve, timeoutId });570    });571572    if (!unlocked || !vault.isUnlocked()) {573      await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time.");574      return;575    }576    // Vault is now unlocked — fall through to authenticate.577  }578579  if (!(await isProUser())) {580    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");581    broadcastPasskeyProGate();582    return;583  }584585  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>586    entry.passkey !== undefined && !entry.deletedAt587  );588  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);589590  const match = passkeyBearing.find((entry) => {591    if (allowedCredentialIds.length > 0) {592      return allowedCredentialIds.includes(entry.passkey.credentialId);593    }594    if (!options.rpId) return false;595    return entry.passkey.rpId === options.rpId;596  });597598  if (!match) {599    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");600    return;601  }602603  const credential = match.passkey;604  // Chrome's webAuthenticationProxy path requires Peach-held private key605  // material to sign the assertion. Firefox-created passkeys delegate to the606  // platform authenticator — the browser will route to the platform UI607  // automatically when we reject, which is the right outcome.608  if (!credential.privateKeyJwk) {609    await rejectGet(610      request.requestId,611      "NotAllowedError",612      "This passkey was created on Firefox; sign in via the platform authenticator."613    );614    return;615  }616617  const approved = await promptViaToast({618    requestId: request.requestId,619    type: "get",620    rpId: credential.rpId,621    rpName: credential.rpName,622    userName: credential.userName,623    userDisplayName: credential.userDisplayName,624  });625  if (!approved) {626    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");627    return;628  }629630  const nextSignCount = credential.signCount + 1;631  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);632  const authData = await buildAuthenticatorData({633    rpId: credential.rpId,634    flags,635    signCount: nextSignCount,636  });637  const clientDataJSON = buildClientDataJSON(638    "webauthn.get",639    options.challenge,640    await resolvePageOrigin(credential.rpId)641  );642  const clientDataHash = new Uint8Array(643    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))644  );645  const signature = await signPasskeyAssertion(646    credential.privateKeyJwk,647    credential.algorithm,648    authData,649    clientDataHash650  );651652  await vault.updateEntry(match.id, {653    passkey: {654      ...credential,655      signCount: nextSignCount,656      lastUsedAt: Date.now(),657    },658  });659660  const responseJson = JSON.stringify({661    id: credential.credentialId,662    rawId: credential.credentialId,663    type: "public-key",664    response: {665      clientDataJSON: base64urlEncode(clientDataJSON),666      authenticatorData: base64urlEncode(authData),667      signature: base64urlEncode(signature),668      userHandle: credential.userHandle,669    },670    authenticatorAttachment: "platform",671    clientExtensionResults: {},672  });673674  await chrome.webAuthenticationProxy.completeGetRequest({675    requestId: request.requestId,676    responseJson,677  });678}679680// ── Firefox path ────────────────────────────────────────────────────────681//682// Firefox 150+ permits extensions to call navigator.credentials.create/get683// from the popup context with their host-permitted RP IDs. Peach uses that684// surface to register passkeys on behalf of the user. The page's own685// navigator.credentials call is observed via the MAIN-world detector686// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's687// flow is parallel and explicit (the user must engage with Peach's button).688689const FIREFOX_PENDING_KEY = "passkey_pending_firefox";690const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";691const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days692693export async function initiateFirefoxPasskeyCreate(args: {694  rpId: string;695  rpName: string;696  userName: string;697  userDisplayName?: string;698}): Promise<{ ok: boolean; error?: string }> {699  if (!(await isProUser())) {700    broadcastPasskeyProGate();701    return { ok: false, error: "passkey creation requires Peach Pro" };702  }703  if (!vault.isUnlocked()) {704    await openApprovalPopup();705    return { ok: false, error: "vault_locked" };706  }707  if (!chrome.storage.session) {708    return { ok: false, error: "session storage unavailable" };709  }710711  // Date.now() alone collides if two Firefox passkey requests land in the712  // same millisecond. Mix in 20 random bits to match the synthetic-id pattern713  // at requestFirefoxApproval (M9/COR-010).714  const requestId = Date.now() + Math.floor(Math.random() * 1_000_000);715  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));716  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));717718  const pending: PendingPasskeyRequest = {719    requestId,720    type: "create",721    rpId: args.rpId,722    rpName: args.rpName,723    userName: args.userName,724    userDisplayName: args.userDisplayName,725    browser: "firefox",726    challenge,727    userHandle,728  };729730  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });731  await openApprovalPopup();732  return { ok: true };733}734735export async function completeFirefoxPasskeyCreate(payload: {736  requestId: number;737  rpId: string;738  rpName: string;739  userName: string;740  userDisplayName?: string;741  userHandle: string;742  credentialId: string;743  publicKeyCose: string;744  algorithm: -7 | -8;745  aaguid?: string;746  transports?: string[];747  backupEligible?: boolean;748  backupState?: boolean;749}): Promise<{ ok: boolean; error?: string }> {750  if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };751752  const passkey: PasskeyData = {753    rpId: payload.rpId,754    rpName: payload.rpName,755    credentialId: payload.credentialId,756    publicKeyCose: payload.publicKeyCose,757    userHandle: payload.userHandle,758    userName: payload.userName,759    userDisplayName: payload.userDisplayName ?? payload.userName,760    algorithm: payload.algorithm,761    signCount: 0,762    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),763    backupEligible: payload.backupEligible ?? false,764    backupState: payload.backupState ?? false,765    browser: "firefox",766    transports: payload.transports ?? ["internal"],767    createdAt: Date.now(),768  };769770  // Same attach-or-create flow as the Chrome path: prefer attaching to an771  // existing login entry for the same domain so the user ends up with one772  // entry per site, not two (a login + a passkey-only).773  const target = findAttachTarget(payload.rpId);774  if (target) {775    await vault.updateEntry(target.id, { passkey });776  } else {777    await vault.addEntry({778      type: "login",779      title: payload.rpName || payload.rpId,780      url: `https://${payload.rpId}`,781      username: payload.userName,782      passkey,783    });784  }785786  if (chrome.storage.session) {787    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);788  }789  return { ok: true };790}791792export async function cancelFirefoxPasskeyCreate(): Promise<void> {793  if (chrome.storage.session) {794    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);795  }796}797798function declineKey(domain: string): string {799  return `${FIREFOX_DECLINE_PREFIX}${domain}`;800}801802export async function recordPasskeyOfferDecline(domain: string): Promise<void> {803  if (!domain) return;804  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });805}806807export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {808  if (!domain) return false;809  const key = declineKey(domain);810  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;811  const at = stored[key];812  if (typeof at !== "number") return false;813  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {814    await chrome.storage.local.remove(key);815    return false;816  }817  return true;818}819820// ── Firefox content-script proxy path (v2.12.0) ────────────────────────821//822// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces823// navigator.credentials.create / .get on every Firefox page. It builds a824// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),825// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,826// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.827//828// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag829// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates830// to false and these handlers short-circuit. The full removal checklist is831// in that file — the deletion is purely subtractive.832833interface FirefoxAllowCredential {834  id: string;835  type?: string;836}837838interface FirefoxPubKeyCredParam {839  type?: string;840  alg: number;841}842843export interface FirefoxPasskeyHasCredentialsPayload {844  rpId: string;845  allowCredentials: FirefoxAllowCredential[];846}847848export interface FirefoxPasskeyCreatePayload {849  rpId: string;850  rpName?: string;851  userId: string;852  userName: string;853  userDisplayName?: string;854  challenge: string;855  clientDataJSON: number[];856  origin: string;857  pubKeyCredParams?: FirefoxPubKeyCredParam[];858  authenticatorSelection?: Record<string, unknown>;859  timeout?: number;860  attestation?: string;861}862863export interface FirefoxPasskeyGetPayload {864  rpId: string;865  challenge: string;866  clientDataJSON: number[];867  origin: string;868  allowCredentials: FirefoxAllowCredential[];869  userVerification?: string;870}871872interface FirefoxCreateCredentialResponse {873  type: "create";874  id: string;875  clientDataJSON: number[];876  attestationObject: string;877  authenticatorData: string;878  publicKey: string;879  algorithm: -7 | -8;880}881882interface FirefoxGetCredentialResponse {883  type: "get";884  id: string;885  clientDataJSON: number[];886  authenticatorData: string;887  signature: string;888  userHandle: string;889}890891type FirefoxPasskeyResult<T> =892  | { ok: true; credential: T }893  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };894895function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {896  return vault.getEntries().filter(897    (entry): entry is VaultEntry & { passkey: PasskeyData } =>898      entry.passkey !== undefined && !entry.deletedAt899  );900}901902export async function handlePasskeyHasCredentials(903  payload: FirefoxPasskeyHasCredentialsPayload904): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {905  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {906    return { ok: false, error: "not_applicable" };907  }908  if (!vault.isUnlocked()) {909    // Cannot enumerate without the vault key. The MAIN-world proxy treats a910    // false answer as "fall through to the platform" — exactly the right911    // outcome when Peach is locked: the user gets the native UI instead.912    return { ok: true, hasCredentials: false };913  }914  const passkeys = listPasskeyEntries().filter(915    (entry) => entry.passkey.rpId === payload.rpId916  );917  if (passkeys.length === 0) return { ok: true, hasCredentials: false };918919  const allow = payload.allowCredentials ?? [];920  if (allow.length === 0) return { ok: true, hasCredentials: true };921922  const hasMatch = passkeys.some((entry) =>923    allow.some((ac) => ac.id === entry.passkey.credentialId)924  );925  return { ok: true, hasCredentials: hasMatch };926}927928/**929 * Approval prompt for the Firefox proxy ceremonies. Reuses the existing930 * pending-passkey channel (badge + popup + 60s timeout) — the same approval931 * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need932 * any new code path. The page-side proxy never sees the request id; the933 * background mints one and discards it after resolution.934 */935async function requestFirefoxApproval(args: {936  type: "create" | "get";937  rpId: string;938  rpName?: string;939  userName: string;940  userDisplayName?: string;941}): Promise<boolean> {942  // Synthetic request id — the Firefox proxy path doesn't have a943  // chrome.webAuthenticationProxy request to tie back to.944  const requestId = Date.now() + Math.floor(Math.random() * 1000);945  return promptUserApproval({946    requestId,947    type: args.type,948    rpId: args.rpId,949    rpName: args.rpName ?? args.rpId,950    userName: args.userName,951    userDisplayName: args.userDisplayName,952  });953}954955export async function handleFirefoxPasskeyCreate(956  payload: FirefoxPasskeyCreatePayload957): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {958  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {959    return { ok: false, error: "not_applicable" };960  }961  if (!(await isProUser())) {962    broadcastPasskeyProGate();963    return { ok: false, error: "pro_required" };964  }965  if (!vault.isUnlocked()) {966    await openApprovalPopup();967    return { ok: false, error: "vault_locked" };968  }969970  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);971  if (!algorithm) {972    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };973  }974975  const approved = await requestFirefoxApproval({976    type: "create",977    rpId: payload.rpId,978    rpName: payload.rpName,979    userName: payload.userName,980    userDisplayName: payload.userDisplayName,981  });982  if (!approved) return { ok: false, error: "user_cancelled" };983984  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();985  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);986987  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).988  // Matches Chrome's create flag set in handleCreateRequest above.989  const authenticatorData = await buildAuthenticatorData({990    rpId: payload.rpId,991    flags: 0x45,992    signCount: 0,993    credentialId: credentialIdBytes,994    publicKeyCose,995  });996  const attestationObject = createAttestationObject(authenticatorData);997998  const passkey: PasskeyData = {999    rpId: payload.rpId,1000    rpName: payload.rpName ?? payload.rpId,1001    credentialId,1002    publicKeyCose: base64urlEncode(publicKeyCose),1003    privateKeyJwk,1004    userHandle: payload.userId,1005    userName: payload.userName,1006    userDisplayName: payload.userDisplayName ?? payload.userName,1007    algorithm,1008    signCount: 0,1009    aaguid: base64urlEncode(new Uint8Array(16)),1010    backupEligible: false,1011    backupState: false,1012    browser: "firefox",1013    transports: ["internal"],1014    createdAt: Date.now(),1015  };10161017  const target = findAttachTarget(payload.rpId);1018  if (target) {1019    await vault.updateEntry(target.id, { passkey });1020  } else {1021    await vault.addEntry({1022      type: "login",1023      title: payload.rpName || payload.rpId,1024      url: `https://${payload.rpId}`,1025      username: payload.userName,1026      passkey,1027    });1028  }10291030  return {1031    ok: true,1032    credential: {1033      type: "create",1034      id: credentialId,1035      // The content script already encoded clientDataJSON with the page1036      // origin; round-trip the exact bytes back so the RP sees the same1037      // hash it computes locally.1038      clientDataJSON: payload.clientDataJSON,1039      attestationObject: base64urlEncode(attestationObject),1040      authenticatorData: base64urlEncode(authenticatorData),1041      publicKey: base64urlEncode(publicKeySpki),1042      algorithm,1043    },1044  };1045}10461047export async function handleFirefoxPasskeyGet(1048  payload: FirefoxPasskeyGetPayload1049): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {1050  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {1051    return { ok: false, error: "not_applicable" };1052  }1053  if (!(await isProUser())) {1054    broadcastPasskeyProGate();1055    return { ok: false, error: "pro_required" };1056  }1057  if (!vault.isUnlocked()) {1058    await openApprovalPopup();1059    return { ok: false, error: "vault_locked" };1060  }10611062  const allow = payload.allowCredentials ?? [];1063  const candidates = listPasskeyEntries().filter((entry) => {1064    if (entry.passkey.rpId !== payload.rpId) return false;1065    if (allow.length === 0) return true;1066    return allow.some((ac) => ac.id === entry.passkey.credentialId);1067  });10681069  if (candidates.length === 0) return { ok: false, error: "no_credentials" };10701071  // Most-recently-used wins when several credentials match. A future1072  // multi-account selector goes here; for now this matches the Chrome path's1073  // behavior for the common one-passkey-per-rp case.1074  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));1075  const match = candidates[0]!;1076  const credential = match.passkey;10771078  if (!credential.privateKeyJwk) {1079    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator1080    // — no Peach-held private key to sign with. The MAIN-world proxy treats1081    // no_credentials as a fall-through, which routes to the platform UI.1082    return { ok: false, error: "no_credentials" };1083  }10841085  const approved = await requestFirefoxApproval({1086    type: "get",1087    rpId: credential.rpId,1088    rpName: credential.rpName,1089    userName: credential.userName,1090    userDisplayName: credential.userDisplayName,1091  });1092  if (!approved) return { ok: false, error: "user_cancelled" };10931094  const nextSignCount = credential.signCount + 1;1095  // 0x05 = UP|UV. backup bits track whatever was set at registration time1096  // (consistent with the Chrome path).1097  const flags =1098    0x05 |1099    (credential.backupEligible ? 0x08 : 0) |1100    (credential.backupState ? 0x10 : 0);1101  const authenticatorData = await buildAuthenticatorData({1102    rpId: credential.rpId,1103    flags,1104    signCount: nextSignCount,1105  });11061107  // The content script supplied clientDataJSON bytes built with the page1108  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash1109  // diverges from what the RP computes.1110  const clientDataBytes = new Uint8Array(payload.clientDataJSON);1111  const clientDataHash = new Uint8Array(1112    await crypto.subtle.digest("SHA-256", clientDataBytes)1113  );1114  const signature = await signPasskeyAssertion(1115    credential.privateKeyJwk,1116    credential.algorithm,1117    authenticatorData,1118    clientDataHash1119  );11201121  await vault.updateEntry(match.id, {1122    passkey: {1123      ...credential,1124      signCount: nextSignCount,1125      lastUsedAt: Date.now(),1126    },1127  });11281129  return {1130    ok: true,1131    credential: {1132      type: "get",1133      id: credential.credentialId,1134      clientDataJSON: payload.clientDataJSON,1135      authenticatorData: base64urlEncode(authenticatorData),1136      signature: base64urlEncode(signature),1137      userHandle: credential.userHandle,1138    },1139  };1140}11411142export async function setupPasskeyProxy(): Promise<void> {1143  if (!isPasskeySupported()) return;11441145  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The1146  // popup-context credential creator is the entry point; nothing to wire here.1147  if (detectSharedBrowser() === "firefox") {1148    await refreshPasskeyProxy();1149    return;1150  }11511152  if (!listenersRegistered) {1153    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {1154      // Peach is a user-verifying platform authenticator whenever the proxy1155      // is attached. The lock state is irrelevant here — the create/get1156      // handlers prompt for unlock when needed. Returning false while locked1157      // makes Google (and any RP that checks isUvpaa as a precondition) show1158      // "A passkey can't be created on this device" before the user even1159      // gets a chance to start the ceremony.1160      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({1161        requestId: requestInfo.requestId,1162        isUvpaa: true,1163      });1164    });1165    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {1166      void handleCreateRequest(requestInfo).catch(async (error) => {1167        console.error("Peach passkey create failed", error);1168        await rejectCreate(1169          requestInfo.requestId,1170          "UnknownError",1171          error instanceof Error ? error.message : "Passkey creation failed."1172        );1173      });1174    });1175    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {1176      void handleGetRequest(requestInfo).catch(async (error) => {1177        console.error("Peach passkey get failed", error);1178        await rejectGet(1179          requestInfo.requestId,1180          "UnknownError",1181          error instanceof Error ? error.message : "Passkey authentication failed."1182        );1183      });1184    });1185    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {1186      void resolvePendingPasskeyRequest(requestId, false);1187    });1188    listenersRegistered = true;1189  }11901191  await refreshPasskeyProxy();1192}