8a118743fab0

add HAS_CREDENTIALS, CREATE, GET handlers and approval wrapper

time
2026-04-29 07:07 UTC · 6 months ago
machine
koh2
parent
996679b2fcce
4 files src/background/messages.ts+29 −0src/background/passkey-crypto.ts+16 −0src/background/passkey.ts+324 −0src/shared/types.ts+6 −0
src/background/messages.ts+29 −0
@@ -26,6 +26,9 @@2626   completeFirefoxPasskeyCreate,2727   getPasskeyProviderState,2828   getPendingPasskeyRequest,29+  handleFirefoxPasskeyCreate,30+  handleFirefoxPasskeyGet,31+  handlePasskeyHasCredentials,2932   initiateFirefoxPasskeyCreate,3033   isPasskeyOfferDeclined,3134   recordPasskeyOfferDecline,@@ -666,6 +669,32 @@666669         const { domain } = (msg.payload ?? {}) as { domain?: string };667670         if (!domain) return { ok: false, error: "domain required" };668671         return { ok: true, data: await isPasskeyOfferDeclined(domain) };672+      }673+674+      case "PASSKEY_HAS_CREDENTIALS": {675+        const payload = msg.payload as {676+          rpId?: string;677+          allowCredentials?: Array<{ id: string; type?: string }>;678+        } | undefined;679+        if (!payload?.rpId) return { ok: false, error: "rpId required" };680+        return await handlePasskeyHasCredentials({681+          rpId: payload.rpId,682+          allowCredentials: payload.allowCredentials ?? [],683+        });684+      }685+686+      case "PASSKEY_CREATE": {687+        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;688+        if (!payload?.rpId || !payload?.userName) {689+          return { ok: false, error: "rpId and userName required" };690+        }691+        return await handleFirefoxPasskeyCreate(payload);692+      }693+694+      case "PASSKEY_GET": {695+        const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;696+        if (!payload?.rpId) return { ok: false, error: "rpId required" };697+        return await handleFirefoxPasskeyGet(payload);669698       }670699 671700       case "GET_PASSKEY_FOR_DOMAIN": {

view file

src/background/passkey-crypto.ts+16 −0
@@ -344,3 +344,19 @@344344   ]);345345 }346346 347+export async function sha256(data: Uint8Array): Promise<Uint8Array> {348+  return new Uint8Array(await crypto.subtle.digest("SHA-256", toArrayBuffer(data)));349+}350+351+/**352+ * Generate a fresh credential ID. WebAuthn requires the value be unguessable353+ * and unique per credential; 32 bytes from the CSPRNG satisfies both.354+ */355+export function generateCredentialId(): {356+  bytes: Uint8Array;357+  base64url: string;358+} {359+  const bytes = crypto.getRandomValues(new Uint8Array(32));360+  return { bytes, base64url: base64urlEncode(bytes) };361+}362+

view file

