import { isProUser } from "./license"; import * as vault from "./vault"; import { base64urlDecode, base64urlEncode, buildAuthenticatorData, buildClientDataJSON, createAttestationObject, generatePasskeyKeypair, pickPasskeyAlgorithm, signPasskeyAssertion, } from "./passkey-crypto"; import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser"; 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 } >(); 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 originFromRpId(rpId: string): string { return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(rpId) ? `http://${rpId}` : `https://${rpId}`; } async function setBadge(text: string): Promise { if (!chrome.action?.setBadgeText) return; await chrome.action.setBadgeText({ text }); if (text) { await chrome.action.setBadgeBackgroundColor({ color: "#D96830" }); } } 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"); } } async function rejectCreate( requestId: number, name: string, message = "" ): Promise { await chrome.webAuthenticationProxy.completeCreateRequest({ requestId, error: { name, message }, }); } async function rejectGet( requestId: number, name: string, message = "" ): Promise { await chrome.webAuthenticationProxy.completeGetRequest({ requestId, error: { name, message }, }); } 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."; } } 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) { await rejectCreate(request.requestId, "NotSupportedError", "Missing RP ID"); return; } if (!PASSKEY_CREATE_E2E_BYPASS && !(await isProUser())) { 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()) { await openApprovalPopup(); await rejectCreate(request.requestId, "NotAllowedError", "Unlock Peach first."); return; } const approved = await promptUserApproval({ requestId: request.requestId, type: "create", rpId, rpName, userName, userDisplayName, }); if (!approved) { await rejectCreate(request.requestId, "NotAllowedError", "User denied request."); return; } const algorithm = pickPasskeyAlgorithm(options.pubKeyCredParams ?? []); if (!algorithm) { 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, originFromRpId(rpId)); 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: false, 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, }); } 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: {}, }), }); } 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 handleGetRequest( request: chrome.webAuthenticationProxy.GetRequest ): Promise { const options = JSON.parse(request.requestDetailsJson) as PublicKeyCredentialRequestOptionsJSON; if (!vault.isUnlocked()) { await openApprovalPopup(); await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first."); return; } if (!(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) => { if (allowedCredentialIds.length > 0) { return allowedCredentialIds.includes(entry.passkey.credentialId); } if (!options.rpId) return false; return entry.passkey.rpId === options.rpId; }); if (!match) { await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found."); return; } const credential = match.passkey; // Chrome's webAuthenticationProxy path requires Peach-held private key // material to sign the assertion. Firefox-created passkeys delegate to the // platform authenticator — the browser will route to the platform UI // automatically when we reject, which is the right outcome. if (!credential.privateKeyJwk) { await rejectGet( request.requestId, "NotAllowedError", "This passkey was created on Firefox; sign in via the platform authenticator." ); return; } const approved = await promptUserApproval({ requestId: request.requestId, type: "get", rpId: credential.rpId, rpName: credential.rpName, userName: credential.userName, userDisplayName: credential.userDisplayName, }); 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, originFromRpId(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: {}, }); console.log("[Peach passkey] completing get assertion", { rpId: credential.rpId, credentialId: credential.credentialId, algorithm: credential.algorithm, signCount: nextSignCount, flags: flags.toString(16), signatureBytes: signature.length, userHandle: credential.userHandle, }); await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId, responseJson, }); } // ── 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; }): Promise<{ ok: boolean; error?: string }> { 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 = Date.now(); 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" }; 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, }); } if (chrome.storage.session) { await chrome.storage.session.remove(FIREFOX_PENDING_KEY); } return { ok: true }; } export async function cancelFirefoxPasskeyCreate(): Promise { if (chrome.storage.session) { await chrome.storage.session.remove(FIREFOX_PENDING_KEY); } } function declineKey(domain: string): string { return `${FIREFOX_DECLINE_PREFIX}${domain}`; } export async function recordPasskeyOfferDecline(domain: string): Promise { if (!domain) return; await chrome.storage.local.set({ [declineKey(domain)]: Date.now() }); } export async function isPasskeyOfferDeclined(domain: string): Promise { if (!domain) return false; const key = declineKey(domain); 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; } 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) => { void chrome.webAuthenticationProxy.completeIsUvpaaRequest({ requestId: requestInfo.requestId, isUvpaa: vault.isUnlocked(), }); }); chrome.webAuthenticationProxy.onCreateRequest.addListener((requestInfo) => { void handleCreateRequest(requestInfo).catch(async (error) => { 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) => { 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) => { void resolvePendingPasskeyRequest(requestId, false); }); listenersRegistered = true; } await refreshPasskeyProxy(); }