fec1978916dd

replace forgeable firefox passkey postmessage relay with exportFunction privileged bridge (PB-26-209, v5.0.65)

time
2026-09-03 18:17 UTC · 1 month ago
machine
koh2
agent
muse-spark-1.3-contributor-free
session
PB-26-209 firefox passkey bridge
parent
2d18f733b547
7 files manifest.firefox.json+1 −11manifest.json+1 −1package.json+1 −1src/content/passkeys-firefox-relay.test.ts+0 −107src/content/passkeys-firefox-relay.ts+0 −74src/content/passkeys-firefox.test.ts+420 −0src/content/passkeys-firefox.ts+264 −180
manifest.firefox.json+1 −11
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.64",4+  "version": "5.0.65",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {@@ -57,16 +57,6 @@5757       ],5858       "js": [5959         "src/content/passkeys-firefox.ts"60-      ],61-      "run_at": "document_start",62-      "world": "MAIN"63-    },64-    {65-      "matches": [66-        "<all_urls>"67-      ],68-      "js": [69-        "src/content/passkeys-firefox-relay.ts"7060       ],7161       "run_at": "document_start"7262     }

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.0.64",5+  "version": "5.0.65",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.0.64",3+  "version": "5.0.65",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/content/passkeys-firefox-relay.test.ts+0 −107
@@ -1,107 +0,0 @@1-// @vitest-environment jsdom2-3-import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";4-5-const mockSendMessage = vi.fn().mockResolvedValue({ ok: true });6-7-vi.stubGlobal("chrome", {8-  runtime: {9-    id: "ext-id",10-    sendMessage: mockSendMessage,11-  },12-});13-14-beforeAll(async () => {15-  await import("./passkeys-firefox-relay");16-});17-18-async function fire(payload: unknown, source: Window | null = window) {19-  window.dispatchEvent(20-    new MessageEvent("message", {21-      data: { type: "PEACH_PASSKEY_REQUEST", id: "x", payload },22-      source,23-    })24-  );25-  await new Promise((resolve) => setTimeout(resolve, 10));26-}27-28-describe("Firefox passkey relay allowlist", () => {29-  beforeEach(() => {30-    mockSendMessage.mockClear();31-  });32-33-  const allowed = [34-    "PASSKEY_CREATE",35-    "PASSKEY_GET",36-    "PASSKEY_HAS_CREDENTIALS",37-    "INITIATE_FIREFOX_PASSKEY_CREATE",38-    "PASSKEY_OFFER_DECLINE",39-    "GET_PASSKEY_OFFER_DECLINED",40-    "GET_PASSKEY_FOR_DOMAIN",41-  ];42-43-  const blocked = [44-    // Popup-only: never page-relayable (Codex Security 2026-08-04).45-    "COMPLETE_FIREFOX_PASSKEY_CREATE",46-    // Approval originates from the extension toast/popup, never the page.47-    // Relayable requestId brute-force → consent bypass (Audit 2026-08-20 #1).48-    "PASSKEY_AUTH_RESPONSE",49-    "GET_ENTRIES",50-    "GET_RECOVERY_WORDS",51-    "DESTROY_VAULT",52-    "ADD_ENTRY",53-    "UPDATE_ENTRY",54-    "DELETE_ENTRY",55-    "CLEAR_VAULT_ENTRIES",56-    "RESTORE_FROM_CODEX",57-    "CHANGE_PASSWORD",58-    "RECOVER_WITH_WORDS",59-    "SETUP_DURESS",60-    "PAIRING_COMPLETED",61-    "ACTIVATE_LICENSE",62-    "TRIGGER_SYNC",63-    "GET_IDENTITIES",64-    "SEARCH_VAULT",65-    "GET_ENTRY_FOR_FILL",66-    "LOCAL_WEBSOCKET_PROBE",67-    "SUBNET_DETECT_PROBE",68-    "",69-    "TOTALLY_MADE_UP",70-  ];71-72-  for (const type of allowed) {73-    it(`forwards allowed message: ${type}`, async () => {74-      await fire({ type });75-      expect(mockSendMessage).toHaveBeenCalledWith({ type });76-    });77-  }78-79-  for (const type of blocked) {80-    it(`blocks message: ${type || "(empty)"}`, async () => {81-      await fire({ type });82-      expect(mockSendMessage).not.toHaveBeenCalled();83-    });84-  }85-86-  it("blocks null payload", async () => {87-    await fire(null);88-    expect(mockSendMessage).not.toHaveBeenCalled();89-  });90-91-  it("blocks empty payload object", async () => {92-    await fire({});93-    expect(mockSendMessage).not.toHaveBeenCalled();94-  });95-96-  it("blocks cross-frame messages", async () => {97-    await fire({ type: "PASSKEY_GET" }, null);98-    expect(mockSendMessage).not.toHaveBeenCalled();99-  });100-101-  it("does not post a response for blocked messages", async () => {102-    const spy = vi.spyOn(window, "postMessage");103-    await fire({ type: "GET_ENTRIES" });104-    expect(spy).not.toHaveBeenCalled();105-    spy.mockRestore();106-  });107-});

