// @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; } let pageScopeValues = new WeakSet(); function stubCloneInto(obj: T, _targetScope: unknown, _options?: { cloneFunctions?: boolean }): T { function clone(value: unknown): unknown { if (typeof value === "function") { return (...args: unknown[]) => { const result: unknown = Reflect.apply(value, undefined, args); if (result && typeof result === "object" && !pageScopeValues.has(result)) { return new Proxy(result, { get() { throw new Error("Permission denied to access privileged object"); }, has() { throw new Error("Permission denied to access privileged object"); }, ownKeys() { throw new Error("Permission denied to access privileged object"); }, }); } return result; }; } if (!value || typeof value !== "object") return value; const copied = value instanceof ArrayBuffer ? value.slice(0) : Array.isArray(value) ? value.map(clone) : Object.fromEntries(Object.entries(value).map(([key, child]) => [key, clone(child)])); pageScopeValues.add(copied); return copied; } return clone(obj) as T; } // ── 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", () => { let nativeGet: ReturnType; beforeEach(async () => { vi.resetModules(); setupChromeMock(); exportedFns.clear(); pageScopeValues = new WeakSet(); // 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); 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("get: leaves conditional requests and their cancellation signal with Firefox", async () => { const controller = new AbortController(); const options: CredentialRequestOptions = { ...makeGetOptions(), mediation: "conditional", signal: controller.signal, }; mockSendMessage .mockResolvedValueOnce({ ok: true, hasCredentials: true }) .mockResolvedValueOnce({ ok: true, credential: validGetCredential() }); await expect(navigator.credentials.get(options)).resolves.toBeNull(); expect(nativeGet).toHaveBeenCalledWith(options); expect(mockSendMessage).not.toHaveBeenCalled(); }); it.each([ "new Uint8Array([1, 2, 3, 4]).buffer", "new Uint8Array(new Uint8Array([9, 1, 2, 3, 4, 9]).buffer, 1, 4)", ])("get: preserves page-realm buffers and view offsets (%s)", async (expression) => { const { runInNewContext } = await import("node:vm"); const challenge: BufferSource = runInNewContext(expression); const credentialId: ArrayBuffer = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer"); expect(challenge instanceof ArrayBuffer).toBe(false); mockSendMessage .mockResolvedValueOnce({ ok: true, hasCredentials: true }) .mockResolvedValueOnce({ ok: true, credential: validGetCredential() }); await navigator.credentials.get({ publicKey: { challenge, rpId: "github.com", allowCredentials: [{ id: credentialId, type: "public-key" }] }, }); const request = mockSendMessage.mock.calls.find(([message]) => message.type === "PASSKEY_GET")?.[0] as { payload: { challenge: string; clientDataJSON: number[]; allowCredentials: { id: string }[] }; }; expect(request.payload.challenge).toBe("AQIDBA"); expect(request.payload.allowCredentials).toEqual([{ id: "BQYHCA", type: "public-key" }]); expect(JSON.parse(new TextDecoder().decode(new Uint8Array(request.payload.clientDataJSON))).challenge).toBe("AQIDBA"); }); it("create: preserves the challenge and user ID from the page's ArrayBuffer realm", async () => { const { runInNewContext } = await import("node:vm"); const options = makeCreateOptions(); options.publicKey!.challenge = runInNewContext("new Uint8Array([1, 2, 3, 4]).buffer"); options.publicKey!.user.id = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer"); mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() }); await navigator.credentials.create(options); const request = mockSendMessage.mock.calls[0]![0] as { payload: { challenge: string; userId: string } }; expect(request.payload.challenge).toBe("AQIDBA"); expect(request.payload.userId).toBe("BQYHCA"); }); it("get: exposes method results to a page serializing the assertion", async () => { const expected = validGetCredential(); mockSendMessage .mockResolvedValueOnce({ ok: true, hasCredentials: true }) .mockResolvedValueOnce({ ok: true, credential: expected }); const credential = await navigator.credentials.get(makeGetOptions()) as PublicKeyCredential; expect(Object.keys(credential.getClientExtensionResults())).toEqual([]); expect(JSON.parse(JSON.stringify(credential))).toMatchObject({ id: expected.id, response: { signature: expected.signature, userHandle: expected.userHandle }, clientExtensionResults: {}, }); }); it("create: exposes binary getters and method results to the page", async () => { const expected = validCreateCredential(); mockSendMessage.mockResolvedValueOnce({ ok: true, credential: expected }); const credential = await navigator.credentials.create(makeCreateOptions()) as PublicKeyCredential; const response = credential.response as AuthenticatorAttestationResponse; expect(response.getTransports()).toEqual(["internal"]); expect(new Uint8Array(response.getAuthenticatorData())).toHaveLength(37); expect(new Uint8Array(response.getPublicKey()!)).toHaveLength(64); expect(response.getPublicKeyAlgorithm()).toBe(-7); expect(Object.keys(credential.getClientExtensionResults())).toEqual([]); expect(JSON.parse(JSON.stringify(credential))).toMatchObject({ id: expected.id, response: { attestationObject: expected.attestationObject }, clientExtensionResults: {}, }); }); 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(); }); }); });