src/background/passkey.ts+324 −0
@@ -6,11 +6,13 @@66   buildAuthenticatorData,77   buildClientDataJSON,88   createAttestationObject,9+  generateCredentialId,910   generatePasskeyKeypair,1011   pickPasskeyAlgorithm,1112   signPasskeyAssertion,1213 } from "./passkey-crypto";1314 import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";15+import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";1416 import type {1517   PasskeyData,1618   PasskeyProviderState,@@ -656,6 +658,328 @@656658     return false;657659   }658660   return true;661+}662+663+// ── Firefox content-script proxy path (v2.12.0) ────────────────────────664+//665+// The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces666+// navigator.credentials.create / .get on every Firefox page. It builds a667+// clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://),668+// then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS,669+// PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages.670+//671+// Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag672+// FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates673+// to false and these handlers short-circuit. The full removal checklist is674+// in that file — the deletion is purely subtractive.675+676+interface FirefoxAllowCredential {677+  id: string;678+  type?: string;679+}680+681+interface FirefoxPubKeyCredParam {682+  type?: string;683+  alg: number;684+}685+686+export interface FirefoxPasskeyHasCredentialsPayload {687+  rpId: string;688+  allowCredentials: FirefoxAllowCredential[];689+}690+691+export interface FirefoxPasskeyCreatePayload {692+  rpId: string;693+  rpName?: string;694+  userId: string;695+  userName: string;696+  userDisplayName?: string;697+  challenge: string;698+  clientDataJSON: number[];699+  origin: string;700+  pubKeyCredParams?: FirefoxPubKeyCredParam[];701+  authenticatorSelection?: Record<string, unknown>;702+  timeout?: number;703+  attestation?: string;704+}705+706+export interface FirefoxPasskeyGetPayload {707+  rpId: string;708+  challenge: string;709+  clientDataJSON: number[];710+  origin: string;711+  allowCredentials: FirefoxAllowCredential[];712+  userVerification?: string;713+}714+715+interface FirefoxCreateCredentialResponse {716+  type: "create";717+  id: string;718+  clientDataJSON: number[];719+  attestationObject: string;720+  authenticatorData: string;721+  publicKey: string;722+  algorithm: -7 | -8;723+}724+725+interface FirefoxGetCredentialResponse {726+  type: "get";727+  id: string;728+  clientDataJSON: number[];729+  authenticatorData: string;730+  signature: string;731+  userHandle: string;732+}733+734+type FirefoxPasskeyResult<T> =735+  | { ok: true; credential: T }736+  | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string };737+738+function listPasskeyEntries(): Array<VaultEntry & { passkey: PasskeyData }> {739+  return vault.getEntries().filter(740+    (entry): entry is VaultEntry & { passkey: PasskeyData } =>741+      entry.passkey !== undefined && !entry.deletedAt742+  );743+}744+745+export async function handlePasskeyHasCredentials(746+  payload: FirefoxPasskeyHasCredentialsPayload747+): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {748+  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {749+    return { ok: false, error: "not_applicable" };750+  }751+  if (!vault.isUnlocked()) {752+    // Cannot enumerate without the vault key. The MAIN-world proxy treats a753+    // false answer as "fall through to the platform" — exactly the right754+    // outcome when Peach is locked: the user gets the native UI instead.755+    return { ok: true, hasCredentials: false };756+  }757+  const passkeys = listPasskeyEntries().filter(758+    (entry) => entry.passkey.rpId === payload.rpId759+  );760+  if (passkeys.length === 0) return { ok: true, hasCredentials: false };761+762+  const allow = payload.allowCredentials ?? [];763+  if (allow.length === 0) return { ok: true, hasCredentials: true };764+765+  const hasMatch = passkeys.some((entry) =>766+    allow.some((ac) => ac.id === entry.passkey.credentialId)767+  );768+  return { ok: true, hasCredentials: hasMatch };769+}770+771+/**772+ * Approval prompt for the Firefox proxy ceremonies. Reuses the existing773+ * pending-passkey channel (badge + popup + 60s timeout) — the same approval774+ * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need775+ * any new code path. The page-side proxy never sees the request id; the776+ * background mints one and discards it after resolution.777+ */778+async function requestFirefoxApproval(args: {779+  type: "create" | "get";780+  rpId: string;781+  rpName?: string;782+  userName: string;783+  userDisplayName?: string;784+}): Promise<boolean> {785+  // Synthetic request id — the Firefox proxy path doesn't have a786+  // chrome.webAuthenticationProxy request to tie back to.787+  const requestId = Date.now() + Math.floor(Math.random() * 1000);788+  return promptUserApproval({789+    requestId,790+    type: args.type,791+    rpId: args.rpId,792+    rpName: args.rpName ?? args.rpId,793+    userName: args.userName,794+    userDisplayName: args.userDisplayName,795+  });796+}797+798+export async function handleFirefoxPasskeyCreate(799+  payload: FirefoxPasskeyCreatePayload800+): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {801+  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {802+    return { ok: false, error: "not_applicable" };803+  }804+  if (!(await isProUser())) {805+    broadcastPasskeyProGate();806+    return { ok: false, error: "pro_required" };807+  }808+  if (!vault.isUnlocked()) {809+    await openApprovalPopup();810+    return { ok: false, error: "vault_locked" };811+  }812+813+  const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []);814+  if (!algorithm) {815+    return { ok: false, error: "internal_error", message: "No supported algorithm offered" };816+  }817+818+  const approved = await requestFirefoxApproval({819+    type: "create",820+    rpId: payload.rpId,821+    rpName: payload.rpName,822+    userName: payload.userName,823+    userDisplayName: payload.userDisplayName,824+  });825+  if (!approved) return { ok: false, error: "user_cancelled" };826+827+  const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId();828+  const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm);829+830+  // 0x45 = UP|UV|AT  (user-present + user-verified + attested-credential-data).831+  // Matches Chrome's create flag set in handleCreateRequest above.832+  const authenticatorData = await buildAuthenticatorData({833+    rpId: payload.rpId,834+    flags: 0x45,835+    signCount: 0,836+    credentialId: credentialIdBytes,837+    publicKeyCose,838+  });839+  const attestationObject = createAttestationObject(authenticatorData);840+841+  const passkey: PasskeyData = {842+    rpId: payload.rpId,843+    rpName: payload.rpName ?? payload.rpId,844+    credentialId,845+    publicKeyCose: base64urlEncode(publicKeyCose),846+    privateKeyJwk,847+    userHandle: payload.userId,848+    userName: payload.userName,849+    userDisplayName: payload.userDisplayName ?? payload.userName,850+    algorithm,851+    signCount: 0,852+    aaguid: base64urlEncode(new Uint8Array(16)),853+    backupEligible: false,854+    backupState: false,855+    browser: "firefox",856+    transports: ["internal"],857+    createdAt: Date.now(),858+  };859+860+  const target = findAttachTarget(payload.rpId);861+  if (target) {862+    await vault.updateEntry(target.id, { passkey });863+  } else {864+    await vault.addEntry({865+      type: "login",866+      title: payload.rpName || payload.rpId,867+      url: `https://${payload.rpId}`,868+      username: payload.userName,869+      passkey,870+    });871+  }872+873+  return {874+    ok: true,875+    credential: {876+      type: "create",877+      id: credentialId,878+      // The content script already encoded clientDataJSON with the page879+      // origin; round-trip the exact bytes back so the RP sees the same880+      // hash it computes locally.881+      clientDataJSON: payload.clientDataJSON,882+      attestationObject: base64urlEncode(attestationObject),883+      authenticatorData: base64urlEncode(authenticatorData),884+      publicKey: base64urlEncode(publicKeySpki),885+      algorithm,886+    },887+  };888+}889+890+export async function handleFirefoxPasskeyGet(891+  payload: FirefoxPasskeyGetPayload892+): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {893+  if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {894+    return { ok: false, error: "not_applicable" };895+  }896+  if (!(await isProUser())) {897+    broadcastPasskeyProGate();898+    return { ok: false, error: "pro_required" };899+  }900+  if (!vault.isUnlocked()) {901+    await openApprovalPopup();902+    return { ok: false, error: "vault_locked" };903+  }904+905+  const allow = payload.allowCredentials ?? [];906+  const candidates = listPasskeyEntries().filter((entry) => {907+    if (entry.passkey.rpId !== payload.rpId) return false;908+    if (allow.length === 0) return true;909+    return allow.some((ac) => ac.id === entry.passkey.credentialId);910+  });911+912+  if (candidates.length === 0) return { ok: false, error: "no_credentials" };913+914+  // Most-recently-used wins when several credentials match. A future915+  // multi-account selector goes here; for now this matches the Chrome path's916+  // behavior for the common one-passkey-per-rp case.917+  candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0));918+  const match = candidates[0]!;919+  const credential = match.passkey;920+921+  if (!credential.privateKeyJwk) {922+    // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator923+    // — no Peach-held private key to sign with. The MAIN-world proxy treats924+    // no_credentials as a fall-through, which routes to the platform UI.925+    return { ok: false, error: "no_credentials" };926+  }927+928+  const approved = await requestFirefoxApproval({929+    type: "get",930+    rpId: credential.rpId,931+    rpName: credential.rpName,932+    userName: credential.userName,933+    userDisplayName: credential.userDisplayName,934+  });935+  if (!approved) return { ok: false, error: "user_cancelled" };936+937+  const nextSignCount = credential.signCount + 1;938+  // 0x05 = UP|UV. backup bits track whatever was set at registration time939+  // (consistent with the Chrome path).940+  const flags =941+    0x05 |942+    (credential.backupEligible ? 0x08 : 0) |943+    (credential.backupState ? 0x10 : 0);944+  const authenticatorData = await buildAuthenticatorData({945+    rpId: credential.rpId,946+    flags,947+    signCount: nextSignCount,948+  });949+950+  // The content script supplied clientDataJSON bytes built with the page951+  // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash952+  // diverges from what the RP computes.953+  const clientDataBytes = new Uint8Array(payload.clientDataJSON);954+  const clientDataHash = new Uint8Array(955+    await crypto.subtle.digest("SHA-256", clientDataBytes)956+  );957+  const signature = await signPasskeyAssertion(958+    credential.privateKeyJwk,959+    credential.algorithm,960+    authenticatorData,961+    clientDataHash962+  );963+964+  await vault.updateEntry(match.id, {965+    passkey: {966+      ...credential,967+      signCount: nextSignCount,968+      lastUsedAt: Date.now(),969+    },970+  });971+972+  return {973+    ok: true,974+    credential: {975+      type: "get",976+      id: credential.credentialId,977+      clientDataJSON: payload.clientDataJSON,978+      authenticatorData: base64urlEncode(authenticatorData),979+      signature: base64urlEncode(signature),980+      userHandle: credential.userHandle,981+    },982+  };659983 }660984 661985 export async function setupPasskeyProxy(): Promise<void> {

view file

src/shared/types.ts+6 −0
@@ -276,6 +276,12 @@276276   | "PASSKEY_OFFER_DECLINE"277277   | "GET_PASSKEY_OFFER_DECLINED"278278   | "GET_PASSKEY_FOR_DOMAIN"279+  // Firefox content-script proxy path (v2.12.0). Sent FROM the page-world280+  // monkey-patch through the extension-world relay TO the background. See281+  // src/shared/passkey-flags.ts for removal conditions.282+  | "PASSKEY_HAS_CREDENTIALS"283+  | "PASSKEY_CREATE"284+  | "PASSKEY_GET"279285   // Content-script-bound message types — sent BY the background TO content280286   // scripts. They never reach messages.ts's switch. Kept in this union so281287   // existing call sites type-check; do not add cases for them in the

view file