view file at the parent

src/content/passkeys-firefox-relay.ts+0 −74
@@ -1,74 +0,0 @@1-/**2- * passkeys-firefox-relay.ts — extension-world relay for the MAIN-world3- * passkey proxy (passkeys-firefox.ts).4- *5- * The MAIN-world script cannot call chrome.runtime.sendMessage — it lives in6- * the page's JS context, not the extension's. This relay listens for the7- * MAIN-world's PEACH_PASSKEY_REQUEST window messages, forwards each payload8- * to the background, and posts the result back as PEACH_PASSKEY_RESPONSE9- * with the same request id.10- *11- * Trust boundary: anything the page can synthesize on `window.message` lands12- * here. Only the message types in ALLOWED_RELAY_TYPES are allowed through to13- * the background. Vault/admin messages must never be proxied from page JS.14- *15- * Removal: deleted alongside the MAIN-world proxy when Firefox ships16- * chrome.webAuthenticationProxy. See src/shared/passkey-flags.ts.17- */18-19-const ALLOWED_RELAY_TYPES = new Set([20-  "PASSKEY_CREATE",21-  "PASSKEY_GET",22-  "PASSKEY_HAS_CREDENTIALS",23-  "INITIATE_FIREFOX_PASSKEY_CREATE",24-  "PASSKEY_OFFER_DECLINE",25-  "GET_PASSKEY_OFFER_DECLINED",26-  "GET_PASSKEY_FOR_DOMAIN",27-]);28-// NOTE: COMPLETE_FIREFOX_PASSKEY_CREATE is deliberately NOT relayable — it is29-// only ever sent by the approval popup (extension page) and is additionally30-// sender-gated to extension origins in messages.ts (Codex Security 2026-08-04).31-// PASSKEY_AUTH_RESPONSE is also NOT relayable: approval originates from the32-// extension-side toast (content script sends chrome.runtime.sendMessage33-// directly, not via this relay) or the approval popup (APPROVE_PASSKEY /34-// DENY_PASSKEY). No MAIN-world path needs it; allowing it here would let a35-// page brute-force the approval requestId. (Audit 2026-08-20 #1.)36-37-window.addEventListener("message", async (event: MessageEvent) => {38-  if (event.source !== window) return;39-  const data = event.data as40-    | { type?: string; id?: string; payload?: unknown }41-    | undefined;42-  if (!data || data.type !== "PEACH_PASSKEY_REQUEST") return;43-  const { id, payload } = data;44-  if (!payload || typeof payload !== "object") return;45-  const payloadType = (payload as { type?: unknown }).type;46-  if (typeof payloadType !== "string" || !ALLOWED_RELAY_TYPES.has(payloadType)) {47-    return;48-  }49-  if (typeof id !== "string") return;50-51-  // NOTE: page-supplied fields in `payload` (including any `origin`) are NOT52-  // trusted. The background re-derives the true caller origin from the message53-  // sender (browser-attested) for all passkey rpId<->origin enforcement — see54-  // attestedSenderOrigin in messages.ts. This relay only gates the message type.55-56-  const reply = (replyPayload: unknown) => {57-    window.postMessage(58-      { type: "PEACH_PASSKEY_RESPONSE", id, payload: replyPayload },59-      window.location.origin60-    );61-  };62-63-  try {64-    const response = await chrome.runtime.sendMessage(payload);65-    reply(response);66-  } catch {67-    // The background may be momentarily unreachable (service worker spin-up,68-    // extension reload). Tell the proxy to fall through to the platform69-    // rather than throwing into the page.70-    reply({ ok: false, error: "extension_error" });71-  }72-});73-74-export {};

view file at the parent

