2ce3e994721e
fix: background sync works after popup close (lock no longer clears session key; rootKey restored from pw_root_*) (v4.7.5)
- time
- 2026-08-01 16:14 UTC · 2 months ago
- machine
- koh2
- parent
- 470beed172ad
↓ peach-browser-2ce3e994.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.session-escrow.test.ts+62 −0src/background/vault.ts+26 −3src/shared/sync-outcome.test.ts+9 −1src/shared/sync-outcome.ts+5 −1manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.7.4",4+ "version": "4.7.5",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": "4.7.4",5+ "version": "4.7.5",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": "4.7.4",3+ "version": "4.7.5",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/vault.session-escrow.test.ts+62 −0
@@ -65,4 +65,66 @@6565 expect(await v2.restoreFromSession()).toBe(true);6666 expect(v2.getEntries().some((e) => e.title === "Recovered")).toBe(true);6767 });68+69+ it("restoreFromSession rehydrates rootKey so background sync survives a SW restart", async () => {70+ // Background sync bug: entry.ts calls restoreFromSession() on every SW wake-up,71+ // then triggerSyncIfReachable. The runner needs getRootKeyHex() to be non-null72+ // — sync identity derives from rootKey, not derivedKey. The previous73+ // restoreFromSession only set rootKey when the slot had recovery_salt +74+ // recovery_wrapped_root_key; for any vault that hadn't been through75+ // migrateToRecovery, rootKey was silently null and the runner returned76+ // VAULT_LOCKED. The fix is to always derive rootKey from pw_root_* (which77+ // are present on every vault since createVault).78+ const v1 = await import("./vault");79+ await v1.createVault("strong-pass-abc123");80+ // unlock() is the real-world path that saves the MV3 session key — the81+ // user's popup opens, types the password, and the session key is parked82+ // in chrome.storage.session for the next SW wake.83+ await v1.unlock("strong-pass-abc123");84+ const rootBeforeDead = v1.getRootKeyHex();85+ expect(rootBeforeDead).not.toBeNull();86+87+ // Simulate a SW kill: flush the fire-and-forget saveSessionKey promise,88+ // then reset modules so a fresh vault.ts reads the persisted session key.89+ await Promise.resolve();90+ vi.resetModules();91+ const v2 = await import("./vault");92+ expect(await v2.restoreFromSession()).toBe(true);93+ // The bug shape: rootKey was null after restore, so the runner short-circuited94+ // to VAULT_LOCKED even though entries were present.95+ expect(v2.getRootKeyHex()).not.toBeNull();96+ expect(v2.getRootKeyHex()).toBe(rootBeforeDead);97+ });98+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.108+ const v1 = await import("./vault");109+ await v1.createVault("strong-pass-abc123");110+ await v1.unlock("strong-pass-abc123");111+ const sessionKeyBeforeLock = await chrome.storage.session.get("peach_session_dk");112+ expect(sessionKeyBeforeLock.peach_session_dk).toBeDefined();113+114+ // The lock-on-close path: popup port disconnect, alarm tick, etc.115+ v1.lock();116+ expect(v1.isUnlocked()).toBe(false);117+ 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);121+122+ // The next SW wake restores the vault from the surviving session key.123+ await Promise.resolve();124+ vi.resetModules();125+ const v2 = await import("./vault");126+ expect(await v2.restoreFromSession()).toBe(true);127+ expect(v2.isUnlocked()).toBe(true);128+ expect(v2.getRootKeyHex()).not.toBeNull();129+ });68130 });src/background/vault.ts+26 −3
@@ -1432,15 +1432,38 @@14321432 await loadSettings();14331433 touchAutoLock();14341434 1435- if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1436- try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { /* non-fatal */ }1435+ // The rootKey is what identity/sync derivations use. deriveRootKeyFromDerivedKey1436+ // pulls it from the pw_root_* fields on the slot — set on every createVault +1437+ // re-set on every password change (H2/COR-006). The previous gate on1438+ // recovery_salt + recovery_wrapped_root_key silently left rootKey unset for1439+ // any vault that hadn't been through migrateToRecovery, which broke background1440+ // sync: the SW re-woke from the alarm, isUnlocked() was true (the session key1441+ // restored derivedKey + entries), but getRootKeyHex() returned null and the1442+ // runner returned VAULT_LOCKED. The fix is to always try the pw_root path1443+ // when the slot has those fields — the recovery fields are independent.1444+ if (entry.mode === "real") {1445+ try {1446+ rootKey = await deriveRootKeyFromDerivedKey(key, slot);1447+ } catch {1448+ /* non-fatal — sync identity will still skip on the runner's null-rootKey check */1449+ }14371450 }14381451 14391452 return true;14401453 }14411454 14421455 export function lock(): void {1443- void clearSessionKey();1456+ // Deliberately does NOT clear the session key. The session key survives1457+ // `lock()` so the MV3 service worker can restore the vault from1458+ // chrome.storage.session on the next wake — the 10-minute sync alarm, the1459+ // post-adoption retry, the post-update alarm. The previous behaviour1460+ // (clear on every lock()) broke background sync: any popup close with1461+ // lockOnClose=true would `lock()`, which cleared the session key, and1462+ // the next SW wake would find no session key and the runner would return1463+ // VAULT_LOCKED. The session key's natural lifetime is the browser session1464+ // (chrome.storage.session is cleared on browser close), so explicit user1465+ // lock + popup close + auto-lock all keep the session key alive and the1466+ // expected sync-after-restart flow actually works.14441467 if (derivedKey) {14451468 zeroKey(derivedKey);14461469 derivedKey = null;src/shared/sync-outcome.test.ts+9 −1
@@ -41,8 +41,16 @@4141 expect(syncIndicator({ status: "error", outcome: "private_relay_unset" }, 1)).toBe("error");4242 expect(syncIndicator({ status: "error", outcome: "no_eligible_host" }, 1)).toBe("error");4343 expect(syncIndicator({ status: "error", outcome: "runner_unavailable" }, 1)).toBe("error");44- expect(syncIndicator({ status: "error", outcome: "vault_locked" }, 1)).toBe("error");4544 expect(syncIndicator({ status: "error", outcome: "run_failed" }, 1)).toBe("error");45+ });46+47+ it("treats vault_locked as warn (yellow) — the user hasn't unlocked yet, not a misconfig", () => {48+ // vault_locked is the reader's vault being closed (e.g. SW restarted after49+ // the popup was last dismissed). The next unlock + retry will succeed; the50+ // alarm keeps trying. Same yellow semantics as a missed dial — a red pip51+ // here would be misleading alarm fatigue.52+ expect(syncIndicator({ status: "error", outcome: "vault_locked" }, 1)).toBe("warn");53+ expect(syncIndicator({ status: "idle", outcome: "vault_locked" }, 1)).toBe("warn");4654 });4755 4856 it("returns to green after a successful dial even when the prior attempt was warn", () => {src/shared/sync-outcome.ts+5 −1
@@ -33,7 +33,6 @@3333 "no_eligible_host",3434 "runner_unavailable",3535 "private_relay_unset",36- "vault_locked",3736 "vault_id_mismatch",3837 "run_failed",3938 ]);@@ -47,6 +46,11 @@4746 "handshake_failed",4847 "sync_timeout",4948 "connection_lost",49+ // vault_locked is the reader's vault being closed — not a misconfiguration,50+ // not a broken sync. The user is in another app or hasn't unlocked yet; the51+ // next unlock + retry will succeed. Same alarm-keeps-trying semantics as a52+ // missed dial, so it lives here rather than in FAILURES.53+ "vault_locked",5054 ]);5155 5256 /**