9b061a75009a

fix: enforce browser-issued autofill authority (PB-26-201, v5.0.56)

time
2026-09-02 19:39 UTC · 1 month ago
machine
koh2
agent
gpt-5.6-sol
session
PB-26-201 authorization correction
parent
287e1fd0b13f
13 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/commands.test.ts+51 −0src/background/commands.ts+5 −1src/background/messages.test.ts+247 −6src/background/messages.ts+46 −82src/content/content.field-interaction.test.ts+49 −1src/content/content.message-routing.test.ts+59 −1src/content/content.selector-ui.test.ts+163 −2src/content/index.ts+42 −20src/content/inject.ts+41 −325src/popup/App.tsx+9 −7
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.55",4+  "version": "5.0.56",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.55",5+  "version": "5.0.56",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.55",3+  "version": "5.0.56",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/commands.test.ts+51 −0
@@ -0,0 +1,51 @@1+import { beforeEach, expect, it, vi } from "vitest";2+3+const { createSummonCapability } = vi.hoisted(() => ({4+  createSummonCapability: vi.fn(() => "summon-token"),5+}));6+7+vi.mock("./messages", () => ({ createSummonCapability }));8+9+type CommandListener = (command: string) => void | Promise<void>;10+11+let listener: CommandListener;12+let sendMessage: ReturnType<typeof vi.fn>;13+14+beforeEach(async () => {15+  vi.resetModules();16+  createSummonCapability.mockClear();17+  sendMessage = vi.fn(18+    (19+      _tabId: number,20+      message: { type?: string },21+      optionsOrCallback?: { frameId?: number } | ((response: unknown) => void),22+    ) => {23+      if (message.type === "PING" && typeof optionsOrCallback === "function") {24+        optionsOrCallback({ ok: true });25+      }26+      return Promise.resolve({ ok: true });27+    },28+  );29+  (globalThis as { chrome?: unknown }).chrome = {30+    runtime: { lastError: undefined },31+    commands: { onCommand: { addListener: vi.fn((next: CommandListener) => { listener = next; }) } },32+    tabs: {33+      query: vi.fn(async () => [{ id: 42, url: "https://shop.example/checkout" }]),34+      sendMessage,35+    },36+    action: { openPopup: vi.fn(async () => {}) },37+  };38+  const { setupCommands } = await import("./commands");39+  setupCommands();40+});41+42+it("PB-26-201: summon command binds and delivers its capability to the top frame only", async () => {43+  await listener("summon-inline");44+45+  expect(createSummonCapability).toHaveBeenCalledWith(42, "https://shop.example", 0);46+  expect(sendMessage).toHaveBeenCalledWith(47+    42,48+    { type: "SUMMON_AUTOFILL", source: "shortcut", capability: "summon-token" },49+    { frameId: 0 },50+  );51+});

view file

src/background/commands.ts+5 −1
@@ -63,7 +63,11 @@6363       // a search-mode palette that copies to clipboard. No popup fallback needed.6464       const origin = new URL(tab.url).origin;6565       const capability = createSummonCapability(tabId, origin, 0);66-      chrome.tabs.sendMessage(tabId, { type: "SUMMON_AUTOFILL", source: "shortcut", capability }).catch(() => {});66+      chrome.tabs.sendMessage(67+        tabId,68+        { type: "SUMMON_AUTOFILL", source: "shortcut", capability },69+        { frameId: 0 },70+      ).catch(() => {});6771       return;6872     }6973 

view file

src/background/messages.test.ts+247 −6
@@ -958,10 +958,73 @@958958 959959   // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---960960 961-  function tabSender(url: string): chrome.runtime.MessageSender {962-    return { id: "peach-extension", tab: { id: 1, url } as chrome.tabs.Tab, url };961+  function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {962+    return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };963963   }964964 965+  async function addPasskeyOnlyEntry(title: string, url: string) {966+    const vault = await import("./vault");967+    const generated = await generatePasskeyKeypair(-7);968+    return vault.addEntry({969+      type: "login",970+      title,971+      url,972+      passkey: {973+        rpId: new URL(url).hostname,974+        rpName: title,975+        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),976+        publicKeyCose: base64urlEncode(generated.publicKeyCose),977+        privateKeyJwk: generated.privateKeyJwk,978+        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),979+        userName: "june",980+        userDisplayName: "June",981+        algorithm: -7,982+        signCount: 0,983+        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",984+        backupEligible: true,985+        backupState: false,986+        createdAt: Date.now(),987+      },988+    });989+  }990+991+  it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {992+    const vault = await import("./vault");993+    await vault.createVault("correct-pass-12345");994+    await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });995+    await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });996+997+    const result = await dispatch(998+      {999+        type: "AUTOFILL_QUERY",1000+        payload: {1001+          domain: "github.com",1002+          includeAll: true,1003+          userGesture: true,1004+          explicitSummon: true,1005+        },1006+      },1007+      tabSender("https://evil.example.com/phish"),1008+    );1009+1010+    expect(result).toMatchObject({ ok: true });1011+    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1012+  });1013+1014+  it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1015+    const vault = await import("./vault");1016+    await vault.createVault("correct-pass-12345");1017+    await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1018+    await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });1019+1020+    const result = await dispatch(1021+      { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1022+      tabSender("https://evil.example.com/verify"),1023+    );1024+1025+    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1026+  });1027+9651028   it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {9661029     const vault = await import("./vault");9671030     const messages = await import("./messages");@@ -1015,15 +1078,193 @@10151078     expect(first.id).not.toBe(second.id);10161079   });10171080 1018-  it("PB-26-201: summon capability rejects wrong operation and sender", async () => {1081+  it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {10191082     const vault = await import("./vault");10201083     const messages = await import("./messages");10211084     await vault.createVault("correct-pass-12345");1022-    const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://github.com" });1085+    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");10231086     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);1087+    const result = await dispatch(1088+      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1089+      tabSender("https://evil.example.com/phish"),1090+    );10261091     expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1092+  });1093+1094+  it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1095+    const vault = await import("./vault");1096+    const messages = await import("./messages");1097+    await vault.createVault("correct-pass-12345");1098+    const entry = await vault.addEntry({1099+      title: "Evil",1100+      password: "known",1101+      url: "https://evil.example.com",1102+    });1103+    const sender = tabSender("https://evil.example.com/login");1104+    const token = messages.createSummonCapability(1, "https://evil.example.com");1105+1106+    const result = await dispatch(1107+      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1108+      sender,1109+    );1110+1111+    expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1112+  });1113+1114+  it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1115+    vi.useFakeTimers();1116+    vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1117+    const vault = await import("./vault");1118+    const messages = await import("./messages");1119+    await vault.createVault("correct-pass-12345");1120+    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1121+    const sender = tabSender("https://evil.example.com/phish");1122+    const token = messages.createSummonCapability(1, "https://evil.example.com");1123+    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1124+    vi.advanceTimersByTime(30_001);1125+1126+    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1127+    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1128+  });1129+1130+  it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1131+    const vault = await import("./vault");1132+    const messages = await import("./messages");1133+    await vault.createVault("correct-pass-12345");1134+    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1135+    const token = messages.createSummonCapability(1, "https://evil.example.com");1136+    await dispatch(1137+      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1138+      tabSender("https://evil.example.com/phish"),1139+    );1140+1141+    const result = await dispatch(1142+      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1143+      tabSender("https://evil.example.com/phish", 2),1144+    );1145+    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1146+  });1147+1148+  it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1149+    const vault = await import("./vault");1150+    const messages = await import("./messages");1151+    await vault.createVault("correct-pass-12345");1152+    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1153+    const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1154+    await dispatch(1155+      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1156+      tabSender("https://evil.example.com/phish", 1, 3),1157+    );1158+1159+    const result = await dispatch(1160+      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1161+      tabSender("https://evil.example.com/phish", 1, 4),1162+    );1163+    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1164+  });1165+1166+  it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1167+    const vault = await import("./vault");1168+    const messages = await import("./messages");1169+    await vault.createVault("correct-pass-12345");1170+    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1171+    const token = messages.createSummonCapability(1, "https://evil.example.com");1172+    await dispatch(1173+      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1174+      tabSender("https://evil.example.com/phish"),1175+    );1176+1177+    const result = await dispatch(1178+      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1179+      tabSender("https://other.example.com/phish"),1180+    );1181+    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1182+  });1183+1184+  it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1185+    const vault = await import("./vault");1186+    const messages = await import("./messages");1187+    await vault.createVault("correct-pass-12345");1188+    const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1189+    const sender = tabSender("https://evil.example.com/phish");1190+    const token = messages.createSummonCapability(1, "https://evil.example.com");1191+    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1192+1193+    const missing = await dispatch(1194+      { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1195+      sender,1196+    );1197+    const existingReplay = await dispatch(1198+      { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1199+      sender,1200+    );1201+    expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1202+    expect(existingReplay).toEqual(missing);1203+  });1204+1205+  it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1206+    const vault = await import("./vault");1207+    const messages = await import("./messages");1208+    await vault.createVault("correct-pass-12345");1209+    await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1210+    const sender = tabSender("https://evil.example.com/phish");1211+    const token = messages.createSummonCapability(1, "https://evil.example.com");1212+    const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1213+    const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1214+1215+    expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1216+    expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1217+  });1218+1219+  it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1220+    const vault = await import("./vault");1221+    const messages = await import("./messages");1222+    await vault.createVault("correct-pass-12345");1223+    const card = await vault.addEntry({1224+      type: "card",1225+      title: "Personal Visa",1226+      cardholderName: "June Peach",1227+      cardNumber: "4111111111111111",1228+      cvc: "123",1229+      expMonth: "12",1230+      expYear: "2030",1231+    });1232+    const sender = tabSender("https://shop.example.com/checkout");1233+    const passive = await dispatch(1234+      { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1235+      sender,1236+    );1237+    expect(passive).toMatchObject({ ok: true, data: [] });1238+1239+    const token = messages.createSummonCapability(1, "https://shop.example.com");1240+    const inventory = await dispatch(1241+      { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1242+      sender,1243+    );1244+    expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1245+    expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");1246+1247+    const fill = await dispatch(1248+      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1249+      sender,1250+    );1251+    expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1252+    const replay = await dispatch(1253+      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1254+      sender,1255+    );1256+    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1257+  });1258+1259+  it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1260+    const result = await dispatch(1261+      { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1262+      tabSender("https://example.com/login"),1263+    );1264+1265+    expect(result).toMatchObject({ ok: true });1266+    expect(sessionStorage.peach_open_entry).toBe("entry-123");1267+    expect(sessionStorage.peach_inline_unlock).toBeUndefined();10271268   });10281269 10291270   it("MV-1: tab request with a matching domain returns the entry", async () => {