src/content/passkeys-firefox.test.ts+420 −0
@@ -0,0 +1,420 @@1+// @vitest-environment jsdom2+3+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";4+5+/**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+ */18+19+// ── 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.22+const exportedFns = new Map<string, (...args: any[]) => any>();23+24+function 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+}39+40+function stubCloneInto<T>(obj: T, _targetScope: any, _options?: { cloneFunctions?: boolean }): T {41+  return obj;42+}43+44+// ── chrome mock ────────────────────────────────────────────────────────────45+46+let mockSendMessage: ReturnType<typeof vi.fn>;47+48+function 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+}58+59+// ── helpers ────────────────────────────────────────────────────────────────60+61+function 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+}66+67+function 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+}77+78+function 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+}87+88+function 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+}99+100+function 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+}110+111+// ── tests ──────────────────────────────────────────────────────────────────112+113+describe("PB-26-209: exportFunction privileged bridge", () => {114+  beforeEach(async () => {115+    vi.resetModules();116+    setupChromeMock();117+    exportedFns.clear();118+119+    // Stub Firefox privileged APIs120+    vi.stubGlobal("exportFunction", stubExportFunction);121+    vi.stubGlobal("cloneInto", stubCloneInto);122+123+    // Reset the install guard so each test re-installs the proxy.124+    delete (window as any).__peachPasskeyProxyInstalled;125+126+    // 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+    });134+135+    await import("./passkeys-firefox");136+  });137+138+  afterEach(() => {139+    vi.unstubAllGlobals();140+  });141+142+  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");147+148+      // 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);158+159+      // 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+    });171+172+    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+  });186+187+  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+      });193+194+      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+    });199+200+    it("create: falls through when credential type is wrong", async () => {201+      mockSendMessage.mockResolvedValueOnce({202+        ok: true,203+        credential: { ...validCreateCredential(), type: "get" },204+      });205+206+      await expect(207+        navigator.credentials.create(makeCreateOptions()),208+      ).resolves.toBeNull();209+    });210+211+    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 });215+216+      await expect(217+        navigator.credentials.create(makeCreateOptions()),218+      ).resolves.toBeNull();219+    });220+221+    it("create: falls through when attestationObject is not base64url", async () => {222+      mockSendMessage.mockResolvedValueOnce({223+        ok: true,224+        credential: { ...validCreateCredential(), attestationObject: "not valid!" },225+      });226+227+      await expect(228+        navigator.credentials.create(makeCreateOptions()),229+      ).resolves.toBeNull();230+    });231+232+    it("create: falls through when algorithm is unsupported", async () => {233+      mockSendMessage.mockResolvedValueOnce({234+        ok: true,235+        credential: { ...validCreateCredential(), algorithm: -999 },236+      });237+238+      await expect(239+        navigator.credentials.create(makeCreateOptions()),240+      ).resolves.toBeNull();241+    });242+243+    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+        });251+252+      await expect(253+        navigator.credentials.get(makeGetOptions()),254+      ).resolves.toBeNull();255+    });256+257+    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+        });264+265+      await expect(266+        navigator.credentials.get(makeGetOptions()),267+      ).resolves.toBeNull();268+    });269+270+    it("get: falls through when credential is null", async () => {271+      mockSendMessage272+        .mockResolvedValueOnce({ ok: true, hasCredentials: true })273+        .mockResolvedValueOnce({ ok: true, credential: null });274+275+      await expect(276+        navigator.credentials.get(makeGetOptions()),277+      ).resolves.toBeNull();278+    });279+  });280+281+  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+      });291+292+      // Trigger a create call — it should use the captured original, not the replacement.293+      mockSendMessage = original;294+      await navigator.credentials.create(makeCreateOptions());295+296+      // The replacement should NOT have been called.297+      expect(replacement).not.toHaveBeenCalled();298+    });299+  });300+301+  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() });304+305+      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+    });313+314+    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() });318+319+      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+    });325+326+    it("create: throws NotAllowedError on user_cancelled", async () => {327+      mockSendMessage.mockResolvedValueOnce({ ok: false, error: "user_cancelled" });328+329+      await expect(330+        navigator.credentials.create(makeCreateOptions()),331+      ).rejects.toMatchObject({ name: "NotAllowedError" });332+    });333+334+    it("get: throws NotAllowedError on user_cancelled", async () => {335+      mockSendMessage336+        .mockResolvedValueOnce({ ok: true, hasCredentials: true })337+        .mockResolvedValueOnce({ ok: false, error: "user_cancelled" });338+339+      await expect(340+        navigator.credentials.get(makeGetOptions()),341+      ).rejects.toMatchObject({ name: "NotAllowedError" });342+    });343+344+    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+    });350+351+    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+    });359+360+    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+  });368+369+  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() });372+373+      await navigator.credentials.create(makeCreateOptions());374+375+      const call = mockSendMessage.mock.calls[0]![0] as { type: string; payload: { origin: string; clientDataJSON: number[] } };376+      expect(call.payload.origin).toBe(window.location.origin);377+378+      // 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+    });382+383+    it("get: sends window.location.origin in the request", async () => {384+      mockSendMessage385+        .mockResolvedValueOnce({ ok: true, hasCredentials: true })386+        .mockResolvedValueOnce({ ok: true, credential: validGetCredential() });387+388+      await navigator.credentials.get(makeGetOptions());389+390+      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+  });397+398+  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);404+405+      const nativeCreate = vi.fn(async () => null);406+      Object.defineProperty(navigator, "credentials", {407+        value: { create: nativeCreate, get: vi.fn(async () => null) },408+        configurable: true,409+      });410+411+      await import("./passkeys-firefox");412+413+      // 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+});

