passkey.ts

12.7 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}3839function getBrowserLabel(): string {40  const ua = navigator.userAgent;41  if (/Firefox\/(\d+)/.test(ua)) {42    const version = ua.match(/Firefox\/(\d+)/)?.[1];43    return version ? `Firefox ${version}` : "Firefox";44  }45  const version = ua.match(/Chrome\/(\d+)/)?.[1];46  return version ? `Chrome ${version}` : "Chromium";47}4849function pendingStorageKey(requestId: number): string {50  return `${PENDING_PREFIX}${requestId}`;51}5253function originFromRpId(rpId: string): string {54  return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId)55    ? `http://${rpId}`56    : `https://${rpId}`;57}5859async function setBadge(text: string): Promise<void> {60  if (!chrome.action?.setBadgeText) return;61  await chrome.action.setBadgeText({ text });62  if (text) {63    await chrome.action.setBadgeBackgroundColor({ color: "#D96830" });64  }65}6667async function clearPendingRequest(requestId: number): Promise<void> {68  if (chrome.storage.session) {69    await chrome.storage.session.remove(pendingStorageKey(requestId));70  }71  await setBadge("");72}7374async function openApprovalPopup(): Promise<void> {75  try {76    await chrome.action.openPopup();77  } catch {78    await setBadge("PK");79  }80}8182async function rejectCreate(83  requestId: number,84  name: string,85  message = ""86): Promise<void> {87  await chrome.webAuthenticationProxy.completeCreateRequest({88    requestId,89    error: { name, message },90  });91}9293async function rejectGet(94  requestId: number,95  name: string,96  message = ""97): Promise<void> {98  await chrome.webAuthenticationProxy.completeGetRequest({99    requestId,100    error: { name, message },101  });102}103104export async function getPendingPasskeyRequest(): Promise<PendingPasskeyRequest | null> {105  if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts106  const stored = (await chrome.storage.session.get(null)) as Record<string, unknown>;107  const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX));108  if (!key) return null;109  const pending = stored[key];110  return pending && typeof pending === "object"111    ? (pending as PendingPasskeyRequest)112    : null;113}114115export async function resolvePendingPasskeyRequest(116  requestId: number,117  approved: boolean118): Promise<void> {119  const pending = approvals.get(requestId);120  if (!pending) {121    await clearPendingRequest(requestId);122    return;123  }124125  clearTimeout(pending.timeoutId);126  approvals.delete(requestId);127  await clearPendingRequest(requestId);128  pending.resolve(approved);129}130131async function promptUserApproval(request: PendingPasskeyRequest): Promise<boolean> {132  if (chrome.storage.session) {133    await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request });134  }135  await openApprovalPopup();136137  return new Promise<boolean>((resolve) => {138    const timeoutId = setTimeout(async () => {139      approvals.delete(request.requestId);140      await clearPendingRequest(request.requestId);141      resolve(false);142    }, APPROVAL_TIMEOUT_MS);143144    approvals.set(request.requestId, { resolve, timeoutId });145  });146}147148export async function getPasskeyProviderState(): Promise<PasskeyProviderState> {149  const settings = await vault.ensureSettingsLoaded();150  const supported = isPasskeySupported();151  const enabled = settings.passkeyProviderEnabled === true;152  return {153    supported,154    enabled,155    attached: supported && attached,156    browserLabel: getBrowserLabel(),157    statusLabel: !supported158      ? "Interception unavailable"159      : attached160        ? "Active"161        : conflict162          ? "Another provider is active"163          : enabled164            ? "Inactive"165            : "Disabled",166    conflict,167  };168}169170async function attachPasskeyProxy(): Promise<void> {171  if (!isPasskeySupported()) {172    attached = false;173    conflict = "This browser does not expose chrome.webAuthenticationProxy.";174    return;175  }176  if (attached) return;177178  try {179    await chrome.webAuthenticationProxy.attach();180    attached = true;181    conflict = undefined;182  } catch (error) {183    attached = false;184    conflict = error instanceof Error ? error.message : "Another provider is active.";185  }186}187188async function detachPasskeyProxy(): Promise<void> {189  if (!isPasskeySupported()) {190    attached = false;191    return;192  }193194  try {195    await chrome.webAuthenticationProxy.detach();196  } catch {197    // Ignore detach failures; the extension may already be detached.198  }199200  attached = false;201  conflict = undefined;202}203204export async function setPasskeyProviderEnabled(enabled: boolean): Promise<PasskeyProviderState> {205  await vault.updateSettings({ passkeyProviderEnabled: enabled });206  if (enabled) {207    await attachPasskeyProxy();208  } else {209    await detachPasskeyProxy();210  }211  return getPasskeyProviderState();212}213214async function refreshPasskeyProxy(): Promise<void> {215  const settings = await vault.ensureSettingsLoaded();216  if (settings.passkeyProviderEnabled === true) {217    await attachPasskeyProxy();218    return;219  }220  await detachPasskeyProxy();221}222223function passkeyEntries(entries: VaultEntry[]): PasskeyEntry[] {224  return entries.filter((entry): entry is PasskeyEntry => entry.type === "passkey");225}226227async function handleCreateRequest(228  request: chrome.webAuthenticationProxy.CreateRequest229): Promise<void> {230  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON;231  const rpId = options.rp.id;232  const rpName = options.rp.name ?? options.rp.id;233  const userName = options.user.name;234  const userDisplayName = options.user.displayName ?? options.user.name;235236  if (!rpId) {237    await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID");238    return;239  }240241  if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) {242    await rejectCreate(243      request.requestId,244      "NotAllowedError",245      "Passkey creation requires Peach Pro."246    );247    return;248  }249250  if (!vault.isUnlocked()) {251    await openApprovalPopup();252    await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");253    return;254  }255256  const approved = await promptUserApproval({257    requestId: request.requestId,258    type: "create",259    rpId,260    rpName,261    userName,262    userDisplayName,263  });264  if (!approved) {265    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");266    return;267  }268269  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);270  if (!algorithm) {271    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");272    return;273  }274275  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));276  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(277    algorithm278  );279  const authData = await buildAuthenticatorData({280    rpId,281    flags: 0x4d,282    signCount: 0,283    credentialId: credentialIdBytes,284    publicKeyCose,285  });286  const attestationObject = createAttestationObject(authData);287  const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));288289  const entry: PasskeyEntry = {290    id: crypto.randomUUID(),291    type: "passkey",292    title: rpName,293    rpId,294    rpName,295    credentialId: base64urlEncode(credentialIdBytes),296    publicKeyCose: base64urlEncode(publicKeyCose),297    privateKeyJwk,298    userHandle: options.user.id,299    userName,300    userDisplayName,301    algorithm,302    signCount: 0,303    aaguid: base64urlEncode(new Uint8Array(16)),304    backupEligible: true,305    backupState: false,306    createdAt: Date.now(),307    modifiedAt: Date.now(),308  };309310  await vault.addEntry(entry);311312  await chrome.webAuthenticationProxy.completeCreateRequest({313    requestId: request.requestId,314    responseJson: JSON.stringify({315      id: entry.credentialId,316      rawId: entry.credentialId,317      type: "public-key",318      response: {319        clientDataJSON: base64urlEncode(clientDataJSON),320        authenticatorData: base64urlEncode(authData),321        attestationObject: base64urlEncode(attestationObject),322        transports: ["internal"],323        publicKey: base64urlEncode(publicKeySpki),324        publicKeyAlgorithm: algorithm,325      },326      authenticatorAttachment: "platform",327      clientExtensionResults: {},328    }),329  });330}331332async function handleGetRequest(333  request: chrome.webAuthenticationProxy.GetRequest334): Promise<void> {335  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;336  if (!vault.isUnlocked()) {337    await openApprovalPopup();338    await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");339    return;340  }341342  const entries = passkeyEntries(vault.getEntries());343  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);344345  const match = entries.find((entry) => {346    if (allowedCredentialIds.length > 0) {347      return allowedCredentialIds.includes(entry.credentialId);348    }349    if (!options.rpId) return false;350    return entry.rpId === options.rpId;351  });352353  if (!match) {354    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");355    return;356  }357358  const approved = await promptUserApproval({359    requestId: request.requestId,360    type: "get",361    rpId: match.rpId,362    rpName: match.rpName,363    userName: match.userName,364    userDisplayName: match.userDisplayName,365  });366  if (!approved) {367    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");368    return;369  }370371  const nextSignCount = (match.signCount ?? 0) + 1;372  const flags = 0x05 | (match.backupEligible ? 0x08 : 0) | (match.backupState ? 0x10 : 0);373  const authData = await buildAuthenticatorData({374    rpId: match.rpId,375    flags,376    signCount: nextSignCount,377  });378  const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(match.rpId));379  const clientDataHash = new Uint8Array(380    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))381  );382  const signature = await signPasskeyAssertion(383    match.privateKeyJwk,384    match.algorithm,385    authData,386    clientDataHash387  );388389  await vault.updateEntry(match.id, {390    signCount: nextSignCount,391    lastUsedAt: Date.now(),392  });393394  await chrome.webAuthenticationProxy.completeGetRequest({395    requestId: request.requestId,396    responseJson: JSON.stringify({397      id: match.credentialId,398      rawId: match.credentialId,399      type: "public-key",400      response: {401        clientDataJSON: base64urlEncode(clientDataJSON),402        authenticatorData: base64urlEncode(authData),403        signature: base64urlEncode(signature),404        userHandle: match.userHandle,405      },406      authenticatorAttachment: "platform",407      clientExtensionResults: {},408    }),409  });410}411412export async function setupPasskeyProxy(): Promise<void> {413  if (!isPasskeySupported()) return;414415  if (!listenersRegistered) {416    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {417      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({418        requestId: requestInfo.requestId,419        isUvpaa: vault.isUnlocked(),420      });421    });422    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {423      void handleCreateRequest(requestInfo).catch(async (error) => {424        console.error("Peach passkey create failed", error);425        await rejectCreate(426          requestInfo.requestId,427          "UnknownError",428          error instanceof Error ? error.message : "Passkey creation failed."429        );430      });431    });432    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {433      void handleGetRequest(requestInfo).catch(async (error) => {434        console.error("Peach passkey get failed", error);435        await rejectGet(436          requestInfo.requestId,437          "UnknownError",438          error instanceof Error ? error.message : "Passkey authentication failed."439        );440      });441    });442    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {443      void resolvePendingPasskeyRequest(requestId, false);444    });445    listenersRegistered = true;446  }447448  await refreshPasskeyProxy();449}