passkeys-firefox.test.ts

20.9 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}3940let pageScopeValues = new WeakSet<object>();4142function stubCloneInto<T>(obj: T, _targetScope: unknown, _options?: { cloneFunctions?: boolean }): T {43  function clone(value: unknown): unknown {44    if (typeof value === "function") {45      return (...args: unknown[]) => {46        const result: unknown = Reflect.apply(value, undefined, args);47        if (result && typeof result === "object" && !pageScopeValues.has(result)) {48          return new Proxy(result, {49            get() { throw new Error("Permission denied to access privileged object"); },50            has() { throw new Error("Permission denied to access privileged object"); },51            ownKeys() { throw new Error("Permission denied to access privileged object"); },52          });53        }54        return result;55      };56    }57    if (!value || typeof value !== "object") return value;58    const copied = value instanceof ArrayBuffer ? value.slice(0) : Array.isArray(value)59      ? value.map(clone)60      : Object.fromEntries(Object.entries(value).map(([key, child]) => [key, clone(child)]));61    pageScopeValues.add(copied);62    return copied;63  }64  return clone(obj) as T;65}6667// ── chrome mock ────────────────────────────────────────────────────────────6869let mockSendMessage: ReturnType<typeof vi.fn>;7071function setupChromeMock() {72  mockSendMessage = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });73  vi.stubGlobal("chrome", {74    runtime: {75      id: "peach-extension",76      sendMessage: mockSendMessage,77      getURL: vi.fn(() => "moz-extension://abcd/"),78    },79  });80}8182// ── helpers ────────────────────────────────────────────────────────────────8384function base64url(bytes: Uint8Array): string {85  let str = "";86  for (const b of bytes) str += String.fromCharCode(b);87  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");88}8990function makeCreateOptions(rpId = "github.com"): CredentialCreationOptions {91  return {92    publicKey: {93      rp: { id: rpId, name: "GitHub" },94      user: { id: new Uint8Array(16), name: "june", displayName: "June" },95      challenge: new Uint8Array(16),96      pubKeyCredParams: [{ alg: -7, type: "public-key" }],97    },98  } as CredentialCreationOptions;99}100101function makeGetOptions(rpId = "github.com"): CredentialRequestOptions {102  return {103    publicKey: {104      rpId,105      challenge: new Uint8Array(16),106      allowCredentials: [{ id: new Uint8Array(32), type: "public-key" }],107    },108  } as CredentialRequestOptions;109}110111function validCreateCredential() {112  return {113    type: "create",114    id: base64url(new Uint8Array(32)),115    clientDataJSON: Array.from(new TextEncoder().encode(JSON.stringify({ type: "webauthn.create", challenge: "x", origin: "https://github.com", crossOrigin: false }))),116    attestationObject: base64url(new Uint8Array(64)),117    authenticatorData: base64url(new Uint8Array(37)),118    publicKey: base64url(new Uint8Array(64)),119    algorithm: -7,120  };121}122123function validGetCredential() {124  return {125    type: "get",126    id: base64url(new Uint8Array(32)),127    clientDataJSON: Array.from(new TextEncoder().encode(JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://github.com", crossOrigin: false }))),128    authenticatorData: base64url(new Uint8Array(37)),129    signature: base64url(new Uint8Array(64)),130    userHandle: base64url(new Uint8Array(16)),131  };132}133134// ── tests ──────────────────────────────────────────────────────────────────135136describe("PB-26-209: exportFunction privileged bridge", () => {137  beforeEach(async () => {138    vi.resetModules();139    setupChromeMock();140    exportedFns.clear();141    pageScopeValues = new WeakSet<object>();142143    // Stub Firefox privileged APIs144    vi.stubGlobal("exportFunction", stubExportFunction);145    vi.stubGlobal("cloneInto", stubCloneInto);146147    // Reset the install guard so each test re-installs the proxy.148    delete (window as any).__peachPasskeyProxyInstalled;149150    // jsdom doesn't have navigator.credentials by default — install a151    // native stub that the bridge will replace via exportFunction.152    const nativeCreate = vi.fn(async () => null);153    const nativeGet = vi.fn(async () => null);154    Object.defineProperty(navigator, "credentials", {155      value: { create: nativeCreate, get: nativeGet },156      configurable: true,157    });158159    await import("./passkeys-firefox");160  });161162  afterEach(() => {163    vi.unstubAllGlobals();164  });165166  describe("no forgeable response channel", () => {167    it("exports non-async wrappers so Firefox can return page-scope promises", () => {168      expect(exportedFns.get("create")?.constructor.name).toBe("Function");169      expect(exportedFns.get("get")?.constructor.name).toBe("Function");170    });171172    it("a forged PEACH_PASSKEY_RESPONSE with a correct-former UUID has no listener and no effect", async () => {173      // The old relay listened for window "message" events of type174      // PEACH_PASSKEY_RESPONSE. The new bridge must not.175      const postSpy = vi.spyOn(window, "postMessage");176177      // Dispatch a forged response — it should be a no-op.178      const forged = new MessageEvent("message", {179        data: {180          type: "PEACH_PASSKEY_RESPONSE",181          id: "00000000-0000-0000-0000-000000000000",182          payload: { ok: true, credential: validCreateCredential() },183        },184        source: window,185      });186      window.dispatchEvent(forged);187188      // No response was posted back — there is no relay.189      // The spy only tracks outgoing postMessage calls from the bridge.190      // The bridge should never call postMessage for passkey traffic.191      const passkeyPostCalls = postSpy.mock.calls.filter(192        ([data]) => {193          const d = data as { type?: string };194          return d?.type === "PEACH_PASSKEY_REQUEST" || d?.type === "PEACH_PASSKEY_RESPONSE";195        },196      );197      expect(passkeyPostCalls).toHaveLength(0);198      postSpy.mockRestore();199    });200201    it("production code has no PEACH_PASSKEY_RESPONSE message listener", async () => {202      // Read the source file and verify no forgeable response channel remains.203      const fs = await import("node:fs");204      const path = await import("node:path");205      const source = fs.readFileSync(206        path.resolve(process.cwd(), "src/content/passkeys-firefox.ts"),207        "utf-8",208      );209      expect(source).not.toContain("PEACH_PASSKEY_RESPONSE");210      expect(source).not.toContain("PEACH_PASSKEY_REQUEST");211      // No window.postMessage call (the forgeable response bus).212      expect(source).not.toMatch(/window\.postMessage\s*\(/);213    });214  });215216  describe("malformed responses fail closed", () => {217    it("create: falls through to original when credential.id is not base64url", async () => {218      mockSendMessage.mockResolvedValueOnce({219        ok: true,220        credential: { ...validCreateCredential(), id: "!!!not-base64url!!!" },221      });222223      const result = navigator.credentials.create(makeCreateOptions());224      // Should fall through, not throw — and not construct a credential225      // from malformed data.226      await expect(result).resolves.toBeNull();227    });228229    it("create: falls through when credential type is wrong", async () => {230      mockSendMessage.mockResolvedValueOnce({231        ok: true,232        credential: { ...validCreateCredential(), type: "get" },233      });234235      await expect(236        navigator.credentials.create(makeCreateOptions()),237      ).resolves.toBeNull();238    });239240    it("create: falls through when clientDataJSON is missing", async () => {241      const cred = validCreateCredential();242      delete (cred as any).clientDataJSON;243      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: cred });244245      await expect(246        navigator.credentials.create(makeCreateOptions()),247      ).resolves.toBeNull();248    });249250    it("create: falls through when attestationObject is not base64url", async () => {251      mockSendMessage.mockResolvedValueOnce({252        ok: true,253        credential: { ...validCreateCredential(), attestationObject: "not valid!" },254      });255256      await expect(257        navigator.credentials.create(makeCreateOptions()),258      ).resolves.toBeNull();259    });260261    it("create: falls through when algorithm is unsupported", async () => {262      mockSendMessage.mockResolvedValueOnce({263        ok: true,264        credential: { ...validCreateCredential(), algorithm: -999 },265      });266267      await expect(268        navigator.credentials.create(makeCreateOptions()),269      ).resolves.toBeNull();270    });271272    it("get: falls through when signature is not base64url", async () => {273      // PASSKEY_HAS_CREDENTIALS must return true for the flow to reach GET274      mockSendMessage275        .mockResolvedValueOnce({ ok: true, hasCredentials: true })276        .mockResolvedValueOnce({277          ok: true,278          credential: { ...validGetCredential(), signature: "!!!invalid!!!" },279        });280281      await expect(282        navigator.credentials.get(makeGetOptions()),283      ).resolves.toBeNull();284    });285286    it("get: falls through when authenticatorData is not base64url", async () => {287      mockSendMessage288        .mockResolvedValueOnce({ ok: true, hasCredentials: true })289        .mockResolvedValueOnce({290          ok: true,291          credential: { ...validGetCredential(), authenticatorData: "" },292        });293294      await expect(295        navigator.credentials.get(makeGetOptions()),296      ).resolves.toBeNull();297    });298299    it("get: falls through when credential is null", async () => {300      mockSendMessage301        .mockResolvedValueOnce({ ok: true, hasCredentials: true })302        .mockResolvedValueOnce({ ok: true, credential: null });303304      await expect(305        navigator.credentials.get(makeGetOptions()),306      ).resolves.toBeNull();307    });308  });309310  describe("privileged bridge captures sendMessage at install time", () => {311    it("wrapper closure uses the captured sendMessage, not a re-lookup", async () => {312      // The wrapper should have captured sendMessage at install time.313      // Even if we overwrite chrome.runtime.sendMessage after install,314      // the wrapper should still use the original.315      const original = mockSendMessage;316      const replacement = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });317      vi.stubGlobal("chrome", {318        runtime: { id: "peach-extension", sendMessage: replacement, getURL: vi.fn(() => "moz-extension://abcd/") },319      });320321      // Trigger a create call — it should use the captured original, not the replacement.322      mockSendMessage = original;323      await navigator.credentials.create(makeCreateOptions());324325      // The replacement should NOT have been called.326      expect(replacement).not.toHaveBeenCalled();327    });328  });329330  describe("valid responses produce credentials", () => {331    it.each([332      "new Uint8Array([1, 2, 3, 4]).buffer",333      "new Uint8Array(new Uint8Array([9, 1, 2, 3, 4, 9]).buffer, 1, 4)",334    ])("get: preserves page-realm buffers and view offsets (%s)", async (expression) => {335      const { runInNewContext } = await import("node:vm");336      const challenge: BufferSource = runInNewContext(expression);337      const credentialId: ArrayBuffer = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");338      expect(challenge instanceof ArrayBuffer).toBe(false);339      mockSendMessage340        .mockResolvedValueOnce({ ok: true, hasCredentials: true })341        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });342      await navigator.credentials.get({343        publicKey: { challenge, rpId: "github.com", allowCredentials: [{ id: credentialId, type: "public-key" }] },344      });345      const request = mockSendMessage.mock.calls.find(([message]) => message.type === "PASSKEY_GET")?.[0] as {346        payload: { challenge: string; clientDataJSON: number[]; allowCredentials: { id: string }[] };347      };348      expect(request.payload.challenge).toBe("AQIDBA");349      expect(request.payload.allowCredentials).toEqual([{ id: "BQYHCA", type: "public-key" }]);350      expect(JSON.parse(new TextDecoder().decode(new Uint8Array(request.payload.clientDataJSON))).challenge).toBe("AQIDBA");351    });352353    it("create: preserves the challenge and user ID from the page's ArrayBuffer realm", async () => {354      const { runInNewContext } = await import("node:vm");355      const options = makeCreateOptions();356      options.publicKey!.challenge = runInNewContext("new Uint8Array([1, 2, 3, 4]).buffer");357      options.publicKey!.user.id = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");358      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });359      await navigator.credentials.create(options);360      const request = mockSendMessage.mock.calls[0]![0] as { payload: { challenge: string; userId: string } };361      expect(request.payload.challenge).toBe("AQIDBA");362      expect(request.payload.userId).toBe("BQYHCA");363    });364365    it("get: exposes method results to a page serializing the assertion", async () => {366      const expected = validGetCredential();367      mockSendMessage368        .mockResolvedValueOnce({ ok: true, hasCredentials: true })369        .mockResolvedValueOnce({ ok: true, credential: expected });370      const credential = await navigator.credentials.get(makeGetOptions()) as PublicKeyCredential;371      expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);372      expect(JSON.parse(JSON.stringify(credential))).toMatchObject({373        id: expected.id, response: { signature: expected.signature, userHandle: expected.userHandle },374        clientExtensionResults: {},375      });376    });377378    it("create: exposes binary getters and method results to the page", async () => {379      const expected = validCreateCredential();380      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: expected });381      const credential = await navigator.credentials.create(makeCreateOptions()) as PublicKeyCredential;382      const response = credential.response as AuthenticatorAttestationResponse;383      expect(response.getTransports()).toEqual(["internal"]);384      expect(new Uint8Array(response.getAuthenticatorData())).toHaveLength(37);385      expect(new Uint8Array(response.getPublicKey()!)).toHaveLength(64);386      expect(response.getPublicKeyAlgorithm()).toBe(-7);387      expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);388      expect(JSON.parse(JSON.stringify(credential))).toMatchObject({389        id: expected.id, response: { attestationObject: expected.attestationObject },390        clientExtensionResults: {},391      });392    });393394    it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => {395      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });396397      const cred = await navigator.credentials.create(makeCreateOptions());398      expect(cred).not.toBeNull();399      expect((cred as any).type).toBe("public-key");400      expect((cred as any).id).toBeDefined();401      expect((cred as any).rawId).toBeDefined();402      expect((cred as any).response).toBeDefined();403      expect((cred as any).response.attestationObject).toBeDefined();404    });405406    it("get: returns a PublicKeyCredential-shaped object for a valid response", async () => {407      mockSendMessage408        .mockResolvedValueOnce({ ok: true, hasCredentials: true })409        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });410411      const cred = await navigator.credentials.get(makeGetOptions());412      expect(cred).not.toBeNull();413      expect((cred as any).type).toBe("public-key");414      expect((cred as any).response.signature).toBeDefined();415      expect((cred as any).response.authenticatorData).toBeDefined();416    });417418    it("create: throws NotAllowedError on user_cancelled", async () => {419      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" });420421      await expect(422        navigator.credentials.create(makeCreateOptions()),423      ).rejects.toMatchObject({ name: "NotAllowedError" });424    });425426    it("get: throws NotAllowedError on user_cancelled", async () => {427      mockSendMessage428        .mockResolvedValueOnce({ ok: true, hasCredentials: true })429        .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });430431      await expect(432        navigator.credentials.get(makeGetOptions()),433      ).rejects.toMatchObject({ name: "NotAllowedError" });434    });435436    it("create: falls through on vault_locked", async () => {437      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });438      await expect(439        navigator.credentials.create(makeCreateOptions()),440      ).resolves.toBeNull();441    });442443    it("get: falls through on no_credentials after has-credentials pre-check", async () => {444      mockSendMessage445        .mockResolvedValueOnce({ ok: true, hasCredentials: true })446        .mockResolvedValueOnce({ ok: false, error: "no_credentials" });447      await expect(448        navigator.credentials.get(makeGetOptions()),449      ).resolves.toBeNull();450    });451452    it("get: falls through when has-credentials returns false", async () => {453      mockSendMessage.mockResolvedValueOnce({ ok: true, hasCredentials: false });454      // Should NOT send a PASSKEY_GET455      await navigator.credentials.get(makeGetOptions());456      const calls = mockSendMessage.mock.calls.map((c) => (c[0] as { type: string }).type);457      expect(calls).not.toContain("PASSKEY_GET");458    });459  });460461  describe("origin handling", () => {462    it("create: sends window.location.origin in the request, not a page-supplied value", async () => {463      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });464465      await navigator.credentials.create(makeCreateOptions());466467      const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } };468      expect(call.payload.origin).toBe(window.location.origin);469470      // clientDataJSON should also contain the same origin471      const clientData = JSON.parse(new TextDecoder().decode(new Uint8Array(call.payload.clientDataJSON)));472      expect(clientData.origin).toBe(window.location.origin);473    });474475    it("get: sends window.location.origin in the request", async () => {476      mockSendMessage477        .mockResolvedValueOnce({ ok: true, hasCredentials: true })478        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });479480      await navigator.credentials.get(makeGetOptions());481482      const getCall = mockSendMessage.mock.calls.find(483        (c) => (c[0] as { type: string }).type === "PASSKEY_GET",484      );485      const payload = (getCall![0] as { payload: { origin: string } }).payload;486      expect(payload.origin).toBe(window.location.origin);487    });488  });489490  describe("exportFunction unavailable", () => {491    it("falls through to native when exportFunction is not available", async () => {492      vi.resetModules();493      // Re-stub exportFunction as undefined — simulates unsupported Firefox.494      vi.stubGlobal("exportFunction", undefined);495      vi.stubGlobal("cloneInto", undefined);496497      const nativeCreate = vi.fn(async () => null);498      Object.defineProperty(navigator, "credentials", {499        value: { create: nativeCreate, get: vi.fn(async () => null) },500        configurable: true,501      });502503      await import("./passkeys-firefox");504505      // The native create should be unchanged (not patched)506      await navigator.credentials.create(makeCreateOptions());507      expect(nativeCreate).toHaveBeenCalled();508      // sendMessage should never be called509      expect(mockSendMessage).not.toHaveBeenCalled();510    });511  });512});