2945bb7a571a
fix: bind Firefox passkey completion to the pending ceremony (v4.8.3)
- time
- 2026-08-04 14:34 UTC · 2 months ago
- machine
- koh2
- parent
- c3c0d13e0280
↓ peach-browser-2945bb7a.tar.gzatlas at this save
files changed8 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+65 −0src/background/messages.ts+1 −1src/background/passkey.ts+29 −1src/content/passkeys-firefox-relay.test.ts+2 −1src/content/passkeys-firefox-relay.ts+3 −1manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.8.2",4+ "version": "4.8.3",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "4.8.2",5+ "version": "4.8.3",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": "4.8.2",3+ "version": "4.8.3",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/messages.test.ts+65 −0
@@ -1263,6 +1263,71 @@12631263 ).resolves.toMatchObject({ ok: true });12641264 });12651265 1266+ it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1267+ const vault = await import("./vault");1268+ await vault.createVault("correct-pass-12345");1269+ const extensionPageSender = {1270+ id: "peach-extension",1271+ url: "chrome-extension://peach-extension/popup.html",1272+ };1273+ const completion = {1274+ requestId: 123,1275+ rpId: "example.com",1276+ rpName: "Example",1277+ userName: "june",1278+ userHandle: "BBBB",1279+ credentialId: "cred-1",1280+ publicKeyCose: "c3BraQ",1281+ algorithm: -7 as const,1282+ };1283+1284+ // No pending ceremony at all -> refuse.1285+ await expect(1286+ dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1287+ ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1288+1289+ // Seed a background-minted pending ceremony.1290+ sessionStorage["passkey_pending_firefox"] = {1291+ requestId: 123,1292+ type: "create",1293+ rpId: "example.com",1294+ rpName: "Example",1295+ userName: "june",1296+ browser: "firefox",1297+ challenge: "AAAA",1298+ userHandle: "BBBB",1299+ };1300+1301+ // Mismatched userHandle (attacker-forged completion) -> refuse.1302+ await expect(1303+ dispatch(1304+ { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1305+ extensionPageSender1306+ )1307+ ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1308+1309+ // Matching completion -> stored.1310+ await expect(1311+ dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1312+ ).resolves.toMatchObject({ ok: true });1313+ const entries = await dispatch({ type: "GET_ENTRIES" });1314+ expect(entries).toMatchObject({ ok: true });1315+ const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1316+ expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1317+ });1318+1319+ it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1320+ await expect(1321+ dispatch(1322+ {1323+ type: "INITIATE_FIREFOX_PASSKEY_CREATE",1324+ payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1325+ },1326+ { id: "peach-extension", url: "https://evil.example/page" }1327+ )1328+ ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1329+ });1330+12661331 it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {12671332 // The cross-manifest refusal is exercised end-to-end in sync-vault-adapter.test.ts;12681333 // here we just pin the storage-level invariant the UNPAIR_DEVICE callsrc/background/messages.ts+1 −1
@@ -1186,7 +1186,7 @@11861186 rpName: rpName ?? rpId,11871187 userName,11881188 userDisplayName,1189- });1189+ }, attestedSenderOrigin(sender));11901190 }11911191 11921192 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {src/background/passkey.ts+29 −1
@@ -823,7 +823,15 @@823823 rpName: string;824824 userName: string;825825 userDisplayName?: string;826-}): Promise<{ ok: boolean; error?: string }> {826+}, callerOrigin?: string): Promise<{ ok: boolean; error?: string }> {827+ // The rpId must belong to the calling page's registrable domain; without828+ // this, a page could initiate a ceremony for a foreign site and the829+ // approval popup would display an attacker-chosen rpId. Origin is the830+ // browser-attested sender origin from messages.ts, never page payload.831+ // (Codex Security 2026-08-04, passkey-ceremony-approval sibling.)832+ if (!callerOrigin || !isValidRpId(args.rpId, callerOrigin)) {833+ return { ok: false, error: "rpid_origin_mismatch" };834+ }827835 if (!(await isProUser())) {828836 broadcastPasskeyProGate();829837 return { ok: false, error: "passkey creation requires Peach Pro" };@@ -876,6 +884,26 @@876884 backupState?: boolean;877885 }): Promise<{ ok: boolean; error?: string }> {878886 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };887+888+ // Bind completion to the pending ceremony: requestId, rpId, challenge, and889+ // userHandle are minted by the background at INITIATE time and only ever890+ // leave extension context through the approval popup, so a page (or anything891+ // replaying relayed messages) cannot synthesize a matching completion for an892+ // rpId it does not own. (Codex Security 2026-08-04 MEDIUM,893+ // broken-access-control.passkey-ceremony-approval.)894+ const pending = chrome.storage.session895+ ? ((await chrome.storage.session.get(FIREFOX_PENDING_KEY))[FIREFOX_PENDING_KEY] as896+ | PendingPasskeyRequest897+ | undefined)898+ : undefined;899+ if (900+ !pending ||901+ pending.requestId !== payload.requestId ||902+ pending.rpId !== payload.rpId ||903+ pending.userHandle !== payload.userHandle904+ ) {905+ return { ok: false, error: "no_pending_request" };906+ }879907 880908 const passkey: PasskeyData = {881909 rpId: payload.rpId,src/content/passkeys-firefox-relay.test.ts+2 −1
@@ -35,7 +35,6 @@3535 "PASSKEY_GET",3636 "PASSKEY_HAS_CREDENTIALS",3737 "INITIATE_FIREFOX_PASSKEY_CREATE",38- "COMPLETE_FIREFOX_PASSKEY_CREATE",3938 "PASSKEY_OFFER_DECLINE",4039 "GET_PASSKEY_OFFER_DECLINED",4140 "GET_PASSKEY_FOR_DOMAIN",@@ -43,6 +42,8 @@4342 ];4443 4544 const blocked = [45+ // Popup-only: never page-relayable (Codex Security 2026-08-04).46+ "COMPLETE_FIREFOX_PASSKEY_CREATE",4647 "GET_ENTRIES",4748 "GET_RECOVERY_WORDS",4849 "DESTROY_VAULT",src/content/passkeys-firefox-relay.ts+3 −1
@@ -21,12 +21,14 @@2121 "PASSKEY_GET",2222 "PASSKEY_HAS_CREDENTIALS",2323 "INITIATE_FIREFOX_PASSKEY_CREATE",24- "COMPLETE_FIREFOX_PASSKEY_CREATE",2524 "PASSKEY_OFFER_DECLINE",2625 "GET_PASSKEY_OFFER_DECLINED",2726 "GET_PASSKEY_FOR_DOMAIN",2827 "PASSKEY_AUTH_RESPONSE",2928 ]);29+// NOTE: COMPLETE_FIREFOX_PASSKEY_CREATE is deliberately NOT relayable — it is30+// only ever sent by the approval popup (extension page) and is additionally31+// sender-gated to extension origins in messages.ts (Codex Security 2026-08-04).3032 3133 window.addEventListener("message", async (event: MessageEvent) => {3234 if (event.source !== window) return;