/** * passkeys-firefox.ts — MAIN-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 page's main world (per * `manifest.firefox.json`'s `world: "MAIN"`) and replaces * `navigator.credentials.create` and `navigator.credentials.get`. * * Architecture: * - This file owns the page-side patch. It cannot import anything that * touches extension APIs (different world). All extension-bound calls * go through window.postMessage to the relay content script * (passkeys-firefox-relay.ts) running in the extension world. * - The relay forwards each request to the background service worker via * chrome.runtime.sendMessage and posts the response back. * - The background owns the vault, runs the Pro gate, shows the approval * UI, performs the cryptographic operation, and returns a serialized * PublicKeyCredential payload. * * 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 — never against * `moz-extension://`. This is what makes the assertion validate. * * Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime * flag in src/shared/passkey-flags.ts evaluates to false and this entire * file (along with the relay) gets deleted. See that file for the full * checklist. */ export function installPeachPasskeyProxy(): void { const w = window as unknown as Record; if (w.__peachPasskeyProxyInstalled) return; w.__peachPasskeyProxyInstalled = true; const credentials = navigator.credentials; if (!credentials || typeof credentials.create !== "function" || typeof credentials.get !== "function") { return; } const originalCreate = credentials.create.bind(credentials); const originalGet = credentials.get.bind(credentials); credentials.create = async function patchedCreate( options?: CredentialCreationOptions ): Promise { if (!options?.publicKey) return originalCreate(options); 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 sendToBackground({ 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") { // The user denied via Peach. Match the WebAuthn contract — this is // the same exception the platform throws when the user dismisses // the OS prompt. throw new DOMException( "The operation either timed out or was not allowed.", "NotAllowedError" ); } if (error === "pro_required") { // Notify the extension-world script to show the inline Pro gate, then // hand the call back to the platform so the user is not stranded. 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. Peach is not in a // position to fulfill this ceremony right now and the user should // still be able to register / authenticate against the RP. return originalCreate(options); } return buildPublicKeyCredential(response.credential); }; credentials.get = async function patchedGet( options?: CredentialRequestOptions ): Promise { if (!options?.publicKey) return originalGet(options); 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. If the vault has no match // for this rpId, hand the ceremony to the platform — no point prompting. const has = await sendToBackground({ 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 sendToBackground({ 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 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); } return buildPublicKeyCredential(response.credential); }; // ── helpers ──────────────────────────────────────────────────────────── 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)); } /** * postMessage round-trip with a per-call UUID. The relay (extension-world * content script) listens for PEACH_PASSKEY_REQUEST and posts back * PEACH_PASSKEY_RESPONSE with the same id. A 60s timeout matches the * background's approval window — outliving the user's decision time * without leaking the listener forever. */ function sendToBackground(message: object): Promise { return new Promise((resolve) => { const id = generateRequestId(); const handler = (event: MessageEvent) => { if (event.source !== window) return; const data = event.data; if (!data || typeof data !== "object") return; if ((data as { type?: string }).type !== "PEACH_PASSKEY_RESPONSE") return; if ((data as { id?: string }).id !== id) return; cleanup(); resolve((data as { payload?: unknown }).payload); }; const timer = setTimeout(() => { cleanup(); resolve({ ok: false, error: "timeout" }); }, 60_000); function cleanup() { window.removeEventListener("message", handler); clearTimeout(timer); } window.addEventListener("message", handler); window.postMessage({ type: "PEACH_PASSKEY_REQUEST", id, payload: message }, "*"); }); } function generateRequestId(): string { if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); const bytes = crypto.getRandomValues(new Uint8Array(16)); return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } /** * Reconstruct a PublicKeyCredential-shaped object from the background's * serialized response. The shape must satisfy what RPs read off the * credential — id, rawId, response.{clientDataJSON, attestationObject | * authenticatorData, signature, userHandle}, plus the helper getters that * some RPs call (getPublicKey, getTransports, etc.). * * `instanceof PublicKeyCredential` will be false because we cannot * construct a real one from page script. RPs that perform that check * directly will reject — but no major RP does, because the original * Spec/JS-API path on iOS Safari produced non-instance credentials for * years. RPs read the duck-typed shape. */ function buildPublicKeyCredential(cred: any): 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); return { id: cred.id, rawId: credentialId, type: "public-key", authenticatorAttachment: "platform", 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", response: { clientDataJSON: bufferToBase64url(clientDataJSON), attestationObject: cred.attestationObject, authenticatorData: cred.authenticatorData, publicKey: cred.publicKey, publicKeyAlgorithm: cred.algorithm ?? -7, transports: ["internal"], }, authenticatorAttachment: "platform", clientExtensionResults: {}, }), } as unknown as PublicKeyCredential; } const authenticatorData = base64urlToBuffer(cred.authenticatorData); const signature = base64urlToBuffer(cred.signature); const userHandle = cred.userHandle ? base64urlToBuffer(cred.userHandle) : null; return { id: cred.id, rawId: credentialId, type: "public-key", authenticatorAttachment: "platform", response: { clientDataJSON, authenticatorData, signature, userHandle, } as AuthenticatorAssertionResponse, getClientExtensionResults: () => ({}), toJSON: () => ({ id: cred.id, rawId: cred.id, type: "public-key", response: { clientDataJSON: bufferToBase64url(clientDataJSON), authenticatorData: cred.authenticatorData, signature: cred.signature, userHandle: cred.userHandle ?? null, }, authenticatorAttachment: "platform", clientExtensionResults: {}, }), } as unknown as PublicKeyCredential; } } installPeachPasskeyProxy();