passkeys-firefox.test.ts

16.2 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("exports non-async wrappers so Firefox can return page-scope promises", () => {144      expect(exportedFns.get("create")?.constructor.name).toBe("Function");145      expect(exportedFns.get("get")?.constructor.name).toBe("Function");146    });147148    it("a forged PEACH_PASSKEY_RESPONSE with a correct-former UUID has no listener and no effect", async () => {149      // The old relay listened for window "message" events of type150      // PEACH_PASSKEY_RESPONSE. The new bridge must not.151      const postSpy = vi.spyOn(window, "postMessage");152153      // Dispatch a forged response — it should be a no-op.154      const forged = new MessageEvent("message", {155        data: {156          type: "PEACH_PASSKEY_RESPONSE",157          id: "00000000-0000-0000-0000-000000000000",158          payload: { ok: true, credential: validCreateCredential() },159        },160        source: window,161      });162      window.dispatchEvent(forged);163164      // No response was posted back — there is no relay.165      // The spy only tracks outgoing postMessage calls from the bridge.166      // The bridge should never call postMessage for passkey traffic.167      const passkeyPostCalls = postSpy.mock.calls.filter(168        ([data]) => {169          const d = data as { type?: string };170          return d?.type === "PEACH_PASSKEY_REQUEST" || d?.type === "PEACH_PASSKEY_RESPONSE";171        },172      );173      expect(passkeyPostCalls).toHaveLength(0);174      postSpy.mockRestore();175    });176177    it("production code has no PEACH_PASSKEY_RESPONSE message listener", async () => {178      // Read the source file and verify no forgeable response channel remains.179      const fs = await import("node:fs");180      const path = await import("node:path");181      const source = fs.readFileSync(182        path.resolve(process.cwd(), "src/content/passkeys-firefox.ts"),183        "utf-8",184      );185      expect(source).not.toContain("PEACH_PASSKEY_RESPONSE");186      expect(source).not.toContain("PEACH_PASSKEY_REQUEST");187      // No window.postMessage call (the forgeable response bus).188      expect(source).not.toMatch(/window\.postMessage\s*\(/);189    });190  });191192  describe("malformed responses fail closed", () => {193    it("create: falls through to original when credential.id is not base64url", async () => {194      mockSendMessage.mockResolvedValueOnce({195        ok: true,196        credential: { ...validCreateCredential(), id: "!!!not-base64url!!!" },197      });198199      const result = navigator.credentials.create(makeCreateOptions());200      // Should fall through, not throw — and not construct a credential201      // from malformed data.202      await expect(result).resolves.toBeNull();203    });204205    it("create: falls through when credential type is wrong", async () => {206      mockSendMessage.mockResolvedValueOnce({207        ok: true,208        credential: { ...validCreateCredential(), type: "get" },209      });210211      await expect(212        navigator.credentials.create(makeCreateOptions()),213      ).resolves.toBeNull();214    });215216    it("create: falls through when clientDataJSON is missing", async () => {217      const cred = validCreateCredential();218      delete (cred as any).clientDataJSON;219      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: cred });220221      await expect(222        navigator.credentials.create(makeCreateOptions()),223      ).resolves.toBeNull();224    });225226    it("create: falls through when attestationObject is not base64url", async () => {227      mockSendMessage.mockResolvedValueOnce({228        ok: true,229        credential: { ...validCreateCredential(), attestationObject: "not valid!" },230      });231232      await expect(233        navigator.credentials.create(makeCreateOptions()),234      ).resolves.toBeNull();235    });236237    it("create: falls through when algorithm is unsupported", async () => {238      mockSendMessage.mockResolvedValueOnce({239        ok: true,240        credential: { ...validCreateCredential(), algorithm: -999 },241      });242243      await expect(244        navigator.credentials.create(makeCreateOptions()),245      ).resolves.toBeNull();246    });247248    it("get: falls through when signature is not base64url", async () => {249      // PASSKEY_HAS_CREDENTIALS must return true for the flow to reach GET250      mockSendMessage251        .mockResolvedValueOnce({ ok: true, hasCredentials: true })252        .mockResolvedValueOnce({253          ok: true,254          credential: { ...validGetCredential(), signature: "!!!invalid!!!" },255        });256257      await expect(258        navigator.credentials.get(makeGetOptions()),259      ).resolves.toBeNull();260    });261262    it("get: falls through when authenticatorData is not base64url", async () => {263      mockSendMessage264        .mockResolvedValueOnce({ ok: true, hasCredentials: true })265        .mockResolvedValueOnce({266          ok: true,267          credential: { ...validGetCredential(), authenticatorData: "" },268        });269270      await expect(271        navigator.credentials.get(makeGetOptions()),272      ).resolves.toBeNull();273    });274275    it("get: falls through when credential is null", async () => {276      mockSendMessage277        .mockResolvedValueOnce({ ok: true, hasCredentials: true })278        .mockResolvedValueOnce({ ok: true, credential: null });279280      await expect(281        navigator.credentials.get(makeGetOptions()),282      ).resolves.toBeNull();283    });284  });285286  describe("privileged bridge captures sendMessage at install time", () => {287    it("wrapper closure uses the captured sendMessage, not a re-lookup", async () => {288      // The wrapper should have captured sendMessage at install time.289      // Even if we overwrite chrome.runtime.sendMessage after install,290      // the wrapper should still use the original.291      const original = mockSendMessage;292      const replacement = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });293      vi.stubGlobal("chrome", {294        runtime: { id: "peach-extension", sendMessage: replacement, getURL: vi.fn(() => "moz-extension://abcd/") },295      });296297      // Trigger a create call — it should use the captured original, not the replacement.298      mockSendMessage = original;299      await navigator.credentials.create(makeCreateOptions());300301      // The replacement should NOT have been called.302      expect(replacement).not.toHaveBeenCalled();303    });304  });305306  describe("valid responses produce credentials", () => {307    it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => {308      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });309310      const cred = await navigator.credentials.create(makeCreateOptions());311      expect(cred).not.toBeNull();312      expect((cred as any).type).toBe("public-key");313      expect((cred as any).id).toBeDefined();314      expect((cred as any).rawId).toBeDefined();315      expect((cred as any).response).toBeDefined();316      expect((cred as any).response.attestationObject).toBeDefined();317    });318319    it("get: returns a PublicKeyCredential-shaped object for a valid response", async () => {320      mockSendMessage321        .mockResolvedValueOnce({ ok: true, hasCredentials: true })322        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });323324      const cred = await navigator.credentials.get(makeGetOptions());325      expect(cred).not.toBeNull();326      expect((cred as any).type).toBe("public-key");327      expect((cred as any).response.signature).toBeDefined();328      expect((cred as any).response.authenticatorData).toBeDefined();329    });330331    it("create: throws NotAllowedError on user_cancelled", async () => {332      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" });333334      await expect(335        navigator.credentials.create(makeCreateOptions()),336      ).rejects.toMatchObject({ name: "NotAllowedError" });337    });338339    it("get: throws NotAllowedError on user_cancelled", async () => {340      mockSendMessage341        .mockResolvedValueOnce({ ok: true, hasCredentials: true })342        .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });343344      await expect(345        navigator.credentials.get(makeGetOptions()),346      ).rejects.toMatchObject({ name: "NotAllowedError" });347    });348349    it("create: falls through on vault_locked", async () => {350      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });351      await expect(352        navigator.credentials.create(makeCreateOptions()),353      ).resolves.toBeNull();354    });355356    it("get: falls through on no_credentials after has-credentials pre-check", async () => {357      mockSendMessage358        .mockResolvedValueOnce({ ok: true, hasCredentials: true })359        .mockResolvedValueOnce({ ok: false, error: "no_credentials" });360      await expect(361        navigator.credentials.get(makeGetOptions()),362      ).resolves.toBeNull();363    });364365    it("get: falls through when has-credentials returns false", async () => {366      mockSendMessage.mockResolvedValueOnce({ ok: true, hasCredentials: false });367      // Should NOT send a PASSKEY_GET368      await navigator.credentials.get(makeGetOptions());369      const calls = mockSendMessage.mock.calls.map((c) => (c[0] as { type: string }).type);370      expect(calls).not.toContain("PASSKEY_GET");371    });372  });373374  describe("origin handling", () => {375    it("create: sends window.location.origin in the request, not a page-supplied value", async () => {376      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });377378      await navigator.credentials.create(makeCreateOptions());379380      const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } };381      expect(call.payload.origin).toBe(window.location.origin);382383      // clientDataJSON should also contain the same origin384      const clientData = JSON.parse(new TextDecoder().decode(new Uint8Array(call.payload.clientDataJSON)));385      expect(clientData.origin).toBe(window.location.origin);386    });387388    it("get: sends window.location.origin in the request", async () => {389      mockSendMessage390        .mockResolvedValueOnce({ ok: true, hasCredentials: true })391        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });392393      await navigator.credentials.get(makeGetOptions());394395      const getCall = mockSendMessage.mock.calls.find(396        (c) => (c[0] as { type: string }).type === "PASSKEY_GET",397      );398      const payload = (getCall![0] as { payload: { origin: string } }).payload;399      expect(payload.origin).toBe(window.location.origin);400    });401  });402403  describe("exportFunction unavailable", () => {404    it("falls through to native when exportFunction is not available", async () => {405      vi.resetModules();406      // Re-stub exportFunction as undefined — simulates unsupported Firefox.407      vi.stubGlobal("exportFunction", undefined);408      vi.stubGlobal("cloneInto", undefined);409410      const nativeCreate = vi.fn(async () => null);411      Object.defineProperty(navigator, "credentials", {412        value: { create: nativeCreate, get: vi.fn(async () => null) },413        configurable: true,414      });415416      await import("./passkeys-firefox");417418      // The native create should be unchanged (not patched)419      await navigator.credentials.create(makeCreateOptions());420      expect(nativeCreate).toHaveBeenCalled();421      // sendMessage should never be called422      expect(mockSendMessage).not.toHaveBeenCalled();423    });424  });425});