2335dde54c5b
fix: LV-5 deduplicate onConnect popup lock — delete dead background/index.ts, accept nonce'd popup-<uuid> port (legacy popup for one release) in entry.ts
- time
- 2026-06-10 02:46 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- audit fix integration
- parent
- e1bf91ef908d
↓ peach-browser-2335dde5.tar.gzatlas at this save
files changed3 files
src/background/entry.lock-port.test.ts+135 −0src/background/entry.ts+16 −4src/background/index.ts+0 −28src/background/entry.lock-port.test.ts+135 −0
@@ -0,0 +1,135 @@1+// LV-5: the lock-on-close onConnect listener accepts only our popup port2+// (legacy bare "popup" or nonce'd "popup-<uuid>") and ignores anything else.3+import { beforeEach, describe, expect, it, vi } from "vitest";4+5+type StorageShape = Record<string, unknown>;6+7+interface FakePort {8+ name: string;9+ onDisconnect: { addListener: (fn: () => void) => void };10+ fireDisconnect: () => Promise<void>;11+}12+13+function makePort(name: string): FakePort {14+ let handler: (() => void) | null = null;15+ return {16+ name,17+ onDisconnect: {18+ addListener: (fn: () => void) => {19+ handler = fn;20+ },21+ },22+ fireDisconnect: async () => {23+ await handler?.();24+ },25+ };26+}27+28+describe("LV-5 lock-on-close port", () => {29+ let storage: StorageShape;30+ let connectListeners: Array<(port: FakePort) => void>;31+32+ beforeEach(() => {33+ vi.resetModules();34+ storage = {};35+ connectListeners = [];36+37+ const chromeMock = {38+ runtime: {39+ onInstalled: { addListener: vi.fn() },40+ onConnect: {41+ addListener: vi.fn((fn: (port: FakePort) => void) => {42+ connectListeners.push(fn);43+ }),44+ },45+ onMessage: { addListener: vi.fn() },46+ sendMessage: vi.fn(async () => undefined),47+ connect: vi.fn(),48+ getURL: vi.fn((p: string) => p),49+ },50+ storage: {51+ local: {52+ get: vi.fn(async (key?: string | string[]) => {53+ if (!key) return storage;54+ if (typeof key === "string") return { [key]: storage[key] };55+ return key.reduce<Record<string, unknown>>((r, k) => {56+ r[k] = storage[k];57+ return r;58+ }, {});59+ }),60+ set: vi.fn(async (next: Record<string, unknown>) => {61+ Object.assign(storage, next);62+ }),63+ remove: vi.fn(async (keys: string | string[]) => {64+ for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];65+ }),66+ },67+ session: {68+ get: vi.fn(async () => ({})),69+ set: vi.fn(async () => undefined),70+ remove: vi.fn(async () => undefined),71+ },72+ },73+ alarms: {74+ clear: vi.fn(async () => true),75+ create: vi.fn(async () => undefined),76+ onAlarm: { addListener: vi.fn() },77+ },78+ idle: {79+ setDetectionInterval: vi.fn(),80+ onStateChanged: { addListener: vi.fn() },81+ },82+ scripting: { executeScript: vi.fn(async () => []) },83+ tabs: { query: vi.fn(async () => []) },84+ commands: { onCommand: { addListener: vi.fn() } },85+ webAuthenticationProxy: {86+ onCreateRequest: { addListener: vi.fn() },87+ onGetRequest: { addListener: vi.fn() },88+ onIsUvpaaRequest: { addListener: vi.fn() },89+ onRequestCanceled: { addListener: vi.fn() },90+ attach: vi.fn(async () => undefined),91+ completeCreateRequest: vi.fn(async () => undefined),92+ completeGetRequest: vi.fn(async () => undefined),93+ completeIsUvpaaRequest: vi.fn(async () => undefined),94+ },95+ } as unknown as typeof chrome;96+97+ (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;98+ vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);99+ vi.stubGlobal("fetch", vi.fn(async () => {100+ throw new Error("network disabled in tests");101+ }));102+ });103+104+ async function setupUnlockedVaultWithLockOnClose() {105+ const vault = await import("./vault");106+ await vault.createVault("correct horse battery staple");107+ await vault.updateSettings({ lockOnClose: true });108+ expect(vault.isUnlocked()).toBe(true);109+ return vault;110+ }111+112+ it("locks the vault when a nonce'd popup-<uuid> port disconnects", async () => {113+ await import("./entry"); // registers the onConnect listener114+ const vault = await setupUnlockedVaultWithLockOnClose();115+ expect(connectListeners).toHaveLength(1);116+117+ const port = makePort(`popup-${"a1b2c3"}`);118+ connectListeners[0](port);119+ await port.fireDisconnect();120+121+ expect(vault.isUnlocked()).toBe(false);122+ });123+124+ it("ignores a port with an unrelated name — no lock", async () => {125+ await import("./entry");126+ const vault = await setupUnlockedVaultWithLockOnClose();127+128+ const port = makePort("evil");129+ connectListeners[0](port);130+ // The listener returned early, so no onDisconnect handler was registered.131+ await port.fireDisconnect();132+133+ expect(vault.isUnlocked()).toBe(true);134+ });135+});src/background/entry.ts+16 −4
@@ -16,11 +16,23 @@1616 // The chrome.idle.onStateChanged listener lives in ./alarms (the architectural1717 // home for auto-lock timing). Keeping it in only one place avoids drift.1818 19-// Lock-on-close via port disconnect. The popup opens a "popup" named port;20-// when that port disconnects (popup closed), lock if lockOnClose is enabled.21-// Previously declared in index.ts which was never bundled — moved here.19+// Lock-on-close via port disconnect. The popup opens a nonce'd port20+// ("popup-<uuid>"); when that port disconnects (popup closed), lock if21+// lockOnClose is enabled. Previously also declared in the never-bundled22+// index.ts (deleted in LV-5) — this is now the single registration.23+//24+// LV-5: the lock port name carries a per-popup-open random suffix. Another25+// extension cannot open ports INTO this extension (onConnectExternal is not26+// registered), so the nonce is defense-in-depth — a port named exactly27+// "popup" from anything that isn't our popup no longer reliably triggers28+// deny-all + lock once the legacy window closes.29+const POPUP_PORT_PREFIX = "popup-";30+2231 chrome.runtime.onConnect.addListener((port) => {23- if (port.name !== "popup") return;32+ // Accept legacy bare "popup" for one release so an in-flight popup from the33+ // previous version straddling the update doesn't lose lock-on-close.34+ // TODO(2026-Q3): drop the bare "popup" acceptance once v3.8.0 has rolled out.35+ if (port.name !== "popup" && !port.name.startsWith(POPUP_PORT_PREFIX)) return;2436 port.onDisconnect.addListener(async () => {2537 await denyAllPendingPasskeyRequests();2638 const s = await ensureSettingsLoaded();src/background/index.ts+0 −28
@@ -1,28 +0,0 @@1-import { setupMessageHandler } from "./messages";2-import { setupAutoLock } from "./alarms";3-import { ensureSettingsLoaded, isUnlocked, lock } from "./vault";4-import { isRecoveryConfirmPending } from "./recovery-pending";5-import { denyAllPendingPasskeyRequests } from "./passkey";6-7-setupMessageHandler();8-void setupAutoLock();9-10-// Lock-on-close via port disconnect. chrome.runtime.sendMessage in `pagehide`11-// is unreliable in MV3 — the popup context tears down before the message lands.12-// A persistent port survives the unload; the SW locks when it sees the disconnect.13-chrome.runtime.onConnect.addListener((port) => {14- if (port.name !== "popup") return;15- port.onDisconnect.addListener(async () => {16- // Pending passkey approvals must resolve as denied regardless of lock17- // settings — leaving them stuck blocks the page's navigator.credentials18- // promise for the full 60s timeout (M8/COR-009).19- await denyAllPendingPasskeyRequests();20- const s = await ensureSettingsLoaded();21- if (!isUnlocked() || s.lockOnClose === false) return;22- // Skip lock while the user is mid-recovery-confirmation; locking here23- // would discard the in-memory key needed to re-derive the recovery24- // words on the next popup open, permanently losing them.25- if (await isRecoveryConfirmPending()) return;26- lock();27- });28-});