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
↓ peach-browser-287e1fd0.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+65 −1src/background/messages.ts+9 −3manifest.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": {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",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",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");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 }