passkey.ts

21.5 KB · raw

1import { isProUser } from "./license";2import * as vault from "./vault";3import {4  base64urlDecode,5  base64urlEncode,6  buildAuthenticatorData,7  buildClientDataJSON,8  createAttestationObject,9  generatePasskeyKeypair,10  pickPasskeyAlgorithm,11  signPasskeyAssertion,12} from "./passkey-crypto";13import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";14import type {15  PasskeyData,16  PasskeyProviderState,17  PendingPasskeyRequest,18  VaultEntry,19} from "../shared/types";2021declare const __PEACH_E2E_PASSKEYS__: boolean;2223const PENDING_PREFIX = "passkey_pending_";24const APPROVAL_TIMEOUT_MS = 60_000;2526let listenersRegistered = false;27let attached = false;28let conflict: string | undefined;29const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;3031const approvals = new Map<32  number,33  { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }34>();3536function isPasskeySupported(): boolean {37  if (typeof chrome.webAuthenticationProxy?.attach === "function") return true;38  // Firefox 150+ exposes navigator.credentials.create/get from the popup39  // context with extension host permissions. Cannot intercept page-side40  // calls (no equivalent of webAuthenticationProxy), but registration via41  // Peach's own popup-driven flow works.42  if (detectSharedBrowser() === "firefox") {43    const ver = firefoxVersion();44    if (Number.isFinite(ver) && ver >= 150) return true;45  }46  return false;47}4849export const detectBrowser = detectSharedBrowser;5051function getBrowserLabel(): string {52  const ua = navigator.userAgent;53  if (/Firefox\/(\d+)/.test(ua)) {54    const version = ua.match(/Firefox\/(\d+)/)?.[1];55    return version ? `Firefox ${version}` : "Firefox";56  }57  const version = ua.match(/Chrome\/(\d+)/)?.[1];58  return version ? `Chrome ${version}` : "Chromium";59}6061function pendingStorageKey(requestId: number): string {62  return `${PENDING_PREFIX}${requestId}`;63}6465function originFromRpId(rpId: string): string {66  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)67    ? `http://${rpId}`68    : `https://${rpId}`;69}7071async function setBadge(text: string): Promise<void> {72  if (!chrome.action?.setBadgeText) return;73  await chrome.action.setBadgeText({ text });74  if (text) {75    await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });76  }77}7879async function clearPendingRequest(requestId: number): Promise<void> {80  if (chrome.storage.session) {81    await chrome.storage.session.remove(pendingStorageKey(requestId));82  }83  await setBadge("");84}8586async function openApprovalPopup(): Promise<void> {87  try {88    await chrome.action.openPopup();89  } catch {90    await setBadge("PK");91  }92}9394async function rejectCreate(95  requestId: number,96  name: string,97  message = ""98): Promise<void> {99  await chrome.webAuthenticationProxy.completeCreateRequest({100    requestId,101    error: { name, message },102  });103}104105async function rejectGet(106  requestId: number,107  name: string,108  message = ""109): Promise<void> {110  await chrome.webAuthenticationProxy.completeGetRequest({111    requestId,112    error: { name, message },113  });114}115116export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {117  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts118  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;119  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));120  if (!key) return null;121  const pending = stored[key];122  return pending && typeof pending === "object"123    ? (pending as PendingPasskeyRequest)124    : null;125}126127export async function resolvePendingPasskeyRequest(128  requestId: number,129  approved: boolean130): Promise<void> {131  const pending = approvals.get(requestId);132  if (!pending) {133    // Either the request expired (Chrome) or this is a Firefox-driven134    // pending where the popup performs the navigator.credentials call135    // directly. Clear both possible session keys so getPendingPasskeyRequest()136    // doesn't surface stale state on the next popup open.137    await clearPendingRequest(requestId);138    if (chrome.storage.session) {139      await chrome.storage.session.remove(FIREFOX_PENDING_KEY);140    }141    return;142  }143144  clearTimeout(pending.timeoutId);145  approvals.delete(requestId);146  await clearPendingRequest(requestId);147  pending.resolve(approved);148}149150async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {151  if (chrome.storage.session) {152    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });153  }154  await openApprovalPopup();155156  return new Promise<boolean>((resolve) => {157    const timeoutId = setTimeout(async () => {158      approvals.delete(request.requestId);159      await clearPendingRequest(request.requestId);160      resolve(false);161    }, APPROVAL_TIMEOUT_MS);162163    approvals.set(request.requestId, { resolve, timeoutId });164  });165}166167export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {168  const settings = await vault.ensureSettingsLoaded();169  const supported = isPasskeySupported();170  const enabled = settings.passkeyProviderEnabled === true;171  return {172    supported,173    enabled,174    attached: supported && attached,175    browserLabel: getBrowserLabel(),176    statusLabel: !supported177      ? "Interception unavailable"178      : attached179        ? "Active"180        : conflict181          ? "Another provider is active"182          : enabled183            ? "Inactive"184            : "Disabled",185    conflict,186  };187}188189async function attachPasskeyProxy(): Promise<void> {190  if (!isPasskeySupported()) {191    attached = false;192    conflict = "This browser does not expose chrome.webAuthenticationProxy.";193    return;194  }195196  // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context197  // navigator.credentials creator is always ready once the user enables the198  // provider. Treat enabled = attached for state reporting purposes.199  if (detectSharedBrowser() === "firefox") {200    attached = true;201    conflict = undefined;202    return;203  }204205  if (attached) return;206207  try {208    await chrome.webAuthenticationProxy.attach();209    attached = true;210    conflict = undefined;211  } catch (error) {212    attached = false;213    conflict = error instanceof Error ? error.message : "Another provider is active.";214  }215}216217async function detachPasskeyProxy(): Promise<void> {218  if (!isPasskeySupported()) {219    attached = false;220    return;221  }222223  if (detectSharedBrowser() === "firefox") {224    attached = false;225    conflict = undefined;226    return;227  }228229  try {230    await chrome.webAuthenticationProxy.detach();231  } catch {232    // Ignore detach failures; the extension may already be detached.233  }234235  attached = false;236  conflict = undefined;237}238239export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {240  await vault.updateSettings({ passkeyProviderEnabled: enabled });241  if (enabled) {242    await attachPasskeyProxy();243  } else {244    await detachPasskeyProxy();245  }246  return getPasskeyProviderState();247}248249async function refreshPasskeyProxy(): Promise<void> {250  const settings = await vault.ensureSettingsLoaded();251  if (settings.passkeyProviderEnabled === true) {252    await attachPasskeyProxy();253    return;254  }255  await detachPasskeyProxy();256}257258/**259 * Find the entry that already holds a passkey for the given RP, or the most260 * recently-modified login entry whose URL/additionalUrls match the RP — that261 * entry is the natural attach target when the user adds a passkey to a site262 * they already have a password for. Returns null when there's no candidate;263 * the caller creates a new entry in that case.264 */265function findAttachTarget(rpId: string): VaultEntry | null {266  const all = vault.getEntries();267  const passkeyMatch = all.find(268    (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt269  );270  if (passkeyMatch) return passkeyMatch;271  const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey);272  if (loginMatches.length === 0) return null;273  // Pick the most recently modified candidate to be deterministic.274  return loginMatches.reduce((best, e) =>275    (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best276  );277}278279async function handleCreateRequest(280  request: chrome.webAuthenticationProxy.CreateRequest281): Promise<void> {282  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;283  const rpId = options.rp.id;284  const rpName = options.rp.name ?? options.rp.id;285  const userName = options.user.name;286  const userDisplayName = options.user.displayName ?? options.user.name;287288  if (!rpId) {289    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");290    return;291  }292293  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {294    await rejectCreate(295      request.requestId,296      "NotAllowedError",297      "Passkey creation requires Peach Pro."298    );299    // Show the inline passkey gate so the user knows what happened and can upgrade.300    broadcastPasskeyProGate();301    return;302  }303304  if (!vault.isUnlocked()) {305    await openApprovalPopup();306    await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");307    return;308  }309310  const approved = await promptUserApproval({311    requestId: request.requestId,312    type: "create",313    rpId,314    rpName,315    userName,316    userDisplayName,317  });318  if (!approved) {319    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");320    return;321  }322323  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);324  if (!algorithm) {325    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");326    return;327  }328329  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));330  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(331    algorithm332  );333  const authData = await buildAuthenticatorData({334    rpId,335    flags: 0x4d,336    signCount: 0,337    credentialId: credentialIdBytes,338    publicKeyCose,339  });340  const attestationObject = createAttestationObject(authData);341  const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));342343  const passkey: PasskeyData = {344    rpId,345    rpName,346    credentialId: base64urlEncode(credentialIdBytes),347    publicKeyCose: base64urlEncode(publicKeyCose),348    privateKeyJwk,349    userHandle: options.user.id,350    userName,351    userDisplayName,352    algorithm,353    signCount: 0,354    aaguid: base64urlEncode(new Uint8Array(16)),355    backupEligible: false,356    backupState: false,357    browser: "chrome",358    transports: ["internal"],359    createdAt: Date.now(),360  };361362  // Attach to an existing login (same domain) when one exists; otherwise363  // create a fresh login entry that exists solely to hold the passkey.364  const target = findAttachTarget(rpId);365  if (target) {366    await vault.updateEntry(target.id, { passkey });367  } else {368    await vault.addEntry({369      type: "login",370      title: rpName,371      url: `https://${rpId}`,372      username: userName,373      passkey,374    });375  }376377  await chrome.webAuthenticationProxy.completeCreateRequest({378    requestId: request.requestId,379    responseJson: JSON.stringify({380      id: passkey.credentialId,381      rawId: passkey.credentialId,382      type: "public-key",383      response: {384        clientDataJSON: base64urlEncode(clientDataJSON),385        authenticatorData: base64urlEncode(authData),386        attestationObject: base64urlEncode(attestationObject),387        transports: ["internal"],388        publicKey: base64urlEncode(publicKeySpki),389        publicKeyAlgorithm: algorithm,390      },391      authenticatorAttachment: "platform",392      clientExtensionResults: {},393    }),394  });395}396397function broadcastPasskeyProGate(): void {398  chrome.tabs.query({}, (tabs) => {399    for (const tab of tabs) {400      if (tab.id !== undefined) {401        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});402      }403    }404  });405}406407async function handleGetRequest(408  request: chrome.webAuthenticationProxy.GetRequest409): Promise<void> {410  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;411  if (!vault.isUnlocked()) {412    await openApprovalPopup();413    await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");414    return;415  }416417  if (!(await isProUser())) {418    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");419    broadcastPasskeyProGate();420    return;421  }422423  const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } =>424    entry.passkey !== undefined && !entry.deletedAt425  );426  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);427428  const match = passkeyBearing.find((entry) => {429    if (allowedCredentialIds.length > 0) {430      return allowedCredentialIds.includes(entry.passkey.credentialId);431    }432    if (!options.rpId) return false;433    return entry.passkey.rpId === options.rpId;434  });435436  if (!match) {437    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");438    return;439  }440441  const credential = match.passkey;442  // Chrome's webAuthenticationProxy path requires Peach-held private key443  // material to sign the assertion. Firefox-created passkeys delegate to the444  // platform authenticator — the browser will route to the platform UI445  // automatically when we reject, which is the right outcome.446  if (!credential.privateKeyJwk) {447    await rejectGet(448      request.requestId,449      "NotAllowedError",450      "This passkey was created on Firefox; sign in via the platform authenticator."451    );452    return;453  }454455  const approved = await promptUserApproval({456    requestId: request.requestId,457    type: "get",458    rpId: credential.rpId,459    rpName: credential.rpName,460    userName: credential.userName,461    userDisplayName: credential.userDisplayName,462  });463  if (!approved) {464    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");465    return;466  }467468  const nextSignCount = credential.signCount + 1;469  const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0);470  const authData = await buildAuthenticatorData({471    rpId: credential.rpId,472    flags,473    signCount: nextSignCount,474  });475  const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(credential.rpId));476  const clientDataHash = new Uint8Array(477    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))478  );479  const signature = await signPasskeyAssertion(480    credential.privateKeyJwk,481    credential.algorithm,482    authData,483    clientDataHash484  );485486  await vault.updateEntry(match.id, {487    passkey: {488      ...credential,489      signCount: nextSignCount,490      lastUsedAt: Date.now(),491    },492  });493494  const responseJson = JSON.stringify({495    id: credential.credentialId,496    rawId: credential.credentialId,497    type: "public-key",498    response: {499      clientDataJSON: base64urlEncode(clientDataJSON),500      authenticatorData: base64urlEncode(authData),501      signature: base64urlEncode(signature),502      userHandle: credential.userHandle,503    },504    authenticatorAttachment: "platform",505    clientExtensionResults: {},506  });507508  console.log("[Peach passkey] completing get assertion", {509    rpId: credential.rpId,510    credentialId: credential.credentialId,511    algorithm: credential.algorithm,512    signCount: nextSignCount,513    flags: flags.toString(16),514    signatureBytes: signature.length,515    userHandle: credential.userHandle,516  });517518  await chrome.webAuthenticationProxy.completeGetRequest({519    requestId: request.requestId,520    responseJson,521  });522}523524// ── Firefox path ────────────────────────────────────────────────────────525//526// Firefox 150+ permits extensions to call navigator.credentials.create/get527// from the popup context with their host-permitted RP IDs. Peach uses that528// surface to register passkeys on behalf of the user. The page's own529// navigator.credentials call is observed via the MAIN-world detector530// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's531// flow is parallel and explicit (the user must engage with Peach's button).532533const FIREFOX_PENDING_KEY = "passkey_pending_firefox";534const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";535const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days536537export async function initiateFirefoxPasskeyCreate(args: {538  rpId: string;539  rpName: string;540  userName: string;541  userDisplayName?: string;542}): Promise<{ ok: boolean; error?: string }> {543  if (!(await isProUser())) {544    broadcastPasskeyProGate();545    return { ok: false, error: "passkey creation requires Peach Pro" };546  }547  if (!vault.isUnlocked()) {548    await openApprovalPopup();549    return { ok: false, error: "vault locked" };550  }551  if (!chrome.storage.session) {552    return { ok: false, error: "session storage unavailable" };553  }554555  const requestId = Date.now();556  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));557  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));558559  const pending: PendingPasskeyRequest = {560    requestId,561    type: "create",562    rpId: args.rpId,563    rpName: args.rpName,564    userName: args.userName,565    userDisplayName: args.userDisplayName,566    browser: "firefox",567    challenge,568    userHandle,569  };570571  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });572  await openApprovalPopup();573  return { ok: true };574}575576export async function completeFirefoxPasskeyCreate(payload: {577  requestId: number;578  rpId: string;579  rpName: string;580  userName: string;581  userDisplayName?: string;582  userHandle: string;583  credentialId: string;584  publicKeyCose: string;585  algorithm: -7 | -8;586  aaguid?: string;587  transports?: string[];588  backupEligible?: boolean;589  backupState?: boolean;590}): Promise<{ ok: boolean; error?: string }> {591  if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };592593  const passkey: PasskeyData = {594    rpId: payload.rpId,595    rpName: payload.rpName,596    credentialId: payload.credentialId,597    publicKeyCose: payload.publicKeyCose,598    userHandle: payload.userHandle,599    userName: payload.userName,600    userDisplayName: payload.userDisplayName ?? payload.userName,601    algorithm: payload.algorithm,602    signCount: 0,603    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),604    backupEligible: payload.backupEligible ?? false,605    backupState: payload.backupState ?? false,606    browser: "firefox",607    transports: payload.transports ?? ["internal"],608    createdAt: Date.now(),609  };610611  // Same attach-or-create flow as the Chrome path: prefer attaching to an612  // existing login entry for the same domain so the user ends up with one613  // entry per site, not two (a login + a passkey-only).614  const target = findAttachTarget(payload.rpId);615  if (target) {616    await vault.updateEntry(target.id, { passkey });617  } else {618    await vault.addEntry({619      type: "login",620      title: payload.rpName || payload.rpId,621      url: `https://${payload.rpId}`,622      username: payload.userName,623      passkey,624    });625  }626627  if (chrome.storage.session) {628    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);629  }630  return { ok: true };631}632633export async function cancelFirefoxPasskeyCreate(): Promise<void> {634  if (chrome.storage.session) {635    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);636  }637}638639function declineKey(domain: string): string {640  return `${FIREFOX_DECLINE_PREFIX}${domain}`;641}642643export async function recordPasskeyOfferDecline(domain: string): Promise<void> {644  if (!domain) return;645  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });646}647648export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {649  if (!domain) return false;650  const key = declineKey(domain);651  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;652  const at = stored[key];653  if (typeof at !== "number") return false;654  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {655    await chrome.storage.local.remove(key);656    return false;657  }658  return true;659}660661export async function setupPasskeyProxy(): Promise<void> {662  if (!isPasskeySupported()) return;663664  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The665  // popup-context credential creator is the entry point; nothing to wire here.666  if (detectSharedBrowser() === "firefox") {667    await refreshPasskeyProxy();668    return;669  }670671  if (!listenersRegistered) {672    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {673      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({674        requestId: requestInfo.requestId,675        isUvpaa: vault.isUnlocked(),676      });677    });678    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {679      void handleCreateRequest(requestInfo).catch(async (error) => {680        console.error("Peach passkey create failed", error);681        await rejectCreate(682          requestInfo.requestId,683          "UnknownError",684          error instanceof Error ? error.message : "Passkey creation failed."685        );686      });687    });688    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {689      void handleGetRequest(requestInfo).catch(async (error) => {690        console.error("Peach passkey get failed", error);691        await rejectGet(692          requestInfo.requestId,693          "UnknownError",694          error instanceof Error ? error.message : "Passkey authentication failed."695        );696      });697    });698    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {699      void resolvePendingPasskeyRequest(requestId, false);700    });701    listenersRegistered = true;702  }703704  await refreshPasskeyProxy();705}