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