view file

src/background/messages.ts+46 −82
@@ -108,7 +108,7 @@108108 import { isMessageSenderAllowed } from "./message-policy";109109 110110 const SUMMON_CAPABILITY_TTL_MS = 30_000;111-type SummonCapability = { tabId: number; frameId: number; origin: string; operation: "inventory" | "fill"; entryId?: string; expiresAt: number };111+type SummonCapability = { tabId: number; frameId: number; origin: string; operation: "inventory" | "fill"; expiresAt: number };112112 const summonCapabilities = new Map<string, SummonCapability>();113113 114114 export function createSummonCapability(tabId: number, origin: string, frameId = 0): string {@@ -117,16 +117,17 @@117117   return token;118118 }119119 120-function consumeSummonCapability(token: unknown, sender: Partial<chrome.runtime.MessageSender>, operation: "inventory" | "fill", entryId?: string): boolean {120+function consumeSummonCapability(token: unknown, sender: Partial<chrome.runtime.MessageSender>, operation: "inventory" | "fill"): boolean {121121   if (typeof token !== "string") return false;122122   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;123+  if (!cap) return false;124+  if (cap.expiresAt <= Date.now()) {125+    summonCapabilities.delete(token);126+    return false;127+  }128+  if (sender.tab?.id !== cap.tabId || (sender.frameId ?? 0) !== cap.frameId || attestedSenderOrigin(sender) !== cap.origin) return false;124129   if (cap.operation !== operation) return false;125130   if (operation === "inventory") cap.operation = "fill";126-  else if (cap.entryId === undefined) {127-    if (entryId === undefined) return false;128-    summonCapabilities.delete(token);129-  } else if (cap.entryId !== entryId) return false;130131   else summonCapabilities.delete(token);131132   return true;132133 }@@ -797,6 +798,9 @@797798           capability?: string;798799         };799800         const includeAll = consumeSummonCapability(capability, sender, "inventory");801+        if (capability !== undefined && !includeAll) {802+          return { ok: false, error: "entry_not_found" };803+        }800804         // kind="card": return saved cards as compact summaries for the801805         // inline card silver platter. Cards are not domain-scoped — every802806         // saved card is a candidate on every checkout page — so domain is@@ -817,11 +821,21 @@817821             })),818822           };819823         }824+        const scopedDomain = isPageSender(sender)825+          ? attestedSenderDomain(sender)826+          : typeof domain === "string"827+            ? extractDomain(domain)828+            : null;829+        if (!includeAll && !scopedDomain) {830+          return isPageSender(sender)831+            ? { ok: false, error: "origin_unverified" }832+            : { ok: true, data: [] };833+        }820834         if (kind === "totp") {821835           const matches = (822836             includeAll823837               ? vault.getEntries().filter((entry) => entry.type !== "note" && entry.type !== "card")824-              : vault.queryDomain(domain)838+              : vault.queryDomain(scopedDomain!)825839           ).filter((entry) => !isPasskeyOnly(entry) && Boolean(entry.totp));826840           return {827841             ok: true,@@ -845,7 +859,7 @@845859             ? vault846860                 .getEntries()847861                 .filter((entry) => entry.type !== "note" && entry.type !== "card")848-            : vault.queryDomain(domain)862+            : vault.queryDomain(scopedDomain!)849863         ).filter((entry) => !isPasskeyOnly(entry));850864         return {851865           ok: true,@@ -882,12 +896,13 @@882896           id: string;883897           capability?: string;884898         };899+        const capabilityAuthorized = capability !== undefined && consumeSummonCapability(capability, sender, "fill");900+        const senderDomain = isPageSender(sender) ? attestedSenderDomain(sender) : null;901+        if (isPageSender(sender) && !senderDomain) {902+          return { ok: false, error: "origin_unverified" };903+        }885904         const entry = vault.getEntryById(id);886905         if (!entry) return { ok: false, error: "entry_not_found" };887-        // Passkey-only entries (passkey credential, no password) cannot be888-        // filled into a form — sign-in goes through the WebAuthn path. A889-        // login that ALSO has a passkey is still fillable for its password.890-        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };891906 892907         // MV-1: re-validate the entry against the requesting frame's origin.893908         // sender.url / sender.tab.url are browser-attested — a page cannot@@ -896,36 +911,16 @@896911         // misattribute a cross-origin iframe's request to the top page. When897912         // the request does not come from a tab at all (popup surfaces use898913         // GET_ENTRY_FOR_EDIT, not this), refuse.899-        if (sender.tab !== undefined || sender.url !== undefined) {900-          let senderDomain: string | null = null;901-          try {902-            const rawUrl = sender.url ?? sender.tab?.url ?? "";903-            senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;904-          } catch {905-            senderDomain = null;906-          }907-          if (!senderDomain) return { ok: false, error: "origin_unverified" };908-909-          const entryDomains = new Set<string>();910-          const primary = extractDomain(entry.url ?? "");911-          if (primary) entryDomains.add(primary);912-          for (const extra of entry.additionalUrls ?? []) {913-            const d = extractDomain(extra);914-            if (d) entryDomains.add(d);915-          }916-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);921-          // explicitSummon=true means the user actively picked this entry922-          // from the cross-domain search affordance — an intentional923-          // off-domain fill. Passive fill paths must stay domain-bound.924-          if (!entryDomains.has(senderDomain) && !capabilityAuthorized) {914+        if (senderDomain && !capabilityAuthorized) {915+          if (!entryDomains(entry.url, entry.additionalUrls).has(senderDomain)) {925916             // Domain mismatch — refuse silently (no oracle about which ids exist).926917             return { ok: false, error: "entry_not_found" };927918           }928919         }920+        // Passkey-only entries (passkey credential, no password) cannot be921+        // filled into a form — sign-in goes through the WebAuthn path. A922+        // login that ALSO has a passkey is still fillable for its password.923+        if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };929924         // Strip the passkey credential AND the raw base32 TOTP seed from the930925         // response. Content scripts never need either: the private key is the931926         // authenticator's, and handing over the TOTP seed would let a@@ -960,54 +955,15 @@960955         }961956         return { ok: true, data: { ...safeEntry, totpCode, totpRemaining } };962957       }963-      // Trusted full-entry fetch for Peach's own inline-popup surfaces that964-      // genuinely need the raw TOTP seed: the edit form (which re-saves the965-      // seed and would otherwise wipe it) and nothing else. Unlike966-      // GET_ENTRY_FOR_FILL this fires only on an explicit user action inside967-      // the inline popup, never during passive autofill on an arbitrary page.968-      // The passkey credential is still stripped — the edit form never touches it.958+      // Trusted full-entry fetch for extension-owned edit surfaces. Content959+      // scripts route editing to the popup and cannot call this message.969960       case "GET_ENTRY_FOR_EDIT": {970961         if (isPageSender(sender)) return { ok: false, error: "invalid_sender" };971962         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };972-        const { id, capability } = msg.payload as {973-          id: string;974-          capability?: string;975-        };963+        const { id } = msg.payload as { id: string };976964         const entry = vault.getEntryById(id);977965         if (!entry) return { ok: false, error: "entry_not_found" };978966 979-        // PCH-01-006: re-validate the browser-attested sender origin, mirroring980-        // GET_ENTRY_FOR_FILL (MV-1). The edit channel ships the raw TOTP seed and981-        // full password history — at least as sensitive as the fill response — so982-        // the boundary must be enforced here, not only by UI reachability. Today983-        // the sole caller is the popup (no sender.tab, so this is a structural984-        // no-op), but a future tab-context caller or an id leak would otherwise985-        // inherit a full-credential oracle. Unlike the fill path we SKIP (not986-        // refuse) when no sender domain resolves, to keep the popup caller working.987-        // PB-26-005: the frame url (sender.url) is authoritative over the988-        // top-frame sender.tab.url — mirrors the fill path.989-        if (sender.tab !== undefined || sender.url !== undefined) {990-          let senderDomain: string | null = null;991-          try {992-            const rawUrl = sender.url ?? sender.tab?.url ?? "";993-            senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;994-          } catch {995-            senderDomain = null;996-          }997-          if (senderDomain) {998-            const entryDomains = new Set<string>();999-            const primary = extractDomain(entry.url ?? "");1000-            if (primary) entryDomains.add(primary);1001-            for (const extra of entry.additionalUrls ?? []) {1002-              const d = extractDomain(extra);1003-              if (d) entryDomains.add(d);1004-            }1005-            if (!entryDomains.has(senderDomain) && !consumeSummonCapability(capability, sender, "fill", id)) {1006-              return { ok: false, error: "entry_not_found" }; // no id oracle1007-            }1008-          }1009-        }1010-1011967         const { passkey: _passkey, ...safeEntry } = entry;1012968         return { ok: true, data: safeEntry };1013969       }@@ -1235,8 +1191,16 @@12351191         // Set a session flag so the popup knows it was opened for inline unlock12361192         // and should close itself once the vault is unlocked (returning the user12371193         // to the inline autofill selector on the page).1194+        const { entryId } = (msg.payload ?? {}) as { entryId?: unknown };1195+        if (entryId !== undefined && (typeof entryId !== "string" || entryId.length === 0 || entryId.length > 64)) {1196+          return { ok: false, error: "invalid_payload" };1197+        }12381198         if (chrome.storage.session) {1239-          await chrome.storage.session.set({ peach_inline_unlock: true });1199+          if (typeof entryId === "string") {1200+            await chrome.storage.session.set({ peach_open_entry: entryId });1201+          } else {1202+            await chrome.storage.session.set({ peach_inline_unlock: true });1203+          }12401204         }12411205         try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }12421206         return { ok: true };