view file

src/content/passkeys-firefox.ts+264 −180
@@ -1,174 +1,296 @@11 /**2- * passkeys-firefox.ts — MAIN-world WebAuthn interception for Firefox.2+ * passkeys-firefox.ts — isolated-world WebAuthn interception for Firefox.33  *44  * Firefox does not yet expose `chrome.webAuthenticationProxy`, so Peach55  * cannot intercept passkey ceremonies the way it does on Chrome. Until that6- * API ships, this script runs in the page's main world (per7- * `manifest.firefox.json`'s `world: "MAIN"`) and replaces8- * `navigator.credentials.create` and `navigator.credentials.get`.6+ * API ships, this script runs in the extension's isolated world (per7+ * `manifest.firefox.json`'s content_scripts) and replaces8+ * `navigator.credentials.create` and `navigator.credentials.get` using9+ * Firefox's privileged `exportFunction()` API.910  *1011  * Architecture:11- *   - This file owns the page-side patch. It cannot import anything that12- *     touches extension APIs (different world). All extension-bound calls13- *     go through window.postMessage to the relay content script14- *     (passkeys-firefox-relay.ts) running in the extension world.15- *   - The relay forwards each request to the background service worker via16- *     chrome.runtime.sendMessage and posts the response back.12+ *   - This file is the ONLY content script for Firefox passkeys. It runs in13+ *     the isolated world and has direct access to `chrome.runtime.sendMessage`.14+ *   - `exportFunction()` installs wrapper functions into the page's main15+ *     world. The wrappers retain their privileged closure — the page can16+ *     call them but cannot forge their return values or race a separate17+ *     response event (PB-26-209).1718  *   - The background owns the vault, runs the Pro gate, shows the approval1819  *     UI, performs the cryptographic operation, and returns a serialized1920  *     PublicKeyCredential payload.2021  *22+ * Security property: the page cannot forge the privileged function's23+ * returned credential or race a separate public response event. There is24+ * no `window.postMessage` response bus — the wrapper returns the result25+ * directly from its privileged closure.26+ *2127  * Origin guarantee: clientDataJSON is built here with `origin =2228  * window.location.origin`. The RP hashes the same bytes the background23- * signed, and verifies origin against its registered RP — never against24- * `moz-extension://`. This is what makes the assertion validate.29+ * signed, and verifies origin against its registered RP. The background30+ * additionally derives the browser-attested origin from `MessageSender`31+ * and enforces rpId<->origin — page-supplied origin fields are untrusted.2532  *33+ * If `exportFunction` is unavailable (unsupported Firefox version), the34+ * provider is disabled and falls through to native WebAuthn. Do not retain35+ * an unauthenticated response bus for feature parity.36+ *2637  * Removal: when Firefox ships chrome.webAuthenticationProxy, the runtime2738  * flag in src/shared/passkey-flags.ts evaluates to false and this entire28- * file (along with the relay) gets deleted. See that file for the full29- * checklist.39+ * file gets deleted. See that file for the full checklist.3040  */3141 42+// Firefox WebExtension privileged APIs — available in content scripts but43+// not in jsdom. The declarations let TypeScript compile without a DOM lib44+// that includes them.45+declare function exportFunction(46+  func: (...args: any[]) => any,47+  targetScope: any,48+  options?: { defineAs?: string },49+): (...args: any[]) => any;50+51+declare function cloneInto<T>(52+  obj: T,53+  targetScope: any,54+  options?: { cloneFunctions?: boolean },55+): T;56+3257 export function installPeachPasskeyProxy(): void {3358   const w = window as unknown as Record<string, unknown>;3459   if (w.__peachPasskeyProxyInstalled) return;3560   w.__peachPasskeyProxyInstalled = true;3661 62+  // Capture sendMessage at installation time — the wrapper closure must not63+  // re-look it up on a page-writable global (PB-26-209). chrome.runtime is64+  // extension-only (not page-writable), but capturing the reference means65+  // later replacement of chrome.runtime.sendMessage has no effect.66+  const sendMessage = chrome.runtime.sendMessage;67+68+  // exportFunction / cloneInto are Firefox-only. If unavailable (unsupported69+  // Firefox version, or a non-Firefox test context), disable the provider70+  // and fall through to native WebAuthn.71+  if (typeof exportFunction !== "function" || typeof cloneInto !== "function") {72+    return;73+  }74+3775   const credentials = navigator.credentials;3876   if (!credentials || typeof credentials.create !== "function" || typeof credentials.get !== "function") {3977     return;4078   }4179 80+  // Access the page-world credentials object through the Xray boundary.81+  const pageCredentials =82+    (credentials as unknown as { wrappedJSObject?: CredentialsContainer }).wrappedJSObject ??83+    credentials;84+85+  // Save originals for fall-through.4286   const originalCreate = credentials.create.bind(credentials);4387   const originalGet = credentials.get.bind(credentials);4488 45-  credentials.create = async function patchedCreate(46-    options?: CredentialCreationOptions47-  ): Promise<Credential | null> {48-    if (!options?.publicKey) return originalCreate(options);89+  exportFunction(90+    async function patchedCreate(options?: CredentialCreationOptions): Promise<Credential | null> {91+      if (!options?.publicKey) return originalCreate(options);4992 50-    const publicKey = options.publicKey;51-    const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));52-    const clientDataJSON = encodeJsonBytes({53-      type: "webauthn.create",54-      challenge,55-      origin: window.location.origin,56-      crossOrigin: false,57-    });93+      try {94+        const publicKey = options.publicKey;95+        const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));96+        const clientDataJSON = encodeJsonBytes({97+          type: "webauthn.create",98+          challenge,99+          origin: window.location.origin,100+          crossOrigin: false,101+        });58102 59-    const userId = bufferToBase64url(toArrayBuffer(publicKey.user.id));103+        const userId = bufferToBase64url(toArrayBuffer(publicKey.user.id));60104 61-    const response = await sendToBackground({62-      type: "PASSKEY_CREATE",63-      payload: {64-        rpId: publicKey.rp.id ?? window.location.hostname,65-        rpName: publicKey.rp.name,66-        userId,67-        userName: publicKey.user.name,68-        userDisplayName: publicKey.user.displayName,69-        challenge,70-        clientDataJSON: Array.from(clientDataJSON),71-        origin: window.location.origin,72-        pubKeyCredParams: publicKey.pubKeyCredParams,73-        authenticatorSelection: publicKey.authenticatorSelection,74-        timeout: publicKey.timeout,75-        attestation: publicKey.attestation,76-      },77-    });105+        const response = await sendMessage({106+          type: "PASSKEY_CREATE",107+          payload: {108+            rpId: publicKey.rp.id ?? window.location.hostname,109+            rpName: publicKey.rp.name,110+            userId,111+            userName: publicKey.user.name,112+            userDisplayName: publicKey.user.displayName,113+            challenge,114+            clientDataJSON: Array.from(clientDataJSON),115+            origin: window.location.origin,116+            pubKeyCredParams: publicKey.pubKeyCredParams,117+            authenticatorSelection: publicKey.authenticatorSelection,118+            timeout: publicKey.timeout,119+            attestation: publicKey.attestation,120+          },121+        });78122 79-    if (!response?.ok) {80-      const error = response?.error;81-      if (error === "user_cancelled") {82-        // The user denied via Peach. Match the WebAuthn contract — this is83-        // the same exception the platform throws when the user dismisses84-        // the OS prompt.85-        throw new DOMException(86-          "The operation either timed out or was not allowed.",87-          "NotAllowedError"88-        );89-      }90-      if (error === "pro_required") {91-        // Notify the extension-world script to show the inline Pro gate, then92-        // hand the call back to the platform so the user is not stranded.93-        window.dispatchEvent(94-          new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } })95-        );123+        if (!response?.ok) {124+          const error = response?.error;125+          if (error === "user_cancelled") {126+            throw new DOMException(127+              "The operation either timed out or was not allowed.",128+              "NotAllowedError",129+            );130+          }131+          if (error === "pro_required") {132+            window.dispatchEvent(133+              new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),134+            );135+            return originalCreate(options);136+          }137+          // vault_locked / no_credentials / not_applicable / timeout / unknown:138+          // fall through to the platform authenticator.139+          return originalCreate(options);140+        }141+142+        const cred = validateCreateCredential(response.credential);143+        return buildPublicKeyCredential(cred);144+      } catch (err) {145+        if (err instanceof DOMException && err.name === "NotAllowedError") throw err;146+        // Validation failure or unexpected error — fail closed, fall through.96147         return originalCreate(options);97148       }98-      // vault_locked / no_credentials / not_applicable / timeout / unknown:99-      // fall through to the platform authenticator. Peach is not in a100-      // position to fulfill this ceremony right now and the user should101-      // still be able to register / authenticate against the RP.102-      return originalCreate(options);103-    }149+    },150+    pageCredentials,151+    { defineAs: "create" },152+  );104153 105-    return buildPublicKeyCredential(response.credential);106-  };154+  exportFunction(155+    async function patchedGet(options?: CredentialRequestOptions): Promise<Credential | null> {156+      if (!options?.publicKey) return originalGet(options);107157 108-  credentials.get = async function patchedGet(109-    options?: CredentialRequestOptions110-  ): Promise<Credential | null> {111-    if (!options?.publicKey) return originalGet(options);158+      try {159+        const publicKey = options.publicKey;160+        const rpId = publicKey.rpId ?? window.location.hostname;161+        const allowCredentials = (publicKey.allowCredentials ?? []).map((c) => ({162+          id: bufferToBase64url(toArrayBuffer(c.id)),163+          type: c.type,164+        }));112165 113-    const publicKey = options.publicKey;114-    const rpId = publicKey.rpId ?? window.location.hostname;115-    const allowCredentials = (publicKey.allowCredentials ?? []).map((c) => ({116-      id: bufferToBase64url(toArrayBuffer(c.id)),117-      type: c.type,118-    }));166+        // Cheap pre-check before showing any Peach UI.167+        const has = await sendMessage({168+          type: "PASSKEY_HAS_CREDENTIALS",169+          payload: { rpId, allowCredentials },170+        });171+        if (!has?.ok || !has.hasCredentials) {172+          return originalGet(options);173+        }119174 120-    // Cheap pre-check before showing any Peach UI. If the vault has no match121-    // for this rpId, hand the ceremony to the platform — no point prompting.122-    const has = await sendToBackground({123-      type: "PASSKEY_HAS_CREDENTIALS",124-      payload: { rpId, allowCredentials },125-    });126-    if (!has?.ok || !has.hasCredentials) {127-      return originalGet(options);128-    }175+        const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));176+        const clientDataJSON = encodeJsonBytes({177+          type: "webauthn.get",178+          challenge,179+          origin: window.location.origin,180+          crossOrigin: false,181+        });129182 130-    const challenge = bufferToBase64url(toArrayBuffer(publicKey.challenge));131-    const clientDataJSON = encodeJsonBytes({132-      type: "webauthn.get",133-      challenge,134-      origin: window.location.origin,135-      crossOrigin: false,136-    });183+        const response = await sendMessage({184+          type: "PASSKEY_GET",185+          payload: {186+            rpId,187+            challenge,188+            clientDataJSON: Array.from(clientDataJSON),189+            origin: window.location.origin,190+            allowCredentials,191+            userVerification: publicKey.userVerification,192+          },193+        });137194 138-    const response = await sendToBackground({139-      type: "PASSKEY_GET",140-      payload: {141-        rpId,142-        challenge,143-        clientDataJSON: Array.from(clientDataJSON),144-        origin: window.location.origin,145-        allowCredentials,146-        userVerification: publicKey.userVerification,147-      },148-    });195+        if (!response?.ok) {196+          const error = response?.error;197+          if (error === "user_cancelled") {198+            throw new DOMException(199+              "The operation either timed out or was not allowed.",200+              "NotAllowedError",201+            );202+          }203+          if (error === "pro_required") {204+            window.dispatchEvent(205+              new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } }),206+            );207+            return originalGet(options);208+          }209+          return originalGet(options);210+        }149211 150-    if (!response?.ok) {151-      const error = response?.error;152-      if (error === "user_cancelled") {153-        throw new DOMException(154-          "The operation either timed out or was not allowed.",155-          "NotAllowedError"156-        );157-      }158-      if (error === "pro_required") {159-        window.dispatchEvent(160-          new CustomEvent("peach:passkey-pro-gate", { detail: { feature: "passkeys" } })161-        );212+        const cred = validateGetCredential(response.credential);213+        return buildPublicKeyCredential(cred);214+      } catch (err) {215+        if (err instanceof DOMException && err.name === "NotAllowedError") throw err;162216         return originalGet(options);163217       }164-      return originalGet(options);165-    }218+    },219+    pageCredentials,220+    { defineAs: "get" },221+  );166222 167-    return buildPublicKeyCredential(response.credential);168-  };223+  // ── validation ────────────────────────────────────────────────────────169224 225+  const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;226+227+  function isBase64url(value: unknown): value is string {228+    return typeof value === "string" && value.length > 0 && BASE64URL_RE.test(value);229+  }230+231+  function isByteArray(value: unknown): value is number[] {232+    return (233+      Array.isArray(value) &&234+      value.length > 0 &&235+      value.every((b) => typeof b === "number" && Number.isInteger(b) && b >= 0 && b <= 255)236+    );237+  }238+239+  function validateCreateCredential(cred: unknown): CreateCredential {240+    if (!cred || typeof cred !== "object") throw new ValidationError("missing credential");241+    const c = cred as Record<string, unknown>;242+    if (c.type !== "create") throw new ValidationError("wrong credential type");243+    if (!isBase64url(c.id)) throw new ValidationError("id must be base64url");244+    if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array");245+    if (!isBase64url(c.attestationObject)) throw new ValidationError("attestationObject must be base64url");246+    if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url");247+    if (!isBase64url(c.publicKey)) throw new ValidationError("publicKey must be base64url");248+    if (c.algorithm !== -7 && c.algorithm !== -8) throw new ValidationError("unsupported algorithm");249+    return c as unknown as CreateCredential;250+  }251+252+  function validateGetCredential(cred: unknown): GetCredential {253+    if (!cred || typeof cred !== "object") throw new ValidationError("missing credential");254+    const c = cred as Record<string, unknown>;255+    if (c.type !== "get") throw new ValidationError("wrong credential type");256+    if (!isBase64url(c.id)) throw new ValidationError("id must be base64url");257+    if (!isByteArray(c.clientDataJSON)) throw new ValidationError("clientDataJSON must be a byte array");258+    if (!isBase64url(c.authenticatorData)) throw new ValidationError("authenticatorData must be base64url");259+    if (!isBase64url(c.signature)) throw new ValidationError("signature must be base64url");260+    if (c.userHandle !== undefined && c.userHandle !== null && !isBase64url(c.userHandle)) {261+      throw new ValidationError("userHandle must be base64url");262+    }263+    return c as unknown as GetCredential;264+  }265+170266   // ── helpers ────────────────────────────────────────────────────────────171267 268+  class ValidationError extends Error {269+    constructor(message: string) {270+      super(message);271+      this.name = "ValidationError";272+    }273+  }274+275+  interface CreateCredential {276+    type: "create";277+    id: string;278+    clientDataJSON: number[];279+    attestationObject: string;280+    authenticatorData: string;281+    publicKey: string;282+    algorithm: -7 | -8;283+  }284+285+  interface GetCredential {286+    type: "get";287+    id: string;288+    clientDataJSON: number[];289+    authenticatorData: string;290+    signature: string;291+    userHandle: string | null;292+  }293+172294   function toArrayBuffer(view: BufferSource): ArrayBuffer {173295     if (view instanceof ArrayBuffer) return view;174296     const u8 = view as ArrayBufferView;@@ -187,7 +309,7 @@187309   function base64urlToBuffer(value: string): ArrayBuffer {188310     const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(189311       Math.ceil(value.length / 4) * 4,190-      "="312+      "=",191313     );192314     const raw = atob(padded);193315     const bytes = new Uint8Array(raw.length);@@ -200,57 +322,17 @@200322   }201323 202324   /**203-   * postMessage round-trip with a per-call UUID. The relay (extension-world204-   * content script) listens for PEACH_PASSKEY_REQUEST and posts back205-   * PEACH_PASSKEY_RESPONSE with the same id. A 60s timeout matches the206-   * background's approval window — outliving the user's decision time207-   * without leaking the listener forever.208-   */209-  function sendToBackground(message: object): Promise<any> {210-    return new Promise((resolve) => {211-      const id = generateRequestId();212-      const handler = (event: MessageEvent) => {213-        if (event.source !== window) return;214-        const data = event.data;215-        if (!data || typeof data !== "object") return;216-        if ((data as { type?: string }).type !== "PEACH_PASSKEY_RESPONSE") return;217-        if ((data as { id?: string }).id !== id) return;218-        cleanup();219-        resolve((data as { payload?: unknown }).payload);220-      };221-      const timer = setTimeout(() => {222-        cleanup();223-        resolve({ ok: false, error: "timeout" });224-      }, 60_000);225-      function cleanup() {226-        window.removeEventListener("message", handler);227-        clearTimeout(timer);228-      }229-      window.addEventListener("message", handler);230-      window.postMessage({ type: "PEACH_PASSKEY_REQUEST", id, payload: message }, "*");231-    });232-  }233-234-  function generateRequestId(): string {235-    if (typeof crypto.randomUUID === "function") return crypto.randomUUID();236-    const bytes = crypto.getRandomValues(new Uint8Array(16));237-    return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");238-  }239-240-  /**241325    * Reconstruct a PublicKeyCredential-shaped object from the background's242-   * serialized response. The shape must satisfy what RPs read off the243-   * credential — id, rawId, response.{clientDataJSON, attestationObject |244-   * authenticatorData, signature, userHandle}, plus the helper getters that245-   * some RPs call (getPublicKey, getTransports, etc.).326+   * serialized response. `cloneInto` makes the object and its methods327+   * callable from page-world — without it the page would see isolated-world328+   * objects through an Xray wrapper and could not call the getter methods.246329    *247330    * `instanceof PublicKeyCredential` will be false because we cannot248-   * construct a real one from page script. RPs that perform that check249-   * directly will reject — but no major RP does, because the original250-   * Spec/JS-API path on iOS Safari produced non-instance credentials for251-   * years. RPs read the duck-typed shape.331+   * construct a real one from content script. RPs that perform that check332+   * directly will reject — but no major RP does. RPs read the duck-typed333+   * shape.252334    */253-  function buildPublicKeyCredential(cred: any): PublicKeyCredential {335+  function buildPublicKeyCredential(cred: CreateCredential | GetCredential): PublicKeyCredential {254336     const credentialId = base64urlToBuffer(cred.id);255337     const clientDataJSON = new Uint8Array(cred.clientDataJSON).buffer;256338 @@ -258,11 +340,11 @@258340       const attestationObject = base64urlToBuffer(cred.attestationObject);259341       const authenticatorData = base64urlToBuffer(cred.authenticatorData);260342       const publicKey = base64urlToBuffer(cred.publicKey);261-      return {343+      const obj = {262344         id: cred.id,263345         rawId: credentialId,264-        type: "public-key",265-        authenticatorAttachment: "platform",346+        type: "public-key" as const,347+        authenticatorAttachment: "platform" as const,266348         response: {267349           clientDataJSON,268350           attestationObject,@@ -275,7 +357,7 @@275357         toJSON: () => ({276358           id: cred.id,277359           rawId: cred.id,278-          type: "public-key",360+          type: "public-key" as const,279361           response: {280362             clientDataJSON: bufferToBase64url(clientDataJSON),281363             attestationObject: cred.attestationObject,@@ -284,20 +366,21 @@284366             publicKeyAlgorithm: cred.algorithm ?? -7,285367             transports: ["internal"],286368           },287-          authenticatorAttachment: "platform",369+          authenticatorAttachment: "platform" as const,288370           clientExtensionResults: {},289371         }),290372       } as unknown as PublicKeyCredential;373+      return cloneInto(obj, window, { cloneFunctions: true });291374     }292375 293376     const authenticatorData = base64urlToBuffer(cred.authenticatorData);294377     const signature = base64urlToBuffer(cred.signature);295378     const userHandle = cred.userHandle ? base64urlToBuffer(cred.userHandle) : null;296-    return {379+    const obj = {297380       id: cred.id,298381       rawId: credentialId,299-      type: "public-key",300-      authenticatorAttachment: "platform",382+      type: "public-key" as const,383+      authenticatorAttachment: "platform" as const,301384       response: {302385         clientDataJSON,303386         authenticatorData,@@ -308,17 +391,18 @@308391       toJSON: () => ({309392         id: cred.id,310393         rawId: cred.id,311-        type: "public-key",394+        type: "public-key" as const,312395         response: {313396           clientDataJSON: bufferToBase64url(clientDataJSON),314397           authenticatorData: cred.authenticatorData,315398           signature: cred.signature,316399           userHandle: cred.userHandle ?? null,317400         },318-        authenticatorAttachment: "platform",401+        authenticatorAttachment: "platform" as const,319402         clientExtensionResults: {},320403       }),321404     } as unknown as PublicKeyCredential;405+    return cloneInto(obj, window, { cloneFunctions: true });322406   }323407 }324408 

view file