passkeys-firefox.test.ts

26.1 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: rejects 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      ).rejects.toMatchObject({ name: "NotAllowedError" });285    });286287    it("get: rejects 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      ).rejects.toMatchObject({ name: "NotAllowedError" });298    });299300    it("get: rejects 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      ).rejects.toMatchObject({ name: "NotAllowedError" });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("get: waits for a cold background instead of opening Firefox native UI", async () => {346      mockSendMessage347        .mockRejectedValueOnce(new Error("Could not establish connection. Receiving end does not exist."))348        .mockResolvedValueOnce({ ok: false, error: "vault_locked" })349        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });350      const result = await navigator.credentials.get(makeGetOptions());351      expect(result?.id).toBe(validGetCredential().id);352      expect(nativeGet).not.toHaveBeenCalled();353    });354355    it("get: retries a missing background response before continuing through unlock", async () => {356      mockSendMessage357        .mockResolvedValueOnce(undefined)358        .mockResolvedValueOnce({ ok: false, error: "vault_locked" })359        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });360      expect((await navigator.credentials.get(makeGetOptions()))?.id).toBe(validGetCredential().id);361      expect(nativeGet).not.toHaveBeenCalled();362    });363364    it("get: rejects unavailable Peach instead of silently opening native Firefox UI", async () => {365      mockSendMessage.mockRejectedValue(new Error("Receiving end does not exist"));366      await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });367      expect(mockSendMessage).toHaveBeenCalledTimes(3);368      expect(nativeGet).not.toHaveBeenCalled();369    });370371    it("get: rejects a Peach operation error instead of opening native Firefox UI", async () => {372      mockSendMessage373        .mockResolvedValueOnce({ ok: true, hasCredentials: true })374        .mockResolvedValueOnce({ ok: false, error: "operation_failed" });375      await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });376      expect(nativeGet).not.toHaveBeenCalled();377    });378379    it("get: never retries a signing request after a delivery error", async () => {380      mockSendMessage381        .mockResolvedValueOnce({ ok: true, hasCredentials: true })382        .mockRejectedValueOnce(new Error("Background disconnected"));383      await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });384      expect(mockSendMessage).toHaveBeenCalledTimes(2);385      expect(nativeGet).not.toHaveBeenCalled();386    });387388    it("get: preserves native sign-in when interception is disabled", async () => {389      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "not_applicable" });390      const options = makeGetOptions();391      await expect(navigator.credentials.get(options)).resolves.toBeNull();392      expect(nativeGet).toHaveBeenCalledWith(options);393      expect(mockSendMessage).toHaveBeenCalledTimes(1);394    });395396    it("get: rejects a failed credential check without retrying a delivered reply", async () => {397      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "internal_error" });398      await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });399      expect(mockSendMessage).toHaveBeenCalledTimes(1);400      expect(nativeGet).not.toHaveBeenCalled();401    });402403    it("get: preserves an explicit security-key request without contacting Peach", async () => {404      const options = makeGetOptions();405      options.publicKey = { ...options.publicKey!, hints: ["security-key"] } as PublicKeyCredentialRequestOptions;406      await expect(navigator.credentials.get(options)).resolves.toBeNull();407      expect(nativeGet).toHaveBeenCalledWith(options);408      expect(mockSendMessage).not.toHaveBeenCalled();409    });410411    it.each([412      "new Uint8Array([1, 2, 3, 4]).buffer",413      "new Uint8Array(new Uint8Array([9, 1, 2, 3, 4, 9]).buffer, 1, 4)",414    ])("get: preserves page-realm buffers and view offsets (%s)", async (expression) => {415      const { runInNewContext } = await import("node:vm");416      const challenge: BufferSource = runInNewContext(expression);417      const credentialId: ArrayBuffer = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");418      expect(challenge instanceof ArrayBuffer).toBe(false);419      mockSendMessage420        .mockResolvedValueOnce({ ok: true, hasCredentials: true })421        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });422      await navigator.credentials.get({423        publicKey: { challenge, rpId: "github.com", allowCredentials: [{ id: credentialId, type: "public-key" }] },424      });425      const request = mockSendMessage.mock.calls.find(([message]) => message.type === "PASSKEY_GET")?.[0] as {426        payload: { challenge: string; clientDataJSON: number[]; allowCredentials: { id: string }[] };427      };428      expect(request.payload.challenge).toBe("AQIDBA");429      expect(request.payload.allowCredentials).toEqual([{ id: "BQYHCA", type: "public-key" }]);430      expect(JSON.parse(new TextDecoder().decode(new Uint8Array(request.payload.clientDataJSON))).challenge).toBe("AQIDBA");431    });432433    it("create: preserves the challenge and user ID from the page's ArrayBuffer realm", async () => {434      const { runInNewContext } = await import("node:vm");435      const options = makeCreateOptions();436      options.publicKey!.challenge = runInNewContext("new Uint8Array([1, 2, 3, 4]).buffer");437      options.publicKey!.user.id = runInNewContext("new Uint8Array([5, 6, 7, 8]).buffer");438      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });439      await navigator.credentials.create(options);440      const request = mockSendMessage.mock.calls[0]![0] as { payload: { challenge: string; userId: string } };441      expect(request.payload.challenge).toBe("AQIDBA");442      expect(request.payload.userId).toBe("BQYHCA");443    });444445    it("get: exposes method results to a page serializing the assertion", async () => {446      const expected = validGetCredential();447      mockSendMessage448        .mockResolvedValueOnce({ ok: true, hasCredentials: true })449        .mockResolvedValueOnce({ ok: true, credential: expected });450      const credential = await navigator.credentials.get(makeGetOptions()) as PublicKeyCredential;451      expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);452      expect(JSON.parse(JSON.stringify(credential))).toMatchObject({453        id: expected.id, response: { signature: expected.signature, userHandle: expected.userHandle },454        clientExtensionResults: {},455      });456    });457458    it("create: exposes binary getters and method results to the page", async () => {459      const expected = validCreateCredential();460      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: expected });461      const credential = await navigator.credentials.create(makeCreateOptions()) as PublicKeyCredential;462      const response = credential.response as AuthenticatorAttestationResponse;463      expect(response.getTransports()).toEqual(["internal"]);464      expect(new Uint8Array(response.getAuthenticatorData())).toHaveLength(37);465      expect(new Uint8Array(response.getPublicKey()!)).toHaveLength(64);466      expect(response.getPublicKeyAlgorithm()).toBe(-7);467      expect(Object.keys(credential.getClientExtensionResults())).toEqual([]);468      expect(JSON.parse(JSON.stringify(credential))).toMatchObject({469        id: expected.id, response: { attestationObject: expected.attestationObject },470        clientExtensionResults: {},471      });472    });473474    it("create: returns a PublicKeyCredential-shaped object for a valid response", async () => {475      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });476477      const cred = await navigator.credentials.create(makeCreateOptions());478      expect(cred).not.toBeNull();479      expect((cred as any).type).toBe("public-key");480      expect((cred as any).id).toBeDefined();481      expect((cred as any).rawId).toBeDefined();482      expect((cred as any).response).toBeDefined();483      expect((cred as any).response.attestationObject).toBeDefined();484    });485486    it("get: returns a PublicKeyCredential-shaped object for a valid response", async () => {487      mockSendMessage488        .mockResolvedValueOnce({ ok: true, hasCredentials: true })489        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });490491      const cred = await navigator.credentials.get(makeGetOptions());492      expect(cred).not.toBeNull();493      expect((cred as any).type).toBe("public-key");494      expect((cred as any).response.signature).toBeDefined();495      expect((cred as any).response.authenticatorData).toBeDefined();496    });497498    it("create: throws NotAllowedError on user_cancelled", async () => {499      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" });500501      await expect(502        navigator.credentials.create(makeCreateOptions()),503      ).rejects.toMatchObject({ name: "NotAllowedError" });504    });505506    it("get: throws NotAllowedError on user_cancelled", async () => {507      mockSendMessage508        .mockResolvedValueOnce({ ok: true, hasCredentials: true })509        .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });510511      await expect(512        navigator.credentials.get(makeGetOptions()),513      ).rejects.toMatchObject({ name: "NotAllowedError" });514    });515516    it("create: falls through on vault_locked", async () => {517      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });518      await expect(519        navigator.credentials.create(makeCreateOptions()),520      ).resolves.toBeNull();521    });522523    it("get: continues through unlock when the credential check reports vault_locked", async () => {524      mockSendMessage525        .mockResolvedValueOnce({ ok: false, error: "vault_locked" })526        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });527      const result = await navigator.credentials.get(makeGetOptions());528      expect(result?.id).toBe(validGetCredential().id);529      expect(nativeGet).not.toHaveBeenCalled();530    });531532    it("get: rejects an abandoned unlock without opening native Firefox UI", async () => {533      mockSendMessage534        .mockResolvedValueOnce({ ok: false, error: "vault_locked" })535        .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });536      await expect(navigator.credentials.get(makeGetOptions())).rejects.toMatchObject({ name: "NotAllowedError" });537      expect(nativeGet).not.toHaveBeenCalled();538    });539540    it("get: falls through on no_credentials after has-credentials pre-check", async () => {541      mockSendMessage542        .mockResolvedValueOnce({ ok: true, hasCredentials: true })543        .mockResolvedValueOnce({ ok: false, error: "no_credentials" });544      await expect(545        navigator.credentials.get(makeGetOptions()),546      ).resolves.toBeNull();547    });548549    it("get: falls through when has-credentials returns false", async () => {550      mockSendMessage.mockResolvedValueOnce({ ok: true, hasCredentials: false });551      // Should NOT send a PASSKEY_GET552      await navigator.credentials.get(makeGetOptions());553      const calls = mockSendMessage.mock.calls.map((c) => (c[0] as { type: string }).type);554      expect(calls).not.toContain("PASSKEY_GET");555    });556  });557558  describe("origin handling", () => {559    it("create: sends window.location.origin in the request, not a page-supplied value", async () => {560      mockSendMessage.mockResolvedValueOnce({ ok: true, credential: validCreateCredential() });561562      await navigator.credentials.create(makeCreateOptions());563564      const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } };565      expect(call.payload.origin).toBe(window.location.origin);566567      // clientDataJSON should also contain the same origin568      const clientData = JSON.parse(new TextDecoder().decode(new Uint8Array(call.payload.clientDataJSON)));569      expect(clientData.origin).toBe(window.location.origin);570    });571572    it("get: sends window.location.origin in the request", async () => {573      mockSendMessage574        .mockResolvedValueOnce({ ok: true, hasCredentials: true })575        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });576577      await navigator.credentials.get(makeGetOptions());578579      const getCall = mockSendMessage.mock.calls.find(580        (c) => (c[0] as { type: string }).type === "PASSKEY_GET",581      );582      const payload = (getCall![0] as { payload: { origin: string } }).payload;583      expect(payload.origin).toBe(window.location.origin);584    });585  });586587  describe("exportFunction unavailable", () => {588    it("falls through to native when exportFunction is not available", async () => {589      vi.resetModules();590      // Re-stub exportFunction as undefined — simulates unsupported Firefox.591      vi.stubGlobal("exportFunction", undefined);592      vi.stubGlobal("cloneInto", undefined);593594      const nativeCreate = vi.fn(async () => null);595      Object.defineProperty(navigator, "credentials", {596        value: { create: nativeCreate, get: vi.fn(async () => null) },597        configurable: true,598      });599600      await import("./passkeys-firefox");601602      // The native create should be unchanged (not patched)603      await navigator.credentials.create(makeCreateOptions());604      expect(nativeCreate).toHaveBeenCalled();605      // sendMessage should never be called606      expect(mockSendMessage).not.toHaveBeenCalled();607    });608  });609});