151df7df811c

PB-26-201 authorization boundary and capability remediation (v5.0.54)

time
2026-09-02 12:41 UTC · 1 month ago
machine
koh2
agent
claude-sonnet-4-6
session
PB-26-201 authorization
parent
9371b98e40b5
8 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/commands.ts+5 −1src/background/message-policy.ts+2 −2src/background/messages.ts+33 −19src/content/index.ts+12 −9src/content/inject.ts+4 −4
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.53",4+  "version": "5.0.54",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.53",5+  "version": "5.0.54",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.53",3+  "version": "5.0.54",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/commands.ts+5 −1
@@ -7,6 +7,8 @@77  *   3. Content script not ready → open popup fallback88  */99 10+import { createSummonCapability } from "./messages";11+1012 const PING_TIMEOUT_MS = 150;1113 1214 async function pingContentScript(tabId: number): Promise<boolean> {@@ -59,7 +61,9 @@5961       // Content script fully handles SUMMON_AUTOFILL with source:"shortcut":6062       // shows the autofill selector when a form is present, or falls back to6163       // a search-mode palette that copies to clipboard. No popup fallback needed.62-      chrome.tabs.sendMessage(tabId, { type: "SUMMON_AUTOFILL", source: "shortcut" }).catch(() => {});64+      const origin = new URL(tab.url).origin;65+      const capability = createSummonCapability(tabId, origin, 0);66+      chrome.tabs.sendMessage(tabId, { type: "SUMMON_AUTOFILL", source: "shortcut", capability }).catch(() => {});6367       return;6468     }6569 

view file

src/background/message-policy.ts+2 −2
@@ -77,7 +77,7 @@7777   GET_VAULT_STATUS: "extension-page-only",7878   GET_ADVANCED_HEALTH_REPORT: "extension-page-only",7979   DEACTIVATE_LICENSE: "extension-page-only",80-  GET_IDENTITIES: "content-script-allowed",80+  GET_IDENTITIES: "extension-page-only",8181   DESTROY_VAULT: "extension-page-only",8282   COMPLETE_FIREFOX_PASSKEY_CREATE: "extension-page-only",8383   VALIDATE_RECOVERY_WORDS: "extension-page-only",@@ -107,7 +107,7 @@107107   COPY: "content-script-allowed",108108   FIRE_CANARY_PING: "content-script-allowed",109109   GET_ENTRY_FOR_FILL: "content-script-allowed",110-  GET_ENTRY_FOR_EDIT: "content-script-allowed",110+  GET_ENTRY_FOR_EDIT: "extension-page-only",111111   GET_SETTINGS: "content-script-allowed",112112   GET_SITE_PASSWORD_POLICY: "content-script-allowed",113113   RECORD_MANUAL_SUMMON: "content-script-allowed",

view file

src/background/messages.ts+33 −19
@@ -107,6 +107,28 @@107107 } from "./sync-trigger";108108 import { isMessageSenderAllowed } from "./message-policy";109109 110+const SUMMON_CAPABILITY_TTL_MS = 30_000;111+type SummonCapability = { tabId: number; frameId: number; origin: string; operation: "inventory" | "fill"; entryId?: string; expiresAt: number };112+const summonCapabilities = new Map<string, SummonCapability>();113+114+export function createSummonCapability(tabId: number, origin: string, frameId = 0): string {115+  const token = crypto.randomUUID();116+  summonCapabilities.set(token, { tabId, frameId, origin, operation: "inventory", expiresAt: Date.now() + SUMMON_CAPABILITY_TTL_MS });117+  return token;118+}119+120+function consumeSummonCapability(token: unknown, sender: Partial<chrome.runtime.MessageSender>, operation: "inventory" | "fill", entryId?: string): boolean {121+  if (typeof token !== "string") return false;122+  const cap = summonCapabilities.get(token);123+  if (!cap || cap.expiresAt <= Date.now() || sender.tab?.id !== cap.tabId || (sender.frameId ?? 0) !== cap.frameId || attestedSenderOrigin(sender) !== cap.origin) return false;124+  if (cap.operation !== operation) return false;125+  if (operation === "inventory") cap.operation = "fill";126+  else if (cap.entryId === undefined) cap.entryId = entryId;127+  else if (cap.entryId !== entryId) return false;128+  else summonCapabilities.delete(token);129+  return true;130+}131+110132 // Compile-time E2E flag, folded to a literal by Vite's `define` (false in111133 // every production build — verified in the bundled output). Mirrors the112134 // create-path bypass in passkey.ts so the Playwright passkey smoke can enable@@ -767,27 +789,18 @@767789         if (!vault.isUnlocked()) {768790           return { ok: false, error: "vault_locked" };769791         }770-        let { includeAll } = msg.payload as { includeAll?: boolean };771-        const { domain, kind, userGesture } = msg.payload as {792+        const { domain, kind, capability } = msg.payload as {772793           domain: string;773794           kind?: "login" | "card" | "totp";774-          userGesture?: boolean;795+          capability?: string;775796         };776-        // MV-2: the vault-wide inventory (every title/username/email/url)777-        // may only leave the background on an explicit user gesture — the778-        // user opening the search affordance — never on passive form focus.779-        // userGesture is self-declared by our own content script; the value780-        // of this gate is that PASSIVE code paths (which never set it)781-        // cannot ship the inventory, shrinking what a compromised page that782-        // merely triggers focus events can observe.783-        if (includeAll && userGesture !== true) {784-          includeAll = false;785-        }797+        const includeAll = consumeSummonCapability(capability, sender, "inventory");786798         // kind="card": return saved cards as compact summaries for the787799         // inline card silver platter. Cards are not domain-scoped — every788800         // saved card is a candidate on every checkout page — so domain is789801         // ignored when kind === "card".790802         if (kind === "card") {803+          if (!includeAll) return { ok: true, data: [] };791804           const cards = vault.getEntries().filter((e) => e.type === "card");792805           return {793806             ok: true,@@ -863,9 +876,9 @@863876       }864877       case "GET_ENTRY_FOR_FILL": {865878         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };866-        const { id, explicitSummon } = msg.payload as {879+        const { id, capability } = msg.payload as {867880           id: string;868-          explicitSummon?: boolean;881+          capability?: string;869882         };870883         const entry = vault.getEntryById(id);871884         if (!entry) return { ok: false, error: "entry_not_found" };@@ -902,7 +915,7 @@902915           // explicitSummon=true means the user actively picked this entry903916           // from the cross-domain search affordance — an intentional904917           // off-domain fill. Passive fill paths must stay domain-bound.905-          if (explicitSummon !== true && !entryDomains.has(senderDomain)) {918+          if (!entryDomains.has(senderDomain) && !consumeSummonCapability(capability, sender, "fill", id)) {906919             // Domain mismatch — refuse silently (no oracle about which ids exist).907920             return { ok: false, error: "entry_not_found" };908921           }@@ -948,10 +961,11 @@948961       // the inline popup, never during passive autofill on an arbitrary page.949962       // The passkey credential is still stripped — the edit form never touches it.950963       case "GET_ENTRY_FOR_EDIT": {964+        if (isPageSender(sender)) return { ok: false, error: "invalid_sender" };951965         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };952-        const { id, explicitSummon } = msg.payload as {966+        const { id, capability } = msg.payload as {953967           id: string;954-          explicitSummon?: boolean;968+          capability?: string;955969         };956970         const entry = vault.getEntryById(id);957971         if (!entry) return { ok: false, error: "entry_not_found" };@@ -982,7 +996,7 @@982996               const d = extractDomain(extra);983997               if (d) entryDomains.add(d);984998             }985-            if (explicitSummon !== true && !entryDomains.has(senderDomain)) {999+            if (!entryDomains.has(senderDomain) && !consumeSummonCapability(capability, sender, "fill", id)) {9861000               return { ok: false, error: "entry_not_found" }; // no id oracle9871001             }9881002           }

