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
2 files src/background/vault.session-escrow.test.ts+68 −0src/background/vault.ts+10 −0
src/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+});

view file

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

view file