passkey.ts

13.6 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 type {14  PasskeyEntry,15  PasskeyProviderState,16  PendingPasskeyRequest,17  VaultEntry,18} from "../shared/types";1920declare const __PEACH_E2E_PASSKEYS__: boolean;2122const PENDING_PREFIX = "passkey_pending_";23const APPROVAL_TIMEOUT_MS = 60_000;2425let listenersRegistered = false;26let attached = false;27let conflict: string | undefined;28const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true;2930const approvals = new Map<31  number,32  { resolve: (approved: boolean) => void; timeoutId: ReturnType<typeof setTimeout> }33>();3435function isPasskeySupported(): boolean {36  return typeof chrome.webAuthenticationProxy?.attach === "function";37}3839export function detectBrowser(): "chrome" | "firefox" | "unknown" {40  if (typeof chrome !== "undefined" && chrome.webAuthenticationProxy) {41    return "chrome";42  }43  try {44    const url = chrome.runtime?.getURL?.("") ?? "";45    if (url.startsWith("moz-extension://")) return "firefox";46  } catch { /* extension context unavailable */ }47  return "unknown";48}4950function getBrowserLabel(): string {51  const ua = navigator.userAgent;52  if (/Firefox\/(\d+)/.test(ua)) {53    const version = ua.match(/Firefox\/(\d+)/)?.[1];54    return version ? `Firefox ${version}` : "Firefox";55  }56  const version = ua.match(/Chrome\/(\d+)/)?.[1];57  return version ? `Chrome ${version}` : "Chromium";58}5960function pendingStorageKey(requestId: number): string {61  return `${PENDING_PREFIX}${requestId}`;62}6364function originFromRpId(rpId: string): string {65  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)66    ? `http://${rpId}`67    : `https://${rpId}`;68}6970async function setBadge(text: string): Promise<void> {71  if (!chrome.action?.setBadgeText) return;72  await chrome.action.setBadgeText({ text });73  if (text) {74    await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });75  }76}7778async function clearPendingRequest(requestId: number): Promise<void> {79  if (chrome.storage.session) {80    await chrome.storage.session.remove(pendingStorageKey(requestId));81  }82  await setBadge("");83}8485async function openApprovalPopup(): Promise<void> {86  try {87    await chrome.action.openPopup();88  } catch {89    await setBadge("PK");90  }91}9293async function rejectCreate(94  requestId: number,95  name: string,96  message = ""97): Promise<void> {98  await chrome.webAuthenticationProxy.completeCreateRequest({99    requestId,100    error: { name, message },101  });102}103104async function rejectGet(105  requestId: number,106  name: string,107  message = ""108): Promise<void> {109  await chrome.webAuthenticationProxy.completeGetRequest({110    requestId,111    error: { name, message },112  });113}114115export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {116  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts117  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;118  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));119  if (!key) return null;120  const pending = stored[key];121  return pending && typeof pending === "object"122    ? (pending as PendingPasskeyRequest)123    : null;124}125126export async function resolvePendingPasskeyRequest(127  requestId: number,128  approved: boolean129): Promise<void> {130  const pending = approvals.get(requestId);131  if (!pending) {132    await clearPendingRequest(requestId);133    return;134  }135136  clearTimeout(pending.timeoutId);137  approvals.delete(requestId);138  await clearPendingRequest(requestId);139  pending.resolve(approved);140}141142async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {143  if (chrome.storage.session) {144    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });145  }146  await openApprovalPopup();147148  return new Promise<boolean>((resolve) => {149    const timeoutId = setTimeout(async () => {150      approvals.delete(request.requestId);151      await clearPendingRequest(request.requestId);152      resolve(false);153    }, APPROVAL_TIMEOUT_MS);154155    approvals.set(request.requestId, { resolve, timeoutId });156  });157}158159export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {160  const settings = await vault.ensureSettingsLoaded();161  const supported = isPasskeySupported();162  const enabled = settings.passkeyProviderEnabled === true;163  return {164    supported,165    enabled,166    attached: supported && attached,167    browserLabel: getBrowserLabel(),168    statusLabel: !supported169      ? "Interception unavailable"170      : attached171        ? "Active"172        : conflict173          ? "Another provider is active"174          : enabled175            ? "Inactive"176            : "Disabled",177    conflict,178  };179}180181async function attachPasskeyProxy(): Promise<void> {182  if (!isPasskeySupported()) {183    attached = false;184    conflict = "This browser does not expose chrome.webAuthenticationProxy.";185    return;186  }187  if (attached) return;188189  try {190    await chrome.webAuthenticationProxy.attach();191    attached = true;192    conflict = undefined;193  } catch (error) {194    attached = false;195    conflict = error instanceof Error ? error.message : "Another provider is active.";196  }197}198199async function detachPasskeyProxy(): Promise<void> {200  if (!isPasskeySupported()) {201    attached = false;202    return;203  }204205  try {206    await chrome.webAuthenticationProxy.detach();207  } catch {208    // Ignore detach failures; the extension may already be detached.209  }210211  attached = false;212  conflict = undefined;213}214215export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {216  await vault.updateSettings({ passkeyProviderEnabled: enabled });217  if (enabled) {218    await attachPasskeyProxy();219  } else {220    await detachPasskeyProxy();221  }222  return getPasskeyProviderState();223}224225async function refreshPasskeyProxy(): Promise<void> {226  const settings = await vault.ensureSettingsLoaded();227  if (settings.passkeyProviderEnabled === true) {228    await attachPasskeyProxy();229    return;230  }231  await detachPasskeyProxy();232}233234function passkeyEntries(entries: VaultEntry[]): PasskeyEntry[] {235  return entries.filter((entry): entry is PasskeyEntry => entry.type === "passkey");236}237238async function handleCreateRequest(239  request: chrome.webAuthenticationProxy.CreateRequest240): Promise<void> {241  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;242  const rpId = options.rp.id;243  const rpName = options.rp.name ?? options.rp.id;244  const userName = options.user.name;245  const userDisplayName = options.user.displayName ?? options.user.name;246247  if (!rpId) {248    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");249    return;250  }251252  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {253    await rejectCreate(254      request.requestId,255      "NotAllowedError",256      "Passkey creation requires Peach Pro."257    );258    // Show the inline passkey gate so the user knows what happened and can upgrade.259    broadcastPasskeyProGate();260    return;261  }262263  if (!vault.isUnlocked()) {264    await openApprovalPopup();265    await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");266    return;267  }268269  const approved = await promptUserApproval({270    requestId: request.requestId,271    type: "create",272    rpId,273    rpName,274    userName,275    userDisplayName,276  });277  if (!approved) {278    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");279    return;280  }281282  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);283  if (!algorithm) {284    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");285    return;286  }287288  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));289  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(290    algorithm291  );292  const authData = await buildAuthenticatorData({293    rpId,294    flags: 0x4d,295    signCount: 0,296    credentialId: credentialIdBytes,297    publicKeyCose,298  });299  const attestationObject = createAttestationObject(authData);300  const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));301302  const entry: PasskeyEntry = {303    id: crypto.randomUUID(),304    type: "passkey",305    title: rpName,306    rpId,307    rpName,308    credentialId: base64urlEncode(credentialIdBytes),309    publicKeyCose: base64urlEncode(publicKeyCose),310    privateKeyJwk,311    userHandle: options.user.id,312    userName,313    userDisplayName,314    algorithm,315    signCount: 0,316    aaguid: base64urlEncode(new Uint8Array(16)),317    backupEligible: true,318    backupState: false,319    browser: "chrome",320    transports: ["internal"],321    createdAt: Date.now(),322    modifiedAt: Date.now(),323  };324325  await vault.addEntry(entry);326327  await chrome.webAuthenticationProxy.completeCreateRequest({328    requestId: request.requestId,329    responseJson: JSON.stringify({330      id: entry.credentialId,331      rawId: entry.credentialId,332      type: "public-key",333      response: {334        clientDataJSON: base64urlEncode(clientDataJSON),335        authenticatorData: base64urlEncode(authData),336        attestationObject: base64urlEncode(attestationObject),337        transports: ["internal"],338        publicKey: base64urlEncode(publicKeySpki),339        publicKeyAlgorithm: algorithm,340      },341      authenticatorAttachment: "platform",342      clientExtensionResults: {},343    }),344  });345}346347function broadcastPasskeyProGate(): void {348  chrome.tabs.query({}, (tabs) => {349    for (const tab of tabs) {350      if (tab.id !== undefined) {351        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});352      }353    }354  });355}356357async function handleGetRequest(358  request: chrome.webAuthenticationProxy.GetRequest359): Promise<void> {360  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;361  if (!vault.isUnlocked()) {362    await openApprovalPopup();363    await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");364    return;365  }366367  if (!(await isProUser())) {368    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");369    broadcastPasskeyProGate();370    return;371  }372373  const entries = passkeyEntries(vault.getEntries());374  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);375376  const match = entries.find((entry) => {377    if (allowedCredentialIds.length > 0) {378      return allowedCredentialIds.includes(entry.credentialId);379    }380    if (!options.rpId) return false;381    return entry.rpId === options.rpId;382  });383384  if (!match) {385    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");386    return;387  }388389  const approved = await promptUserApproval({390    requestId: request.requestId,391    type: "get",392    rpId: match.rpId,393    rpName: match.rpName,394    userName: match.userName,395    userDisplayName: match.userDisplayName,396  });397  if (!approved) {398    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");399    return;400  }401402  const nextSignCount = (match.signCount ?? 0) + 1;403  const flags = 0x05 | (match.backupEligible ? 0x08 : 0) | (match.backupState ? 0x10 : 0);404  const authData = await buildAuthenticatorData({405    rpId: match.rpId,406    flags,407    signCount: nextSignCount,408  });409  const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(match.rpId));410  const clientDataHash = new Uint8Array(411    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))412  );413  const signature = await signPasskeyAssertion(414    match.privateKeyJwk,415    match.algorithm,416    authData,417    clientDataHash418  );419420  await vault.updateEntry(match.id, {421    signCount: nextSignCount,422    lastUsedAt: Date.now(),423  });424425  await chrome.webAuthenticationProxy.completeGetRequest({426    requestId: request.requestId,427    responseJson: JSON.stringify({428      id: match.credentialId,429      rawId: match.credentialId,430      type: "public-key",431      response: {432        clientDataJSON: base64urlEncode(clientDataJSON),433        authenticatorData: base64urlEncode(authData),434        signature: base64urlEncode(signature),435        userHandle: match.userHandle,436      },437      authenticatorAttachment: "platform",438      clientExtensionResults: {},439    }),440  });441}442443export async function setupPasskeyProxy(): Promise<void> {444  if (!isPasskeySupported()) return;445446  if (!listenersRegistered) {447    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {448      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({449        requestId: requestInfo.requestId,450        isUvpaa: vault.isUnlocked(),451      });452    });453    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {454      void handleCreateRequest(requestInfo).catch(async (error) => {455        console.error("Peach passkey create failed", error);456        await rejectCreate(457          requestInfo.requestId,458          "UnknownError",459          error instanceof Error ? error.message : "Passkey creation failed."460        );461      });462    });463    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {464      void handleGetRequest(requestInfo).catch(async (error) => {465        console.error("Peach passkey get failed", error);466        await rejectGet(467          requestInfo.requestId,468          "UnknownError",469          error instanceof Error ? error.message : "Passkey authentication failed."470        );471      });472    });473    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {474      void resolvePendingPasskeyRequest(requestId, false);475    });476    listenersRegistered = true;477  }478479  await refreshPasskeyProxy();480}