/** * firefox-passkey.ts — popup-context WebAuthn caller for the Firefox path. * * Runs inside the extension popup (the only context, on Firefox 150+, where * `navigator.credentials.create` accepts a host-permitted RP ID supplied by * the extension). The background handler `INITIATE_FIREFOX_PASSKEY_CREATE` * stashes a pending request in session storage; this module reads it, * triggers the platform authenticator, and sends the resulting credential * metadata back to the background via `COMPLETE_FIREFOX_PASSKEY_CREATE`. * * The private key never leaves the platform authenticator (Touch ID, * Windows Hello, security key). Peach stores only public metadata. */ import { sendMessage } from "./App"; import type { PendingPasskeyRequest } from "../shared/types"; function base64urlEncode(bytes: Uint8Array | ArrayBuffer): string { const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let str = ""; for (let i = 0; i < u8.length; i++) str += String.fromCharCode(u8[i]!); return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function base64urlDecode(value: string): ArrayBuffer { const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4); const bin = atob(padded); const buf = new ArrayBuffer(bin.length); const view = new Uint8Array(buf); for (let i = 0; i < bin.length; i++) view[i] = bin.charCodeAt(i); return buf; } export async function createFirefoxPasskey( pending: PendingPasskeyRequest ): Promise<{ ok: boolean; error?: string }> { if (!pending.challenge || !pending.userHandle) { return { ok: false, error: "Firefox pending request missing challenge or userHandle" }; } const challenge = base64urlDecode(pending.challenge); const userHandle = base64urlDecode(pending.userHandle); let credential: PublicKeyCredential; try { const result = await navigator.credentials.create({ publicKey: { challenge, rp: { id: pending.rpId, name: pending.rpName }, user: { id: userHandle, name: pending.userName, displayName: pending.userDisplayName ?? pending.userName, }, pubKeyCredParams: [ { type: "public-key", alg: -7 }, // ES256 { type: "public-key", alg: -8 }, // EdDSA ], authenticatorSelection: { residentKey: "preferred", userVerification: "preferred", }, timeout: 60_000, }, }); if (!result || !(result instanceof PublicKeyCredential)) { return { ok: false, error: "no credential returned" }; } credential = result; } catch (err) { if (err instanceof DOMException && err.name === "NotAllowedError") { return { ok: false, error: "user cancelled" }; } return { ok: false, error: err instanceof Error ? err.message : "credential creation failed" }; } const response = credential.response as AuthenticatorAttestationResponse; const algorithm = response.getPublicKeyAlgorithm() === -8 ? -8 : -7; const publicKeySpki = response.getPublicKey(); const transports = typeof response.getTransports === "function" ? response.getTransports() : ["internal"]; const completion = await sendMessage({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { requestId: pending.requestId, rpId: pending.rpId, rpName: pending.rpName, userName: pending.userName, userDisplayName: pending.userDisplayName, userHandle: pending.userHandle, credentialId: credential.id, // SPKI-formatted public key, not strictly COSE. Stored for reference; // Peach does not sign assertions for Firefox passkeys (the platform // authenticator handles that on every use). publicKeyCose: publicKeySpki ? base64urlEncode(publicKeySpki) : "", algorithm, transports, backupEligible: false, backupState: false, }, }); if (!completion?.ok) { return { ok: false, error: completion?.error ?? "vault store failed" }; } return { ok: true }; }