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
3 files src/background/entry.lock-port.test.ts+135 −0src/background/entry.ts+16 −4src/background/index.ts+0 −28
src/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+});

view file

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();

view file

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-});

view file at the parent