e40a67cca1a3

clear MV3 session key in lock() so a manual lock is not silently undone by the next SW restart

time
2026-08-10 14:26 UTC · 2 months ago
machine
koh2
agent
deepseek-v4-flash-free
session
lock-screen-escape-fix
parent
0e9a9f1e1ac5
2 files src/background/vault.session-escrow.test.ts+19 −17src/background/vault.ts+12 −14
src/background/vault.session-escrow.test.ts+19 −17
@@ -96,15 +96,16 @@9696     expect(v2.getRootKeyHex()).toBe(rootBeforeDead);9797   });9898 99-  it("lock() does NOT clear the MV3 session key — background sync survives popup close", async () => {100-    // Bug shape: any lock() caller (popup close with lockOnClose=true, auto-lock,101-    // explicit user lock) used to clear the session key. The next SW wake102-    // (10-minute sync alarm, post-adoption retry, post-update alarm) found no103-    // session key, restoreFromSession() returned false, the runner returned104-    // VAULT_LOCKED, and the indicator went yellow every 10 minutes. The fix105-    // is to keep the session key across lock() — chrome.storage.session is106-    // cleared on browser close anyway, so the natural lifetime is the107-    // browser session, which is what we want.99+  it("lock() clears the MV3 session key so a manual lock survives a SW restart", async () => {100+    // Bug shape: lock() used to keep the session key in chrome.storage.session101+    // so background sync could re-wake a restored vault. MV3 idle-kills the SW102+    // after ~30s, so a popup reopened a minute after locking woke a cold SW,103+    // restoreFromSession() found the surviving key, and the vault silently104+    // re-unlocked without the password. Every lock path (manual lock icon,105+    // popup close with lockOnClose=true, auto-lock alarm, idle) flows through106+    // lock(), and all of them are deliberate locks — none should be undone by107+    // the next SW restart. clearSessionKey() mirrors the fire-and-forget108+    // saveSessionKey() pattern used by unlock().108109     const v1 = await import("./vault");109110     await v1.createVault("strong-pass-abc123");110111     await v1.unlock("strong-pass-abc123");@@ -114,17 +115,18 @@114115     // The lock-on-close path: popup port disconnect, alarm tick, etc.115116     v1.lock();116117     expect(v1.isUnlocked()).toBe(false);118+    // Flush the fire-and-forget clearSessionKey microtask, then confirm the119+    // key was actually removed from chrome.storage.session.120+    await Promise.resolve();117121     const sessionKeyAfterLock = await chrome.storage.session.get("peach_session_dk");118-    expect(sessionKeyAfterLock.peach_session_dk).toBeDefined();119-    // Same key bytes — clearSessionKey would have removed the entry.120-    expect(sessionKeyAfterLock.peach_session_dk).toEqual(sessionKeyBeforeLock.peach_session_dk);122+    expect(sessionKeyAfterLock.peach_session_dk).toBeUndefined();121123 122-    // The next SW wake restores the vault from the surviving session key.123-    await Promise.resolve();124+    // The next SW wake must NOT restore the vault — restoreFromSession() finds125+    // no session key and the vault stays locked until the password is typed.124126     vi.resetModules();125127     const v2 = await import("./vault");126-    expect(await v2.restoreFromSession()).toBe(true);127-    expect(v2.isUnlocked()).toBe(true);128-    expect(v2.getRootKeyHex()).not.toBeNull();128+    expect(await v2.restoreFromSession()).toBe(false);129+    expect(v2.isUnlocked()).toBe(false);130+    expect(v2.getRootKeyHex()).toBeNull();129131   });130132 });

view file

src/background/vault.ts+12 −14
@@ -1413,9 +1413,8 @@14131413 14141414 /**14151415  * On SW restart, re-derive all in-memory vault state from the session key1416- * stored in chrome.storage.session (set at unlock time; deliberately NOT1417- * cleared by lock() — see lock() below. Cleared only on browser close or1418- * when restoration fails).1416+ * stored in chrome.storage.session (set at unlock time, cleared by lock();1417+ * also cleared on browser close or when restoration fails).14191418  * Returns true if the vault was restored, false if no session key was found.14201419  * No-ops if the vault is already unlocked.14211420  */@@ -1464,17 +1463,16 @@14641463 }14651464 14661465 export function lock(): void {1467-  // Deliberately does NOT clear the session key. The session key survives1468-  // `lock()` so the MV3 service worker can restore the vault from1469-  // chrome.storage.session on the next wake — the 10-minute sync alarm, the1470-  // post-adoption retry, the post-update alarm. The previous behaviour1471-  // (clear on every lock()) broke background sync: any popup close with1472-  // lockOnClose=true would `lock()`, which cleared the session key, and1473-  // the next SW wake would find no session key and the runner would return1474-  // VAULT_LOCKED. The session key's natural lifetime is the browser session1475-  // (chrome.storage.session is cleared on browser close), so explicit user1476-  // lock + popup close + auto-lock all keep the session key alive and the1477-  // expected sync-after-restart flow actually works.1466+  // A user-initiated lock must be durable across MV3 service-worker restarts.1467+  // The SW is idle-killed after ~30s of inactivity, and the popup's next open1468+  // wakes a cold SW whose restoreFromSession() reads chrome.storage.session.1469+  // If the session key survived lock(), that restore would silently re-unlock1470+  // the vault ~a minute after the user locked it — a lock that only held1471+  // while the SW happened to stay alive. The background-sync convenience the1472+  // session key once bought (sync runner re-waking to a restored vault) is1473+  // not worth an unlocked vault the user never asked to unlock; when locked,1474+  // sync-runner correctly reports VAULT_LOCKED instead.1475+  void clearSessionKey();14781476   if (derivedKey) {14791477     zeroKey(derivedKey);14801478     derivedKey = null;

view file