2945bb7a571a

fix: bind Firefox passkey completion to the pending ceremony (v4.8.3)

Codex Security 2026-08-04 MEDIUM (broken-access-control.passkey-ceremony-approval): COMPLETE_FIREFOX_PASSKEY_CREATE stored a passkey for any caller-chosen rpId without binding to the pending ceremony, so any page could overwrite a stored passkey via the relay (findAttachTarget/updateEntry). Fix: - completeFirefoxPasskeyCreate now requires an exact match against the background-minted pending ceremony (requestId + rpId + userHandle; the userHandle is 32 random bytes that never leave extension context). - INITIATE_FIREFOX_PASSKEY_CREATE validates the rpId against the browser-attested sender origin (isValidRpId), closing the sibling rpId-spoofing gap in the approval popup. - COMPLETE_FIREFOX_PASSKEY_CREATE removed from the page-relay allowlist; the legitimate sender is only the approval popup (already sender-gated to extension origins in v4.8.2). Tests: pending-binding reject/accept, INITIATE origin mismatch, relay block.

time
2026-08-04 14:34 UTC · 2 months ago
machine
koh2
parent
c3c0d13e0280
8 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 −1
manifest.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": {

view file

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",

view file

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",

view file

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 call

view file

src/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": {

view file

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,

view file

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",

view file

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;

view file