passkey.ts

19.8 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  PasskeyEntry,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}257258function passkeyEntries(entries: VaultEntry[]): PasskeyEntry[] {259  return entries.filter((entry): entry is PasskeyEntry => entry.type === "passkey");260}261262async function handleCreateRequest(263  request: chrome.webAuthenticationProxy.CreateRequest264): Promise<void> {265  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;266  const rpId = options.rp.id;267  const rpName = options.rp.name ?? options.rp.id;268  const userName = options.user.name;269  const userDisplayName = options.user.displayName ?? options.user.name;270271  if (!rpId) {272    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");273    return;274  }275276  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {277    await rejectCreate(278      request.requestId,279      "NotAllowedError",280      "Passkey creation requires Peach Pro."281    );282    // Show the inline passkey gate so the user knows what happened and can upgrade.283    broadcastPasskeyProGate();284    return;285  }286287  if (!vault.isUnlocked()) {288    await openApprovalPopup();289    await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");290    return;291  }292293  const approved = await promptUserApproval({294    requestId: request.requestId,295    type: "create",296    rpId,297    rpName,298    userName,299    userDisplayName,300  });301  if (!approved) {302    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");303    return;304  }305306  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);307  if (!algorithm) {308    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");309    return;310  }311312  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));313  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(314    algorithm315  );316  const authData = await buildAuthenticatorData({317    rpId,318    flags: 0x4d,319    signCount: 0,320    credentialId: credentialIdBytes,321    publicKeyCose,322  });323  const attestationObject = createAttestationObject(authData);324  const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));325326  const entry: PasskeyEntry = {327    id: crypto.randomUUID(),328    type: "passkey",329    title: rpName,330    rpId,331    rpName,332    credentialId: base64urlEncode(credentialIdBytes),333    publicKeyCose: base64urlEncode(publicKeyCose),334    privateKeyJwk,335    userHandle: options.user.id,336    userName,337    userDisplayName,338    algorithm,339    signCount: 0,340    aaguid: base64urlEncode(new Uint8Array(16)),341    backupEligible: true,342    backupState: false,343    browser: "chrome",344    transports: ["internal"],345    createdAt: Date.now(),346    modifiedAt: Date.now(),347  };348349  await vault.addEntry(entry);350351  await chrome.webAuthenticationProxy.completeCreateRequest({352    requestId: request.requestId,353    responseJson: JSON.stringify({354      id: entry.credentialId,355      rawId: entry.credentialId,356      type: "public-key",357      response: {358        clientDataJSON: base64urlEncode(clientDataJSON),359        authenticatorData: base64urlEncode(authData),360        attestationObject: base64urlEncode(attestationObject),361        transports: ["internal"],362        publicKey: base64urlEncode(publicKeySpki),363        publicKeyAlgorithm: algorithm,364      },365      authenticatorAttachment: "platform",366      clientExtensionResults: {},367    }),368  });369}370371function broadcastPasskeyProGate(): void {372  chrome.tabs.query({}, (tabs) => {373    for (const tab of tabs) {374      if (tab.id !== undefined) {375        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});376      }377    }378  });379}380381async function handleGetRequest(382  request: chrome.webAuthenticationProxy.GetRequest383): Promise<void> {384  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;385  if (!vault.isUnlocked()) {386    await openApprovalPopup();387    await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");388    return;389  }390391  if (!(await isProUser())) {392    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");393    broadcastPasskeyProGate();394    return;395  }396397  const entries = passkeyEntries(vault.getEntries());398  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);399400  const match = entries.find((entry) => {401    if (allowedCredentialIds.length > 0) {402      return allowedCredentialIds.includes(entry.credentialId);403    }404    if (!options.rpId) return false;405    return entry.rpId === options.rpId;406  });407408  if (!match) {409    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");410    return;411  }412413  // Chrome's webAuthenticationProxy path requires Peach-held private key414  // material to sign the assertion. Firefox-created passkeys delegate to the415  // platform authenticator — the browser will route to the platform UI416  // automatically when we reject, which is the right outcome.417  if (!match.privateKeyJwk) {418    await rejectGet(419      request.requestId,420      "NotAllowedError",421      "This passkey was created on Firefox; sign in via the platform authenticator."422    );423    return;424  }425426  const approved = await promptUserApproval({427    requestId: request.requestId,428    type: "get",429    rpId: match.rpId,430    rpName: match.rpName,431    userName: match.userName,432    userDisplayName: match.userDisplayName,433  });434  if (!approved) {435    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");436    return;437  }438439  const nextSignCount = (match.signCount ?? 0) + 1;440  const flags = 0x05 | (match.backupEligible ? 0x08 : 0) | (match.backupState ? 0x10 : 0);441  const authData = await buildAuthenticatorData({442    rpId: match.rpId,443    flags,444    signCount: nextSignCount,445  });446  const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(match.rpId));447  const clientDataHash = new Uint8Array(448    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))449  );450  const signature = await signPasskeyAssertion(451    match.privateKeyJwk,452    match.algorithm,453    authData,454    clientDataHash455  );456457  await vault.updateEntry(match.id, {458    signCount: nextSignCount,459    lastUsedAt: Date.now(),460  });461462  const responseJson = JSON.stringify({463    id: match.credentialId,464    rawId: match.credentialId,465    type: "public-key",466    response: {467      clientDataJSON: base64urlEncode(clientDataJSON),468      authenticatorData: base64urlEncode(authData),469      signature: base64urlEncode(signature),470      userHandle: match.userHandle,471    },472    authenticatorAttachment: "platform",473    clientExtensionResults: {},474  });475476  console.log("[Peach passkey] completing get assertion", {477    rpId: match.rpId,478    credentialId: match.credentialId,479    algorithm: match.algorithm,480    signCount: nextSignCount,481    flags: flags.toString(16),482    signatureBytes: signature.length,483    userHandle: match.userHandle,484  });485486  await chrome.webAuthenticationProxy.completeGetRequest({487    requestId: request.requestId,488    responseJson,489  });490}491492// ── Firefox path ────────────────────────────────────────────────────────493//494// Firefox 150+ permits extensions to call navigator.credentials.create/get495// from the popup context with their host-permitted RP IDs. Peach uses that496// surface to register passkeys on behalf of the user. The page's own497// navigator.credentials call is observed via the MAIN-world detector498// (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's499// flow is parallel and explicit (the user must engage with Peach's button).500501const FIREFOX_PENDING_KEY = "passkey_pending_firefox";502const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:";503const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days504505export async function initiateFirefoxPasskeyCreate(args: {506  rpId: string;507  rpName: string;508  userName: string;509  userDisplayName?: string;510}): Promise<{ ok: boolean; error?: string }> {511  if (!(await isProUser())) {512    broadcastPasskeyProGate();513    return { ok: false, error: "passkey creation requires Peach Pro" };514  }515  if (!vault.isUnlocked()) {516    await openApprovalPopup();517    return { ok: false, error: "vault locked" };518  }519  if (!chrome.storage.session) {520    return { ok: false, error: "session storage unavailable" };521  }522523  const requestId = Date.now();524  const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));525  const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32)));526527  const pending: PendingPasskeyRequest = {528    requestId,529    type: "create",530    rpId: args.rpId,531    rpName: args.rpName,532    userName: args.userName,533    userDisplayName: args.userDisplayName,534    browser: "firefox",535    challenge,536    userHandle,537  };538539  await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending });540  await openApprovalPopup();541  return { ok: true };542}543544export async function completeFirefoxPasskeyCreate(payload: {545  requestId: number;546  rpId: string;547  rpName: string;548  userName: string;549  userDisplayName?: string;550  userHandle: string;551  credentialId: string;552  publicKeyCose: string;553  algorithm: -7 | -8;554  aaguid?: string;555  transports?: string[];556  backupEligible?: boolean;557  backupState?: boolean;558}): Promise<{ ok: boolean; error?: string }> {559  if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };560561  const entry: PasskeyEntry = {562    id: crypto.randomUUID(),563    type: "passkey",564    title: payload.rpName || payload.rpId,565    rpId: payload.rpId,566    rpName: payload.rpName,567    credentialId: payload.credentialId,568    publicKeyCose: payload.publicKeyCose,569    userHandle: payload.userHandle,570    userName: payload.userName,571    userDisplayName: payload.userDisplayName ?? payload.userName,572    algorithm: payload.algorithm,573    signCount: 0,574    aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)),575    backupEligible: payload.backupEligible ?? false,576    backupState: payload.backupState ?? false,577    browser: "firefox",578    transports: payload.transports ?? ["internal"],579    createdAt: Date.now(),580    modifiedAt: Date.now(),581  };582583  await vault.addEntry(entry);584  if (chrome.storage.session) {585    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);586  }587  return { ok: true };588}589590export async function cancelFirefoxPasskeyCreate(): Promise<void> {591  if (chrome.storage.session) {592    await chrome.storage.session.remove(FIREFOX_PENDING_KEY);593  }594}595596function declineKey(domain: string): string {597  return `${FIREFOX_DECLINE_PREFIX}${domain}`;598}599600export async function recordPasskeyOfferDecline(domain: string): Promise<void> {601  if (!domain) return;602  await chrome.storage.local.set({ [declineKey(domain)]: Date.now() });603}604605export async function isPasskeyOfferDeclined(domain: string): Promise<boolean> {606  if (!domain) return false;607  const key = declineKey(domain);608  const stored = (await chrome.storage.local.get(key)) as Record<string, number | undefined>;609  const at = stored[key];610  if (typeof at !== "number") return false;611  if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) {612    await chrome.storage.local.remove(key);613    return false;614  }615  return true;616}617618export async function setupPasskeyProxy(): Promise<void> {619  if (!isPasskeySupported()) return;620621  // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The622  // popup-context credential creator is the entry point; nothing to wire here.623  if (detectSharedBrowser() === "firefox") {624    await refreshPasskeyProxy();625    return;626  }627628  if (!listenersRegistered) {629    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {630      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({631        requestId: requestInfo.requestId,632        isUvpaa: vault.isUnlocked(),633      });634    });635    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {636      void handleCreateRequest(requestInfo).catch(async (error) => {637        console.error("Peach passkey create failed", error);638        await rejectCreate(639          requestInfo.requestId,640          "UnknownError",641          error instanceof Error ? error.message : "Passkey creation failed."642        );643      });644    });645    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {646      void handleGetRequest(requestInfo).catch(async (error) => {647        console.error("Peach passkey get failed", error);648        await rejectGet(649          requestInfo.requestId,650          "UnknownError",651          error instanceof Error ? error.message : "Passkey authentication failed."652        );653      });654    });655    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {656      void resolvePendingPasskeyRequest(requestId, false);657    });658    listenersRegistered = true;659  }660661  await refreshPasskeyProxy();662}