14cea33605e5
fix: PCH-01-008 re-escrow new derived key to chrome.storage.session after changePassword + changePasswordWithNewKey swaps — SW restore survives a password change (v3.8.1)
- time
- 2026-06-10 19:46 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- pch-01 fix integration
- parent
- de64511b0d62
↓ peach-browser-14cea336.tar.gzatlas at this save
files changed2 files
src/background/vault.session-escrow.test.ts+68 −0src/background/vault.ts+10 −0src/background/vault.session-escrow.test.ts+68 −0
@@ -0,0 +1,68 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import { deriveKey } from "../crypto/argon2";3+4+// PCH-01-008: a password change must refresh the MV3 session-key escrow so the5+// next service-worker restore decrypts the new-key Slot A. The backing stores6+// are closed over by the chrome mock on globalThis, so they survive the7+// vi.resetModules() we use to simulate a service-worker restart mid-test.8+9+type StorageShape = Record<string, unknown>;10+11+describe("vault session escrow refresh (PCH-01-008)", () => {12+ let local: StorageShape;13+ let session: StorageShape;14+15+ beforeEach(() => {16+ vi.resetModules();17+ local = {};18+ session = {};19+ const area = (store: StorageShape) => ({20+ get: vi.fn(async (key?: string | string[]) => {21+ if (!key) return store;22+ if (typeof key === "string") return { [key]: store[key] };23+ return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});24+ }),25+ set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),26+ remove: vi.fn(async (keys: string | string[]) => {27+ for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];28+ }),29+ });30+ (globalThis as { chrome?: typeof chrome }).chrome = {31+ storage: { local: area(local), session: area(session) },32+ alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },33+ idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },34+ } as unknown as typeof chrome;35+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));36+ });37+38+ it("change_password_updates_session_escrow", async () => {39+ const v1 = await import("./vault");40+ await v1.createVault("old-pass-abc123");41+ await v1.addEntry({ type: "login", title: "Acct", password: "pw" });42+ expect(await v1.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);43+44+ // Flush the fire-and-forget saveSessionKey microtask, then simulate a SW45+ // restart: a fresh module has derivedKey=null but session/local persist.46+ await Promise.resolve();47+ vi.resetModules();48+ const v2 = await import("./vault");49+ expect(await v2.restoreFromSession()).toBe(true);50+ expect(v2.getEntries().some((e) => e.title === "Acct")).toBe(true);51+ });52+53+ it("change_password_with_new_key_updates_session_escrow", async () => {54+ const v1 = await import("./vault");55+ await v1.createVault("orig-pass-abc123");56+ await v1.addEntry({ type: "login", title: "Recovered", password: "pw" });57+58+ const newSalt = crypto.getRandomValues(new Uint8Array(16));59+ const newKey = await deriveKey("reset-pass-xyz789", newSalt);60+ await v1.changePasswordWithNewKey(newSalt, newKey);61+62+ await Promise.resolve();63+ vi.resetModules();64+ const v2 = await import("./vault");65+ expect(await v2.restoreFromSession()).toBe(true);66+ expect(v2.getEntries().some((e) => e.title === "Recovered")).toBe(true);67+ });68+});src/background/vault.ts+10 −0
@@ -423,6 +423,10 @@423423 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });424424 if (derivedKey) zeroKey(derivedKey);425425 derivedKey = newKey;426+ // PCH-01-008: re-escrow the new key (see changePassword). The recovery flow427+ // reaches this path, so a post-recovery SW restart would otherwise lock out428+ // a user who just reset their password.429+ void saveSessionKey(newKey, "real");426430 // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works427431 // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow428432 // should have populated it — fall back silently rather than blowing up the@@ -2798,6 +2802,12 @@27982802 // would overwrite Slot A with old-key ciphertext, locking the user out.27992803 zeroKey(derivedKey);28002804 derivedKey = newKey;2805+2806+ // PCH-01-008: the MV3 session escrow still holds the OLD key. Without this,2807+ // the next service-worker restore decrypts the new-key Slot A with the stale2808+ // key, fails tryDecryptSlotResult, and silently drops the user to a lock2809+ // screen. Re-escrow the new key so restore survives a password change.2810+ void saveSessionKey(newKey, "real");28012811 28022812 // Regenerate noise for slot B when duress is not in use. Runs after the28032813 // key swap so a failure here is a disposable noise regeneration, not a