import { isProUser } from "./license"; import * as vault from "./vault"; import { base64urlEncode, buildAuthenticatorData, buildClientDataJSON, createAttestationObject, generateCredentialId, generatePasskeyKeypair, pickPasskeyAlgorithm, signPasskeyAssertion, } from "./passkey-crypto"; import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser"; import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags"; import { isValidRpId } from "../shared/passkey-utils"; import type { PasskeyData, PasskeyProviderState, PendingPasskeyRequest, VaultEntry, } from "../shared/types"; declare const __PEACH_E2E_PASSKEYS__: boolean; const PENDING_PREFIX = "passkey_pending_"; const APPROVAL_TIMEOUT_MS = 60_000; let listenersRegistered = false; let attached = false; let conflict: string | undefined; const PASSKEY_CREATE_E2E_BYPASS = __PEACH_E2E_PASSKEYS__ === true; const approvals = new Map< number, { resolve: (approved: boolean) => void; timeoutId: ReturnType } >(); // Resolvers for GET requests that arrived while the vault was locked and are // waiting for the user to unlock before the passkey flow can continue. const unlockWaiters = new Map void; timeoutId: ReturnType }>(); export function onVaultUnlocked(): void { for (const { resolve, timeoutId } of unlockWaiters.values()) { clearTimeout(timeoutId); resolve(true); } unlockWaiters.clear(); } /** * Resolve every still-pending passkey request as denied and clear unlock * waiters. Called from the popup-disconnect path so a closed popup releases * the page-side caller in <200ms instead of leaving it stuck for the full * APPROVAL_TIMEOUT_MS (60s) (M8/COR-009). */ export async function denyAllPendingPasskeyRequests(): Promise { if (approvals.size === 0 && unlockWaiters.size === 0) return; for (const [rid, { resolve, timeoutId }] of approvals.entries()) { clearTimeout(timeoutId); resolve(false); approvals.delete(rid); await clearPendingRequest(rid).catch(() => {}); } for (const { resolve, timeoutId } of unlockWaiters.values()) { clearTimeout(timeoutId); resolve(false); } unlockWaiters.clear(); } function isPasskeySupported(): boolean { if (typeof chrome.webAuthenticationProxy?.attach === "function") return true; // Firefox 150+ exposes navigator.credentials.create/get from the popup // context with extension host permissions. Cannot intercept page-side // calls (no equivalent of webAuthenticationProxy), but registration via // Peach's own popup-driven flow works. if (detectSharedBrowser() === "firefox") { const ver = firefoxVersion(); if (Number.isFinite(ver) && ver >= 150) return true; } return false; } export const detectBrowser = detectSharedBrowser; function getBrowserLabel(): string { const ua = navigator.userAgent; if (/Firefox\/(\d+)/.test(ua)) { const version = ua.match(/Firefox\/(\d+)/)?.[1]; return version ? `Firefox ${version}` : "Firefox"; } const version = ua.match(/Chrome\/(\d+)/)?.[1]; return version ? `Chrome ${version}` : "Chromium"; } function pendingStorageKey(requestId: number): string { return `${PENDING_PREFIX}${requestId}`; } function randomRequestId(): number { // 53-bit crypto-random integer — unbrute-forceable, unlike the old // Date.now()+Math.random synthetic id (~10-20 bits). Fits a JS safe // integer so it round-trips through String/Number for the toast path. // (Audit 2026-08-20 #1/#10.) const u = crypto.getRandomValues(new Uint32Array(2)); return u[0] * 0x200000 + (u[1] >>> 11); } function originFromRpId(rpId: string): string { return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId) ? `http://${rpId}` : `https://${rpId}`; } /** * The clientDataJSON sent back to the RP must contain the page's actual * origin — not a synthesized one. `chrome.webAuthenticationProxy` does not * surface the requesting origin in CreateRequest/GetRequest, so we read it * from the active tab and validate that its hostname is a registrable * suffix match for the rpId. WebAuthn requires this relationship anyway, so * a mismatch indicates either a stale active tab or a hostile environment; * we fall back to https:// in that case to avoid silently signing the * wrong origin. * * Without this, a passkey created on https://myaccount.google.com gets * clientDataJSON.origin = "https://google.com", and Google's RP rejects the * attestation with "Something went wrong" because the origin hash doesn't * match what its own JS captured at create time. */ async function resolvePageOrigin(rpId: string): Promise { try { const tabs = await new Promise((resolve) => chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve) ); const url = tabs[0]?.url; if (url) { const parsed = new URL(url); const host = parsed.hostname.toLowerCase(); const rp = rpId.toLowerCase(); if (host === rp || host.endsWith(`.${rp}`)) { return parsed.origin; } } } catch { // fall through to rpId-derived origin } return originFromRpId(rpId); } async function setBadge(text: string): Promise { if (!chrome.action?.setBadgeText) return; await chrome.action.setBadgeText({ text }); if (text) { await chrome.action.setBadgeBackgroundColor({ color: "#E5805C" }); } } async function clearPendingRequest(requestId: number): Promise { if (chrome.storage.session) { await chrome.storage.session.remove(pendingStorageKey(requestId)); } await setBadge(""); } async function openApprovalPopup(): Promise { try { await chrome.action.openPopup(); } catch { await setBadge("PK"); } } /** * Some ceremonies — especially conditional-UI `navigator.credentials.get` * calls — get cancelled by Chrome before we finish processing. Calling * complete*Request on a cancelled requestId rejects with "Invalid * requestId". That is expected, not exceptional: swallow it. Re-throwing * would surface an uncaught promise rejection in the SW and cascade through * the catch wrappers in setupPasskeyProxy. */ function isInvalidRequestIdError(err: unknown): boolean { return err instanceof Error && /invalid requestid/i.test(err.message); } /** * Chrome throws "Missing GetResponseDetails.responseJson" when * completeGetRequest is called with no responseJson and no error (the * pass-through signal). The throw is a Chrome API quirk — the request IS * routed to the platform authenticator — but if we let it propagate, the * outer catch calls rejectGet, which sends a second completeGetRequest with * an error that cancels the pass-through and shows "Something went wrong" on * the page. Swallow it exactly as we swallow "Invalid requestId". */ function isMissingResponseJsonError(err: unknown): boolean { return err instanceof Error && /missing getresponsedetails/i.test(err.message); } async function rejectCreate( requestId: number, name: string, message = "" ): Promise { try { await chrome.webAuthenticationProxy.completeCreateRequest({ requestId, error: { name, message }, }); } catch (err) { if (isInvalidRequestIdError(err)) { return; } throw err; } } async function rejectGet( requestId: number, name: string, message = "" ): Promise { try { await chrome.webAuthenticationProxy.completeGetRequest({ requestId, error: { name, message }, }); } catch (err) { if (isInvalidRequestIdError(err)) { return; } throw err; } } export async function getPendingPasskeyRequest(): Promise { if (!chrome.storage.session) return null; // Firefox <132 / unsupported contexts const stored = (await chrome.storage.session.get(null)) as Record; const key = Object.keys(stored).find((item) => item.startsWith(PENDING_PREFIX)); if (!key) return null; const pending = stored[key]; return pending && typeof pending === "object" ? (pending as PendingPasskeyRequest) : null; } export async function resolvePendingPasskeyRequest( requestId: number, approved: boolean ): Promise { const pending = approvals.get(requestId); if (!pending) { // Either the request expired (Chrome) or this is a Firefox-driven // pending where the popup performs the navigator.credentials call // directly. Clear both possible session keys so getPendingPasskeyRequest() // doesn't surface stale state on the next popup open. await clearPendingRequest(requestId); if (chrome.storage.session) { await chrome.storage.session.remove(FIREFOX_PENDING_KEY); } return; } clearTimeout(pending.timeoutId); approvals.delete(requestId); await clearPendingRequest(requestId); pending.resolve(approved); } async function promptUserApproval(request: PendingPasskeyRequest): Promise { if (chrome.storage.session) { await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request }); } await openApprovalPopup(); return new Promise((resolve) => { const timeoutId = setTimeout(async () => { approvals.delete(request.requestId); await clearPendingRequest(request.requestId); resolve(false); }, APPROVAL_TIMEOUT_MS); approvals.set(request.requestId, { resolve, timeoutId }); }); } export async function getPasskeyProviderState(): Promise { const settings = await vault.ensureSettingsLoaded(); const supported = isPasskeySupported(); const enabled = settings.passkeyProviderEnabled === true; return { supported, enabled, attached: supported && attached, browserLabel: getBrowserLabel(), statusLabel: !supported ? "Interception unavailable" : attached ? "Active" : conflict ? "Another provider is active" : enabled ? "Inactive" : "Disabled", conflict, }; } async function attachPasskeyProxy(): Promise { if (!isPasskeySupported()) { attached = false; conflict = "This browser does not expose chrome.webAuthenticationProxy."; return; } // Firefox path: no chrome.webAuthenticationProxy exists. The popup-context // navigator.credentials creator is always ready once the user enables the // provider. Treat enabled = attached for state reporting purposes. if (detectSharedBrowser() === "firefox") { attached = true; conflict = undefined; return; } if (attached) return; try { await chrome.webAuthenticationProxy.attach(); attached = true; conflict = undefined; } catch (error) { attached = false; conflict = error instanceof Error ? error.message : "Another provider is active."; console.warn("[Peach passkey] proxy attach failed", { conflict }); } } async function detachPasskeyProxy(): Promise { if (!isPasskeySupported()) { attached = false; return; } if (detectSharedBrowser() === "firefox") { attached = false; conflict = undefined; return; } try { await chrome.webAuthenticationProxy.detach(); } catch { // Ignore detach failures; the extension may already be detached. } attached = false; conflict = undefined; } export async function setPasskeyProviderEnabled(enabled: boolean): Promise { await vault.updateSettings({ passkeyProviderEnabled: enabled }); if (enabled) { await attachPasskeyProxy(); } else { await detachPasskeyProxy(); } return getPasskeyProviderState(); } async function refreshPasskeyProxy(): Promise { const settings = await vault.ensureSettingsLoaded(); if (settings.passkeyProviderEnabled === true) { await attachPasskeyProxy(); return; } await detachPasskeyProxy(); } /** * Find the entry that already holds a passkey for the given RP, or the most * recently-modified login entry whose URL/additionalUrls match the RP — that * entry is the natural attach target when the user adds a passkey to a site * they already have a password for. Returns null when there's no candidate; * the caller creates a new entry in that case. */ function findAttachTarget(rpId: string): VaultEntry | null { const all = vault.getEntries(); const passkeyMatch = all.find( (entry) => entry.passkey?.rpId === rpId && !entry.deletedAt ); if (passkeyMatch) return passkeyMatch; const loginMatches = vault.queryDomain(rpId).filter((entry) => entry.type === "login" && !entry.passkey); if (loginMatches.length === 0) return null; // Pick the most recently modified candidate to be deterministic. return loginMatches.reduce((best, e) => (e.modifiedAt ?? 0) > (best.modifiedAt ?? 0) ? e : best ); } async function handleCreateRequest( request: chrome.webAuthenticationProxy.CreateRequest ): Promise { const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialCreationOptionsJSON; const rpId = options.rp.id; const rpName = options.rp.name ?? options.rp.id; const userName = options.user.name; const userDisplayName = options.user.displayName ?? options.user.name; if (!rpId) { console.warn("[Peach passkey] reject: missing rp.id"); await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID"); return; } // Capture the page origin NOW — before any popup opens. Once the extension // action popup fires, it can steal lastFocusedWindow so the tabs query // returns no results and falls back to https://. For sites where the // page origin is a subdomain of the rpId (e.g. myaccount.google.com vs // rpId google.com) that fallback produces the wrong origin and the RP // rejects the attestation with "Something went wrong". const pageOrigin = await resolvePageOrigin(rpId); if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) { console.warn("[Peach passkey] reject: user is not Pro"); await rejectCreate( request.requestId, "NotAllowedError", "Passkey creation requires Peach Pro." ); // Show the inline passkey gate so the user knows what happened and can upgrade. broadcastPasskeyProGate(); return; } if (!vault.isUnlocked()) { // Same wait-for-unlock pattern handleGetRequest uses: summon the inline // unlock prompt on the active tab so the user can unlock without leaving // the page, then hold the create request open until the vault unlocks or // the timeout fires. Immediately rejecting here means Google shows // "Something went wrong" the instant the user picks Peach. const tabs = await new Promise((resolve) => chrome.tabs.query({ active: true, lastFocusedWindow: true }, resolve) ); const activeTab = tabs[0]; if (activeTab?.id) { chrome.tabs.sendMessage(activeTab.id, { type: 'SUMMON_AUTOFILL', source: 'passkey-unlock', }).catch((err) => { console.warn("[Peach passkey] SUMMON_AUTOFILL failed, falling back to popup", err); void openApprovalPopup(); }); } else { await openApprovalPopup(); } const unlocked = await new Promise((resolve) => { const timeoutId = setTimeout(() => { unlockWaiters.delete(request.requestId); resolve(false); }, APPROVAL_TIMEOUT_MS); unlockWaiters.set(request.requestId, { resolve, timeoutId }); }); if (!unlocked || !vault.isUnlocked()) { console.warn("[Peach passkey] reject: unlock timeout"); await rejectCreate(request.requestId, "NotAllowedError", "Not unlocked in time."); return; } // Vault is now unlocked — fall through to approval. } const approved = await promptUserApproval({ requestId: request.requestId, type: "create", rpId, rpName, userName, userDisplayName, }); if (!approved) { console.warn("[Peach passkey] reject: user denied"); await rejectCreate(request.requestId, "NotAllowedError", "User denied request."); return; } const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []); if (!algorithm) { console.warn("[Peach passkey] reject: no supported algorithm", options.pubKeyCredParams); await rejectCreate(request.requestId, "NotSupportedError", "No supported algorithm offered."); return; } const credentialIdBytes = crypto.getRandomValues(new Uint8Array(32)); const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair( algorithm ); const authData = await buildAuthenticatorData({ rpId, flags: 0x4d, signCount: 0, credentialId: credentialIdBytes, publicKeyCose, }); const attestationObject = createAttestationObject(authData); const clientDataJSON = buildClientDataJSON( "webauthn.create", options.challenge, pageOrigin ); const passkey: PasskeyData = { rpId, rpName, credentialId: base64urlEncode(credentialIdBytes), publicKeyCose: base64urlEncode(publicKeyCose), privateKeyJwk, userHandle: options.user.id, userName, userDisplayName, algorithm, signCount: 0, aaguid: base64urlEncode(new Uint8Array(16)), backupEligible: true, backupState: false, browser: "chrome", transports: ["internal"], createdAt: Date.now(), }; // Attach to an existing login (same domain) when one exists; otherwise // create a fresh login entry that exists solely to hold the passkey. const target = findAttachTarget(rpId); if (target) { await vault.updateEntry(target.id, { passkey }); } else { await vault.addEntry({ type: "login", title: rpName, url: `https://${rpId}`, username: userName, passkey, }); } // Merge any same-domain TOTP-only entries into the passkey entry right now // so the user sees one consolidated entry rather than waiting until next // unlock for the decodePayload merge pass to run. await vault.consolidatePasskeyEntries(); try { await chrome.webAuthenticationProxy.completeCreateRequest({ requestId: request.requestId, responseJson: JSON.stringify({ id: passkey.credentialId, rawId: passkey.credentialId, type: "public-key", response: { clientDataJSON: base64urlEncode(clientDataJSON), authenticatorData: base64urlEncode(authData), attestationObject: base64urlEncode(attestationObject), transports: ["internal"], publicKey: base64urlEncode(publicKeySpki), publicKeyAlgorithm: algorithm, }, authenticatorAttachment: "platform", clientExtensionResults: {}, }), }); } catch (err) { if (isInvalidRequestIdError(err)) { return; } throw err; } } function broadcastPasskeyProGate(): void { chrome.tabs.query({}, (tabs) => { for (const tab of tabs) { if (tab.id !== undefined) { chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {}); } } }); } async function promptViaToast(request: PendingPasskeyRequest): Promise { if (chrome.storage.session) { await chrome.storage.session.set({ [pendingStorageKey(request.requestId)]: request }); } const tabs = await new Promise((resolve) => { chrome.tabs.query({ active: true, currentWindow: true }, resolve); }); const tab = tabs[0]; if (tab?.id) { chrome.tabs.sendMessage(tab.id, { type: 'SHOW_PASSKEY_TOAST', payload: { toastToken: crypto.randomUUID(), site: request.rpId, displayName: request.userDisplayName ?? request.userName, username: request.userName, requestId: String(request.requestId), }, }).catch(() => {}); } else { await openApprovalPopup(); } return new Promise((resolve) => { const timeoutId = setTimeout(async () => { approvals.delete(request.requestId); await clearPendingRequest(request.requestId); resolve(false); }, APPROVAL_TIMEOUT_MS); approvals.set(request.requestId, { resolve, timeoutId }); }); } async function handleGetRequest( request: chrome.webAuthenticationProxy.GetRequest ): Promise { const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON; if (!vault.isUnlocked()) { // Summon the inline unlock prompt on the active tab so the user can unlock // without leaving the page. Fall back to the popup if the content script // is unavailable (e.g. restricted URL, script not yet injected). const tabs = await new Promise((resolve) => chrome.tabs.query({ active: true, currentWindow: true }, resolve) ); const activeTab = tabs[0]; if (activeTab?.id) { chrome.tabs.sendMessage(activeTab.id, { type: 'SUMMON_AUTOFILL', source: 'passkey-unlock', }).catch(() => { void openApprovalPopup(); }); } else { await openApprovalPopup(); } // Hold the request open until the vault unlocks or the timeout fires. const unlocked = await new Promise((resolve) => { const timeoutId = setTimeout(() => { unlockWaiters.delete(request.requestId); resolve(false); }, APPROVAL_TIMEOUT_MS); unlockWaiters.set(request.requestId, { resolve, timeoutId }); }); if (!unlocked || !vault.isUnlocked()) { await rejectGet(request.requestId, "NotAllowedError", "Not unlocked in time."); return; } // Vault is now unlocked — fall through to authenticate. } if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) { await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro."); broadcastPasskeyProGate(); return; } const passkeyBearing = vault.getEntries().filter((entry): entry is VaultEntry & { passkey: PasskeyData } => entry.passkey !== undefined && !entry.deletedAt ); const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id); const match = passkeyBearing.find((entry) => { // PB-26-015: a credential-ID match must still belong to the requesting // RP — the assertion later signs with the stored rpId, so a foreign-RP // request must never select a credential from a different rpId. if (options.rpId && entry.passkey.rpId !== options.rpId) return false; if (allowedCredentialIds.length > 0) { return allowedCredentialIds.includes(entry.passkey.credentialId); } if (!options.rpId) return false; return entry.passkey.rpId === options.rpId; }); if (!match) { // No Peach passkey for this RP — pass through to the platform authenticator. // completeGetRequest with no error and no responseJson is the webAuthenticationProxy // signal for "I'm not handling this"; Chrome routes the request to the next // authenticator in the chain (the built-in browser passkey manager etc.). // Rejecting with NotAllowedError would propagate as an error to the page and // break RPs (e.g. Google) that issue a GET as part of their create-passkey flow. try { await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId }); } catch (err) { if (isInvalidRequestIdError(err)) return; if (isMissingResponseJsonError(err)) return; throw err; } return; } const credential = match.passkey; // Chrome's webAuthenticationProxy path requires Peach-held private key material. // Passkeys created on Firefox have no privateKeyJwk — pass through so Chrome // can route to a capable authenticator instead of surfacing an error to the page. if (!credential.privateKeyJwk) { try { await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId }); } catch (err) { if (isInvalidRequestIdError(err)) return; if (isMissingResponseJsonError(err)) return; throw err; } return; } const tabs = await new Promise((resolve) => { chrome.tabs.query({ active: true, currentWindow: true }, resolve); }); const approvalBinding = { tabId: tabs[0]?.id, origin: await resolvePageOrigin(credential.rpId), }; const approved = await promptViaToast({ requestId: request.requestId, type: "get", rpId: credential.rpId, rpName: credential.rpName, userName: credential.userName, userDisplayName: credential.userDisplayName, ...approvalBinding, }); if (!approved) { await rejectGet(request.requestId, "NotAllowedError", "User denied request."); return; } const nextSignCount = credential.signCount + 1; const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0); const authData = await buildAuthenticatorData({ rpId: credential.rpId, flags, signCount: nextSignCount, }); const clientDataJSON = buildClientDataJSON( "webauthn.get", options.challenge, await resolvePageOrigin(credential.rpId) ); const clientDataHash = new Uint8Array( await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON)) ); const signature = await signPasskeyAssertion( credential.privateKeyJwk, credential.algorithm, authData, clientDataHash ); await vault.updateEntry(match.id, { passkey: { ...credential, signCount: nextSignCount, lastUsedAt: Date.now(), }, }); const responseJson = JSON.stringify({ id: credential.credentialId, rawId: credential.credentialId, type: "public-key", response: { clientDataJSON: base64urlEncode(clientDataJSON), authenticatorData: base64urlEncode(authData), signature: base64urlEncode(signature), userHandle: credential.userHandle, }, authenticatorAttachment: "platform", clientExtensionResults: {}, }); try { await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId, responseJson, }); } catch (err) { if (isInvalidRequestIdError(err)) { return; } throw err; } } // ── Firefox path ──────────────────────────────────────────────────────── // // Firefox 150+ permits extensions to call navigator.credentials.create/get // from the popup context with their host-permitted RP IDs. Peach uses that // surface to register passkeys on behalf of the user. The page's own // navigator.credentials call is observed via the MAIN-world detector // (src/content/passkeys-firefox.ts) but never blocked or replaced; Peach's // flow is parallel and explicit (the user must engage with Peach's button). const FIREFOX_PENDING_KEY = "passkey_pending_firefox"; const FIREFOX_DECLINE_PREFIX = "peach_passkey_offer_declined:"; const FIREFOX_DECLINE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days export async function initiateFirefoxPasskeyCreate(args: { rpId: string; rpName: string; userName: string; userDisplayName?: string; }, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> { // The rpId must belong to the calling page's registrable domain; without // this, a page could initiate a ceremony for a foreign site and the // approval popup would display an attacker-chosen rpId. Origin is the // browser-attested sender origin from messages.ts, never page payload. // (Codex Security 2026-08-04, passkey-ceremony-approval sibling.) if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) { return { ok: false, error: "rpid_origin_mismatch" }; } if (!(await isProUser())) { broadcastPasskeyProGate(); return { ok: false, error: "passkey creation requires Peach Pro" }; } if (!vault.isUnlocked()) { await openApprovalPopup(); return { ok: false, error: "vault_locked" }; } if (!chrome.storage.session) { return { ok: false, error: "session storage unavailable" }; } const requestId = randomRequestId(); const challenge = base64urlEncode(crypto.getRandomValues(new Uint8Array(32))); const userHandle = base64urlEncode(crypto.getRandomValues(new Uint8Array(32))); const pending: PendingPasskeyRequest = { requestId, type: "create", rpId: args.rpId, rpName: args.rpName, userName: args.userName, userDisplayName: args.userDisplayName, browser: "firefox", challenge, userHandle, }; await chrome.storage.session.set({ [FIREFOX_PENDING_KEY]: pending }); await openApprovalPopup(); return { ok: true }; } export async function completeFirefoxPasskeyCreate(payload: { requestId: number; rpId: string; rpName: string; userName: string; userDisplayName?: string; userHandle: string; credentialId: string; publicKeyCose: string; algorithm: -7 | -8; aaguid?: string; transports?: string[]; backupEligible?: boolean; backupState?: boolean; }): Promise<{ ok: boolean; error?: string }> { if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" }; // Bind completion to the pending ceremony: requestId, rpId, challenge, and // userHandle are minted by the background at INITIATE time and only ever // leave extension context through the approval popup, so a page (or anything // replaying relayed messages) cannot synthesize a matching completion for an // rpId it does not own. (Codex Security 2026-08-04 MEDIUM, // broken-access-control.passkey-ceremony-approval.) const pending = chrome.storage.session ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as | PendingPasskeyRequest | undefined) : undefined; if ( !pending || pending.requestId !== payload.requestId || pending.rpId !== payload.rpId || pending.userHandle !== payload.userHandle ) { return { ok: false, error: "no_pending_request" }; } const passkey: PasskeyData = { rpId: payload.rpId, rpName: payload.rpName, credentialId: payload.credentialId, publicKeyCose: payload.publicKeyCose, userHandle: payload.userHandle, userName: payload.userName, userDisplayName: payload.userDisplayName ?? payload.userName, algorithm: payload.algorithm, signCount: 0, aaguid: payload.aaguid ?? base64urlEncode(new Uint8Array(16)), backupEligible: payload.backupEligible ?? false, backupState: payload.backupState ?? false, browser: "firefox", transports: payload.transports ?? ["internal"], createdAt: Date.now(), }; // Same attach-or-create flow as the Chrome path: prefer attaching to an // existing login entry for the same domain so the user ends up with one // entry per site, not two (a login + a passkey-only). const target = findAttachTarget(payload.rpId); if (target) { await vault.updateEntry(target.id, { passkey }); } else { await vault.addEntry({ type: "login", title: payload.rpName || payload.rpId, url: `https://${payload.rpId}`, username: payload.userName, passkey, }); } await vault.consolidatePasskeyEntries(); if (chrome.storage.session) { await chrome.storage.session.remove(FIREFOX_PENDING_KEY); } return { ok: true }; } const MAX_DECLINE_DOMAIN_LENGTH = 253; function normaliseDeclineDomain(domain: string): string | null { if (!domain || domain.length > MAX_DECLINE_DOMAIN_LENGTH) return null; try { const url = new URL(`https://${domain}`); return isValidRpId(url.hostname, url.origin) ? url.hostname : null; } catch { return null; } } function declineKey(domain: string): string { return `${FIREFOX_DECLINE_PREFIX}${domain}`; } export async function recordPasskeyOfferDecline(domain: string): Promise { const validDomain = normaliseDeclineDomain(domain); if (!validDomain) return; await chrome.storage.local.set({ [declineKey(validDomain)]: Date.now() }); } export async function isPasskeyOfferDeclined(domain: string): Promise { const validDomain = normaliseDeclineDomain(domain); if (!validDomain) return false; const key = declineKey(validDomain); const stored = (await chrome.storage.local.get(key)) as Record; const at = stored[key]; if (typeof at !== "number") return false; if (Date.now() - at > FIREFOX_DECLINE_TTL_MS) { await chrome.storage.local.remove(key); return false; } return true; } // ── Firefox content-script proxy path (v2.12.0) ──────────────────────── // // The MAIN-world monkey-patch in src/content/passkeys-firefox.ts replaces // navigator.credentials.create / .get on every Firefox page. It builds a // clientDataJSON whose `origin` is the PAGE's origin (not moz-extension://), // then hands every ceremony to the background via PASSKEY_HAS_CREDENTIALS, // PASSKEY_CREATE, and PASSKEY_GET. The handlers below own those messages. // // Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime flag // FIREFOX_USE_CONTENT_SCRIPT_PROXY in src/shared/passkey-flags.ts evaluates // to false and these handlers short-circuit. The full removal checklist is // in that file — the deletion is purely subtractive. interface FirefoxAllowCredential { id: string; type?: string; } interface FirefoxPubKeyCredParam { type?: string; alg: number; } export interface FirefoxPasskeyHasCredentialsPayload { rpId: string; allowCredentials: FirefoxAllowCredential[]; } export interface FirefoxPasskeyCreatePayload { rpId: string; rpName?: string; userId: string; userName: string; userDisplayName?: string; challenge: string; clientDataJSON: number[]; origin: string; pubKeyCredParams?: FirefoxPubKeyCredParam[]; authenticatorSelection?: Record; timeout?: number; attestation?: string; } export interface FirefoxPasskeyGetPayload { rpId: string; challenge: string; clientDataJSON: number[]; origin: string; allowCredentials: FirefoxAllowCredential[]; userVerification?: string; } interface FirefoxCreateCredentialResponse { type: "create"; id: string; clientDataJSON: number[]; attestationObject: string; authenticatorData: string; publicKey: string; algorithm: -7 | -8; } interface FirefoxGetCredentialResponse { type: "get"; id: string; clientDataJSON: number[]; authenticatorData: string; signature: string; userHandle: string; } type FirefoxPasskeyResult = | { ok: true; credential: T } | { ok: false; error: "pro_required" | "user_cancelled" | "no_credentials" | "vault_locked" | "not_applicable" | "internal_error"; message?: string }; function listPasskeyEntries(): Array { return vault.getEntries().filter( (entry): entry is VaultEntry & { passkey: PasskeyData } => entry.passkey !== undefined && !entry.deletedAt ); } /** Decode the `origin` field out of a WebAuthn clientDataJSON byte array. */ function clientDataJsonOrigin(bytes: number[] | undefined): string | null { if (!Array.isArray(bytes)) return null; try { const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown }; return typeof obj.origin === "string" ? obj.origin : null; } catch { return null; } } /** * Enforce the WebAuthn rpId<->origin relationship on the Firefox proxy path, * where Peach is the authenticator. The ONLY trustworthy origin is the one the * browser attested for the calling page (`callerOrigin`, derived from the * message sender in messages.ts) — NEVER `payload.origin`, which the page * controls and can set to match any rpId (SEV-1-A / audit CRITICAL). Also * refuse to sign a clientDataJSON whose embedded origin disagrees with the * attested origin, so a forged ceremony can't smuggle a foreign origin into * the signed bytes the RP verifies. */ function assertOriginOwnsRpId( rpId: string, callerOrigin: string | undefined, clientDataJSON?: number[], ): void { if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) { throw new DOMException( `SecurityError: relying party ID "${rpId}" is not a registrable domain ` + `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`, "SecurityError", ); } const cdjOrigin = clientDataJsonOrigin(clientDataJSON); if (cdjOrigin !== null && cdjOrigin !== callerOrigin) { throw new DOMException( `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` + `attested calling origin "${callerOrigin}".`, "SecurityError", ); } } export async function handlePasskeyHasCredentials( payload: FirefoxPasskeyHasCredentialsPayload, callerOrigin?: string, ): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> { if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) { return { ok: false, error: "not_applicable" }; } // Scope the existence check to the caller's own origin so an arbitrary page // cannot enumerate which sites the user holds passkeys for (audit MEDIUM). // A false answer is indistinguishable from "no credential" to the page. if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) { return { ok: true, hasCredentials: false }; } if (!vault.isUnlocked()) { // Cannot enumerate without the vault key. The MAIN-world proxy treats a // false answer as "fall through to the platform" — exactly the right // outcome when Peach is locked: the user gets the native UI instead. return { ok: true, hasCredentials: false }; } const passkeys = listPasskeyEntries().filter( (entry) => entry.passkey.rpId === payload.rpId ); if (passkeys.length === 0) return { ok: true, hasCredentials: false }; const allow = payload.allowCredentials ?? []; if (allow.length === 0) return { ok: true, hasCredentials: true }; const hasMatch = passkeys.some((entry) => allow.some((ac) => ac.id === entry.passkey.credentialId) ); return { ok: true, hasCredentials: hasMatch }; } /** * Approval prompt for the Firefox proxy ceremonies. Reuses the existing * pending-passkey channel (badge + popup + 60s timeout) — the same approval * UI Chrome ceremonies use, so the popup's State 11/12 view doesn't need * any new code path. The page-side proxy never sees the request id; the * background mints one and discards it after resolution. */ async function requestFirefoxApproval(args: { type: "create" | "get"; rpId: string; rpName?: string; userName: string; userDisplayName?: string; }): Promise { // Synthetic request id — the Firefox proxy path doesn't have a // chrome.webAuthenticationProxy request to tie back to. const requestId = randomRequestId(); return promptUserApproval({ requestId, type: args.type, rpId: args.rpId, rpName: args.rpName ?? args.rpId, userName: args.userName, userDisplayName: args.userDisplayName, }); } export async function handleFirefoxPasskeyCreate( payload: FirefoxPasskeyCreatePayload, callerOrigin?: string, ): Promise> { if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) { return { ok: false, error: "not_applicable" }; } // WebAuthn §7.1 step 6 — the requested rpId must be owned by the page's // browser-ATTESTED origin (threaded in as callerOrigin from the message // sender), NOT the page-supplied payload.origin which an attacker controls. // On the Firefox proxy path Peach is the authenticator and must enforce this // before minting anything (audit CRITICAL — origin spoofing). assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON); if (!(await isProUser())) { broadcastPasskeyProGate(); return { ok: false, error: "pro_required" }; } if (!vault.isUnlocked()) { await openApprovalPopup(); return { ok: false, error: "vault_locked" }; } const algorithm = pickPasskeyAlgorithm(payload.pubKeyCredParams ?? []); if (!algorithm) { return { ok: false, error: "internal_error", message: "No supported algorithm offered" }; } const approved = await requestFirefoxApproval({ type: "create", rpId: payload.rpId, rpName: payload.rpName, userName: payload.userName, userDisplayName: payload.userDisplayName, }); if (!approved) return { ok: false, error: "user_cancelled" }; const { bytes: credentialIdBytes, base64url: credentialId } = generateCredentialId(); const { publicKeyCose, publicKeySpki, privateKeyJwk } = await generatePasskeyKeypair(algorithm); // 0x4d = UP|UV|BE|AT. Peach passkeys are synced, so BE is true on both // registration paths and on later assertions. const authenticatorData = await buildAuthenticatorData({ rpId: payload.rpId, flags: 0x4d, signCount: 0, credentialId: credentialIdBytes, publicKeyCose, }); const attestationObject = createAttestationObject(authenticatorData); const passkey: PasskeyData = { rpId: payload.rpId, rpName: payload.rpName ?? payload.rpId, credentialId, publicKeyCose: base64urlEncode(publicKeyCose), privateKeyJwk, userHandle: payload.userId, userName: payload.userName, userDisplayName: payload.userDisplayName ?? payload.userName, algorithm, signCount: 0, aaguid: base64urlEncode(new Uint8Array(16)), backupEligible: true, backupState: false, browser: "firefox", transports: ["internal"], createdAt: Date.now(), }; const target = findAttachTarget(payload.rpId); if (target) { await vault.updateEntry(target.id, { passkey }); } else { await vault.addEntry({ type: "login", title: payload.rpName || payload.rpId, url: `https://${payload.rpId}`, username: payload.userName, passkey, }); } await vault.consolidatePasskeyEntries(); return { ok: true, credential: { type: "create", id: credentialId, // The content script already encoded clientDataJSON with the page // origin; round-trip the exact bytes back so the RP sees the same // hash it computes locally. clientDataJSON: payload.clientDataJSON, attestationObject: base64urlEncode(attestationObject), authenticatorData: base64urlEncode(authenticatorData), publicKey: base64urlEncode(publicKeySpki), algorithm, }, }; } export async function handleFirefoxPasskeyGet( payload: FirefoxPasskeyGetPayload, callerOrigin?: string, ): Promise> { if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) { return { ok: false, error: "not_applicable" }; } // WebAuthn §7.2 step 8 — the rpId must be owned by the page's browser-attested // origin (callerOrigin from the message sender), NOT the page-supplied // payload.origin. Both rpId and payload.origin are attacker-controlled, so // validating one against the other is no protection; a cross-origin page must // not be able to coax Peach into signing an assertion scoped to another site // (audit CRITICAL — assertion forgery). assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON); if (!(await isProUser())) { broadcastPasskeyProGate(); return { ok: false, error: "pro_required" }; } if (!vault.isUnlocked()) { await openApprovalPopup(); return { ok: false, error: "vault_locked" }; } const allow = payload.allowCredentials ?? []; const candidates = listPasskeyEntries().filter((entry) => { if (entry.passkey.rpId !== payload.rpId) return false; if (allow.length === 0) return true; return allow.some((ac) => ac.id === entry.passkey.credentialId); }); if (candidates.length === 0) return { ok: false, error: "no_credentials" }; // Most-recently-used wins when several credentials match. A future // multi-account selector goes here; for now this matches the Chrome path's // behavior for the common one-passkey-per-rp case. candidates.sort((a, b) => (b.passkey.lastUsedAt ?? 0) - (a.passkey.lastUsedAt ?? 0)); const match = candidates[0]!; const credential = match.passkey; if (!credential.privateKeyJwk) { // Pre-v2.12.0 Firefox passkeys delegated to the platform authenticator // — no Peach-held private key to sign with. The MAIN-world proxy treats // no_credentials as a fall-through, which routes to the platform UI. return { ok: false, error: "no_credentials" }; } const approved = await requestFirefoxApproval({ type: "get", rpId: credential.rpId, rpName: credential.rpName, userName: credential.userName, userDisplayName: credential.userDisplayName, }); if (!approved) return { ok: false, error: "user_cancelled" }; const nextSignCount = credential.signCount + 1; // 0x05 = UP|UV. backup bits track whatever was set at registration time // (consistent with the Chrome path). const flags = 0x05 | (credential.backupEligible ? 0x08 : 0) | (credential.backupState ? 0x10 : 0); const authenticatorData = await buildAuthenticatorData({ rpId: credential.rpId, flags, signCount: nextSignCount, }); // The content script supplied clientDataJSON bytes built with the page // origin. SHA-256 those bytes verbatim — never re-serialize, or the hash // diverges from what the RP computes. const clientDataBytes = new Uint8Array(payload.clientDataJSON); const clientDataHash = new Uint8Array( await crypto.subtle.digest("SHA-256", clientDataBytes) ); const signature = await signPasskeyAssertion( credential.privateKeyJwk, credential.algorithm, authenticatorData, clientDataHash ); await vault.updateEntry(match.id, { passkey: { ...credential, signCount: nextSignCount, lastUsedAt: Date.now(), }, }); return { ok: true, credential: { type: "get", id: credential.credentialId, clientDataJSON: payload.clientDataJSON, authenticatorData: base64urlEncode(authenticatorData), signature: base64urlEncode(signature), userHandle: credential.userHandle, }, }; } export async function setupPasskeyProxy(): Promise { if (!isPasskeySupported()) { return; } // Firefox doesn't expose chrome.webAuthenticationProxy listeners. The // popup-context credential creator is the entry point; nothing to wire here. if (detectSharedBrowser() === "firefox") { await refreshPasskeyProxy(); return; } if (!listenersRegistered) { chrome.webAuthenticationProxy.onIsUvpaaRequest.addListener((requestInfo) => { // Peach is a user-verifying platform authenticator whenever the proxy // is attached. The lock state is irrelevant here — the create/get // handlers prompt for unlock when needed. Returning false while locked // makes Google (and any RP that checks isUvpaa as a precondition) show // "A passkey can't be created on this device" before the user even // gets a chance to start the ceremony. void chrome.webAuthenticationProxy.completeIsUvpaaRequest({ requestId: requestInfo.requestId, isUvpaa: true, }); }); chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => { void handleCreateRequest(requestInfo).catch(async (error) => { // "Invalid requestId" means Chrome already cancelled — handlers will // short-circuit, but if it propagates out from a still-in-flight branch, // don't double-complete. if (isInvalidRequestIdError(error)) { return; } console.error("Peach passkey create failed", error); await rejectCreate( requestInfo.requestId, "UnknownError", error instanceof Error ? error.message : "Passkey creation failed." ); }); }); chrome.webAuthenticationProxy.onGetRequest.addListener((requestInfo) => { void handleGetRequest(requestInfo).catch(async (error) => { if (isInvalidRequestIdError(error)) { return; } console.error("Peach passkey get failed", error); await rejectGet( requestInfo.requestId, "UnknownError", error instanceof Error ? error.message : "Passkey authentication failed." ); }); }); chrome.webAuthenticationProxy.onRequestCanceled.addListener((requestId) => { // Release any unlock waiter parked for this request. const waiter = unlockWaiters.get(requestId); if (waiter) { clearTimeout(waiter.timeoutId); waiter.resolve(false); unlockWaiters.delete(requestId); } void resolvePendingPasskeyRequest(requestId, false); }); listenersRegistered = true; } await refreshPasskeyProxy(); }