passkey.ts

13.2 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    // Show the inline passkey gate so the user knows what happened and can upgrade.248    broadcastPasskeyProGate();249    return;250  }251252  if (!vault.isUnlocked()) {253    await openApprovalPopup();254    await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first.");255    return;256  }257258  const approved = await promptUserApproval({259    requestId: request.requestId,260    type: "create",261    rpId,262    rpName,263    userName,264    userDisplayName,265  });266  if (!approved) {267    await rejectCreate(request.requestId, "NotAllowedError", "User denied request.");268    return;269  }270271  const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []);272  if (!algorithm) {273    await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered.");274    return;275  }276277  const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32));278  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(279    algorithm280  );281  const authData = await buildAuthenticatorData({282    rpId,283    flags: 0x4d,284    signCount: 0,285    credentialId: credentialIdBytes,286    publicKeyCose,287  });288  const attestationObject = createAttestationObject(authData);289  const clientDataJSON = buildClientDataJSON("webauthn.create", options.challenge, originFromRpId(rpId));290291  const entry: PasskeyEntry = {292    id: crypto.randomUUID(),293    type: "passkey",294    title: rpName,295    rpId,296    rpName,297    credentialId: base64urlEncode(credentialIdBytes),298    publicKeyCose: base64urlEncode(publicKeyCose),299    privateKeyJwk,300    userHandle: options.user.id,301    userName,302    userDisplayName,303    algorithm,304    signCount: 0,305    aaguid: base64urlEncode(new Uint8Array(16)),306    backupEligible: true,307    backupState: false,308    createdAt: Date.now(),309    modifiedAt: Date.now(),310  };311312  await vault.addEntry(entry);313314  await chrome.webAuthenticationProxy.completeCreateRequest({315    requestId: request.requestId,316    responseJson: JSON.stringify({317      id: entry.credentialId,318      rawId: entry.credentialId,319      type: "public-key",320      response: {321        clientDataJSON: base64urlEncode(clientDataJSON),322        authenticatorData: base64urlEncode(authData),323        attestationObject: base64urlEncode(attestationObject),324        transports: ["internal"],325        publicKey: base64urlEncode(publicKeySpki),326        publicKeyAlgorithm: algorithm,327      },328      authenticatorAttachment: "platform",329      clientExtensionResults: {},330    }),331  });332}333334function broadcastPasskeyProGate(): void {335  chrome.tabs.query({}, (tabs) => {336    for (const tab of tabs) {337      if (tab.id !== undefined) {338        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});339      }340    }341  });342}343344async function handleGetRequest(345  request: chrome.webAuthenticationProxy.GetRequest346): Promise<void> {347  const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON;348  if (!vault.isUnlocked()) {349    await openApprovalPopup();350    await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");351    return;352  }353354  if (!(await isProUser())) {355    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");356    broadcastPasskeyProGate();357    return;358  }359360  const entries = passkeyEntries(vault.getEntries());361  const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id);362363  const match = entries.find((entry) => {364    if (allowedCredentialIds.length > 0) {365      return allowedCredentialIds.includes(entry.credentialId);366    }367    if (!options.rpId) return false;368    return entry.rpId === options.rpId;369  });370371  if (!match) {372    await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found.");373    return;374  }375376  const approved = await promptUserApproval({377    requestId: request.requestId,378    type: "get",379    rpId: match.rpId,380    rpName: match.rpName,381    userName: match.userName,382    userDisplayName: match.userDisplayName,383  });384  if (!approved) {385    await rejectGet(request.requestId, "NotAllowedError", "User denied request.");386    return;387  }388389  const nextSignCount = (match.signCount ?? 0) + 1;390  const flags = 0x05 | (match.backupEligible ? 0x08 : 0) | (match.backupState ? 0x10 : 0);391  const authData = await buildAuthenticatorData({392    rpId: match.rpId,393    flags,394    signCount: nextSignCount,395  });396  const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(match.rpId));397  const clientDataHash = new Uint8Array(398    await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))399  );400  const signature = await signPasskeyAssertion(401    match.privateKeyJwk,402    match.algorithm,403    authData,404    clientDataHash405  );406407  await vault.updateEntry(match.id, {408    signCount: nextSignCount,409    lastUsedAt: Date.now(),410  });411412  await chrome.webAuthenticationProxy.completeGetRequest({413    requestId: request.requestId,414    responseJson: JSON.stringify({415      id: match.credentialId,416      rawId: match.credentialId,417      type: "public-key",418      response: {419        clientDataJSON: base64urlEncode(clientDataJSON),420        authenticatorData: base64urlEncode(authData),421        signature: base64urlEncode(signature),422        userHandle: match.userHandle,423      },424      authenticatorAttachment: "platform",425      clientExtensionResults: {},426    }),427  });428}429430export async function setupPasskeyProxy(): Promise<void> {431  if (!isPasskeySupported()) return;432433  if (!listenersRegistered) {434    chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => {435      void chrome.webAuthenticationProxy.completeIsUvpaaRequest({436        requestId: requestInfo.requestId,437        isUvpaa: vault.isUnlocked(),438      });439    });440    chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => {441      void handleCreateRequest(requestInfo).catch(async (error) => {442        console.error("Peach passkey create failed", error);443        await rejectCreate(444          requestInfo.requestId,445          "UnknownError",446          error instanceof Error ? error.message : "Passkey creation failed."447        );448      });449    });450    chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => {451      void handleGetRequest(requestInfo).catch(async (error) => {452        console.error("Peach passkey get failed", error);453        await rejectGet(454          requestInfo.requestId,455          "UnknownError",456          error instanceof Error ? error.message : "Passkey authentication failed."457        );458      });459    });460    chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => {461      void resolvePendingPasskeyRequest(requestId, false);462    });463    listenersRegistered = true;464  }465466  await refreshPasskeyProxy();467}