/** * passkeys-firefox.ts — isolated-world WebAuthn interception for Firefox. * * Firefox does not yet expose `chrome.webAuthenticationProxy`, so Peach * cannot intercept passkey ceremonies the way it does on Chrome. Until that * API ships, this script runs in the extension's isolated world (per * `manifest.firefox.json`'s content_scripts) and replaces * `navigator.credentials.create` and `navigator.credentials.get` using * Firefox's privileged `exportFunction()` API. * * Architecture: * - This file is the ONLY content script for Firefox passkeys. It runs in * the isolated world and has direct access to `chrome.runtime.sendMessage`. * - `exportFunction()` installs wrapper functions into the page's main * world. The wrappers retain their privileged closure — the page can * call them but cannot forge their return values or race a separate * response event (PB-26-209). * - The background owns the vault, runs the Pro gate, shows the approval * UI, performs the cryptographic operation, and returns a serialized * PublicKeyCredential payload. * * Security property: the page cannot forge the privileged function's * returned credential or race a separate public response event. There is * no `window.postMessage` response bus — the wrapper returns the result * directly from its privileged closure. * * Origin guarantee: clientDataJSON is built here with `origin = * window.location.origin`. The RP hashes the same bytes the background * signed, and verifies origin against its registered RP. The background * additionally derives the browser-attested origin from `MessageSender` * and enforces rpId<->origin — page-supplied origin fields are untrusted. * * If `exportFunction` is unavailable (unsupported Firefox version), the * provider is disabled and falls through to native WebAuthn. Do not retain * an unauthenticated response bus for feature parity. * * Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime * flag in src/shared/passkey-flags.ts evaluates to false and this entire * file gets deleted. See that file for the full checklist. */ // Firefox WebExtension privileged APIs — available in content scripts but // not in jsdom. The declarations let TypeScript compile without a DOM lib // that includes them. declare function exportFunction( func: (...args: any[]) => any, targetScope: any, options?: { defineAs?: string }, ): (...args: any[]) => any; declare function cloneInto( obj: T, targetScope: any, options?: { cloneFunctions?: boolean }, ): T; export function installPeachPasskeyProxy(): void { const w = window as unknown as Record; if (w.__peachPasskeyProxyInstalled) return; w.__peachPasskeyProxyInstalled = true; // Capture sendMessage at installation time — the wrapper closure must not // re-look it up on a page-writable global (PB-26-209). chrome.runtime is // extension-only (not page-writable), but capturing the reference means // later replacement of chrome.runtime.sendMessage has no effect. const sendMessage = chrome.runtime.sendMessage; // exportFunction / cloneInto are Firefox-only. If unavailable (unsupported // Firefox version, or a non-Firefox test context), disable the provider // and fall through to native WebAuthn. if (typeof exportFunction !== "function" || typeof cloneInto !== "function") { return; } const credentials = navigator.credentials; if (!credentials || typeof credentials.create !== "function" || typeof credentials.get !== "function") { return; } // Access the page-world credentials object through the Xray boundary. const pageCredentials = (credentials as unknown as { wrappedJSObject?: CredentialsContainer }).wrappedJSObject ?? credentials; // Save originals for fall-through. const originalCreate = credentials.create.bind(credentials); const originalGet = credentials.get.bind(credentials); function pagePromise(task: () => Promise): Promise { return new window.Promise((resolve, reject) => { task().then(resolve, reject); }); } exportFunction( function patchedCreate(options?: CredentialCreationOptions): Promise { return pagePromise(async () => { if (!options?.publicKey) return originalCreate(options); try { const publicKey = options.publicKey; const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge)); const clientDataJSON = encodeJsonBytes({ type: "webauthn.create", challenge, origin: window.location.origin, crossOrigin: false, }); const userId = bufferToBase64url(toArrayBuffer(publicKey.user.id)); const response = await sendMessage({ type: "PASSKEY_CREATE", payload: { rpId: publicKey.rp.id ?? window.location.hostname, rpName: publicKey.rp.name, userId, userName: publicKey.user.name, userDisplayName: publicKey.user.displayName, challenge, clientDataJSON: Array.from(clientDataJSON), origin: window.location.origin, pubKeyCredParams: publicKey.pubKeyCredParams, authenticatorSelection: publicKey.authenticatorSelection, timeout: publicKey.timeout, attestation: publicKey.attestation, }, }); if (!response?.ok) { const error = response?.error; if (error === "user_cancelled") { throw new window.DOMException( "The operation either timed out or was not allowed.", "NotAllowedError", ); } if (error === "pro_required") { window.dispatchEvent( new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }), ); return originalCreate(options); } // vault_locked / no_credentials / not_applicable / timeout / unknown: // fall through to the platform authenticator. return originalCreate(options); } const cred = validateCreateCredential(response.credential); return buildPublicKeyCredential(cred); } catch (err) { if (err instanceof DOMException && err.name === "NotAllowedError") throw err; // Validation failure or unexpected error — fail closed, fall through. return originalCreate(options); } }); }, pageCredentials, { defineAs: "create" }, ); exportFunction( function patchedGet(options?: CredentialRequestOptions): Promise { return pagePromise(async () => { if (!options?.publicKey) return originalGet(options); try { const publicKey = options.publicKey; const rpId = publicKey.rpId ?? window.location.hostname; const allowCredentials = (publicKey.allowCredentials ?? []).map((c) => ({ id: bufferToBase64url(toArrayBuffer(c.id)), type: c.type, })); // Cheap pre-check before showing any Peach UI. const has = await sendMessage({ type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId, allowCredentials }, }); if (!has?.ok || !has.hasCredentials) { return originalGet(options); } const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge)); const clientDataJSON = encodeJsonBytes({ type: "webauthn.get", challenge, origin: window.location.origin, crossOrigin: false, }); const response = await sendMessage({ type: "PASSKEY_GET", payload: { rpId, challenge, clientDataJSON: Array.from(clientDataJSON), origin: window.location.origin, allowCredentials, userVerification: publicKey.userVerification, }, }); if (!response?.ok) { const error = response?.error; if (error === "user_cancelled") { throw new window.DOMException( "The operation either timed out or was not allowed.", "NotAllowedError", ); } if (error === "pro_required") { window.dispatchEvent( new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }), ); return originalGet(options); } return originalGet(options); } const cred = validateGetCredential(response.credential); return buildPublicKeyCredential(cred); } catch (err) { if (err instanceof DOMException && err.name === "NotAllowedError") throw err; return originalGet(options); } }); }, pageCredentials, { defineAs: "get" }, ); // ── validation ──────────────────────────────────────────────────────── const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; function isBase64url(value: unknown): value is string { return typeof value === "string" && value.length > 0 && BASE64URL_RE.test(value); } function isByteArray(value: unknown): value is number[] { return ( Array.isArray(value) && value.length > 0 && value.every((b) => typeof b === "number" && Number.isInteger(b) && b >= 0 && b <= 255) ); } function validateCreateCredential(cred: unknown): CreateCredential { if (!cred || typeof cred !== "object") throw new ValidationError("missing credential"); const c = cred as Record; if (c.type !== "create") throw new ValidationError("wrong credential type"); if (!isBase64url(c.id)) throw new ValidationError("id must be base64url"); if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array"); if (!isBase64url(c.attestationObject)) throw new ValidationError("attestationObject must be base64url"); if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url"); if (!isBase64url(c.publicKey)) throw new ValidationError("publicKey must be base64url"); if (c.algorithm !== -7 && c.algorithm !== -8) throw new ValidationError("unsupported algorithm"); return c as unknown as CreateCredential; } function validateGetCredential(cred: unknown): GetCredential { if (!cred || typeof cred !== "object") throw new ValidationError("missing credential"); const c = cred as Record; if (c.type !== "get") throw new ValidationError("wrong credential type"); if (!isBase64url(c.id)) throw new ValidationError("id must be base64url"); if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array"); if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url"); if (!isBase64url(c.signature)) throw new ValidationError("signature must be base64url"); if (c.userHandle !== undefined && c.userHandle !== null && !isBase64url(c.userHandle)) { throw new ValidationError("userHandle must be base64url"); } return c as unknown as GetCredential; } // ── helpers ──────────────────────────────────────────────────────────── class ValidationError extends Error { constructor(message: string) { super(message); this.name = "ValidationError"; } } interface CreateCredential { type: "create"; id: string; clientDataJSON: number[]; attestationObject: string; authenticatorData: string; publicKey: string; algorithm: -7 | -8; } interface GetCredential { type: "get"; id: string; clientDataJSON: number[]; authenticatorData: string; signature: string; userHandle: string | null; } function toArrayBuffer(view: BufferSource): ArrayBuffer { if (view instanceof ArrayBuffer) return view; const u8 = view as ArrayBufferView; const out = new Uint8Array(u8.byteLength); out.set(new Uint8Array(u8.buffer, u8.byteOffset, u8.byteLength)); return out.buffer; } function bufferToBase64url(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let str = ""; for (let i = 0; i < bytes.length; i += 1) str += String.fromCharCode(bytes[i]!); return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function base64urlToBuffer(value: string): ArrayBuffer { const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd( Math.ceil(value.length / 4) * 4, "=", ); const raw = atob(padded); const bytes = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i); return bytes.buffer; } function encodeJsonBytes(value: unknown): Uint8Array { return new TextEncoder().encode(JSON.stringify(value)); } /** * Reconstruct a PublicKeyCredential-shaped object from the background's * serialized response. `cloneInto` makes the object and its methods * callable from page-world — without it the page would see isolated-world * objects through an Xray wrapper and could not call the getter methods. * * `instanceof PublicKeyCredential` will be false because we cannot * construct a real one from content script. RPs that perform that check * directly will reject — but no major RP does. RPs read the duck-typed * shape. */ function buildPublicKeyCredential(cred: CreateCredential | GetCredential): PublicKeyCredential { const credentialId = base64urlToBuffer(cred.id); const clientDataJSON = new Uint8Array(cred.clientDataJSON).buffer; if (cred.type === "create") { const attestationObject = base64urlToBuffer(cred.attestationObject); const authenticatorData = base64urlToBuffer(cred.authenticatorData); const publicKey = base64urlToBuffer(cred.publicKey); const obj = { id: cred.id, rawId: credentialId, type: "public-key" as const, authenticatorAttachment: "platform" as const, response: { clientDataJSON, attestationObject, getTransports: () => ["internal"], getAuthenticatorData: () => authenticatorData, getPublicKey: () => publicKey, getPublicKeyAlgorithm: () => cred.algorithm ?? -7, } as AuthenticatorAttestationResponse, getClientExtensionResults: () => ({}), toJSON: () => ({ id: cred.id, rawId: cred.id, type: "public-key" as const, response: { clientDataJSON: bufferToBase64url(clientDataJSON), attestationObject: cred.attestationObject, authenticatorData: cred.authenticatorData, publicKey: cred.publicKey, publicKeyAlgorithm: cred.algorithm ?? -7, transports: ["internal"], }, authenticatorAttachment: "platform" as const, clientExtensionResults: {}, }), } as unknown as PublicKeyCredential; return cloneInto(obj, window, { cloneFunctions: true }); } const authenticatorData = base64urlToBuffer(cred.authenticatorData); const signature = base64urlToBuffer(cred.signature); const userHandle = cred.userHandle ? base64urlToBuffer(cred.userHandle) : null; const obj = { id: cred.id, rawId: credentialId, type: "public-key" as const, authenticatorAttachment: "platform" as const, response: { clientDataJSON, authenticatorData, signature, userHandle, } as AuthenticatorAssertionResponse, getClientExtensionResults: () => ({}), toJSON: () => ({ id: cred.id, rawId: cred.id, type: "public-key" as const, response: { clientDataJSON: bufferToBase64url(clientDataJSON), authenticatorData: cred.authenticatorData, signature: cred.signature, userHandle: cred.userHandle ?? null, }, authenticatorAttachment: "platform" as const, clientExtensionResults: {}, }), } as unknown as PublicKeyCredential; return cloneInto(obj, window, { cloneFunctions: true }); } } installPeachPasskeyProxy();