f0ba500e86ed

bind content-script entry writes to the entry domain set and route remember-site through the popup (PB-26-106, v5.0.42)

time
2026-09-02 04:00 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
assessment remediation corrections
parent
08e9038e7ce8
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/message-policy.test.ts+1 −0src/background/message-policy.ts+1 −0src/background/messages.entry-write-origin.test.ts+311 −0src/background/messages.ts+139 −14src/content/inject.ts+4 −1src/popup/App.tsx+65 −0src/shared/types.ts+2 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.41",4+  "version": "5.0.42",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.41",5+  "version": "5.0.42",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.41",3+  "version": "5.0.42",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/message-policy.test.ts+1 −0
@@ -15,6 +15,7 @@1515       "REJECT_VOUCHED_DEVICE", "VOUCHED_DEVICE_PENDING", "ROTATE_SYNC_IDENTITY", "DEVICE_KEY_ROTATED",1616       "UNLOCK", "UNLOCK_WITH_PIN", "GET_PIN_STATUS", "LOCK", "IS_UNLOCKED", "HAS_VAULT", "GET_ENTRIES", "ADD_ENTRY",1717       "BATCH_ADD_ENTRIES", "RESTORE_ENTRIES", "CLEAR_VAULT_ENTRIES", "UPDATE_ENTRY", "ADD_ADDITIONAL_URL",18+      "REQUEST_REMEMBER_SITE",1819       "DELETE_ENTRY", "AUTOFILL_QUERY", "COPY", "CREATE_VAULT", "CREATE_VAULT_FROM_ROOT_KEY",1920       "GET_ENTRY_FOR_FILL", "GET_ENTRY_FOR_EDIT", "CHECK_ALL_BREACHES", "GET_SETTINGS", "UPDATE_SETTINGS",2021       "SETUP_PIN", "DISABLE_PIN", "FIRE_CANARY_PING", "ACTIVATE_LICENSE", "GET_LICENSE", "GET_LICENSE_KEY",

view file

src/background/message-policy.ts+1 −0
@@ -102,6 +102,7 @@102102   ADD_ENTRY: "content-script-allowed",103103   UPDATE_ENTRY: "content-script-allowed",104104   ADD_ADDITIONAL_URL: "content-script-allowed",105+  REQUEST_REMEMBER_SITE: "content-script-allowed",105106   AUTOFILL_QUERY: "content-script-allowed",106107   COPY: "content-script-allowed",107108   FIRE_CANARY_PING: "content-script-allowed",

view file

