2b15bb2c1fdd

fix(security): Firefox passkey rpId↔origin validation — reject cross-origin rpId spoofing in handleFirefoxPasskeyCreate/Get via new isValidRpId (v3.6.120)

time
2026-05-28 20:23 UTC · 5 months ago
machine
koh2
parent
dd13df9e1692
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/passkey-firefox-proxy.test.ts+62 −0src/background/passkey.ts+22 −0src/shared/passkey-utils.test.ts+37 −0src/shared/passkey-utils.ts+53 −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.119",5+  "version": "3.6.120",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.119",4+  "version": "3.6.120",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.119",3+  "version": "3.6.120",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/passkey-firefox-proxy.test.ts+62 −0
@@ -243,6 +243,68 @@243243     ).resolves.toMatchObject({ ok: true, hasCredentials: false });244244   });245245 246+  it("PASSKEY_CREATE rejects a cross-origin rpId without minting a credential (SEV-1-A)", async () => {247+    const vault = await import("./vault");248+    await vault.createVault("correct-pass-12345");249+250+    autoApprove();251+    const res = await dispatch({252+      type: "PASSKEY_CREATE",253+      payload: {254+        // evil.com requests a credential scoped to google.com — must be refused.255+        rpId: "google.com",256+        rpName: "Google",257+        userId: base64urlEncode(crypto.getRandomValues(new Uint8Array(16))),258+        userName: "june",259+        userDisplayName: "June",260+        challenge: "x",261+        clientDataJSON: Array.from(new TextEncoder().encode("{}")),262+        origin: "https://evil.com",263+        pubKeyCredParams: [{ alg: -7, type: "public-key" }],264+      },265+    });266+267+    expect(res.ok).toBe(false);268+    expect(String(res.error)).toContain("SecurityError");269+    // Crucially, nothing was written to the vault.270+    expect(vault.getEntries().find((e) => e.passkey?.rpId === "google.com")).toBeUndefined();271+  });272+273+  it("PASSKEY_GET rejects a cross-origin rpId (SEV-1-A)", async () => {274+    const vault = await import("./vault");275+    await vault.createVault("correct-pass-12345");276+277+    const res = await dispatch({278+      type: "PASSKEY_GET",279+      payload: {280+        rpId: "google.com",281+        challenge: "x",282+        clientDataJSON: Array.from(new TextEncoder().encode("{}")),283+        origin: "https://evil.com",284+        allowCredentials: [],285+      },286+    });287+288+    expect(res.ok).toBe(false);289+    expect(String(res.error)).toContain("SecurityError");290+  });291+292+  it("handleFirefoxPasskeyCreate throws a SecurityError directly on rpId mismatch", async () => {293+    const { handleFirefoxPasskeyCreate } = await import("./passkey");294+    await expect(295+      handleFirefoxPasskeyCreate({296+        rpId: "google.com",297+        rpName: "Google",298+        userId: "dGVzdA",299+        userName: "june",300+        challenge: "x",301+        clientDataJSON: [],302+        origin: "https://evil.com",303+        pubKeyCredParams: [{ alg: -7, type: "public-key" }],304+      })305+    ).rejects.toThrow("SecurityError");306+  });307+246308   it("PASSKEY_GET returns no_credentials when nothing matches", async () => {247309     const vault = await import("./vault");248310     await vault.createVault("correct-pass-12345");

view file

src/background/passkey.ts+22 −0
@@ -12,6 +12,7 @@1212 } from "./passkey-crypto";1313 import { detectBrowser as detectSharedBrowser, firefoxVersion } from "../shared/browser";1414 import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";15+import { isValidRpId } from "../shared/passkey-utils";1516 import type {1617   PasskeyData,1718   PasskeyProviderState,@@ -1045,6 +1046,17 @@10451046   if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {10461047     return { ok: false, error: "not_applicable" };10471048   }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+  }10481060   if (!(await isProUser())) {10491061     broadcastPasskeyProGate();10501062     return { ok: false, error: "pro_required" };@@ -1136,6 +1148,16 @@11361148 ): Promise<FirefoxPasskeyResult<FirefoxGetCredentialResponse>> {11371149   if (!FIREFOX_USE_CONTENT_SCRIPT_PROXY) {11381150     return { ok: false, error: "not_applicable" };1151+  }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+    );11391161   }11401162   if (!(await isProUser())) {11411163     broadcastPasskeyProGate();

view file

src/shared/passkey-utils.test.ts+37 −0
@@ -0,0 +1,37 @@1+import { describe, expect, it } from "vitest";2+3+import { isValidRpId } from "./passkey-utils";4+5+describe("isValidRpId (Firefox passkey rpId↔origin validation, SEV-1-A)", () => {6+  it("accepts rpId equal to the caller hostname", () => {7+    expect(isValidRpId("google.com", "https://google.com")).toBe(true);8+  });9+10+  it("accepts rpId as a registrable suffix of the caller", () => {11+    expect(isValidRpId("google.com", "https://accounts.google.com")).toBe(true);12+  });13+14+  it("rejects rpId from an unrelated domain", () => {15+    expect(isValidRpId("google.com", "https://evil.com")).toBe(false);16+  });17+18+  it("rejects rpId spoofing via suffix confusion", () => {19+    expect(isValidRpId("google.com", "https://evil.google.com.attacker.com")).toBe(false);20+  });21+22+  it("rejects a bare-eTLD rpId", () => {23+    expect(isValidRpId("com", "https://google.com")).toBe(false);24+  });25+26+  it("rejects a dotless rpId such as localhost", () => {27+    expect(isValidRpId("localhost", "https://localhost")).toBe(false);28+  });29+30+  it("rejects an unparseable caller origin", () => {31+    expect(isValidRpId("google.com", "not a url")).toBe(false);32+  });33+34+  it("is case-insensitive on host and rpId", () => {35+    expect(isValidRpId("Google.com", "https://ACCOUNTS.google.COM")).toBe(true);36+  });37+});

view file

src/shared/passkey-utils.ts+53 −0
@@ -0,0 +1,53 @@1+/**2+ * passkey-utils.ts — WebAuthn relying-party-id validation.3+ *4+ * The native browser enforces that a page may only request a passkey whose5+ * rpId is a registrable-domain-suffix of the page's own origin. On Firefox,6+ * Peach intercepts `navigator.credentials` in a MAIN-world content script and7+ * services the ceremony itself, so Peach — not the browser — must re-impose8+ * that rule. Without it a page at `evil.com` could request (and Peach would9+ * happily mint) a credential scoped to `google.com` (SEV-1-A).10+ */11+12+/**13+ * Validates that `rpId` is equal to, or a registrable-domain-suffix of, the14+ * hostname of `callerOrigin`. Mirrors WebAuthn §7.1 step 6 / §7.2 step 8.15+ *16+ * This is a conservative suffix check, not a full Public-Suffix-List lookup:17+ * it requires the rpId to contain at least one dot (blocking bare-eTLD rpIds18+ * like "com" or "localhost"), and requires the caller host to be exactly the19+ * rpId or to end with "." + rpId. That correctly rejects the classic20+ * suffix-confusion payload "google.com.attacker.com" for rpId "google.com".21+ *22+ *   isValidRpId("google.com", "https://accounts.google.com") → true23+ *   isValidRpId("google.com", "https://google.com")          → true24+ *   isValidRpId("com",        "https://google.com")          → false (bare eTLD)25+ *   isValidRpId("google.com", "https://evil.google.com.x.com")→ false26+ *   isValidRpId("evil.com",   "https://google.com")          → false27+ */28+export function isValidRpId(rpId: string, callerOrigin: string): boolean {29+  let callerHost: string;30+  try {31+    callerHost = new URL(callerOrigin).hostname;32+  } catch {33+    return false;34+  }35+  if (!rpId || !callerHost) return false;36+37+  const id = rpId.toLowerCase();38+  const host = callerHost.toLowerCase();39+40+  // Require at least one dot — blocks bare public suffixes ("com", "co.uk"41+  // collapses are not handled here, but a bare TLD is the dangerous case).42+  if (!id.includes(".")) return false;43+44+  // Exact match.45+  if (host === id) return true;46+47+  // Registrable-suffix match: host must end with ".<rpId>". This rejects48+  // "google.com.attacker.com" for rpId "google.com" because that host ends49+  // with ".attacker.com", not ".google.com".50+  if (host.endsWith("." + id)) return true;51+52+  return false;53+}

view file