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
↓ peach-browser-9b061a75.tar.gzatlas at this save
files changed13 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 −7manifest.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": {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",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",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+});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 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 () => {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 };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 });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 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 });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") {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");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);