8a118743fab0
add HAS_CREDENTIALS, CREATE, GET handlers and approval wrapper
- time
- 2026-04-29 07:07 UTC · 6 months ago
- machine
- koh2
- parent
- 996679b2fcce
↓ peach-browser-8a118743.tar.gzatlas at this save
files changed4 files
src/background/messages.ts+29 −0src/background/passkey-crypto.ts+16 −0src/background/passkey.ts+324 −0src/shared/types.ts+6 −0src/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": {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+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> {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