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
↓ peach-browser-fec19789.tar.gzatlas at this save
files changed7 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 −180manifest.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 }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",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",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-});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 {};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+});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