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
↓ peach-browser-e40a67cc.tar.gzatlas at this save
files changed2 files
src/background/vault.session-escrow.test.ts+19 −17src/background/vault.ts+12 −14src/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 });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;