view file

src/content/content.field-interaction.test.ts+49 −1
@@ -36,7 +36,7 @@3636 3737 import { afterEach, beforeEach, describe, expect, it } from "vitest";3838 import "./_setup";39-import { setMessageResponder, resetChromeMock, makeInput, createLoginForm, createEmailFirstForm } from "./_setup";39+import { setMessageResponder, resetChromeMock, sentMessages, makeInput, createLoginForm, createEmailFirstForm } from "./_setup";4040 import { handleFieldInteraction } from "./index";4141 import { showAutofillSelector, showTOTPSelector } from "./inject";4242 @@ -190,5 +190,53 @@190190     await Promise.all([handleFieldInteraction(password), handleFieldInteraction(password)]);191191     await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());192192     expect(showSelector).toHaveBeenCalledTimes(1);193+  });194+});195+196+describe("PB-26-201: browser-command capability propagation", () => {197+  it("passes the capability through form inventory and the selected fill", async () => {198+    const { password } = createLoginForm();199+    setMessageResponder((msg) => {200+      if (msg.type === "AUTOFILL_QUERY") {201+        return { ok: true, data: [{ id: "foreign-entry", title: "Foreign", username: "alice" }] };202+      }203+      if (msg.type === "GET_ENTRY_FOR_FILL") {204+        return { ok: true, data: { id: "foreign-entry", title: "Foreign", username: "alice", password: "secret" } };205+      }206+      return { ok: true, data: null };207+    });208+209+    await handleFieldInteraction(password, true, "form-capability");210+    await vi.waitFor(() => expect(showSelector).toHaveBeenCalled());211+212+    expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({213+      payload: { includeAll: true, capability: "form-capability" },214+    });215+    const onSelect = showSelector.mock.calls.at(-1)![2] as (id: string) => Promise<void>;216+    await onSelect("foreign-entry");217+    expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({218+      payload: { id: "foreign-entry", capability: "form-capability" },219+    });220+  });221+222+  it("passes the capability through TOTP inventory and the selected fill", async () => {223+    const totp = makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" });224+    setMessageResponder((msg) => {225+      if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "totp-entry", title: "Foreign 2FA" }] };226+      if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "totp-entry", totpCode: "123456" } };227+      return { ok: true, data: null };228+    });229+230+    await handleFieldInteraction(totp, true, "totp-capability");231+    await vi.waitFor(() => expect(showTOTP).toHaveBeenCalled());232+    expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({233+      payload: { kind: "totp", capability: "totp-capability" },234+    });235+236+    const onSelect = showTOTP.mock.calls.at(-1)![2] as (id: string) => Promise<void>;237+    await onSelect("totp-entry");238+    expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({239+      payload: { id: "totp-entry", capability: "totp-capability" },240+    });193241   });194242 });

view file

src/content/content.message-routing.test.ts+59 −1
@@ -35,11 +35,12 @@3535 3636 import { afterEach, beforeEach, describe, expect, it } from "vitest";3737 import "./_setup";38-import { resetChromeMock, setMessageResponder, messageListeners, createLoginForm, makeInput } from "./_setup";38+import { resetChromeMock, setMessageResponder, sentMessages, messageListeners, createLoginForm, makeInput } from "./_setup";3939 import "./index"; // registers the onMessage listener at import time4040 import * as inject from "./inject";4141 4242 const showSelector = inject.showAutofillSelector as unknown as ReturnType<typeof vi.fn>;43+const showCardSelector = inject.showCardSelector as unknown as ReturnType<typeof vi.fn>;4344 const showPasskeyProGate = inject.showPasskeyProGate as unknown as ReturnType<typeof vi.fn>;4445 const SELF_SENDER = { id: "peach-test-extension-id" };4546 const ENTRIES = [{ id: "e1", title: "Acme", username: "alice" }];@@ -65,6 +66,7 @@6566   await new Promise((r) => setTimeout(r, 0));6667   resetChromeMock();6768   showSelector.mockClear();69+  showCardSelector.mockClear();6870   showPasskeyProGate.mockClear();6971   predictBatch.mockReset();7072   predictBatch.mockReturnValue(Promise.resolve([]));@@ -90,6 +92,62 @@9092   it("T-M02: SUMMON_AUTOFILL with no login form → no crash, no credential selector", () => {9193     expect(() => dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut" })).not.toThrow();9294     expect(showSelector).not.toHaveBeenCalled();95+  });96+97+  it("PB-26-201: no-form command carries capability from inventory to selected fill", async () => {98+    const originalAttachShadow = Element.prototype.attachShadow;99+    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {100+      return originalAttachShadow.call(this, { mode: "open" });101+    });102+    setMessageResponder((msg) => {103+      if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: ENTRIES };104+      if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { ...ENTRIES[0], password: "secret" } };105+      return { ok: true, data: null };106+    });107+108+    try {109+      dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut", capability: "no-form-capability" });110+      await vi.waitFor(() => {111+        expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY")).toMatchObject({112+          payload: { includeAll: true, capability: "no-form-capability" },113+        });114+      });115+      const findRow = () => Array.from(document.body.children)116+        .map((host) => host.shadowRoot?.querySelector<HTMLElement>(".e"))117+        .find((candidate): candidate is HTMLElement => candidate !== null && candidate !== undefined);118+      await vi.waitFor(() => expect(findRow()).toBeDefined());119+      const row = findRow();120+      row?.click();121+      await vi.waitFor(() => {122+        expect(sentMessages.find((msg) => msg.type === "GET_ENTRY_FOR_FILL")).toMatchObject({123+          payload: { id: "e1", capability: "no-form-capability" },124+        });125+      });126+    } finally {127+      attachSpy.mockRestore();128+    }129+  });130+131+  it("PB-26-201: card command carries capability into inventory and selector actions", async () => {132+    const form = document.createElement("form");133+    document.body.appendChild(form);134+    const number = makeInput({ type: "text", autocomplete: "cc-number" }, form);135+    makeInput({ type: "text", autocomplete: "cc-exp" }, form);136+    makeInput({ type: "text", autocomplete: "cc-csc" }, form);137+    number.focus();138+    setMessageResponder((msg) =>139+      msg.type === "AUTOFILL_QUERY"140+        ? { ok: true, data: [{ id: "card-1", title: "Personal Visa", last4: "1111" }] }141+        : { ok: true, data: null },142+    );143+144+    dispatch({ type: "SUMMON_AUTOFILL", source: "shortcut", capability: "card-capability" });145+146+    await vi.waitFor(() => expect(showCardSelector).toHaveBeenCalled());147+    expect(sentMessages.find((msg) => msg.type === "AUTOFILL_QUERY" && msg.payload?.kind === "card")).toMatchObject({148+      payload: { capability: "card-capability" },149+    });150+    expect(showCardSelector.mock.calls.at(-1)![2]).toMatchObject({ capability: "card-capability" });93151   });94152 });95153 

view file

src/content/content.selector-ui.test.ts+163 −2
@@ -17,8 +17,8 @@1717 1818 import "./_setup"; // chrome mock + ResizeObserver polyfill1919 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";20-import { showAutofillSelector } from "./inject";21-import { makeInput } from "./_setup";20+import { showAutofillSelector, showCardSelector } from "./inject";21+import { makeInput, resetChromeMock, sentMessages, setMessageResponder } from "./_setup";2222 2323 const ENTRIES = [2424   { id: "e1", title: "Acme", username: "[email protected]" },@@ -33,6 +33,7 @@3333 3434 beforeEach(() => {3535   document.body.innerHTML = "";36+  resetChromeMock();3637 });3738 afterEach(() => {3839   document.body.innerHTML = "";@@ -79,5 +80,165 @@7980     expect(document.body.querySelectorAll('div[style*="2147483647"]').length).toBe(2);8081     d1();8182     d2();83+  });84+});85+86+describe("PB-26-201: content-safe privileged actions", () => {87+  it("uses a command capability for exactly one login-with-TOTP secret fetch", async () => {88+    const originalAttachShadow = Element.prototype.attachShadow;89+    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {90+      return originalAttachShadow.call(this, { mode: "open" });91+    });92+    const anchor = makeInput({ type: "password" });93+    const onSelect = vi.fn(async (id: string) => {94+      await chrome.runtime.sendMessage({95+        type: "GET_ENTRY_FOR_FILL",96+        payload: { id, capability: "login-capability" },97+      });98+    });99+    setMessageResponder((message) =>100+      message.type === "GET_ENTRY_FOR_FILL"101+        ? { ok: true, data: { password: "secret", totpCode: "123456" } }102+        : { ok: true, data: null },103+    );104+105+    try {106+      const dismiss = showAutofillSelector(107+        anchor,108+        [{ id: "e1", title: "Acme", username: "alice", hasTotp: true }],109+        onSelect,110+        { domain: "example.com", capability: "login-capability" },111+      );112+      const host = selectorHost();113+      expect(sentMessages.some((message) => message.type === "GET_ENTRY_FOR_FILL")).toBe(false);114+      host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn")?.click();115+116+      await vi.waitFor(() => expect(onSelect).toHaveBeenCalledWith("e1"));117+      expect(sentMessages.filter((message) => message.type === "GET_ENTRY_FOR_FILL")).toHaveLength(1);118+      dismiss();119+    } finally {120+      attachSpy.mockRestore();121+    }122+  });123+124+  it("passes the command capability into a selected password copy", async () => {125+    const originalAttachShadow = Element.prototype.attachShadow;126+    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {127+      return originalAttachShadow.call(this, { mode: "open" });128+    });129+    const anchor = makeInput({ type: "password" });130+    setMessageResponder((message) =>131+      message.type === "GET_ENTRY_FOR_FILL"132+        ? { ok: true, data: { password: "correct horse battery staple" } }133+        : { ok: true, data: null },134+    );135+136+    try {137+      const dismiss = showAutofillSelector(anchor, [ENTRIES[0]!], vi.fn(), {138+        domain: "example.com",139+        capability: "login-capability",140+      } as Parameters<typeof showAutofillSelector>[3]);141+      const host = selectorHost();142+      host?.shadowRoot?.querySelector<HTMLButtonElement>('button[title="Copy password"]')?.click();143+144+      await vi.waitFor(() => {145+        expect(sentMessages.find((message) => message.type === "GET_ENTRY_FOR_FILL")).toMatchObject({146+          payload: { id: "e1", capability: "login-capability" },147+        });148+      });149+      dismiss();150+    } finally {151+      attachSpy.mockRestore();152+    }153+  });154+155+  it("routes inline edit to the extension popup without requesting raw edit data", async () => {156+    const originalAttachShadow = Element.prototype.attachShadow;157+    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {158+      return originalAttachShadow.call(this, { mode: "open" });159+    });160+    const anchor = makeInput({ type: "password" });161+162+    try {163+      const dismiss = showAutofillSelector(anchor, [ENTRIES[0]!], vi.fn(), { domain: "example.com" });164+      const host = selectorHost();165+      const edit = host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-action-edit");166+      expect(edit).not.toBeNull();167+      edit?.click();168+169+      await vi.waitFor(() => {170+        expect(sentMessages).toContainEqual({ type: "OPEN_POPUP", payload: { entryId: "e1" } });171+      });172+      expect(sentMessages.some((message) => message.type === "GET_ENTRY_FOR_EDIT")).toBe(false);173+      dismiss();174+    } finally {175+      attachSpy.mockRestore();176+    }177+  });178+179+  it("passes the card command capability into the selected card fill", async () => {180+    const originalAttachShadow = Element.prototype.attachShadow;181+    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {182+      return originalAttachShadow.call(this, { mode: "open" });183+    });184+    const anchor = makeInput({ type: "text", autocomplete: "cc-number" });185+    const onFill = vi.fn();186+    setMessageResponder((message) =>187+      message.type === "GET_ENTRY_FOR_FILL"188+        ? { ok: true, data: { id: "card-1", type: "card", title: "Personal Visa", cardNumber: "4111111111111111" } }189+        : { ok: true, data: null },190+    );191+192+    try {193+      const dismiss = showCardSelector(194+        anchor,195+        [{ id: "card-1", title: "Personal Visa", last4: "1111" }],196+        { domain: "shop.example", capability: "card-capability", onFill } as Parameters<typeof showCardSelector>[2],197+      );198+      const host = selectorHost();199+      host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn")?.click();200+201+      await vi.waitFor(() => {202+        expect(sentMessages.find((message) => message.type === "GET_ENTRY_FOR_FILL")).toMatchObject({203+          payload: { id: "card-1", capability: "card-capability" },204+        });205+      });206+      dismiss();207+    } finally {208+      attachSpy.mockRestore();209+    }210+  });211+212+  it("allows only one card secret action per command capability", async () => {213+    const originalAttachShadow = Element.prototype.attachShadow;214+    const attachSpy = vi.spyOn(Element.prototype, "attachShadow").mockImplementation(function (this: Element) {215+      return originalAttachShadow.call(this, { mode: "open" });216+    });217+    const anchor = makeInput({ type: "text", autocomplete: "cc-number" });218+    setMessageResponder((message) =>219+      message.type === "GET_ENTRY_FOR_FILL"220+        ? { ok: true, data: { id: "card-1", type: "card", cardNumber: "4111111111111111", cvc: "123" } }221+        : { ok: true, data: null },222+    );223+224+    try {225+      const dismiss = showCardSelector(226+        anchor,227+        [{ id: "card-1", title: "Personal Visa", last4: "1111" }],228+        { domain: "shop.example", capability: "card-capability", onFill: vi.fn() },229+      );230+      const host = selectorHost();231+      const copyNumber = host?.shadowRoot?.querySelector<HTMLButtonElement>('button[title="Copy card number"]');232+      const fill = host?.shadowRoot?.querySelector<HTMLButtonElement>(".sp-fill-btn");233+      copyNumber?.click();234+      fill?.click();235+236+      await vi.waitFor(() => {237+        expect(sentMessages.filter((message) => message.type === "GET_ENTRY_FOR_FILL")).toHaveLength(1);238+      });239+      dismiss();240+    } finally {241+      attachSpy.mockRestore();242+    }82243   });83244 });

view file

src/content/index.ts+42 −20
@@ -284,9 +284,9 @@284284 285285   searchInput.oninput = () => { query = searchInput.value; render(); };286286 287-  // MV-2: the summon palette is an explicit user gesture (keyboard shortcut /288-  // toolbar icon), so it may load the vault-wide inventory.289-  fetchAutofillEntries(getPageDomain(), true, true).then(result => {287+  // The browser-issued command capability, not page UI state, authorizes the288+  // vault-wide inventory.289+  fetchAutofillEntries(getPageDomain(), true, capability).then(result => {290290     if (result.locked) { showToast("Vault is locked — open Peach to unlock"); close(); return; }291291     allEntries = result.entries;292292     render();@@ -389,7 +389,6 @@389389 async function fetchAutofillEntries(390390   domain: string,391391   includeAll = false,392-  _userGesture = false,393392   capability?: string394393 ): Promise<{ entries: SelectorEntry[]; locked: boolean }> {395394   const response = await chrome.runtime.sendMessage({@@ -404,11 +403,12 @@404403 }405404 406405 async function fetchTOTPEntries(407-  domain: string406+  domain: string,407+  capability?: string,408408 ): Promise<{ entries: SelectorEntry[]; locked: boolean }> {409409   const response = await chrome.runtime.sendMessage({410410     type: "AUTOFILL_QUERY",411-    payload: { domain, kind: "totp" },411+    payload: { domain, kind: "totp", capability },412412   });413413   const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };414414   if (!res.ok && res.error === "vault_locked") {@@ -449,7 +449,8 @@449449   loadSearchableEntries: () => Promise<SelectorEntry[]>,450450   domain: string,451451   identityKind: "email" | "username",452-  emailEl?: HTMLInputElement | null452+  emailEl?: HTMLInputElement | null,453+  capability?: string,453454 ): Promise<void> {454455   // No saved entries for this site → signup/new-account mode.455456   // Fetch vault identities for the identity-first surface and locate@@ -481,7 +482,7 @@481482       currentAnchorField = null;482483       const entryResponse = await chrome.runtime.sendMessage({483484         type: "GET_ENTRY_FOR_FILL",484-        payload: { id },485+        payload: { id, capability },485486       });486487        const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;487488        if (entry) {@@ -504,6 +505,7 @@504505     },505506     {506507       domain,508+      capability,507509       loadSearchableEntries,508510       isSignup,509511       identityKind,@@ -520,7 +522,11 @@520522   );521523 }522524 523-async function openTOTPSelector(anchorField: HTMLInputElement, otpDigitBoxes?: HTMLInputElement[]): Promise<void> {525+async function openTOTPSelector(526+  anchorField: HTMLInputElement,527+  otpDigitBoxes?: HTMLInputElement[],528+  capability?: string,529+): Promise<void> {524530   if (!canOpen()) return;525531   if (currentDismiss && currentAnchorField === anchorField) return;526532   if (anchorField !== lastDismissedField) lastDismissedField = null;@@ -528,7 +534,7 @@528534   if (wasAlreadyFilled(anchorField)) return;529535 530536   const domain = getPageDomain();531-  const result = await fetchTOTPEntries(domain);537+  const result = await fetchTOTPEntries(domain, capability);532538 533539   if (result.locked) {534540     currentDismiss?.();@@ -563,7 +569,7 @@563569       try {564570         const entryResponse = await chrome.runtime.sendMessage({565571           type: "GET_ENTRY_FOR_FILL",566-          payload: { id },572+          payload: { id, capability },567573         }).catch(() => null) as { ok: boolean; data?: VaultEntry } | null;568574         const entry = entryResponse?.ok ? entryResponse.data : undefined;569575         const code = (entry as { totpCode?: string } | undefined)?.totpCode;@@ -639,14 +645,14 @@639645     // name/id/label matches otp/totp/2fa) but lack maxlength="1".640646     const otpGroup = findOTPDigitBoxGroup(field) ?? findOtpSignalingGroup(field);641647     if (otpGroup) {642-      await openTOTPSelector(otpGroup[0]!, otpGroup);648+      await openTOTPSelector(otpGroup[0]!, otpGroup, capability);643649       return;644650     }645651 646652     if (isTOTPField(field)) {647653       const formFields = detectLoginFields();648654       if (!isLoginForm(formFields) && !isSignupForm(formFields)) {649-        await openTOTPSelector(field);655+        await openTOTPSelector(field, undefined, capability);650656         return;651657       }652658     }@@ -683,7 +689,12 @@683689     }684690 685691     const domain = getPageDomain();686-    const firstResult = await fetchAutofillEntries(domain);692+    const commandAuthorized = explicitSummon && capability !== undefined;693+    const firstResult = await fetchAutofillEntries(694+      domain,695+      commandAuthorized,696+      commandAuthorized ? capability : undefined,697+    );687698     if (passkeyToastActive) return;688699 689700     // Vault is locked — show an inline unlock prompt.@@ -709,7 +720,9 @@709720     // world — and never hands out the entry ids that made MV-1 a full-vault710721     // primitive.711722     const loadSearchableEntries = async (): Promise<SelectorEntry[]> =>712-      (await fetchAutofillEntries(domain, true, true, capability)).entries;723+      commandAuthorized724+        ? entries725+        : (await fetchAutofillEntries(domain)).entries;713726 714727     if (passkeyToastActive) return;715728 @@ -729,6 +742,7 @@729742       domain,730743       autofillFieldType === "email" ? "email" : "username",731744       emailEl,745+      commandAuthorized ? capability : undefined,732746     );733747   } finally {734748     pendingInteractions.delete(field);@@ -962,23 +976,23 @@962976 let currentCardAnchor: HTMLInputElement | null = null;963977 let lastDismissedCardField: HTMLInputElement | null = null;964978 965-async function fetchSavedCards(): Promise<CardSelectorEntry[]> {979+async function fetchSavedCards(capability?: string): Promise<CardSelectorEntry[]> {966980   const resp = await chrome.runtime.sendMessage({967981     type: "AUTOFILL_QUERY",968-    payload: { domain: getPageDomain(), kind: "card" },982+    payload: { domain: getPageDomain(), kind: "card", capability },969983   }).catch(() => null);970984   const r = resp as { ok: boolean; data?: CardSelectorEntry[] } | null;971985   return r?.ok ? (r.data ?? []) : [];972986 }973987 974-async function openCardSelector(anchor: HTMLInputElement): Promise<void> {988+async function openCardSelector(anchor: HTMLInputElement, capability?: string): Promise<void> {975989   if (!canOpen()) return;976990   if (currentCardDismiss && currentCardAnchor === anchor) return;977991   if (anchor === lastDismissedCardField) return;978992   if (anchor !== lastDismissedCardField) lastDismissedCardField = null;979993   if (wasAlreadyFilled(anchor)) return;980994 981-  const cards = await fetchSavedCards();995+  const cards = await fetchSavedCards(capability);982996   if (cards.length === 0) return; // no saved cards → nothing to offer983997 984998   const cardForm = detectCardForm();@@ -991,6 +1005,7 @@9911005     cards,9921006     {9931007       domain: getPageDomain(),1008+      capability,9941009       onFill: (entry) => {9951010         if (!cardForm) return;9961011         fillCardForm(cardForm, {@@ -1090,6 +1105,7 @@10901105     const activeInput = getDeepActiveInput();10911106     const fields = detectLoginFields();10921107     const hasForm = isAnyCredentialForm(fields);1108+    const cardForm = detectCardForm();10931109 10941110     if (hasForm) {10951111       // Prefer the currently focused field if it's a login field, otherwise@@ -1108,6 +1124,12 @@11081124         lastDismissedField = null;11091125         void handleFieldInteraction(target, true, capability);11101126       }1127+    } else if (isShortcut && cardForm?.number) {1128+      clearFillStateForExplicitSummon(cardForm.number);1129+      dismissLock = false;1130+      if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }1131+      lastDismissedCardField = null;1132+      void openCardSelector(cardForm.number, capability);11111133     } else if (isPasskeyUnlock) {11121134       // Vault is locked during a passkey GET request. Show the inline unlock11131135       // prompt anchored to whatever element is focused on the page (typically@@ -1138,7 +1160,7 @@11381160       }11391161     }11401162 1141-    sendResponse({ ok: hasForm || isShortcut || isPasskeyUnlock });1163+    sendResponse({ ok: hasForm || Boolean(cardForm?.number) || isShortcut || isPasskeyUnlock });11421164     return true;11431165   }11441166   if (msg?.type === "PASSKEY_PRO_GATE") {

view file

src/content/inject.ts+41 −325
@@ -9,7 +9,7 @@99  * to fill come back across the boundary.1010  */1111 12-import { ACCENT_COLORS, type PasswordPolicy, type PasswordHistoryItem, type SavedIdentity } from "../shared/types";12+import { ACCENT_COLORS, type PasswordPolicy, type SavedIdentity } from "../shared/types";1313 import { generatePassword } from "../shared/generate";1414 import { detectPasswordPolicy } from "./policy";1515 import {@@ -20,22 +20,6 @@2020   type IdentityKind,2121 } from "./identity-helper";2222 23-// Coarse "time since" label for the inline password-history list. Mirrors the24-// main popup's helper — "roughly when", not a precise duration.25-function timeSince(when: number): string {26-  const secs = Math.max(0, Math.floor((Date.now() - when) / 1000));27-  if (secs < 60) return "just now";28-  const mins = Math.floor(secs / 60);29-  if (mins < 60) return `${mins}m ago`;30-  const hours = Math.floor(mins / 60);31-  if (hours < 24) return `${hours}h ago`;32-  const days = Math.floor(hours / 24);33-  if (days < 30) return `${days}d ago`;34-  const months = Math.floor(days / 30);35-  if (months < 12) return `${months}mo ago`;36-  return `${Math.floor(months / 12)}y ago`;37-}38-3923 let accentHex = "#E8956D";4024 let popupTheme: "dark" | "light" = "dark";4125 chrome.runtime.sendMessage({ type: "GET_SETTINGS" }, (resp: { ok: boolean; data?: { accentColor?: string; theme?: string } } | undefined) => {@@ -68,6 +52,8 @@6852 6953 interface SelectorOptions {7054   domain?: string;55+  /** Browser-issued one-shot authorization for a user-summoned cross-origin selection. */56+  capability?: string;7157   searchableEntries?: SelectorEntry[];7258   /**7359    * MV-2: lazy loader for the vault-wide searchable list. Invoked only on an@@ -2640,6 +2626,13 @@26402626 26412627     const platter = document.createElement("div");26422628     platter.className = "silver-platter";2629+    let capabilityActionClaimed = false;2630+    const claimCapabilityAction = () => {2631+      if (!options.capability) return true;2632+      if (capabilityActionClaimed) return false;2633+      capabilityActionClaimed = true;2634+      return true;2635+    };26432636 26442637     const titleEl = document.createElement("div");26452638     titleEl.className = "sp-title";@@ -2677,7 +2670,7 @@26772670     fillBtn.type = "button";26782671     fillBtn.textContent = "Fill credentials";26792672     fillBtn.addEventListener("click", () => {2680-      if (fillBtn.disabled) return;2673+      if (fillBtn.disabled || !claimCapabilityAction()) return;26812674       fillBtn.disabled = true;26822675       fillBtn.classList.add("filled");26832676       fillBtn.innerHTML = checkSvgStr();@@ -2759,12 +2752,15 @@27592752     copyP.textContent = "Password";27602753     copyP.addEventListener("click", async (e) => {27612754       e.stopPropagation();2755+      if (!claimCapabilityAction()) return;27622756       const resp = await chrome.runtime.sendMessage({27632757         type: "GET_ENTRY_FOR_FILL",2764-        // MV-1: user explicitly picked this entry from Peach's inline summon UI.2765-        payload: { id: entry.id },2758+        payload: { id: entry.id, capability: options.capability },27662759       }).catch(() => null) as { ok: boolean; data?: { password?: string } } | null;2767-      if (!resp?.ok || !resp.data?.password) return;2760+      if (!resp?.ok || !resp.data?.password) {2761+        if (options.capability) dismiss();2762+        return;2763+      }27682764       let alreadyWritten = false;27692765       try {27702766         await navigator.clipboard.writeText(resp.data.password);@@ -2778,6 +2774,7 @@27782774       }).catch(() => null) as { ok?: boolean } | null;27792775       if (alreadyWritten || copyResp?.ok) flashActionBtn(copyP);27802776       else flashCopyError(copyP);2777+      if (options.capability) dismiss();27812778     });27822779     actions.appendChild(copyP);27832780 @@ -2801,8 +2798,7 @@28012798       async function fetchCode(): Promise<{ code: string; remaining: number } | null> {28022799         const r = await chrome.runtime.sendMessage({28032800           type: "GET_ENTRY_FOR_FILL",2804-          // MV-1: explicit user summon of this entry's live TOTP code.2805-          payload: { id: entry.id },2801+          payload: { id: entry.id, capability: options.capability },28062802         }).catch(() => null) as { ok: boolean; data?: { totpCode?: string; totpRemaining?: number } } | null;28072803         if (!r?.ok || !r.data?.totpCode) return null;28082804         return { code: r.data.totpCode, remaining: r.data.totpRemaining ?? 30 };@@ -2905,310 +2901,19 @@29052901     // Always show the 2FA tray when the entry has TOTP — hiding it behind a29062902     // "···" toggle was zero-savings (the toggle button took the same space29072903     // the "2FA" label needed) and made the code one extra click away.2908-    if (entry.hasTotp) void openTOTPTray();2904+    if (entry.hasTotp && !options.capability) void openTOTPTray();29092905 29102906     platter.appendChild(actionsGroup);29112907     container.appendChild(platter);29122908   }29132909 2914-  /**2915-   * Inline-popup edit mode. Swaps the silver platter for an editable form2916-   * over the same shadow-DOM surface so the user never leaves the page.2917-   * Saves go through UPDATE_ENTRY; on success we refresh the silver platter2918-   * with the new fields. Cancel returns without writing.2919-   */2910+  /** Raw TOTP seeds and password history stay inside the extension popup. */29202911   async function renderInlineEdit(summary: SelectorEntry) {2921-    currentMode = "edit";2922-    container.innerHTML = "";2923-    const wrap = document.createElement("div");2924-    wrap.className = "create-form-wrap";2925-2926-    const head = document.createElement("div");2927-    head.className = "create-head";2928-    const back = document.createElement("button");2929-    back.className = "back-btn";2930-    back.type = "button";2931-    back.title = "Cancel";2932-    back.innerHTML = `<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"><path d="M7 2L3 5.5L7 9"/></svg>`;2933-    back.addEventListener("click", () => renderSilverPlatter(summary));2934-    head.appendChild(back);2935-    const headLbl = document.createElement("div");2936-    headLbl.className = "create-label";2937-    headLbl.textContent = "Edit entry";2938-    head.appendChild(headLbl);2939-    wrap.appendChild(head);2940-2941-    const body = document.createElement("div");2942-    body.className = "create-body";2943-2944-    // Loading shimmer while we fetch the full entry2945-    const loading = document.createElement("div");2946-    loading.className = "empty-hint";2947-    loading.textContent = "loading…";2948-    body.appendChild(loading);2949-    wrap.appendChild(body);2950-    container.appendChild(wrap);2951-2952-    const [res, idRes] = await Promise.all([2953-      // The edit form needs the raw TOTP seed so a save doesn't wipe it — use2954-      // the trusted edit channel, not the seed-stripped GET_ENTRY_FOR_FILL.2955-      chrome.runtime.sendMessage({2956-        type: "GET_ENTRY_FOR_EDIT",2957-        payload: { id: summary.id },2958-      }).catch(() => null),2959-      chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null),2960-    ]);2961-    const entryRes = res as { ok: boolean; data?: { title?: string; username?: string; email?: string; password?: string; url?: string; totp?: string; notes?: string; passwordHistory?: PasswordHistoryItem[] } } | null;2962-    const editIdentities: SavedIdentity[] = (idRes as { ok?: boolean; data?: SavedIdentity[] } | null)?.data ?? [];2963-2964-    if (!entryRes || !entryRes.ok || !entryRes.data) {2965-      loading.textContent = "couldn't load this entry. try again from the popup.";2966-      return;2967-    }2968-    body.innerHTML = "";2969-2970-    function field(label: string, value: string, type: "text" | "password" = "text"): { g: HTMLDivElement; inp: HTMLInputElement } {2971-      const group = document.createElement("div");2972-      group.className = "f-group";2973-      const lbl = document.createElement("div");2974-      lbl.className = "f-label";2975-      lbl.textContent = label;2976-      const input = document.createElement("input");2977-      input.className = "f-input";2978-      input.type = type;2979-      input.value = value;2980-      input.autocomplete = "off";2981-      group.append(lbl, input);2982-      body.appendChild(group);2983-      return { g: group, inp: input };2984-    }2985-2986-    const titleInput = field("title", entryRes.data.title ?? "").inp;2987-    const urlInput = field("url", entryRes.data.url ?? "").inp;2988-    urlInput.classList.add("url-f");2989-    const usernameLabel = (entryRes.data.username ?? "").includes("@") ? "email" : "username";2990-    const { g: usernameGroup, inp: usernameInput } = field(usernameLabel, entryRes.data.username ?? "");2991-    if (editIdentities.length) attachIdentitySuggestions(usernameGroup, usernameInput, editIdentities, "username");2992-    const { g: emailGroup, inp: emailInput } = field("email", entryRes.data.email ?? "");2993-    if (editIdentities.length) attachIdentitySuggestions(emailGroup, emailInput, editIdentities, "email");2994-2995-    // Password row with reveal toggle + generator2996-    const pwGroup = document.createElement("div");2997-    pwGroup.className = "f-group";2998-    const pwLbl = document.createElement("div");2999-    pwLbl.className = "f-label";3000-    pwLbl.textContent = "password";3001-    const pwRow = document.createElement("div");3002-    pwRow.className = "f-row";3003-    const pwInput = document.createElement("input");3004-    pwInput.className = "f-input";3005-    pwInput.type = "password";3006-    pwInput.value = entryRes.data.password ?? "";3007-    pwInput.autocomplete = "new-password";3008-    const revealBtn = document.createElement("button");3009-    revealBtn.className = "gen-btn";3010-    revealBtn.type = "button";3011-    revealBtn.textContent = "show";3012-    revealBtn.addEventListener("click", () => {3013-      const reveal = pwInput.type === "password";3014-      pwInput.type = reveal ? "text" : "password";3015-      revealBtn.textContent = reveal ? "hide" : "show";3016-    });3017-    pwRow.append(pwInput, revealBtn);3018-    const editSmartGen = createSmartGenerator({ policy: detectPasswordPolicy(), onChange: (pw) => { pwInput.value = pw; } });3019-    editSmartGen.root.style.display = "none";3020-    let editGenVisible = false;3021-    const genToggleBtn = document.createElement("button");3022-    genToggleBtn.className = "gen-toggle-link";3023-    genToggleBtn.type = "button";3024-    genToggleBtn.textContent = "⚙ generate";3025-    genToggleBtn.addEventListener("click", () => {3026-      editGenVisible = !editGenVisible;3027-      pwRow.style.display = editGenVisible ? "none" : "";3028-      editSmartGen.root.style.display = editGenVisible ? "" : "none";3029-      genToggleBtn.textContent = editGenVisible ? "☰ hide generator" : "⚙ generate";3030-    });3031-    pwGroup.append(pwLbl, pwRow, genToggleBtn, editSmartGen.root);3032-3033-    // Past-password history: reveal, copy, or restore a previously-used value.3034-    // Rows are masked by default; "use" loads the old value into the editor so3035-    // the standard save flow records the swap (no separate write path). The3036-    // toggle is always present (matching the main popup) so the affordance is3037-    // discoverable — even before any history exists it explains how it fills.3038-    const history = entryRes.data.passwordHistory ?? [];3039-    {3040-      const histList = document.createElement("div");3041-      histList.className = "pw-history";3042-      histList.style.display = "none";3043-      let histVisible = false;3044-3045-      const restLabel = history.length ? `↺ history (${history.length})` : "↺ history";3046-      const histToggle = document.createElement("button");3047-      histToggle.className = "gen-toggle-link";3048-      histToggle.type = "button";3049-      histToggle.textContent = restLabel;3050-      histToggle.addEventListener("click", () => {3051-        histVisible = !histVisible;3052-        histList.style.display = histVisible ? "" : "none";3053-        histToggle.textContent = histVisible ? "↺ hide history" : restLabel;3054-      });3055-3056-      if (history.length === 0) {3057-        const empty = document.createElement("div");3058-        empty.className = "pw-history-empty";3059-        empty.textContent = "No earlier passwords yet. When you change this password, the old one is saved here so you can copy or restore it.";3060-        histList.appendChild(empty);3061-      }3062-3063-      for (const item of history) {3064-        const row = document.createElement("div");3065-        row.className = "pw-history-row";3066-        const main = document.createElement("div");3067-        main.className = "pw-history-main";3068-        const pwText = document.createElement("div");3069-        pwText.className = "pw-history-pw";3070-        let revealed = false;3071-        const mask = "••••••••••••";3072-        pwText.textContent = mask;3073-        const age = document.createElement("div");3074-        age.className = "pw-history-age";3075-        age.textContent = timeSince(item.changedAt);3076-        main.append(pwText, age);3077-3078-        const acts = document.createElement("div");3079-        acts.className = "pw-history-actions";3080-        const revealBtn2 = document.createElement("button");3081-        revealBtn2.className = "pw-history-btn";3082-        revealBtn2.type = "button";3083-        revealBtn2.textContent = "show";3084-        revealBtn2.addEventListener("click", () => {3085-          revealed = !revealed;3086-          pwText.textContent = revealed ? item.password : mask;3087-          revealBtn2.textContent = revealed ? "hide" : "show";3088-        });3089-        const copyBtn2 = document.createElement("button");3090-        copyBtn2.className = "pw-history-btn";3091-        copyBtn2.type = "button";3092-        copyBtn2.textContent = "copy";3093-        copyBtn2.addEventListener("click", async () => {3094-          let alreadyWritten = false;3095-          try {3096-            await navigator.clipboard.writeText(item.password);3097-            alreadyWritten = true;3098-          } catch (err) {3099-            console.warn("[Peach] clipboard write denied:", err);3100-          }3101-          await chrome.runtime.sendMessage({3102-            type: "COPY",3103-            payload: { text: item.password, label: "Password", alreadyWritten },3104-          }).catch(() => null);3105-          copyBtn2.textContent = "copied";3106-          setTimeout(() => { if (copyBtn2.isConnected) copyBtn2.textContent = "copy"; }, 1200);3107-        });3108-        const useBtn = document.createElement("button");3109-        useBtn.className = "pw-history-btn";3110-        useBtn.type = "button";3111-        useBtn.textContent = "use";3112-        useBtn.title = "Use this password again";3113-        useBtn.addEventListener("click", () => {3114-          pwInput.value = item.password;3115-          pwInput.type = "text";3116-          revealBtn.textContent = "hide";3117-          if (editGenVisible) {3118-            editGenVisible = false;3119-            pwRow.style.display = "";3120-            editSmartGen.root.style.display = "none";3121-            genToggleBtn.textContent = "⚙ generate";3122-          }3123-          histVisible = false;3124-          histList.style.display = "none";3125-          histToggle.textContent = `↺ history (${history.length})`;3126-        });3127-        acts.append(revealBtn2, copyBtn2, useBtn);3128-        row.append(main, acts);3129-        histList.appendChild(row);3130-      }3131-3132-      pwGroup.append(histToggle, histList);3133-    }3134-3135-    body.appendChild(pwGroup);3136-3137-    const totpInput = field("2fa secret", entryRes.data.totp ?? "").inp;3138-3139-    // Notes3140-    const notesGroup = document.createElement("div");3141-    notesGroup.className = "f-group";3142-    const notesLbl = document.createElement("div");3143-    notesLbl.className = "f-label";3144-    notesLbl.textContent = "notes";3145-    const notesInput = document.createElement("textarea");3146-    notesInput.className = "f-input";3147-    notesInput.rows = 3;3148-    notesInput.value = entryRes.data.notes ?? "";3149-    notesInput.style.fontFamily = "Inter, sans-serif";3150-    notesInput.style.resize = "vertical";3151-    notesGroup.append(notesLbl, notesInput);3152-    body.appendChild(notesGroup);3153-3154-    // Save / Cancel row3155-    const actionsRow = document.createElement("div");3156-    actionsRow.className = "f-row";3157-    const saveBtn = document.createElement("button");3158-    saveBtn.className = "save-btn";3159-    saveBtn.type = "button";3160-    saveBtn.textContent = "Save changes";3161-    saveBtn.style.flex = "1";3162-    const cancelBtn = document.createElement("button");3163-    cancelBtn.className = "gen-btn";3164-    cancelBtn.type = "button";3165-    cancelBtn.textContent = "cancel";3166-    cancelBtn.addEventListener("click", () => renderSilverPlatter(summary));3167-    actionsRow.append(saveBtn, cancelBtn);3168-    body.appendChild(actionsRow);3169-3170-    saveBtn.addEventListener("click", async () => {3171-      saveBtn.disabled = true;3172-      const originalLabel = saveBtn.textContent;3173-      saveBtn.textContent = "Saving…";3174-      const patch: Record<string, unknown> = {3175-        title: titleInput.value.trim(),3176-        url: urlInput.value.trim(),3177-        username: usernameInput.value.trim(),3178-        email: emailInput.value.trim() || undefined,3179-        password: pwInput.value,3180-        totp: totpInput.value.trim(),3181-        notes: notesInput.value,3182-      };3183-      const updateRes = (await chrome.runtime.sendMessage({3184-        type: "UPDATE_ENTRY",3185-        payload: { id: summary.id, patch },3186-      }).catch(() => null)) as { ok: boolean; error?: string } | null;3187-      if (!updateRes?.ok) {3188-        saveBtn.disabled = false;3189-        saveBtn.textContent = originalLabel;3190-        const err = document.createElement("div");3191-        err.className = "empty-hint";3192-        err.style.color = "color-mix(in srgb, #e55 70%, transparent)";3193-        err.textContent = updateRes?.error ?? "Couldn't save. Try the popup.";3194-        body.appendChild(err);3195-        setTimeout(() => err.remove(), 3000);3196-        return;3197-      }3198-      // Refresh the platter with updated summary fields3199-      const refreshed: SelectorEntry = {3200-        id: summary.id,3201-        title: patch.title as string,3202-        username: patch.username as string,3203-        email: patch.email as string | undefined,3204-        url: patch.url as string,3205-        hasTotp: Boolean(patch.totp),3206-        hasPasskey: summary.hasPasskey,3207-      };3208-      renderSilverPlatter(refreshed);3209-    });3210-3211-    queueMicrotask(() => titleInput.focus());2912+    await chrome.runtime.sendMessage({2913+      type: "OPEN_POPUP",2914+      payload: { entryId: summary.id },2915+    }).catch(() => null);2916+    dismiss();32122917   }32132918 32142919   function renderLocked() {@@ -3656,6 +3361,7 @@36563361 36573362 interface CardSelectorOptions {36583363   domain?: string;3364+  capability?: string;36593365   /** Caller-provided fill — receives the full card payload from the SW. */36603366   onFill: (entry: import("../shared/types").VaultEntry) => void;36613367   /** Called when the user explicitly dismisses (Escape, click-outside, ✕). */@@ -4243,6 +3949,13 @@42433949 ): () => void {42443950   if (cards.length === 0) return () => {};42453951   const card = cards[0]!;3952+  let capabilityActionClaimed = false;3953+  const claimCapabilityAction = () => {3954+    if (!options.capability) return true;3955+    if (capabilityActionClaimed) return false;3956+    capabilityActionClaimed = true;3957+    return true;3958+  };42463959 42473960   const host = document.createElement("div");42483961   host.style.cssText = "position: absolute; z-index: 2147483647;";@@ -4325,14 +4038,14 @@43254038   fillBtn.type = "button";43264039   fillBtn.textContent = "Fill card details";43274040   fillBtn.addEventListener("click", async () => {4328-    if (fillBtn.disabled) return;4041+    if (fillBtn.disabled || !claimCapabilityAction()) return;43294042     fillBtn.disabled = true;43304043     fillBtn.classList.add("filled");43314044     fillBtn.innerHTML = checkSvgStr();43324045     const resp = await chrome.runtime.sendMessage({43334046       type: "GET_ENTRY_FOR_FILL",43344047       // MV-1: explicit user click to fill this card's details.4335-      payload: { id: card.id },4048+      payload: { id: card.id, capability: options.capability },43364049     }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;43374050     if (resp?.ok && resp.data) options.onFill(resp.data);43384051     setTimeout(() => dismiss(), 400);@@ -4361,12 +4074,15 @@43614074   }43624075 43634076   async function fetchFullCard(): Promise<import("../shared/types").VaultEntry | null> {4077+    if (!claimCapabilityAction()) return null;43644078     const resp = await chrome.runtime.sendMessage({43654079       type: "GET_ENTRY_FOR_FILL",43664080       // MV-1: explicit user action on a card the user opened in Peach's UI.4367-      payload: { id: card.id },4081+      payload: { id: card.id, capability: options.capability },43684082     }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;4369-    return resp?.ok && resp.data ? resp.data : null;4083+    const full = resp?.ok && resp.data ? resp.data : null;4084+    if (options.capability) dismiss();4085+    return full;43704086   }43714087 43724088   const copyNum = document.createElement("button");

view file

src/popup/App.tsx+9 −7
@@ -643,16 +643,18 @@643643       recoveryPending = out.ok === true && out.data === true;644644     } catch { /* best-effort */ }645645 646-    // If this popup was opened from the inline "Open Peach to unlock" button,647-    // the background set a session flag. Read and clear it so handleUnlock()648-    // can close the popup after unlock and return the user to the page.646+    // Content UI can ask the background to open this trusted popup either for647+    // unlock or at a specific entry. Consume both session-scoped handoffs once.648+    let pendingOpenEntryId: string | undefined;649649     if (chrome.storage.session) {650650       try {651-        const s = await chrome.storage.session.get("peach_inline_unlock");652-        if ((s as { peach_inline_unlock?: boolean }).peach_inline_unlock) {651+        const s = await chrome.storage.session.get(["peach_inline_unlock", "peach_open_entry"]);652+        const pending = s as { peach_inline_unlock?: boolean; peach_open_entry?: string };653+        if (pending.peach_inline_unlock) {653654           openedForInlineUnlock = true;654-          await chrome.storage.session.remove("peach_inline_unlock");655655         }656+        if (typeof pending.peach_open_entry === "string") pendingOpenEntryId = pending.peach_open_entry;657+        await chrome.storage.session.remove(["peach_inline_unlock", "peach_open_entry"]);656658       } catch { /* best-effort */ }657659     }658660 @@ -664,7 +666,7 @@664666     } else if (unlocked && vaultExists && pendingInitialSyncDeviceId) {665667       void beginInitialSyncWait(pendingInitialSyncDeviceId);666668     } else if (unlocked && vaultExists) {667-      await openVault(undefined, shouldCelebrate);669+      await openVault(pendingOpenEntryId, shouldCelebrate);668670       if (shouldCelebrate) {669671         setView("pro-celebration");670672         window.setTimeout(() => setView("entry"), 2500);

view file