passkeys-firefox.test.ts

16.0 KB · raw

1// @vitest-environment jsdom23import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";45/**6 * PB-26-209: The forgeable window.postMessage relay is replaced by an7 * exportFunction-based privileged bridge. These tests prove the security8 * properties without a real Firefox:9 *10 * 1. A forged PEACH_PASSKEY_RESPONSE has no listener and no effect.11 * 2. Malformed extension responses fail closed / fall through before12 *    credential construction.13 * 3. The bridge captures sendMessage at install time and does not14 *    re-look it up on a page-writable global.15 * 4. No PEACH_PASSKEY_RESPONSE or response-bearing window.postMessage16 *    remains in production code.17 */1819// ── stubs for Firefox privileged APIs ─────────────────────────────────────20// exportFunction installs a wrapper into a target scope; in jsdom we just21// call the function directly and assign it to the target.22const exportedFns = new Map<string, (...args: any[]) => any>();2324function stubExportFunction(25  func: (...args: any[]) => any,26  _targetScope: any,27  options?: { defineAs?: string },28): (...args: any[]) => any {29  const name = options?.defineAs;30  if (name) {31    exportedFns.set(name, func);32    // Also assign to navigator.credentials for realism33    if (_targetScope && _targetScope !== globalThis) {34      _targetScope[name] = func;35    }36  }37  return func;38}3940function stubCloneInto<T>(obj: T, _targetScope: any, _options?: { cloneFunctions?: boolean }): T {41  return obj;42}4344// ── chrome mock ────────────────────────────────────────────────────────────4546let mockSendMessage: ReturnType<typeof vi.fn>;4748function setupChromeMock() {49  mockSendMessage = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });50  vi.stubGlobal("chrome", {51    runtime: {52      id: "peach-extension",53      sendMessage: mockSendMessage,54      getURL: vi.fn(() => "moz-extension://abcd/"),55    },56  });57}5859// ── helpers ────────────────────────────────────────────────────────────────6061function base64url(bytes: Uint8Array): string {62  let str = "";63  for (const b of bytes) str += String.fromCharCode(b);64  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");65}6667function makeCreateOptions(rpId = "github.com"): CredentialCreationOptions {68  return {69    publicKey: {70      rp: { id: rpId, name: "GitHub" },71      user: { id: new Uint8Array(16), name: "june", displayName: "June" },72      challenge: new Uint8Array(16),73      pubKeyCredParams: [{ alg: -7, type: "public-key" }],74    },75  } as CredentialCreationOptions;76}7778function makeGetOptions(rpId = "github.com"): CredentialRequestOptions {79  return {80    publicKey: {81      rpId,82      challenge: new Uint8Array(16),83      allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }],84    },85  } as CredentialRequestOptions;86}8788function validCreateCredential() {89  return {90    type: "create",91    id: base64url(new Uint8Array(32)),92    clientDataJSON: Array.from(new TextEncoder().encode(JSON.stringify({ type: "webauthn.create", challenge: "x", origin: "https://github.com", crossOrigin: false }))),93    attestationObject: base64url(new Uint8Array(64)),94    authenticatorData: base64url(new Uint8Array(37)),95    publicKey: base64url(new Uint8Array(64)),96    algorithm: -7,97  };98}99100function validGetCredential() {101  return {102    type: "get",103    id: base64url(new Uint8Array(32)),104    clientDataJSON: Array.from(new TextEncoder().encode(JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false }))),105    authenticatorData: base64url(new Uint8Array(37)),106    signature: base64url(new Uint8Array(64)),107    userHandle: base64url(new Uint8Array(16)),108  };109}110111// ── tests ──────────────────────────────────────────────────────────────────112113describe("PB-26-209: exportFunction privileged bridge", () => {114  beforeEach(async () => {115    vi.resetModules();116    setupChromeMock();117    exportedFns.clear();118119    // Stub Firefox privileged APIs120    vi.stubGlobal("exportFunction", stubExportFunction);121    vi.stubGlobal("cloneInto", stubCloneInto);122123    // Reset the install guard so each test re-installs the proxy.124    delete (window as any).__peachPasskeyProxyInstalled;125126    // jsdom doesn't have navigator.credentials by default — install a127    // native stub that the bridge will replace via exportFunction.128    const nativeCreate = vi.fn(async () => null);129    const nativeGet = vi.fn(async () => null);130    Object.defineProperty(navigator, "credentials", {131      value: { create: nativeCreate, get: nativeGet },132      configurable: true,133    });134135    await import("./passkeys-firefox");136  });137138  afterEach(() => {139    vi.unstubAllGlobals();140  });141142  describe("no forgeable response channel", () => {143    it("a forged PEACH_PASSKEY_RESPONSE with a correct-former UUID has no listener and no effect", async () => {144      // The old relay listened for window "message" events of type145      // PEACH_PASSKEY_RESPONSE. The new bridge must not.146      const postSpy = vi.spyOn(window, "postMessage");147148      // Dispatch a forged response — it should be a no-op.149      const forged = new MessageEvent("message", {150        data: {151          type: "PEACH_PASSKEY_RESPONSE",152          id: "00000000-0000-0000-0000-000000000000",153          payload: { ok: true, credential: validCreateCredential() },154        },155        source: window,156      });157      window.dispatchEvent(forged);158159      // No response was posted back — there is no relay.160      // The spy only tracks outgoing postMessage calls from the bridge.161      // The bridge should never call postMessage for passkey traffic.162      const passkeyPostCalls = postSpy.mock.calls.filter(163        ([data]) => {164          const d = data as { type?: string };165          return d?.type === "PEACH_PASSKEY_REQUEST" || d?.type === "PEACH_PASSKEY_RESPONSE";166        },167      );168      expect(passkeyPostCalls).toHaveLength(0);169      postSpy.mockRestore();170    });171172    it("production code has no PEACH_PASSKEY_RESPONSE message listener", async () => {173      // Read the source file and verify no forgeable response channel remains.174      const fs = await import("node:fs");175      const path = await import("node:path");176      const source = fs.readFileSync(177        path.resolve(process.cwd(), "src/content/passkeys-firefox.ts"),178        "utf-8",179      );180      expect(source).not.toContain("PEACH_PASSKEY_RESPONSE");181      expect(source).not.toContain("PEACH_PASSKEY_REQUEST");182      // No window.postMessage call (the forgeable response bus).183      expect(source).not.toMatch(/window\.postMessage\s*\(/);184    });185  });186187  describe("malformed responses fail closed", () => {188    it("create: falls through to original when credential.id is not base64url", async () => {189      mockSendMessage.mockResolvedValueOnce({190        ok: true,191        credential: { ...validCreateCredential(), id: "!!!not-base64url!!!" },192      });193194      const result = navigator.credentials.create(makeCreateOptions());195      // Should fall through, not throw — and not construct a credential196      // from malformed data.197      await expect(result).resolves.toBeNull();198    });199200    it("create: falls through when credential type is wrong", async () => {201      mockSendMessage.mockResolvedValueOnce({202        ok: true,203        credential: { ...validCreateCredential(), type: "get" },204      });205206      await expect(207        navigator.credentials.create(makeCreateOptions()),208      ).resolves.toBeNull();209    });210211    it("create: falls through when clientDataJSON is missing", async () => {212      const cred = validCreateCredential();213      delete (cred as any).clientDataJSON;214      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: cred });215216      await expect(217        navigator.credentials.create(makeCreateOptions()),218      ).resolves.toBeNull();219    });220221    it("create: falls through when attestationObject is not base64url", async () => {222      mockSendMessage.mockResolvedValueOnce({223        ok: true,224        credential: { ...validCreateCredential(), attestationObject: "not valid!" },225      });226227      await expect(228        navigator.credentials.create(makeCreateOptions()),229      ).resolves.toBeNull();230    });231232    it("create: falls through when algorithm is unsupported", async () => {233      mockSendMessage.mockResolvedValueOnce({234        ok: true,235        credential: { ...validCreateCredential(), algorithm: -999 },236      });237238      await expect(239        navigator.credentials.create(makeCreateOptions()),240      ).resolves.toBeNull();241    });242243    it("get: falls through when signature is not base64url", async () => {244      // PASSKEY_HAS_CREDENTIALS must return true for the flow to reach GET245      mockSendMessage246        .mockResolvedValueOnce({ ok: true, hasCredentials: true })247        .mockResolvedValueOnce({248          ok: true,249          credential: { ...validGetCredential(), signature: "!!!invalid!!!" },250        });251252      await expect(253        navigator.credentials.get(makeGetOptions()),254      ).resolves.toBeNull();255    });256257    it("get: falls through when authenticatorData is not base64url", async () => {258      mockSendMessage259        .mockResolvedValueOnce({ ok: true, hasCredentials: true })260        .mockResolvedValueOnce({261          ok: true,262          credential: { ...validGetCredential(), authenticatorData: "" },263        });264265      await expect(266        navigator.credentials.get(makeGetOptions()),267      ).resolves.toBeNull();268    });269270    it("get: falls through when credential is null", async () => {271      mockSendMessage272        .mockResolvedValueOnce({ ok: true, hasCredentials: true })273        .mockResolvedValueOnce({ ok: true, credential: null });274275      await expect(276        navigator.credentials.get(makeGetOptions()),277      ).resolves.toBeNull();278    });279  });280281  describe("privileged bridge captures sendMessage at install time", () => {282    it("wrapper closure uses the captured sendMessage, not a re-lookup", async () => {283      // The wrapper should have captured sendMessage at install time.284      // Even if we overwrite chrome.runtime.sendMessage after install,285      // the wrapper should still use the original.286      const original = mockSendMessage;287      const replacement = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });288      vi.stubGlobal("chrome", {289        runtime: { id: "peach-extension", sendMessage: replacement, getURL: vi.fn(() => "moz-extension://abcd/") },290      });291292      // Trigger a create call — it should use the captured original, not the replacement.293      mockSendMessage = original;294      await navigator.credentials.create(makeCreateOptions());295296      // The replacement should NOT have been called.297      expect(replacement).not.toHaveBeenCalled();298    });299  });300301  describe("valid responses produce credentials", () => {302    it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => {303      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });304305      const cred = await navigator.credentials.create(makeCreateOptions());306      expect(cred).not.toBeNull();307      expect((cred as any).type).toBe("public-key");308      expect((cred as any).id).toBeDefined();309      expect((cred as any).rawId).toBeDefined();310      expect((cred as any).response).toBeDefined();311      expect((cred as any).response.attestationObject).toBeDefined();312    });313314    it("get: returns a PublicKeyCredential-shaped object for a valid response", async () => {315      mockSendMessage316        .mockResolvedValueOnce({ ok: true, hasCredentials: true })317        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });318319      const cred = await navigator.credentials.get(makeGetOptions());320      expect(cred).not.toBeNull();321      expect((cred as any).type).toBe("public-key");322      expect((cred as any).response.signature).toBeDefined();323      expect((cred as any).response.authenticatorData).toBeDefined();324    });325326    it("create: throws NotAllowedError on user_cancelled", async () => {327      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" });328329      await expect(330        navigator.credentials.create(makeCreateOptions()),331      ).rejects.toMatchObject({ name: "NotAllowedError" });332    });333334    it("get: throws NotAllowedError on user_cancelled", async () => {335      mockSendMessage336        .mockResolvedValueOnce({ ok: true, hasCredentials: true })337        .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });338339      await expect(340        navigator.credentials.get(makeGetOptions()),341      ).rejects.toMatchObject({ name: "NotAllowedError" });342    });343344    it("create: falls through on vault_locked", async () => {345      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });346      await expect(347        navigator.credentials.create(makeCreateOptions()),348      ).resolves.toBeNull();349    });350351    it("get: falls through on no_credentials after has-credentials pre-check", async () => {352      mockSendMessage353        .mockResolvedValueOnce({ ok: true, hasCredentials: true })354        .mockResolvedValueOnce({ ok: false, error: "no_credentials" });355      await expect(356        navigator.credentials.get(makeGetOptions()),357      ).resolves.toBeNull();358    });359360    it("get: falls through when has-credentials returns false", async () => {361      mockSendMessage.mockResolvedValueOnce({ ok: true, hasCredentials: false });362      // Should NOT send a PASSKEY_GET363      await navigator.credentials.get(makeGetOptions());364      const calls = mockSendMessage.mock.calls.map((c) => (c[0] as { type: string }).type);365      expect(calls).not.toContain("PASSKEY_GET");366    });367  });368369  describe("origin handling", () => {370    it("create: sends window.location.origin in the request, not a page-supplied value", async () => {371      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });372373      await navigator.credentials.create(makeCreateOptions());374375      const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } };376      expect(call.payload.origin).toBe(window.location.origin);377378      // clientDataJSON should also contain the same origin379      const clientData = JSON.parse(new TextDecoder().decode(new Uint8Array(call.payload.clientDataJSON)));380      expect(clientData.origin).toBe(window.location.origin);381    });382383    it("get: sends window.location.origin in the request", async () => {384      mockSendMessage385        .mockResolvedValueOnce({ ok: true, hasCredentials: true })386        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });387388      await navigator.credentials.get(makeGetOptions());389390      const getCall = mockSendMessage.mock.calls.find(391        (c) => (c[0] as { type: string }).type === "PASSKEY_GET",392      );393      const payload = (getCall![0] as { payload: { origin: string } }).payload;394      expect(payload.origin).toBe(window.location.origin);395    });396  });397398  describe("exportFunction unavailable", () => {399    it("falls through to native when exportFunction is not available", async () => {400      vi.resetModules();401      // Re-stub exportFunction as undefined — simulates unsupported Firefox.402      vi.stubGlobal("exportFunction", undefined);403      vi.stubGlobal("cloneInto", undefined);404405      const nativeCreate = vi.fn(async () => null);406      Object.defineProperty(navigator, "credentials", {407        value: { create: nativeCreate, get: vi.fn(async () => null) },408        configurable: true,409      });410411      await import("./passkeys-firefox");412413      // The native create should be unchanged (not patched)414      await navigator.credentials.create(makeCreateOptions());415      expect(nativeCreate).toHaveBeenCalled();416      // sendMessage should never be called417      expect(mockSendMessage).not.toHaveBeenCalled();418    });419  });420});