src/background/messages.entry-write-origin.test.ts+311 −0
@@ -0,0 +1,311 @@1+// PB-26-106: content-script entry mutations must be bound to the entry's own2+// domain set.3+//4+// The autofill READ path re-validates the browser-attested frame origin against5+// the entry's domains on every request (GET_ENTRY_FOR_FILL). The write path had6+// no symmetric binding, and `additionalUrls` feeds the same domain index the7+// read path consults — so a content script that knew an entry id could repoint8+// the entry at its own origin and then walk through the (origin-correct) read9+// path to obtain the full credential.10+//11+// The binding has two halves and both are required:12+//13+//   1. the sender's attested origin must still be inside the entry's domain set14+//      AFTER the write, and15+//   2. every domain the write ADDS must be the sender's own origin.16+//17+// Half 1 alone is not enough: a content script sitting on the entry's own18+// (legitimate) origin would pass it while repointing the entry at a third19+// party — the escalation this file pins down. Half 2 is what makes the write20+// self-binding, which is also what keeps the inline "remember this site" toast21+// working: that toast legitimately adds the page the user is standing on.22+23+import { beforeEach, describe, expect, it, vi } from "vitest";24+25+type StorageShape = Record<string, unknown>;26+27+function makeChromeStorageMock() {28+  const storage: StorageShape = {};29+  const chrome = {30+    storage: {31+      local: {32+        get: vi.fn(async (key?: string | string[]) => {33+          if (!key) return { ...storage };34+          if (typeof key === "string") return { [key]: storage[key] };35+          return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(36+            (acc, k) => { acc[k] = storage[k]; return acc; },37+            {},38+          );39+        }),40+        set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),41+        remove: vi.fn(async (keys: string | string[]) => {42+          const ks = Array.isArray(keys) ? keys : [keys];43+          for (const k of ks) delete storage[k];44+        }),45+      },46+      session: {47+        get: vi.fn(async () => ({})),48+        set: vi.fn(async () => {}),49+        remove: vi.fn(async () => {}),50+      },51+    },52+    alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },53+    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },54+    runtime: {55+      id: "test-extension-id",56+      getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),57+    },58+  } as unknown as typeof globalThis.chrome;59+  return { chrome };60+}61+62+const LEGIT = "https://example.com/login";63+const FOREIGN = "https://attacker.example.net/steal";64+65+function pageSender(url: string): chrome.runtime.MessageSender {66+  return { tab: { url }, url } as unknown as chrome.runtime.MessageSender;67+}68+69+const extensionSender = {70+  url: "chrome-extension://test-extension-id/src/popup/index.html",71+} as unknown as chrome.runtime.MessageSender;72+73+describe("PB-26-106: content-script entry write origin binding", () => {74+  let vault: typeof import("./vault");75+  let messages: typeof import("./messages");76+77+  beforeEach(async () => {78+    vi.resetModules();79+    const mock = makeChromeStorageMock();80+    (globalThis as Record<string, unknown>).chrome = mock.chrome;81+    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));82+    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);83+84+    vault = await import("./vault");85+    messages = await import("./messages");86+87+    await vault.createVault("test-messages-pass");88+  });89+90+  async function addLegitEntry() {91+    return vault.addEntry({92+      type: "login",93+      title: "Example",94+      username: "victim",95+      password: "hunter2",96+      url: "https://example.com",97+    });98+  }99+100+  // The assessment's stated criterion: a foreign-origin sender may not mutate101+  // an entry it does not own, even when it knows the id.102+  it("UPDATE_ENTRY from a foreign-origin sender against a known id is refused", async () => {103+    const entry = await addLegitEntry();104+105+    const res = await messages.handleMessage(106+      { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { title: "pwned" } } },107+      pageSender(FOREIGN),108+    );109+110+    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });111+  });112+113+  // The escalation: a content script on the entry's OWN (legitimate) origin114+  // passes any pre-write origin check and repoints the entry at a third party.115+  // This is the case that made the read path's binding worthless.116+  it("UPDATE_ENTRY from the entry's own origin cannot repoint it at a foreign domain", async () => {117+    const entry = await addLegitEntry();118+119+    const res = await messages.handleMessage(120+      {121+        type: "UPDATE_ENTRY",122+        payload: { id: entry.id, patch: { additionalUrls: ["https://attacker.example.net"] } },123+      },124+      pageSender(LEGIT),125+    );126+127+    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });128+    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);129+  });130+131+  // Control: a non-origin field from the entry's own origin still succeeds.132+  it("UPDATE_ENTRY from the entry's own origin still patches non-origin fields", async () => {133+    const entry = await addLegitEntry();134+135+    const res = await messages.handleMessage(136+      { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { title: "Renamed" } } },137+      pageSender(LEGIT),138+    );139+140+    expect(res).toMatchObject({ ok: true });141+    expect(vault.getEntryById(entry.id)?.title).toBe("Renamed");142+  });143+144+  // Same escalation through the dedicated additional-url handler.145+  it("ADD_ADDITIONAL_URL cannot attach a third-party origin to the entry", async () => {146+    const entry = await addLegitEntry();147+148+    const res = await messages.handleMessage(149+      {150+        type: "ADD_ADDITIONAL_URL",151+        payload: { id: entry.id, url: "https://attacker.example.net" },152+      },153+      pageSender(LEGIT),154+    );155+156+    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });157+    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);158+  });159+160+  // A page may not grow the set even with the origin it is standing on — that161+  // is precisely what the attacker in the chain below would attach.162+  it("ADD_ADDITIONAL_URL is refused even for the sender's own origin", async () => {163+    const entry = await addLegitEntry();164+165+    const res = await messages.handleMessage(166+      {167+        type: "ADD_ADDITIONAL_URL",168+        payload: { id: entry.id, url: "https://new-site.example.org" },169+      },170+      pageSender("https://new-site.example.org/checkout"),171+    );172+173+    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });174+    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);175+  });176+177+  // The legitimate replacement: the inline "remember this site" toast asks, and178+  // a page may only ever ask about its own attested origin. The write itself179+  // happens from the popup, which is exempt.180+  it("REQUEST_REMEMBER_SITE queues the sender's own origin for the popup to confirm", async () => {181+    const entry = await addLegitEntry();182+183+    const res = await messages.handleMessage(184+      {185+        type: "REQUEST_REMEMBER_SITE",186+        payload: { id: entry.id, url: "https://new-site.example.org" },187+      },188+      pageSender("https://new-site.example.org/checkout"),189+    );190+191+    expect(res).toMatchObject({ ok: true });192+    expect(chrome.storage.session.set).toHaveBeenCalledWith({193+      pendingRememberSite: { entryId: entry.id, url: "https://new-site.example.org" },194+    });195+    // Nothing was written yet — the popup confirms.196+    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);197+  });198+199+  it("REQUEST_REMEMBER_SITE cannot queue a third-party origin", async () => {200+    const entry = await addLegitEntry();201+202+    const res = await messages.handleMessage(203+      {204+        type: "REQUEST_REMEMBER_SITE",205+        payload: { id: entry.id, url: "https://attacker.example.net" },206+      },207+      pageSender(LEGIT),208+    );209+210+    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });211+    expect(chrome.storage.session.set).not.toHaveBeenCalled();212+  });213+214+  // A page may still shrink the set — clearing a stale mapping needs no popup.215+  it("UPDATE_ENTRY from the entry's own origin may drop an additional url", async () => {216+    const entry = await addLegitEntry();217+    await vault.updateEntry(entry.id, { additionalUrls: ["https://example.com"] });218+219+    const res = await messages.handleMessage(220+      {221+        type: "UPDATE_ENTRY",222+        payload: { id: entry.id, patch: { additionalUrls: [] } },223+      },224+      pageSender(LEGIT),225+    );226+227+    expect(res).toMatchObject({ ok: true });228+    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);229+  });230+231+  it("ADD_ENTRY from a page sender cannot create an entry bound to a foreign site", async () => {232+    const res = await messages.handleMessage(233+      {234+        type: "ADD_ENTRY",235+        payload: {236+          type: "login",237+          title: "Bank",238+          username: "u",239+          password: "attacker-chosen",240+          url: "https://bank.example.org",241+        },242+      },243+      pageSender(LEGIT),244+    );245+246+    expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });247+    expect(vault.getEntries()).toHaveLength(0);248+  });249+250+  it("ADD_ENTRY from a page sender succeeds for its own origin", async () => {251+    const res = await messages.handleMessage(252+      {253+        type: "ADD_ENTRY",254+        payload: {255+          type: "login",256+          title: "Example",257+          username: "u",258+          password: "p",259+          url: "https://example.com",260+        },261+      },262+      pageSender(LEGIT),263+    );264+265+    expect(res).toMatchObject({ ok: true });266+    expect(vault.getEntries()).toHaveLength(1);267+  });268+269+  // The popup creates entries for sites it is not standing on. Extension pages270+  // are exempt from the binding.271+  it("ADD_ENTRY from the popup can still create an entry for any site", async () => {272+    const res = await messages.handleMessage(273+      {274+        type: "ADD_ENTRY",275+        payload: {276+          type: "login",277+          title: "Bank",278+          username: "u",279+          password: "p",280+          url: "https://bank.example.org",281+        },282+      },283+      extensionSender,284+    );285+286+    expect(res).toMatchObject({ ok: true });287+    expect(vault.getEntries()).toHaveLength(1);288+  });289+290+  // End-to-end shape of the reported attack: repoint the entry, then read the291+  // credential from the attacker origin. Both halves must fail.292+  it("the repoint-then-read chain stays closed from the attacker origin", async () => {293+    const entry = await addLegitEntry();294+295+    await messages.handleMessage(296+      {297+        type: "UPDATE_ENTRY",298+        payload: { id: entry.id, patch: { additionalUrls: ["https://attacker.example.net"] } },299+      },300+      pageSender(FOREIGN),301+    );302+303+    const res = await messages.handleMessage(304+      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },305+      pageSender(FOREIGN),306+    );307+308+    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });309+    expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);310+  });311+});

