287e1fd0b13f

consume summon capability on same-domain fill and bump to v5.0.55

time
2026-09-02 13:11 UTC · 1 month ago
machine
koh2
agent
claude-sonnet-4-6
session
PB-26-201 authorization
parent
d7d47c3cc1d2
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+65 −1src/background/messages.ts+9 −3
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.54",4+  "version": "5.0.55",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": "5.0.54",5+  "version": "5.0.55",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": "5.0.54",3+  "version": "5.0.55",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.test.ts+65 −1
@@ -959,8 +959,72 @@959959   // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---960960 961961   function tabSender(url: string): chrome.runtime.MessageSender {962-    return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };962+    return { id: "peach-extension", tab: { id: 1, url } as chrome.tabs.Tab, url };963963   }964+965+  it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {966+    const vault = await import("./vault");967+    const messages = await import("./messages");968+    await vault.createVault("correct-pass-12345");969+    const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });970+    const sender = tabSender("https://evil.example.com/phish");971+    const token = messages.createSummonCapability(1, "https://evil.example.com");972+973+    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);974+    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });975+976+    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);977+    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });978+    const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);979+    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });980+  });981+982+  it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {983+    const vault = await import("./vault");984+    const messages = await import("./messages");985+    await vault.createVault("correct-pass-12345");986+    const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });987+    const sender = tabSender("https://evil.example.com/login");988+    const token = messages.createSummonCapability(1, "https://evil.example.com");989+990+    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);991+    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });992+    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);993+    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });994+995+    const offDomainReplay = await dispatch(996+      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },997+      tabSender("https://github.com/login"),998+    );999+    expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1000+  });1001+1002+  it("PB-26-201: summon capability cannot fill a different entry", async () => {1003+    const vault = await import("./vault");1004+    const messages = await import("./messages");1005+    await vault.createVault("correct-pass-12345");1006+    const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1007+    const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1008+    const sender = tabSender("https://evil.example.com/phish");1009+    const token = messages.createSummonCapability(1, "https://evil.example.com");1010+    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1011+    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1012+    expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1013+    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1014+    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1015+    expect(first.id).not.toBe(second.id);1016+  });1017+1018+  it("PB-26-201: summon capability rejects wrong operation and sender", async () => {1019+    const vault = await import("./vault");1020+    const messages = await import("./messages");1021+    await vault.createVault("correct-pass-12345");1022+    const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://github.com" });1023+    const token = messages.createSummonCapability(1, "https://evil.example.com");1024+    const wrongSender = tabSender("https://other.example.com/phish");1025+    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, wrongSender);1026+    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1027+  });9641028 9651029   it("MV-1: tab request with a matching domain returns the entry", async () => {9661030     const vault = await import("./vault");

view file

src/background/messages.ts+9 −3
@@ -123,8 +123,10 @@123123   if (!cap || cap.expiresAt <= Date.now() || sender.tab?.id !== cap.tabId || (sender.frameId ?? 0) !== cap.frameId || attestedSenderOrigin(sender) !== cap.origin) return false;124124   if (cap.operation !== operation) return false;125125   if (operation === "inventory") cap.operation = "fill";126-  else if (cap.entryId === undefined) cap.entryId = entryId;127-  else if (cap.entryId !== entryId) return false;126+  else if (cap.entryId === undefined) {127+    if (entryId === undefined) return false;128+    summonCapabilities.delete(token);129+  } else if (cap.entryId !== entryId) return false;128130   else summonCapabilities.delete(token);129131   return true;130132 }@@ -912,10 +914,14 @@912914             if (d) entryDomains.add(d);913915           }914916 917+          // Consume a summon capability even when the selected entry already918+          // matches the page origin; otherwise a same-domain fill leaves the919+          // bearer reusable for a later off-domain request.920+          const capabilityAuthorized = capability !== undefined && consumeSummonCapability(capability, sender, "fill", id);915921           // explicitSummon=true means the user actively picked this entry916922           // from the cross-domain search affordance — an intentional917923           // off-domain fill. Passive fill paths must stay domain-bound.918-          if (!entryDomains.has(senderDomain) && !consumeSummonCapability(capability, sender, "fill", id)) {924+          if (!entryDomains.has(senderDomain) && !capabilityAuthorized) {919925             // Domain mismatch — refuse silently (no oracle about which ids exist).920926             return { ok: false, error: "entry_not_found" };921927           }

view file