view file

src/content/index.ts+12 −9
@@ -158,7 +158,7 @@158158   }, 2000);159159 }160160 161-function showSearchPalette(): void {161+function showSearchPalette(capability?: string): void {162162   const host = document.createElement("div");163163   host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";164164   document.body.appendChild(host);@@ -253,7 +253,7 @@253253       row.onclick = async () => {254254         // MV-1: vault-wide search list is a cross-domain summon — the user255255         // explicitly picked this entry to fill on this page.256-        const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, explicitSummon: true } });256+        const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability } });257257         const r = resp as { ok: boolean; data?: { password?: string } };258258         if (r.ok && r.data?.password) {259259           let alreadyWritten = false;@@ -389,11 +389,12 @@389389 async function fetchAutofillEntries(390390   domain: string,391391   includeAll = false,392-  userGesture = false392+  _userGesture = false,393+  capability?: string393394 ): Promise<{ entries: SelectorEntry[]; locked: boolean }> {394395   const response = await chrome.runtime.sendMessage({395396     type: "AUTOFILL_QUERY",396-    payload: { domain, includeAll, userGesture },397+    payload: { domain, includeAll, capability },397398   });398399   const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };399400   if (!res.ok && res.error === "vault_locked") {@@ -595,7 +596,8 @@595596 596597 export /* @testable */ async function handleFieldInteraction(597598   anchorField?: HTMLInputElement | null,598-  explicitSummon = false599+  explicitSummon = false,600+  capability?: string599601 ): Promise<void> {600602   const field = anchorField ?? getDeepActiveInput();601603   if (!field) return;@@ -707,7 +709,7 @@707709     // world — and never hands out the entry ids that made MV-1 a full-vault708710     // primitive.709711     const loadSearchableEntries = async (): Promise<SelectorEntry[]> =>710-      (await fetchAutofillEntries(domain, true, true)).entries;712+      (await fetchAutofillEntries(domain, true, true, capability)).entries;711713 712714     if (passkeyToastActive) return;713715 @@ -1080,7 +1082,8 @@10801082     return true;10811083   }10821084   if (msg?.type === "SUMMON_AUTOFILL") {1083-    const source = (msg as { type: string; source?: string }).source;1085+    const source = (msg as { type: string; source?: string; capability?: string }).source;1086+    const capability = (msg as { capability?: string }).capability;10841087     const isShortcut = source === "shortcut";10851088     const isPasskeyUnlock = source === "passkey-unlock";10861089 @@ -1103,7 +1106,7 @@11031106         dismissLock = false;11041107         if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }11051108         lastDismissedField = null;1106-        void handleFieldInteraction(target);1109+        void handleFieldInteraction(target, true, capability);11071110       }11081111     } else if (isPasskeyUnlock) {11091112       // Vault is locked during a passkey GET request. Show the inline unlock@@ -1124,7 +1127,7 @@11241127       }11251128     } else if (isShortcut) {11261129       // Shortcut on a page with no login form — show clipboard-copy search mode.1127-      showSearchPalette();1130+      showSearchPalette(capability);11281131     }11291132 11301133     // Record this domain for the recall system when triggered via shortcut.

view file

src/content/inject.ts+4 −4
@@ -2762,7 +2762,7 @@27622762       const resp = await chrome.runtime.sendMessage({27632763         type: "GET_ENTRY_FOR_FILL",27642764         // MV-1: user explicitly picked this entry from Peach's inline summon UI.2765-        payload: { id: entry.id, explicitSummon: true },2765+        payload: { id: entry.id },27662766       }).catch(() => null) as { ok: boolean; data?: { password?: string } } | null;27672767       if (!resp?.ok || !resp.data?.password) return;27682768       let alreadyWritten = false;@@ -2802,7 +2802,7 @@28022802         const r = await chrome.runtime.sendMessage({28032803           type: "GET_ENTRY_FOR_FILL",28042804           // MV-1: explicit user summon of this entry's live TOTP code.2805-          payload: { id: entry.id, explicitSummon: true },2805+          payload: { id: entry.id },28062806         }).catch(() => null) as { ok: boolean; data?: { totpCode?: string; totpRemaining?: number } } | null;28072807         if (!r?.ok || !r.data?.totpCode) return null;28082808         return { code: r.data.totpCode, remaining: r.data.totpRemaining ?? 30 };@@ -4332,7 +4332,7 @@43324332     const resp = await chrome.runtime.sendMessage({43334333       type: "GET_ENTRY_FOR_FILL",43344334       // MV-1: explicit user click to fill this card's details.4335-      payload: { id: card.id, explicitSummon: true },4335+      payload: { id: card.id },43364336     }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;43374337     if (resp?.ok && resp.data) options.onFill(resp.data);43384338     setTimeout(() => dismiss(), 400);@@ -4364,7 +4364,7 @@43644364     const resp = await chrome.runtime.sendMessage({43654365       type: "GET_ENTRY_FOR_FILL",43664366       // MV-1: explicit user action on a card the user opened in Peach's UI.4367-      payload: { id: card.id, explicitSummon: true },4367+      payload: { id: card.id },43684368     }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;43694369     return resp?.ok && resp.data ? resp.data : null;43704370   }

view file