// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; /** * PB-26-209: The forgeable window.postMessage relay is replaced by an * exportFunction-based privileged bridge. These tests prove the security * properties without a real Firefox: * * 1. A forged PEACH_PASSKEY_RESPONSE has no listener and no effect. * 2. Malformed extension responses fail closed / fall through before * credential construction. * 3. The bridge captures sendMessage at install time and does not * re-look it up on a page-writable global. * 4. No PEACH_PASSKEY_RESPONSE or response-bearing window.postMessage * remains in production code. */ // ── stubs for Firefox privileged APIs ───────────────────────────────────── // exportFunction installs a wrapper into a target scope; in jsdom we just // call the function directly and assign it to the target. const exportedFns = new Map any>(); function stubExportFunction( func: (...args: any[]) => any, _targetScope: any, options?: { defineAs?: string }, ): (...args: any[]) => any { const name = options?.defineAs; if (name) { exportedFns.set(name, func); // Also assign to navigator.credentials for realism if (_targetScope && _targetScope !== globalThis) { _targetScope[name] = func; } } return func; } function stubCloneInto(obj: T, _targetScope: any, _options?: { cloneFunctions?: boolean }): T { return obj; } // ── chrome mock ──────────────────────────────────────────────────────────── let mockSendMessage: ReturnType; function setupChromeMock() { mockSendMessage = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" }); vi.stubGlobal("chrome", { runtime: { id: "peach-extension", sendMessage: mockSendMessage, getURL: vi.fn(() => "moz-extension://abcd/"), }, }); } // ── helpers ──────────────────────────────────────────────────────────────── function base64url(bytes: Uint8Array): string { let str = ""; for (const b of bytes) str += String.fromCharCode(b); return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function makeCreateOptions(rpId = "github.com"): CredentialCreationOptions { return { publicKey: { rp: { id: rpId, name: "GitHub" }, user: { id: new Uint8Array(16), name: "june", displayName: "June" }, challenge: new Uint8Array(16), pubKeyCredParams: [{ alg: -7, type: "public-key" }], }, } as CredentialCreationOptions; } function makeGetOptions(rpId = "github.com"): CredentialRequestOptions { return { publicKey: { rpId, challenge: new Uint8Array(16), allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }], }, } as CredentialRequestOptions; } function validCreateCredential() { return { type: "create", id: base64url(new Uint8Array(32)), clientDataJSON: Array.from(new TextEncoder().encode(JSON.stringify({ type: "webauthn.create", challenge: "x", origin: "https://github.com", crossOrigin: false }))), attestationObject: base64url(new Uint8Array(64)), authenticatorData: base64url(new Uint8Array(37)), publicKey: base64url(new Uint8Array(64)), algorithm: -7, }; } function validGetCredential() { return { type: "get", id: base64url(new Uint8Array(32)), clientDataJSON: Array.from(new TextEncoder().encode(JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false }))), authenticatorData: base64url(new Uint8Array(37)), signature: base64url(new Uint8Array(64)), userHandle: base64url(new Uint8Array(16)), }; } // ── tests ────────────────────────────────────────────────────────────────── describe("PB-26-209: exportFunction privileged bridge", () => { beforeEach(async () => { vi.resetModules(); setupChromeMock(); exportedFns.clear(); // Stub Firefox privileged APIs vi.stubGlobal("exportFunction", stubExportFunction); vi.stubGlobal("cloneInto", stubCloneInto); // Reset the install guard so each test re-installs the proxy. delete (window as any).__peachPasskeyProxyInstalled; // jsdom doesn't have navigator.credentials by default — install a // native stub that the bridge will replace via exportFunction. const nativeCreate = vi.fn(async () => null); const nativeGet = vi.fn(async () => null); Object.defineProperty(navigator, "credentials", { value: { create: nativeCreate, get: nativeGet }, configurable: true, }); await import("./passkeys-firefox"); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("no forgeable response channel", () => { it("exports non-async wrappers so Firefox can return page-scope promises", () => { expect(exportedFns.get("create")?.constructor.name).toBe("Function"); expect(exportedFns.get("get")?.constructor.name).toBe("Function"); }); it("a forged PEACH_PASSKEY_RESPONSE with a correct-former UUID has no listener and no effect", async () => { // The old relay listened for window "message" events of type // PEACH_PASSKEY_RESPONSE. The new bridge must not. const postSpy = vi.spyOn(window, "postMessage"); // Dispatch a forged response — it should be a no-op. const forged = new MessageEvent("message", { data: { type: "PEACH_PASSKEY_RESPONSE", id: "00000000-0000-0000-0000-000000000000", payload: { ok: true, credential: validCreateCredential() }, }, source: window, }); window.dispatchEvent(forged); // No response was posted back — there is no relay. // The spy only tracks outgoing postMessage calls from the bridge. // The bridge should never call postMessage for passkey traffic. const passkeyPostCalls = postSpy.mock.calls.filter( ([data]) => { const d = data as { type?: string }; return d?.type === "PEACH_PASSKEY_REQUEST" || d?.type === "PEACH_PASSKEY_RESPONSE"; }, ); expect(passkeyPostCalls).toHaveLength(0); postSpy.mockRestore(); }); it("production code has no PEACH_PASSKEY_RESPONSE message listener", async () => { // Read the source file and verify no forgeable response channel remains. const fs = await import("node:fs"); const path = await import("node:path"); const source = fs.readFileSync( path.resolve(process.cwd(), "src/content/passkeys-firefox.ts"), "utf-8", ); expect(source).not.toContain("PEACH_PASSKEY_RESPONSE"); expect(source).not.toContain("PEACH_PASSKEY_REQUEST"); // No window.postMessage call (the forgeable response bus). expect(source).not.toMatch(/window\.postMessage\s*\(/); }); }); describe("malformed responses fail closed", () => { it("create: falls through to original when credential.id is not base64url", async () => { mockSendMessage.mockResolvedValueOnce({ ok: true, credential: { ...validCreateCredential(), id: "!!!not-base64url!!!" }, }); const result = navigator.credentials.create(makeCreateOptions()); // Should fall through, not throw — and not construct a credential // from malformed data. await expect(result).resolves.toBeNull(); }); it("create: falls through when credential type is wrong", async () => { mockSendMessage.mockResolvedValueOnce({ ok: true, credential: { ...validCreateCredential(), type: "get" }, }); await expect( navigator.credentials.create(makeCreateOptions()), ).resolves.toBeNull(); }); it("create: falls through when clientDataJSON is missing", async () => { const cred = validCreateCredential(); delete (cred as any).clientDataJSON; mockSendMessage.mockResolvedValueOnce({ ok: true, credential: cred }); await expect( navigator.credentials.create(makeCreateOptions()), ).resolves.toBeNull(); }); it("create: falls through when attestationObject is not base64url", async () => { mockSendMessage.mockResolvedValueOnce({ ok: true, credential: { ...validCreateCredential(), attestationObject: "not valid!" }, }); await expect( navigator.credentials.create(makeCreateOptions()), ).resolves.toBeNull(); }); it("create: falls through when algorithm is unsupported", async () => { mockSendMessage.mockResolvedValueOnce({ ok: true, credential: { ...validCreateCredential(), algorithm: -999 }, }); await expect( navigator.credentials.create(makeCreateOptions()), ).resolves.toBeNull(); }); it("get: falls through when signature is not base64url", async () => { // PASSKEY_HAS_CREDENTIALS must return true for the flow to reach GET mockSendMessage .mockResolvedValueOnce({ ok: true, hasCredentials: true }) .mockResolvedValueOnce({ ok: true, credential: { ...validGetCredential(), signature: "!!!invalid!!!" }, }); await expect( navigator.credentials.get(makeGetOptions()), ).resolves.toBeNull(); }); it("get: falls through when authenticatorData is not base64url", async () => { mockSendMessage .mockResolvedValueOnce({ ok: true, hasCredentials: true }) .mockResolvedValueOnce({ ok: true, credential: { ...validGetCredential(), authenticatorData: "" }, }); await expect( navigator.credentials.get(makeGetOptions()), ).resolves.toBeNull(); }); it("get: falls through when credential is null", async () => { mockSendMessage .mockResolvedValueOnce({ ok: true, hasCredentials: true }) .mockResolvedValueOnce({ ok: true, credential: null }); await expect( navigator.credentials.get(makeGetOptions()), ).resolves.toBeNull(); }); }); describe("privileged bridge captures sendMessage at install time", () => { it("wrapper closure uses the captured sendMessage, not a re-lookup", async () => { // The wrapper should have captured sendMessage at install time. // Even if we overwrite chrome.runtime.sendMessage after install, // the wrapper should still use the original. const original = mockSendMessage; const replacement = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" }); vi.stubGlobal("chrome", { runtime: { id: "peach-extension", sendMessage: replacement, getURL: vi.fn(() => "moz-extension://abcd/") }, }); // Trigger a create call — it should use the captured original, not the replacement. mockSendMessage = original; await navigator.credentials.create(makeCreateOptions()); // The replacement should NOT have been called. expect(replacement).not.toHaveBeenCalled(); }); }); describe("valid responses produce credentials", () => { it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => { mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() }); const cred = await navigator.credentials.create(makeCreateOptions()); expect(cred).not.toBeNull(); expect((cred as any).type).toBe("public-key"); expect((cred as any).id).toBeDefined(); expect((cred as any).rawId).toBeDefined(); expect((cred as any).response).toBeDefined(); expect((cred as any).response.attestationObject).toBeDefined(); }); it("get: returns a PublicKeyCredential-shaped object for a valid response", async () => { mockSendMessage .mockResolvedValueOnce({ ok: true, hasCredentials: true }) .mockResolvedValueOnce({ ok: true, credential: validGetCredential() }); const cred = await navigator.credentials.get(makeGetOptions()); expect(cred).not.toBeNull(); expect((cred as any).type).toBe("public-key"); expect((cred as any).response.signature).toBeDefined(); expect((cred as any).response.authenticatorData).toBeDefined(); }); it("create: throws NotAllowedError on user_cancelled", async () => { mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" }); await expect( navigator.credentials.create(makeCreateOptions()), ).rejects.toMatchObject({ name: "NotAllowedError" }); }); it("get: throws NotAllowedError on user_cancelled", async () => { mockSendMessage .mockResolvedValueOnce({ ok: true, hasCredentials: true }) .mockResolvedValueOnce({ ok: false, error: "user_cancelled" }); await expect( navigator.credentials.get(makeGetOptions()), ).rejects.toMatchObject({ name: "NotAllowedError" }); }); it("create: falls through on vault_locked", async () => { mockSendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" }); await expect( navigator.credentials.create(makeCreateOptions()), ).resolves.toBeNull(); }); it("get: falls through on no_credentials after has-credentials pre-check", async () => { mockSendMessage .mockResolvedValueOnce({ ok: true, hasCredentials: true }) .mockResolvedValueOnce({ ok: false, error: "no_credentials" }); await expect( navigator.credentials.get(makeGetOptions()), ).resolves.toBeNull(); }); it("get: falls through when has-credentials returns false", async () => { mockSendMessage.mockResolvedValueOnce({ ok: true, hasCredentials: false }); // Should NOT send a PASSKEY_GET await navigator.credentials.get(makeGetOptions()); const calls = mockSendMessage.mock.calls.map((c) => (c[0] as { type: string }).type); expect(calls).not.toContain("PASSKEY_GET"); }); }); describe("origin handling", () => { it("create: sends window.location.origin in the request, not a page-supplied value", async () => { mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() }); await navigator.credentials.create(makeCreateOptions()); const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } }; expect(call.payload.origin).toBe(window.location.origin); // clientDataJSON should also contain the same origin const clientData = JSON.parse(new TextDecoder().decode(new Uint8Array(call.payload.clientDataJSON))); expect(clientData.origin).toBe(window.location.origin); }); it("get: sends window.location.origin in the request", async () => { mockSendMessage .mockResolvedValueOnce({ ok: true, hasCredentials: true }) .mockResolvedValueOnce({ ok: true, credential: validGetCredential() }); await navigator.credentials.get(makeGetOptions()); const getCall = mockSendMessage.mock.calls.find( (c) => (c[0] as { type: string }).type === "PASSKEY_GET", ); const payload = (getCall![0] as { payload: { origin: string } }).payload; expect(payload.origin).toBe(window.location.origin); }); }); describe("exportFunction unavailable", () => { it("falls through to native when exportFunction is not available", async () => { vi.resetModules(); // Re-stub exportFunction as undefined — simulates unsupported Firefox. vi.stubGlobal("exportFunction", undefined); vi.stubGlobal("cloneInto", undefined); const nativeCreate = vi.fn(async () => null); Object.defineProperty(navigator, "credentials", { value: { create: nativeCreate, get: vi.fn(async () => null) }, configurable: true, }); await import("./passkeys-firefox"); // The native create should be unchanged (not patched) await navigator.credentials.create(makeCreateOptions()); expect(nativeCreate).toHaveBeenCalled(); // sendMessage should never be called expect(mockSendMessage).not.toHaveBeenCalled(); }); }); });