view file

src/background/messages.ts+139 −14
@@ -207,22 +207,105 @@207207   return undefined;208208 }209209 210-function entryAllowsSender(entry: VaultEntry, sender: Partial<chrome.runtime.MessageSender>): boolean {211-  if (sender.url?.startsWith("chrome-extension://") || sender.url?.startsWith("moz-extension://")) return true;212-  if (sender.tab === undefined && sender.url === undefined) return false;210+function isExtensionPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {211+  return sender.url?.startsWith("chrome-extension://") === true ||212+    sender.url?.startsWith("moz-extension://") === true;213+}214+215+/** True when the message came from a page frame rather than extension UI. */216+function isPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {217+  return !isExtensionPageSender(sender) &&218+    (sender.tab !== undefined || sender.url !== undefined);219+}220+221+function entryDomains(url: unknown, additionalUrls: unknown): Set<string> {222+  const domains = new Set<string>();223+  const primary = typeof url === "string" ? extractDomain(url) : null;224+  if (primary) domains.add(primary);225+  if (Array.isArray(additionalUrls)) {226+    for (const extra of additionalUrls) {227+      if (typeof extra !== "string") continue;228+      const domain = extractDomain(extra);229+      if (domain) domains.add(domain);230+    }231+  }232+  return domains;233+}234+235+/**236+ * PB-26-106: the autofill read path re-validates the frame origin against the237+ * entry's domains on every request, so an unbound write path would let a238+ * content script repoint an entry at its own origin and then read the239+ * credential through a read path that is behaving correctly.240+ *241+ * Creating an entry: a page may only bind it to itself. Mutating one: a page242+ * may never GROW the domain set — only shrink it, and never below its own243+ * attested origin.244+ *245+ * Self-binding on mutation was considered and is not enough: the content246+ * script on a foreign origin IS the attacker in the reported chain, so letting247+ * it attach "its own" origin lets it mint the very capability the read path248+ * exists to withhold. The legitimate flow that needs a new domain — the inline249+ * "remember this site" toast — therefore asks the popup instead250+ * (REQUEST_REMEMBER_SITE), which is an extension page and exempt here.251+ *252+ * Extension pages are exempt: the popup edits entries for sites it is not on.253+ */254+function senderMayBindEntryDomains(255+  sender: Partial<chrome.runtime.MessageSender>,256+  domains: Set<string>,257+): boolean {258+  if (isExtensionPageSender(sender)) return true;259+  const domain = attestedSenderDomain(sender);260+  if (domain === null) return false;261+  for (const candidate of domains) {262+    if (candidate !== domain) return false;263+  }264+  return true;265+}266+267+function senderMayMutateEntryDomains(268+  sender: Partial<chrome.runtime.MessageSender>,269+  before: Set<string>,270+  after: Set<string>,271+): boolean {272+  if (isExtensionPageSender(sender)) return true;273+  const domain = attestedSenderDomain(sender);274+  if (domain === null || !after.has(domain)) return false;275+  for (const kept of after) {276+    if (!before.has(kept)) return false;277+  }278+  return true;279+}280+281+/**282+ * Null for extension pages (which the callers exempt), for senders with no283+ * attested origin at all, and for unparseable frame urls.284+ */285+function attestedSenderDomain(sender: Partial<chrome.runtime.MessageSender>): string | null {286+  if (sender.tab === undefined && sender.url === undefined) return null;213287   const origin = attestedSenderOrigin(sender);214-  if (!origin) return false;215-  let hostname: string;216-  try { hostname = new URL(origin).hostname; } catch { return false; }217-  const domains = [entry.url, ...(entry.additionalUrls ?? [])]218-    .map((url) => extractDomain(url ?? ""))219-    .filter((domain): domain is string => Boolean(domain));220-  return domains.includes(extractDomain(hostname) ?? "");288+  if (!origin) return null;289+  try { return extractDomain(new URL(origin).hostname); } catch { return null; }221290 }222291 223292 function patchChangesEntryOrigin(patch: object): boolean {224293   return Object.hasOwn(patch, "url") || Object.hasOwn(patch, "additionalUrls");225294 }295+296+/** The entry's domain-bearing fields as the patch would leave them. */297+function patchedUrlShape(298+  patch: object,299+  existing: Pick<VaultEntry, "url" | "additionalUrls">,300+): { url: unknown; additionalUrls: unknown } {301+  const candidate = patch as { url?: unknown; additionalUrls?: unknown };302+  return {303+    url: Object.hasOwn(patch, "url") ? candidate.url : existing.url,304+    additionalUrls: Object.hasOwn(patch, "additionalUrls")305+      ? candidate.additionalUrls306+      : existing.additionalUrls,307+  };308+}226309 // Resolves once restoreFromSession() has run, so handlers see the correct227310 // post-restore unlock state. entry.ts registers this BEFORE the message228311 // handler so the onMessage listener is added synchronously at SW startup@@ -464,6 +547,15 @@464547         if (!payload || typeof payload !== "object" || Array.isArray(payload)) {465548           return { ok: false, error: "invalid_payload" };466549         }550+        // PB-26-106: a page may only create an entry bound to itself. Entries551+        // carrying no url have no domain surface to bind, so they are exempt.552+        if (isPageSender(sender)) {553+          const draft = payload as { url?: unknown; additionalUrls?: unknown };554+          const domains = entryDomains(draft.url, draft.additionalUrls);555+          if (domains.size > 0 && !senderMayBindEntryDomains(sender, domains)) {556+            return { ok: false, error: "origin_binding_required" };557+          }558+        }467559         const entry = await vault.addEntry(payload as Parameters<typeof vault.addEntry>[0]);468560         return { ok: true, data: entry };469561       }@@ -534,8 +626,16 @@534626         }535627         const existingEntry = vault.getEntryById(id);536628         if (!existingEntry) return { ok: false, error: "entry_not_found" };537-        if ((patchChangesEntryOrigin(patch) || sender.tab !== undefined || sender.url !== undefined) && !entryAllowsSender(existingEntry, sender)) {538-          return { ok: false, error: "origin_binding_required" };629+        if (patchChangesEntryOrigin(patch) || isPageSender(sender)) {630+          const before = entryDomains(existingEntry.url, existingEntry.additionalUrls);631+          const after = patchedUrlShape(patch, existingEntry);632+          if (!senderMayMutateEntryDomains(633+            sender,634+            before,635+            entryDomains(after.url, after.additionalUrls),636+          )) {637+            return { ok: false, error: "origin_binding_required" };638+          }539639         }540640         // Single enforcement point for the Canary Entries Pro gate. Only541641         // gates the transition into a canary entry — clearing/disabling a@@ -558,10 +658,35 @@558658         if (!trimmed) return { ok: false, error: "url_required" };559659         const existing = vault.getEntryById(id);560660         if (!existing) return { ok: false, error: "entry_not_found" };561-        if (!entryAllowsSender(existing, sender)) return { ok: false, error: "origin_binding_required" };562-        if (existing.type !== "login") return { ok: false, error: "login_entries_only" };563661         const merged = [...(existing.additionalUrls ?? []), trimmed];662+        if (!senderMayMutateEntryDomains(663+          sender,664+          entryDomains(existing.url, existing.additionalUrls),665+          entryDomains(existing.url, merged),666+        )) {667+          return { ok: false, error: "origin_binding_required" };668+        }669+        if (existing.type !== "login") return { ok: false, error: "login_entries_only" };564670         await vault.updateEntry(id, { additionalUrls: merged });671+        return { ok: true };672+      }673+      case "REQUEST_REMEMBER_SITE": {674+        // PB-26-106: growing an entry's domain set needs a decision the page675+        // cannot make for itself, so the page asks and the popup confirms. A676+        // page may only ask about its own attested origin — otherwise this677+        // becomes the same unbound write under a friendlier name.678+        const { id, url } = (msg.payload ?? {}) as { id?: unknown; url?: unknown };679+        if (typeof id !== "string" || !id) return { ok: false, error: "invalid_payload" };680+        const trimmed = typeof url === "string" ? url.trim() : "";681+        if (!trimmed) return { ok: false, error: "url_required" };682+        if (!senderMayBindEntryDomains(sender, entryDomains(trimmed, undefined))) {683+          return { ok: false, error: "origin_binding_required" };684+        }685+        if (!vault.getEntryById(id)) return { ok: false, error: "entry_not_found" };686+        if (chrome.storage.session) {687+          await chrome.storage.session.set({ pendingRememberSite: { entryId: id, url: trimmed } });688+        }689+        try { await chrome.action.openPopup(); } catch { /* user opens via toolbar */ }565690         return { ok: true };566691       }567692       case "DELETE_ENTRY": {

view file

src/content/inject.ts+4 −1
@@ -206,8 +206,11 @@206206   const yesBtn = document.createElement("button"); yesBtn.className = "yes";207207   yesBtn.textContent = "Remember";208208   yesBtn.addEventListener("click", async () => {209+    // The write itself is popup-confirmed: growing an entry's domain set is210+    // the one autofill decision a page must not make for itself, because the211+    // domain set is what gates future fills (PB-26-106).209212     await chrome.runtime.sendMessage({210-      type: "ADD_ADDITIONAL_URL",213+      type: "REQUEST_REMEMBER_SITE",211214       payload: { id: entryId, url: location.origin },212215     });213216     host.remove();

view file

src/popup/App.tsx+65 −0
@@ -221,6 +221,14 @@221221   const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);222222   const [pendingCelebration, setPendingCelebration] = createSignal(false);223223   const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);224+  // A page asked to be remembered for an entry it did not match. Pages cannot225+  // grow an entry's domain set themselves (PB-26-106), so the background queues226+  // the request here and this banner is the confirmation gate.227+  const [pendingRememberSite, setPendingRememberSite] = createSignal<{228+    entryId: string;229+    url: string;230+    title: string;231+  } | null>(null);224232   // True while Panic Step 1 has bounced the user out to Export. When Export's225233   // onBack fires we return to Panic instead of Settings so the user keeps the226234   // destruction context they came from.@@ -711,6 +719,28 @@711719         await chrome.storage.session.remove("pendingProNotice");712720         setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });713721       }722+      const remembered = (await chrome.storage.session.get("pendingRememberSite")) as {723+        pendingRememberSite?: { entryId?: unknown; url?: unknown };724+      };725+      const request = remembered.pendingRememberSite;726+      if (727+        typeof request?.entryId === "string" &&728+        typeof request.url === "string"729+      ) {730+        const entry = await sendMessage({ type: "GET_ENTRIES" });731+        const match = entry.ok732+          ? (entry.data as Array<{ id: string; title?: string }> | undefined)?.find(733+              (candidate) => candidate.id === request.entryId,734+            )735+          : undefined;736+        if (match) {737+          let host = request.url;738+          try { host = new URL(request.url).hostname; } catch { /* keep raw */ }739+          setPendingRememberSite({ entryId: request.entryId, url: request.url, title: match.title ?? host });740+        } else {741+          await chrome.storage.session.remove("pendingRememberSite");742+        }743+      }714744     }715745 716746     // Warm the lazy-module cache so the Suspense fallback never shows for@@ -1493,6 +1523,41 @@14931523                 </button>14941524                 <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>14951525               </div>1526+            </Show>1527+1528+            <Show when={pendingRememberSite()}>1529+              {(request) => {1530+                let host = request().url;1531+                try { host = new URL(request().url).hostname; } catch { /* keep raw */ }1532+                return (1533+                  <div class="peach-pro-notice-banner">1534+                    <span>Also use “{request().title}” on {host}?</span>1535+                    <button1536+                      class="peach-pro-notice-link"1537+                      onClick={async () => {1538+                        await sendMessage({1539+                          type: "ADD_ADDITIONAL_URL",1540+                          payload: { id: request().entryId, url: request().url },1541+                        });1542+                        await chrome.storage.session.remove("pendingRememberSite");1543+                        setPendingRememberSite(null);1544+                      }}1545+                    >1546+                      Add1547+                    </button>1548+                    <button1549+                      class="peach-pro-notice-close"1550+                      onClick={async () => {1551+                        await chrome.storage.session.remove("pendingRememberSite");1552+                        setPendingRememberSite(null);1553+                      }}1554+                      aria-label="Dismiss"1555+                    >1556+                      ✕1557+                    </button>1558+                  </div>1559+                );1560+              }}14961561             </Show>14971562 14981563             <div class="peach-shell-body">

view file

src/shared/types.ts+2 −0
@@ -215,6 +215,7 @@215215   | "CLEAR_VAULT_ENTRIES"216216   | "UPDATE_ENTRY"217217   | "ADD_ADDITIONAL_URL"218+  | "REQUEST_REMEMBER_SITE"218219   | "DELETE_ENTRY"219220   | "AUTOFILL_QUERY"220221   | "COPY"@@ -313,6 +314,7 @@313314   | "PASSKEY_PRO_GATE" // background → content: show passkey Pro gate in inline popup314315   | "OPEN_PRO_GATE" // content → background: open popup and navigate to License section315316   | "OPEN_POPUP" // content → background: open the extension popup (e.g. to unlock)317+  | "REQUEST_REMEMBER_SITE" // content → background: queue "use this entry on this site" for the popup to confirm316318   | "OPEN_DETACHED_POPUP" // popup → background: open the popup HTML in a standalone window (file-picker flows)317319   | "SHOW_PASSKEY_TOAST"    // background → content tab: show passkey auth toast318320   | "DISMISS_PASSKEY_TOAST" // background → content tab: dismiss passkey auth toast

view file