import { isProUser } from "./license"; import * as vault from "./vault"; import { base64urlDecode, base64urlEncode, buildAuthenticatorData, buildClientDataJSON, createAttestationObject, generatePasskeyKeypair, pickPasskeyAlgorithm, signPasskeyAssertion, } from "./passkey-crypto"; import type { PasskeyEntry, 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 { return typeof chrome.webAuthenticationProxy?.attach === "function"; } export function detectBrowser(): "chrome" | "firefox" | "unknown" { if (typeof chrome !== "undefined" && chrome.webAuthenticationProxy) { return "chrome"; } try { const url = chrome.runtime?.getURL?.("") ?? ""; if (url.startsWith("moz-extension://")) return "firefox"; } catch { /* extension context unavailable */ } return "unknown"; } 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) { await clearPendingRequest(requestId); 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; } 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; } 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(); } function passkeyEntries(entries: VaultEntry[]): PasskeyEntry[] { return entries.filter((entry): entry is PasskeyEntry => entry.type === "passkey"); } 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 entry: PasskeyEntry = { id: crypto.randomUUID(), type: "passkey", title: rpName, 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(), modifiedAt: Date.now(), }; await vault.addEntry(entry); await chrome.webAuthenticationProxy.completeCreateRequest({ requestId: request.requestId, responseJson: JSON.stringify({ id: entry.credentialId, rawId: entry.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 entries = passkeyEntries(vault.getEntries()); const allowedCredentialIds = (options.allowCredentials ?? []).map((descriptor) => descriptor.id); const match = entries.find((entry) => { if (allowedCredentialIds.length > 0) { return allowedCredentialIds.includes(entry.credentialId); } if (!options.rpId) return false; return entry.rpId === options.rpId; }); if (!match) { await rejectGet(request.requestId, "NotAllowedError", "No matching passkey found."); return; } const approved = await promptUserApproval({ requestId: request.requestId, type: "get", rpId: match.rpId, rpName: match.rpName, userName: match.userName, userDisplayName: match.userDisplayName, }); if (!approved) { await rejectGet(request.requestId, "NotAllowedError", "User denied request."); return; } const nextSignCount = (match.signCount ?? 0) + 1; const flags = 0x05 | (match.backupEligible ? 0x08 : 0) | (match.backupState ? 0x10 : 0); const authData = await buildAuthenticatorData({ rpId: match.rpId, flags, signCount: nextSignCount, }); const clientDataJSON = buildClientDataJSON("webauthn.get", options.challenge, originFromRpId(match.rpId)); const clientDataHash = new Uint8Array( await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON)) ); const signature = await signPasskeyAssertion( match.privateKeyJwk, match.algorithm, authData, clientDataHash ); await vault.updateEntry(match.id, { signCount: nextSignCount, lastUsedAt: Date.now(), }); await chrome.webAuthenticationProxy.completeGetRequest({ requestId: request.requestId, responseJson: JSON.stringify({ id: match.credentialId, rawId: match.credentialId, type: "public-key", response: { clientDataJSON: base64urlEncode(clientDataJSON), authenticatorData: base64urlEncode(authData), signature: base64urlEncode(signature), userHandle: match.userHandle, }, authenticatorAttachment: "platform", clientExtensionResults: {}, }), }); } export async function setupPasskeyProxy(): Promise { if (!isPasskeySupported()) 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(); }