819c2b01c71c

fix(security): enforce Firefox passkey rpId against the browser-attested sender origin, not page-supplied payload.origin — closes cross-origin assertion/credential forgery; verify clientDataJSON origin; scope HAS_CREDENTIALS+GET_PASSKEY_FOR_DOMAIN to caller origin (CRITICAL) (v3.6.123)

time
2026-05-28 22:02 UTC · 5 months ago
machine
koh2
parent
77d4f52c16bb
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+25 −3src/background/passkey-firefox-proxy.test.ts+80 −5src/background/passkey.ts+68 −24src/content/passkeys-firefox-relay.ts+5 −0
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "3.6.122",5+  "version": "3.6.123",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.6.122",4+  "version": "3.6.123",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.122",3+  "version": "3.6.123",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+25 −3
@@ -54,6 +54,7 @@5454 } from "../shared/types";5555 import { isPasskeyOnly } from "../shared/types";5656 import { generateTOTP } from "../crypto/totp";57+import { isValidRpId } from "../shared/passkey-utils";5758 import { isNativePlatform, nativePlugin } from "../shared/native";5859 import { predictBatch as mlPredictBatch } from "./ml";5960 import type { MLFieldFeatures } from "../shared/ml";@@ -183,6 +184,22 @@183184   return typeof m === "object" && m !== null && "type" in m;184185 }185186 187+/**188+ * The browser-attested origin of the page that originated this message. A web189+ * page cannot call chrome.runtime.sendMessage directly (no externally_connectable),190+ * so every page-driven message arrives via the content-script relay and the191+ * browser stamps `sender.origin`/`sender.url` with the real frame origin — a192+ * value the page cannot forge. This is the ONLY trustworthy origin for passkey193+ * rpId<->origin enforcement; payload-supplied origins are attacker-controlled.194+ */195+function attestedSenderOrigin(sender: Partial<chrome.runtime.MessageSender>): string | undefined {196+  if (sender.origin) return sender.origin;197+  if (sender.url) {198+    try { return new URL(sender.url).origin; } catch { return undefined; }199+  }200+  return undefined;201+}202+186203 export async function handleMessage(187204   msg: Message,188205   sender: Partial<chrome.runtime.MessageSender> = {}@@ -924,7 +941,7 @@924941         return await handlePasskeyHasCredentials({925942           rpId: payload.rpId,926943           allowCredentials: payload.allowCredentials ?? [],927-        });944+        }, attestedSenderOrigin(sender));928945       }929946 930947       case "PASSKEY_CREATE": {@@ -932,13 +949,13 @@932949         if (!payload?.rpId || !payload?.userName) {933950           return { ok: false, error: "rpid_and_username_required" };934951         }935-        return await handleFirefoxPasskeyCreate(payload);952+        return await handleFirefoxPasskeyCreate(payload, attestedSenderOrigin(sender));936953       }937954 938955       case "PASSKEY_GET": {939956         const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;940957         if (!payload?.rpId) return { ok: false, error: "rpid_required" };941-        return await handleFirefoxPasskeyGet(payload);958+        return await handleFirefoxPasskeyGet(payload, attestedSenderOrigin(sender));942959       }943960 944961       case "PASSKEY_AUTH_RESPONSE": {@@ -956,6 +973,11 @@956973       case "GET_PASSKEY_FOR_DOMAIN": {957974         const { domain } = (msg.payload ?? {}) as { domain?: string };958975         if (!domain) return { ok: false, error: "domain_required" };976+        // Scope to the caller's own origin: a page may only ask about passkeys977+        // for a domain it actually owns, so an arbitrary page cannot enumerate978+        // which sites the user holds passkeys for or read the username (audit MEDIUM).979+        const origin = attestedSenderOrigin(sender);980+        if (!origin || !isValidRpId(domain, origin)) return { ok: true, data: null };959981         if (!vault.isUnlocked()) return { ok: true, data: null };960982         const match = vault961983           .getEntries()

view file

src/background/passkey-firefox-proxy.test.ts+80 −5
@@ -114,9 +114,12 @@114114     vi.unstubAllGlobals();115115   });116116 117-  async function dispatch(message: Message): Promise<MessageResponse> {117+  // senderUrl models the browser-attested page origin the relay's message118+  // carries. Defaults to github.com so the existing github.com ceremonies pass119+  // their rpId<->origin check; cross-origin tests pass an attacker URL.120+  async function dispatch(message: Message, senderUrl = "https://github.com/login"): Promise<MessageResponse> {118121     return await new Promise<MessageResponse>((resolve) => {119-      const open = listener(message, { id: "peach-extension" }, (r) => resolve(r));122+      const open = listener(message, { id: "peach-extension", url: senderUrl, origin: new URL(senderUrl).origin }, (r) => resolve(r));120123       expect(open).toBe(true);121124     });122125   }@@ -248,10 +251,10 @@248251     await vault.createVault("correct-pass-12345");249252 250253     autoApprove();254+    // evil.com (attested sender) requests a credential scoped to google.com.251255     const res = await dispatch({252256       type: "PASSKEY_CREATE",253257       payload: {254-        // evil.com requests a credential scoped to google.com — must be refused.255258         rpId: "google.com",256259         rpName: "Google",257260         userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),@@ -262,7 +265,7 @@262265         origin: "https://evil.com",263266         pubKeyCredParams: [{ alg: -7, type: "public-key" }],264267       },265-    });268+    }, "https://evil.com/attack");266269 267270     expect(res.ok).toBe(false);268271     expect(String(res.error)).toContain("SecurityError");@@ -283,10 +286,82 @@283286         origin: "https://evil.com",284287         allowCredentials: [],285288       },286-    });289+    }, "https://evil.com/attack");287290 288291     expect(res.ok).toBe(false);289292     expect(String(res.error)).toContain("SecurityError");293+  });294+295+  it("PASSKEY_GET refuses a FORGED payload.origin that matches rpId when the attested sender origin differs (audit CRITICAL — origin spoofing)", async () => {296+    const vault = await import("./vault");297+    await vault.createVault("correct-pass-12345");298+299+    // The attacker self-consistently forges rpId + payload.origin + clientDataJSON300+    // origin all to victimbank.com — exactly the bypass that defeated the old301+    // isValidRpId(payload.rpId, payload.origin) check. The real frame is evil.com.302+    const forgedClientData = Array.from(new TextEncoder().encode(303+      JSON.stringify({ type: "webauthn.get", challenge: "x", origin: "https://victimbank.com", crossOrigin: false }),304+    ));305+    const res = await dispatch({306+      type: "PASSKEY_GET",307+      payload: {308+        rpId: "victimbank.com",309+        origin: "https://victimbank.com",310+        challenge: "x",311+        clientDataJSON: forgedClientData,312+        allowCredentials: [],313+      },314+    }, "https://evil.com/attack");315+316+    expect(res.ok).toBe(false);317+    expect(String(res.error)).toContain("SecurityError");318+  });319+320+  it("PASSKEY_HAS_CREDENTIALS is scoped to the caller origin (no cross-site enumeration)", async () => {321+    const vault = await import("./vault");322+    await vault.createVault("correct-pass-12345");323+    await vault.addEntry({324+      type: "login", title: "GitHub", url: "https://github.com", username: "june",325+      passkey: {326+        rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",327+        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,328+        userHandle: "u", userName: "june", userDisplayName: "June", algorithm: -7,329+        signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,330+      },331+    });332+333+    // A page on evil.com may not learn the user holds a github.com passkey.334+    await expect(dispatch({335+      type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] },336+    }, "https://evil.com/")).resolves.toMatchObject({ ok: true, hasCredentials: false });337+338+    // github.com itself sees its own credential.339+    await expect(dispatch({340+      type: "PASSKEY_HAS_CREDENTIALS", payload: { rpId: "github.com", allowCredentials: [] },341+    }, "https://github.com/")).resolves.toMatchObject({ ok: true, hasCredentials: true });342+  });343+344+  it("GET_PASSKEY_FOR_DOMAIN is scoped to the caller origin (no username leak cross-site)", async () => {345+    const vault = await import("./vault");346+    await vault.createVault("correct-pass-12345");347+    await vault.addEntry({348+      type: "login", title: "GitHub", url: "https://github.com", username: "june",349+      passkey: {350+        rpId: "github.com", rpName: "GitHub", credentialId: "c1", publicKeyCose: "pk",351+        privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" } as JsonWebKey,352+        userHandle: "u", userName: "[email protected]", userDisplayName: "June", algorithm: -7,353+        signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1,354+      },355+    });356+357+    await expect(dispatch({358+      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },359+    }, "https://evil.com/")).resolves.toMatchObject({ ok: true, data: null });360+361+    const own = await dispatch({362+      type: "GET_PASSKEY_FOR_DOMAIN", payload: { domain: "github.com" },363+    }, "https://github.com/");364+    expect((own.data as { userName?: string } | null)?.userName).toBe("[email protected]");290365   });291366 292367   it("handleFirefoxPasskeyCreate throws a SecurityError directly on rpId mismatch", async () => {

view file

src/background/passkey.ts+68 −24
@@ -987,12 +987,62 @@987987   );988988 }989989 990+/** Decode the `origin` field out of a WebAuthn clientDataJSON byte array. */991+function clientDataJsonOrigin(bytes: number[] | undefined): string | null {992+  if (!Array.isArray(bytes)) return null;993+  try {994+    const obj = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes))) as { origin?: unknown };995+    return typeof obj.origin === "string" ? obj.origin : null;996+  } catch {997+    return null;998+  }999+}1000+1001+/**1002+ * Enforce the WebAuthn rpId<->origin relationship on the Firefox proxy path,1003+ * where Peach is the authenticator. The ONLY trustworthy origin is the one the1004+ * browser attested for the calling page (`callerOrigin`, derived from the1005+ * message sender in messages.ts) — NEVER `payload.origin`, which the page1006+ * controls and can set to match any rpId (SEV-1-A / audit CRITICAL). Also1007+ * refuse to sign a clientDataJSON whose embedded origin disagrees with the1008+ * attested origin, so a forged ceremony can't smuggle a foreign origin into1009+ * the signed bytes the RP verifies.1010+ */1011+function assertOriginOwnsRpId(1012+  rpId: string,1013+  callerOrigin: string | undefined,1014+  clientDataJSON?: number[],1015+): void {1016+  if (!callerOrigin || !isValidRpId(rpId, callerOrigin)) {1017+    throw new DOMException(1018+      `SecurityError: relying party ID "${rpId}" is not a registrable domain ` +1019+        `suffix of the calling origin "${callerOrigin ?? "(unknown)"}".`,1020+      "SecurityError",1021+    );1022+  }1023+  const cdjOrigin = clientDataJsonOrigin(clientDataJSON);1024+  if (cdjOrigin !== null && cdjOrigin !== callerOrigin) {1025+    throw new DOMException(1026+      `SecurityError: clientDataJSON origin "${cdjOrigin}" does not match the ` +1027+        `attested calling origin "${callerOrigin}".`,1028+      "SecurityError",1029+    );1030+  }1031+}1032+9901033 export async function handlePasskeyHasCredentials(991-  payload: FirefoxPasskeyHasCredentialsPayload1034+  payload: FirefoxPasskeyHasCredentialsPayload,1035+  callerOrigin?: string,9921036 ): Promise<{ ok: true; hasCredentials: boolean } | { ok: false; error: string }> {9931037   if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {9941038     return { ok: false, error: "not_applicable" };9951039   }1040+  // Scope the existence check to the caller's own origin so an arbitrary page1041+  // cannot enumerate which sites the user holds passkeys for (audit MEDIUM).1042+  // A false answer is indistinguishable from "no credential" to the page.1043+  if (!callerOrigin || !isValidRpId(payload.rpId, callerOrigin)) {1044+    return { ok: true, hasCredentials: false };1045+  }9961046   if (!vault.isUnlocked()) {9971047     // Cannot enumerate without the vault key. The MAIN-world proxy treats a9981048     // false answer as "fall through to the platform" — exactly the right@@ -1041,22 +1091,18 @@10411091 }10421092 10431093 export async function handleFirefoxPasskeyCreate(1044-  payload: FirefoxPasskeyCreatePayload1094+  payload: FirefoxPasskeyCreatePayload,1095+  callerOrigin?: string,10451096 ): Promise<FirefoxPasskeyResult<FirefoxCreateCredentialResponse>> {10461097   if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {10471098     return { ok: false, error: "not_applicable" };10481099   }1049-  // WebAuthn §7.1 step 6 — the page's origin (captured by the MAIN-world1050-  // content-script proxy as payload.origin) must own the requested rpId.1051-  // The browser enforces this natively; on the Firefox proxy path Peach is1052-  // the authenticator, so Peach must enforce it before minting anything (SEV-1-A).1053-  if (!isValidRpId(payload.rpId, payload.origin)) {1054-    throw new DOMException(1055-      `SecurityError: relying party ID "${payload.rpId}" is not a registrable ` +1056-        `domain suffix of the origin "${payload.origin}".`,1057-      "SecurityError",1058-    );1059-  }1100+  // WebAuthn §7.1 step 6 — the requested rpId must be owned by the page's1101+  // browser-ATTESTED origin (threaded in as callerOrigin from the message1102+  // sender), NOT the page-supplied payload.origin which an attacker controls.1103+  // On the Firefox proxy path Peach is the authenticator and must enforce this1104+  // before minting anything (audit CRITICAL — origin spoofing).1105+  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);10601106   if (!(await isProUser())) {10611107     broadcastPasskeyProGate();10621108     return { ok: false, error: "pro_required" };@@ -1144,21 +1190,19 @@11441190 }11451191 11461192 export async function handleFirefoxPasskeyGet(1147-  payload: FirefoxPasskeyGetPayload1193+  payload: FirefoxPasskeyGetPayload,1194+  callerOrigin?: string,11481195 ): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {11491196   if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {11501197     return { ok: false, error: "not_applicable" };11511198   }1152-  // WebAuthn §7.2 step 8 — reject a get() whose rpId is not a registrable1153-  // domain suffix of the calling page's origin, so a cross-origin page cannot1154-  // coax Peach into signing an assertion scoped to another site (SEV-1-A).1155-  if (!isValidRpId(payload.rpId, payload.origin)) {1156-    throw new DOMException(1157-      `SecurityError: relying party ID "${payload.rpId}" is not a registrable ` +1158-        `domain suffix of the origin "${payload.origin}".`,1159-      "SecurityError",1160-    );1161-  }1199+  // WebAuthn §7.2 step 8 — the rpId must be owned by the page's browser-attested1200+  // origin (callerOrigin from the message sender), NOT the page-supplied1201+  // payload.origin. Both rpId and payload.origin are attacker-controlled, so1202+  // validating one against the other is no protection; a cross-origin page must1203+  // not be able to coax Peach into signing an assertion scoped to another site1204+  // (audit CRITICAL — assertion forgery).1205+  assertOriginOwnsRpId(payload.rpId, callerOrigin, payload.clientDataJSON);11621206   if (!(await isProUser())) {11631207     broadcastPasskeyProGate();11641208     return { ok: false, error: "pro_required" };

view file

src/content/passkeys-firefox-relay.ts+5 −0
@@ -42,6 +42,11 @@4242   }4343   if (typeof id !== "string") return;4444 45+  // NOTE: page-supplied fields in `payload` (including any `origin`) are NOT46+  // trusted. The background re-derives the true caller origin from the message47+  // sender (browser-attested) for all passkey rpId<->origin enforcement — see48+  // attestedSenderOrigin in messages.ts. This relay only gates the message type.49+4550   const reply = (replyPayload: unknown) => {4651     window.postMessage(4752       { type: "PEACH_PASSKEY_RESPONSE", id, payload: replyPayload },

view file