/** * passkey-firefox-proxy.test.ts — covers the v2.12.0 Firefox content-script * proxy path. We mock isProUser (license verification needs real Ed25519 * keys we don't ship in tests) and exercise the three handlers via the * messages dispatcher. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto"; import type { Message, MessageResponse } from "../shared/types"; vi.mock("./license", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, isProUser: vi.fn(async () => true), }; }); // FIREFOX_USE_CONTENT_SCRIPT_PROXY is computed at module init time from the // presence of chrome.webAuthenticationProxy + the moz-extension:// origin. // The test harness installs a chrome mock without webAuthenticationProxy and // stubs runtime.getURL to return a moz-extension URL — so the flag evaluates // to true under the same conditions Firefox would see in the wild. vi.mock("../shared/passkey-flags", () => ({ FIREFOX_USE_CONTENT_SCRIPT_PROXY: true, })); type StorageShape = Record; type MessageListener = ( msg: unknown, sender: chrome.runtime.MessageSender, sendResponse: (response: MessageResponse) => void ) => boolean; describe("firefox passkey proxy handlers", () => { let storage: StorageShape; let sessionStorage: StorageShape; let listener: MessageListener; beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); storage = { peach_settings: { breachMonitoring: false } }; sessionStorage = {}; const tabs = [{ id: 1 }]; const chromeMock = { runtime: { id: "peach-extension", getURL: vi.fn((_path: string) => "moz-extension://abcd/"), onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }), }, sendMessage: vi.fn(async () => ({ ok: true })), }, tabs: { query: vi.fn((_q: unknown, callback?: (t: Array<{ id?: number }>) => void) => { if (typeof callback === "function") { callback(tabs); return; } return Promise.resolve(tabs); }), sendMessage: vi.fn(async () => ({ ok: true })), }, storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === "string") return { [key]: storage[key] }; return key.reduce>((r, k) => { r[k] = storage[k]; return r; }, {}); }), set: vi.fn(async (next: Record) => Object.assign(storage, next)), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const k of list) delete storage[k]; }), }, session: { get: vi.fn(async (key?: string | string[]) => { if (!key) return sessionStorage; if (typeof key === "string") return { [key]: sessionStorage[key] }; return key.reduce>((r, k) => { r[k] = sessionStorage[k]; return r; }, {}); }), set: vi.fn(async (next: Record) => Object.assign(sessionStorage, next)), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const k of list) delete sessionStorage[k]; }), clear: vi.fn(async () => { sessionStorage = {}; }), }, }, action: { setBadgeText: vi.fn(async () => {}), setBadgeBackgroundColor: vi.fn(async () => {}), openPopup: vi.fn(async () => {}), }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, scripting: { executeScript: vi.fn(async () => []) }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false); const messages = await import("./messages"); messages.setupMessageHandler(); const vault = await import("./vault"); await vault.ensureSettingsLoaded(); }); afterEach(() => { vi.unstubAllGlobals(); }); // senderUrl models the browser-attested page origin the relay's message // carries. Defaults to github.com so the existing github.com ceremonies pass // their rpId<->origin check; cross-origin tests pass an attacker URL. async function dispatch(message: Message, senderUrl = "https://github.com/login"): Promise { return await new Promise((resolve) => { const open = listener(message, { id: "peach-extension", url: senderUrl, origin: new URL(senderUrl).origin }, (r) => resolve(r)); expect(open).toBe(true); }); } // Auto-approve the next ceremony by resolving the pending request as // approved as soon as the badge is set. Avoids depending on the popup. async function autoApprove() { const passkey = await import("./passkey"); const wait = async () => { for (let i = 0; i < 50; i += 1) { const pending = await passkey.getPendingPasskeyRequest(); if (pending) { await passkey.resolvePendingPasskeyRequest(pending.requestId, true); return; } await new Promise((r) => setTimeout(r, 10)); } }; void wait(); } it("PASSKEY_HAS_CREDENTIALS returns false when vault has no passkey for rpId", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await expect( dispatch({ type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] }, }) ).resolves.toMatchObject({ ok: true, hasCredentials: false }); }); it("PASSKEY_HAS_CREDENTIALS returns true when allowCredentials is empty and rpId matches", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const generated = await generatePasskeyKeypair(-7); await vault.addEntry({ type: "login", title: "GitHub", passkey: { rpId: "github.com", rpName: "GitHub", credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), publicKeyCose: base64urlEncode(generated.publicKeyCose), privateKeyJwk: generated.privateKeyJwk, userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), userName: "june", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: true, backupState: false, createdAt: Date.now(), }, }); await expect( dispatch({ type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] }, }) ).resolves.toMatchObject({ ok: true, hasCredentials: true }); }); it("PASSKEY_HAS_CREDENTIALS matches by credentialId in allowCredentials", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const generated = await generatePasskeyKeypair(-7); const credentialId = base64urlEncode(crypto.getRandomValues(new Uint8Array(32))); await vault.addEntry({ type: "login", title: "GitHub", passkey: { rpId: "github.com", rpName: "GitHub", credentialId, publicKeyCose: base64urlEncode(generated.publicKeyCose), privateKeyJwk: generated.privateKeyJwk, userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))), userName: "june", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: true, backupState: false, createdAt: Date.now(), }, }); await expect( dispatch({ type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [{ id: "different-id", type: "public-key" }], }, }) ).resolves.toMatchObject({ ok: true, hasCredentials: false }); await expect( dispatch({ type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [{ id: credentialId, type: "public-key" }], }, }) ).resolves.toMatchObject({ ok: true, hasCredentials: true }); }); it("PASSKEY_HAS_CREDENTIALS returns hasCredentials:false when vault is locked", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); vault.lock(); await expect( dispatch({ type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] }, }) ).resolves.toMatchObject({ ok: true, hasCredentials: false }); }); it("PASSKEY_CREATE rejects a cross-origin rpId without minting a credential (SEV-1-A)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); autoApprove(); // evil.com (attested sender) requests a credential scoped to google.com. const res = await dispatch({ type: "PASSKEY_CREATE", payload: { rpId: "google.com", rpName: "Google", userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))), userName: "june", userDisplayName: "June", challenge: "x", clientDataJSON: Array.from(new TextEncoder().encode("{}")), origin: "https://evil.com", pubKeyCredParams: [{ alg: -7, type: "public-key" }], }, }, "https://evil.com/attack"); expect(res.ok).toBe(false); expect(String(res.error)).toContain("SecurityError"); // Crucially, nothing was written to the vault. expect(vault.getEntries().find((e) => e.passkey?.rpId === "google.com")).toBeUndefined(); }); it("PASSKEY_GET rejects a cross-origin rpId (SEV-1-A)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const res = await dispatch({ type: "PASSKEY_GET", payload: { rpId: "google.com", challenge: "x", clientDataJSON: Array.from(new TextEncoder().encode("{}")), origin: "https://evil.com", allowCredentials: [], }, }, "https://evil.com/attack"); expect(res.ok).toBe(false); expect(String(res.error)).toContain("SecurityError"); }); it("PASSKEY_GET refuses a FORGED payload.origin that matches rpId when the attested sender origin differs (audit CRITICAL — origin spoofing)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); // The attacker self-consistently forges rpId + payload.origin + clientDataJSON // origin all to victimbank.com — exactly the bypass that defeated the old // isValidRpId(payload.rpId, payload.origin) check. The real frame is evil.com. const forgedClientData = Array.from(new TextEncoder().encode( JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://victimbank.com", crossOrigin: false }), )); const res = await dispatch({ type: "PASSKEY_GET", payload: { rpId: "victimbank.com", origin: "https://victimbank.com", challenge: "x", clientDataJSON: forgedClientData, allowCredentials: [], }, }, "https://evil.com/attack"); expect(res.ok).toBe(false); expect(String(res.error)).toContain("SecurityError"); }); it("PASSKEY_HAS_CREDENTIALS is scoped to the caller origin (no cross-site enumeration)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ type: "login", title: "GitHub", url: "https://github.com", username: "june", passkey: { rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk", privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey, userHandle: "u", userName: "june", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1, }, }); // A page on evil.com may not learn the user holds a github.com passkey. await expect(dispatch({ type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] }, }, "https://evil.com/")).resolves.toMatchObject({ ok: true, hasCredentials: false }); // github.com itself sees its own credential. await expect(dispatch({ type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] }, }, "https://github.com/")).resolves.toMatchObject({ ok: true, hasCredentials: true }); }); it("GET_PASSKEY_FOR_DOMAIN is scoped to the caller origin (no username leak cross-site)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ type: "login", title: "GitHub", url: "https://github.com", username: "june", passkey: { rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk", privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey, userHandle: "u", userName: "june@example.com", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1, }, }); await expect(dispatch({ type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" }, }, "https://evil.com/")).resolves.toMatchObject({ ok: true, data: null }); const own = await dispatch({ type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" }, }, "https://github.com/"); expect((own.data as { userName?: string } | null)?.userName).toBe("june@example.com"); }); it("MARK_PASSKEY_BANNER_SHOWN is origin-bound and surfaces via bannerShown (PB-26-203)", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); await vault.addEntry({ type: "login", title: "GitHub", url: "https://github.com", username: "june", passkey: { rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk", privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey, userHandle: "u", userName: "june@example.com", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1, }, }); // evil.com cannot mark github.com's flag: no write, no error leak. await expect(dispatch({ type: "MARK_PASSKEY_BANNER_SHOWN", payload: { domain: "github.com" }, }, "https://evil.com/")).resolves.toMatchObject({ ok: true }); const before = await dispatch({ type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" }, }, "https://github.com/"); expect((before.data as { bannerShown?: boolean } | null)?.bannerShown).toBe(false); // github.com marks its own flag; the read path surfaces it. await expect(dispatch({ type: "MARK_PASSKEY_BANNER_SHOWN", payload: { domain: "github.com" }, }, "https://github.com/")).resolves.toMatchObject({ ok: true }); const after = await dispatch({ type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" }, }, "https://github.com/"); expect((after.data as { bannerShown?: boolean } | null)?.bannerShown).toBe(true); }); it("handleFirefoxPasskeyCreate throws a SecurityError directly on rpId mismatch", async () => { const { handleFirefoxPasskeyCreate } = await import("./passkey"); await expect( handleFirefoxPasskeyCreate({ rpId: "google.com", rpName: "Google", userId: "dGVzdA", userName: "june", challenge: "x", clientDataJSON: [], origin: "https://evil.com", pubKeyCredParams: [{ alg: -7, type: "public-key" }], }) ).rejects.toThrow("SecurityError"); }); it("PASSKEY_GET returns no_credentials when nothing matches", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const clientDataJSON = Array.from(new TextEncoder().encode( JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false }) )); await expect( dispatch({ type: "PASSKEY_GET", payload: { rpId: "github.com", challenge: "x", clientDataJSON, origin: "https://github.com", allowCredentials: [], }, }) ).resolves.toMatchObject({ ok: false, error: "no_credentials" }); }); it("PASSKEY_CREATE registers a passkey and increments signCount on PASSKEY_GET", async () => { const vault = await import("./vault"); await vault.createVault("correct-pass-12345"); const challenge = "challenge-base64url"; const origin = "https://github.com"; const clientDataCreate = Array.from(new TextEncoder().encode( JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false }) )); autoApprove(); const createRes = await dispatch({ type: "PASSKEY_CREATE", payload: { rpId: "github.com", rpName: "GitHub", userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))), userName: "june", userDisplayName: "June", challenge, clientDataJSON: clientDataCreate, origin, pubKeyCredParams: [{ alg: -7, type: "public-key" }], }, }); expect(createRes).toMatchObject({ ok: true }); const cred = (createRes as any).credential; expect(cred.type).toBe("create"); expect(typeof cred.id).toBe("string"); expect(cred.algorithm).toBe(-7); const stored = vault.getEntries().find((e) => e.passkey?.rpId === "github.com"); expect(stored).toBeDefined(); expect(stored!.passkey!.signCount).toBe(0); expect(stored!.passkey!.browser).toBe("firefox"); const clientDataGet = Array.from(new TextEncoder().encode( JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false }) )); autoApprove(); const getRes = await dispatch({ type: "PASSKEY_GET", payload: { rpId: "github.com", challenge, clientDataJSON: clientDataGet, origin, allowCredentials: [{ id: cred.id, type: "public-key" }], }, }); expect(getRes).toMatchObject({ ok: true }); const assertion = (getRes as any).credential; expect(assertion.type).toBe("get"); expect(typeof assertion.signature).toBe("string"); expect(typeof assertion.authenticatorData).toBe("string"); const after = vault.getEntries().find((e) => e.passkey?.rpId === "github.com"); expect(after!.passkey!.signCount).toBe(1); expect(after!.passkey!.lastUsedAt).toBeDefined(); }); });