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
↓ peach-browser-819c2b01.tar.gzatlas at this save
files changed7 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 −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.122",5+ "version": "3.6.123",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.122",4+ "version": "3.6.123",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.122",3+ "version": "3.6.123",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {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()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 () => {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" };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 },