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
7 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 −1
manifest.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": {

view file

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",

view file

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",

view file

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

view file

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;

view file

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", () => {

view file

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 /**

view file