passkeys-firefox.test.ts

21.6 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  let nativeGet: ReturnType<typeof vi.fn>;138  beforeEach(async () => {139    vi.resetModules();140    setupChromeMock();141    exportedFns.clear();142    pageScopeValues = new WeakSet<object>();143144    // Stub Firefox privileged APIs145    vi.stubGlobal("exportFunction", stubExportFunction);146    vi.stubGlobal("cloneInto", stubCloneInto);147148    // Reset the install guard so each test re-installs the proxy.149    delete (window as any).__peachPasskeyProxyInstalled;150151    // jsdom doesn't have navigator.credentials by default — install a152    // native stub that the bridge will replace via exportFunction.153    const nativeCreate = vi.fn(async () => null);154    nativeGet = vi.fn(async () => null);155    Object.defineProperty(navigator, "credentials", {156      value: { create: nativeCreate, get: nativeGet },157      configurable: true,158    });159160    await import("./passkeys-firefox");161  });162163  afterEach(() => {164    vi.unstubAllGlobals();165  });166167  describe("no forgeable response channel", () => {168    it("exports non-async wrappers so Firefox can return page-scope promises", () => {169      expect(exportedFns.get("create")?.constructor.name).toBe("Function");170      expect(exportedFns.get("get")?.constructor.name).toBe("Function");171    });172173    it("a forged PEACH_PASSKEY_RESPONSE with a correct-former UUID has no listener and no effect", async () => {174      // The old relay listened for window "message" events of type175      // PEACH_PASSKEY_RESPONSE. The new bridge must not.176      const postSpy = vi.spyOn(window, "postMessage");177178      // Dispatch a forged response — it should be a no-op.179      const forged = new MessageEvent("message", {180        data: {181          type: "PEACH_PASSKEY_RESPONSE",182          id: "00000000-0000-0000-0000-000000000000",183          payload: { ok: true, credential: validCreateCredential() },184        },185        source: window,186      });187      window.dispatchEvent(forged);188189      // No response was posted back — there is no relay.190      // The spy only tracks outgoing postMessage calls from the bridge.191      // The bridge should never call postMessage for passkey traffic.192      const passkeyPostCalls = postSpy.mock.calls.filter(193        ([data]) => {194          const d = data as { type?: string };195          return d?.type === "PEACH_PASSKEY_REQUEST" || d?.type === "PEACH_PASSKEY_RESPONSE";196        },197      );198      expect(passkeyPostCalls).toHaveLength(0);199      postSpy.mockRestore();200    });201202    it("production code has no PEACH_PASSKEY_RESPONSE message listener", async () => {203      // Read the source file and verify no forgeable response channel remains.204      const fs = await import("node:fs");205      const path = await import("node:path");206      const source = fs.readFileSync(207        path.resolve(process.cwd(), "src/content/passkeys-firefox.ts"),208        "utf-8",209      );210      expect(source).not.toContain("PEACH_PASSKEY_RESPONSE");211      expect(source).not.toContain("PEACH_PASSKEY_REQUEST");212      // No window.postMessage call (the forgeable response bus).213      expect(source).not.toMatch(/window\.postMessage\s*\(/);214    });215  });216217  describe("malformed responses fail closed", () => {218    it("create: falls through to original when credential.id is not base64url", async () => {219      mockSendMessage.mockResolvedValueOnce({220        ok: true,221        credential: { ...validCreateCredential(), id: "!!!not-base64url!!!" },222      });223224      const result = navigator.credentials.create(makeCreateOptions());225      // Should fall through, not throw — and not construct a credential226      // from malformed data.227      await expect(result).resolves.toBeNull();228    });229230    it("create: falls through when credential type is wrong", async () => {231      mockSendMessage.mockResolvedValueOnce({232        ok: true,233        credential: { ...validCreateCredential(), type: "get" },234      });235236      await expect(237        navigator.credentials.create(makeCreateOptions()),238      ).resolves.toBeNull();239    });240241    it("create: falls through when clientDataJSON is missing", async () => {242      const cred = validCreateCredential();243      delete (cred as any).clientDataJSON;244      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: cred });245246      await expect(247        navigator.credentials.create(makeCreateOptions()),248      ).resolves.toBeNull();249    });250251    it("create: falls through when attestationObject is not base64url", async () => {252      mockSendMessage.mockResolvedValueOnce({253        ok: true,254        credential: { ...validCreateCredential(), attestationObject: "not valid!" },255      });256257      await expect(258        navigator.credentials.create(makeCreateOptions()),259      ).resolves.toBeNull();260    });261262    it("create: falls through when algorithm is unsupported", async () => {263      mockSendMessage.mockResolvedValueOnce({264        ok: true,265        credential: { ...validCreateCredential(), algorithm: -999 },266      });267268      await expect(269        navigator.credentials.create(makeCreateOptions()),270      ).resolves.toBeNull();271    });272273    it("get: falls through when signature is not base64url", async () => {274      // PASSKEY_HAS_CREDENTIALS must return true for the flow to reach GET275      mockSendMessage276        .mockResolvedValueOnce({ ok: true, hasCredentials: true })277        .mockResolvedValueOnce({278          ok: true,279          credential: { ...validGetCredential(), signature: "!!!invalid!!!" },280        });281282      await expect(283        navigator.credentials.get(makeGetOptions()),284      ).resolves.toBeNull();285    });286287    it("get: falls through when authenticatorData is not base64url", async () => {288      mockSendMessage289        .mockResolvedValueOnce({ ok: true, hasCredentials: true })290        .mockResolvedValueOnce({291          ok: true,292          credential: { ...validGetCredential(), authenticatorData: "" },293        });294295      await expect(296        navigator.credentials.get(makeGetOptions()),297      ).resolves.toBeNull();298    });299300    it("get: falls through when credential is null", async () => {301      mockSendMessage302        .mockResolvedValueOnce({ ok: true, hasCredentials: true })303        .mockResolvedValueOnce({ ok: true, credential: null });304305      await expect(306        navigator.credentials.get(makeGetOptions()),307      ).resolves.toBeNull();308    });309  });310311  describe("privileged bridge captures sendMessage at install time", () => {312    it("wrapper closure uses the captured sendMessage, not a re-lookup", async () => {313      // The wrapper should have captured sendMessage at install time.314      // Even if we overwrite chrome.runtime.sendMessage after install,315      // the wrapper should still use the original.316      const original = mockSendMessage;317      const replacement = vi.fn().mockResolvedValue({ ok: false, error: "not_applicable" });318      vi.stubGlobal("chrome", {319        runtime: { id: "peach-extension", sendMessage: replacement, getURL: vi.fn(() => "moz-extension://abcd/") },320      });321322      // Trigger a create call — it should use the captured original, not the replacement.323      mockSendMessage = original;324      await navigator.credentials.create(makeCreateOptions());325326      // The replacement should NOT have been called.327      expect(replacement).not.toHaveBeenCalled();328    });329  });330331  describe("valid responses produce credentials", () => {332    it("get: leaves conditional requests and their cancellation signal with Firefox", async () => {333      const controller = new AbortController();334      const options: CredentialRequestOptions = {335        ...makeGetOptions(), mediation: "conditional", signal: controller.signal,336      };337      mockSendMessage338        .mockResolvedValueOnce({ ok: true, hasCredentials: true })339        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });340      await expect(navigator.credentials.get(options)).resolves.toBeNull();341      expect(nativeGet).toHaveBeenCalledWith(options);342      expect(mockSendMessage).not.toHaveBeenCalled();343    });344345    it.each([346      "new Uint8Array([1, 2, 3, 4]).buffer",347      "new Uint8Array(new Uint8Array([9, 1, 2, 3, 4, 9]).buffer, 1, 4)",348    ])("get: preserves page-realm buffers and view offsets (%s)", async (expression) => {349      const { runInNewContext } = await import("node:vm");350      const challenge: BufferSource = runInNewContext(expression);351      const credentialId: ArrayBuffer = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");352      expect(challenge instanceof ArrayBuffer).toBe(false);353      mockSendMessage354        .mockResolvedValueOnce({ ok: true, hasCredentials: true })355        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });356      await navigator.credentials.get({357        publicKey: { challenge, rpId: "github.com", allowCredentials: [{ id: credentialId, type: "public-key" }] },358      });359      const request = mockSendMessage.mock.calls.find(([message]) => message.type === "PASSKEY_GET")?.[0] as {360        payload: { challenge: string; clientDataJSON: number[]; allowCredentials: { id: string }[] };361      };362      expect(request.payload.challenge).toBe("AQIDBA");363      expect(request.payload.allowCredentials).toEqual([{ id: "BQYHCA", type: "public-key" }]);364      expect(JSON.parse(new TextDecoder().decode(new Uint8Array(request.payload.clientDataJSON))).challenge).toBe("AQIDBA");365    });366367    it("create: preserves the challenge and user ID from the page's ArrayBuffer realm", async () => {368      const { runInNewContext } = await import("node:vm");369      const options = makeCreateOptions();370      options.publicKey!.challenge = runInNewContext("new Uint8Array([1, 2, 3, 4]).buffer");371      options.publicKey!.user.id = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");372      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });373      await navigator.credentials.create(options);374      const request = mockSendMessage.mock.calls[0]![0] as { payload: { challenge: string; userId: string } };375      expect(request.payload.challenge).toBe("AQIDBA");376      expect(request.payload.userId).toBe("BQYHCA");377    });378379    it("get: exposes method results to a page serializing the assertion", async () => {380      const expected = validGetCredential();381      mockSendMessage382        .mockResolvedValueOnce({ ok: true, hasCredentials: true })383        .mockResolvedValueOnce({ ok: true, credential: expected });384      const credential = await navigator.credentials.get(makeGetOptions()) as PublicKeyCredential;385      expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);386      expect(JSON.parse(JSON.stringify(credential))).toMatchObject({387        id: expected.id, response: { signature: expected.signature, userHandle: expected.userHandle },388        clientExtensionResults: {},389      });390    });391392    it("create: exposes binary getters and method results to the page", async () => {393      const expected = validCreateCredential();394      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: expected });395      const credential = await navigator.credentials.create(makeCreateOptions()) as PublicKeyCredential;396      const response = credential.response as AuthenticatorAttestationResponse;397      expect(response.getTransports()).toEqual(["internal"]);398      expect(new Uint8Array(response.getAuthenticatorData())).toHaveLength(37);399      expect(new Uint8Array(response.getPublicKey()!)).toHaveLength(64);400      expect(response.getPublicKeyAlgorithm()).toBe(-7);401      expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);402      expect(JSON.parse(JSON.stringify(credential))).toMatchObject({403        id: expected.id, response: { attestationObject: expected.attestationObject },404        clientExtensionResults: {},405      });406    });407408    it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => {409      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });410411      const cred = await navigator.credentials.create(makeCreateOptions());412      expect(cred).not.toBeNull();413      expect((cred as any).type).toBe("public-key");414      expect((cred as any).id).toBeDefined();415      expect((cred as any).rawId).toBeDefined();416      expect((cred as any).response).toBeDefined();417      expect((cred as any).response.attestationObject).toBeDefined();418    });419420    it("get: returns a PublicKeyCredential-shaped object for a valid response", async () => {421      mockSendMessage422        .mockResolvedValueOnce({ ok: true, hasCredentials: true })423        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });424425      const cred = await navigator.credentials.get(makeGetOptions());426      expect(cred).not.toBeNull();427      expect((cred as any).type).toBe("public-key");428      expect((cred as any).response.signature).toBeDefined();429      expect((cred as any).response.authenticatorData).toBeDefined();430    });431432    it("create: throws NotAllowedError on user_cancelled", async () => {433      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" });434435      await expect(436        navigator.credentials.create(makeCreateOptions()),437      ).rejects.toMatchObject({ name: "NotAllowedError" });438    });439440    it("get: throws NotAllowedError on user_cancelled", async () => {441      mockSendMessage442        .mockResolvedValueOnce({ ok: true, hasCredentials: true })443        .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });444445      await expect(446        navigator.credentials.get(makeGetOptions()),447      ).rejects.toMatchObject({ name: "NotAllowedError" });448    });449450    it("create: falls through on vault_locked", async () => {451      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });452      await expect(453        navigator.credentials.create(makeCreateOptions()),454      ).resolves.toBeNull();455    });456457    it("get: falls through on no_credentials after has-credentials pre-check", async () => {458      mockSendMessage459        .mockResolvedValueOnce({ ok: true, hasCredentials: true })460        .mockResolvedValueOnce({ ok: false, error: "no_credentials" });461      await expect(462        navigator.credentials.get(makeGetOptions()),463      ).resolves.toBeNull();464    });465466    it("get: falls through when has-credentials returns false", async () => {467      mockSendMessage.mockResolvedValueOnce({ ok: true, hasCredentials: false });468      // Should NOT send a PASSKEY_GET469      await navigator.credentials.get(makeGetOptions());470      const calls = mockSendMessage.mock.calls.map((c) => (c[0] as { type: string }).type);471      expect(calls).not.toContain("PASSKEY_GET");472    });473  });474475  describe("origin handling", () => {476    it("create: sends window.location.origin in the request, not a page-supplied value", async () => {477      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });478479      await navigator.credentials.create(makeCreateOptions());480481      const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } };482      expect(call.payload.origin).toBe(window.location.origin);483484      // clientDataJSON should also contain the same origin485      const clientData = JSON.parse(new TextDecoder().decode(new Uint8Array(call.payload.clientDataJSON)));486      expect(clientData.origin).toBe(window.location.origin);487    });488489    it("get: sends window.location.origin in the request", async () => {490      mockSendMessage491        .mockResolvedValueOnce({ ok: true, hasCredentials: true })492        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });493494      await navigator.credentials.get(makeGetOptions());495496      const getCall = mockSendMessage.mock.calls.find(497        (c) => (c[0] as { type: string }).type === "PASSKEY_GET",498      );499      const payload = (getCall![0] as { payload: { origin: string } }).payload;500      expect(payload.origin).toBe(window.location.origin);501    });502  });503504  describe("exportFunction unavailable", () => {505    it("falls through to native when exportFunction is not available", async () => {506      vi.resetModules();507      // Re-stub exportFunction as undefined — simulates unsupported Firefox.508      vi.stubGlobal("exportFunction", undefined);509      vi.stubGlobal("cloneInto", undefined);510511      const nativeCreate = vi.fn(async () => null);512      Object.defineProperty(navigator, "credentials", {513        value: { create: nativeCreate, get: vi.fn(async () => null) },514        configurable: true,515      });516517      await import("./passkeys-firefox");518519      // The native create should be unchanged (not patched)520      await navigator.credentials.create(makeCreateOptions());521      expect(nativeCreate).toHaveBeenCalled();522      // sendMessage should never be called523      expect(mockSendMessage).not.toHaveBeenCalled();524    });525  });526});