1import { beforeEach, describe, expect, it, vi } from "vitest";2import { deriveKey } from "../crypto/argon2";3456789type StorageShape = Record<string, unknown>;1011function deferred(): { promise: Promise<void>; resolve: () => void } {12 let resolve!: () => void;13 const promise = new Promise<void>((done) => {14 resolve = done;15 });16 return { promise, resolve };17}1819describe("vault session escrow refresh (PCH-01-008)", () => {20 let local: StorageShape;21 let session: StorageShape;2223 beforeEach(() => {24 vi.resetModules();25 local = {};26 session = {};27 const area = (store: StorageShape) => ({28 get: vi.fn(async (key?: string | string[]) => {29 if (!key) return store;30 if (typeof key === "string") return { [key]: store[key] };31 return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});32 }),33 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),34 remove: vi.fn(async (keys: string | string[]) => {35 for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];36 }),37 });38 (globalThis as { chrome?: typeof chrome }).chrome = {39 storage: { local: area(local), session: area(session) },40 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },41 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },42 } as unknown as typeof chrome;43 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));44 });4546 it("change_password_updates_session_escrow", async () => {47 const v1 = await import("./vault");48 await v1.createVault("old-pass-abc123");49 await v1.addEntry({ type: "login", title: "Acct", password: "pw" });50 expect(await v1.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);5152 53 54 await Promise.resolve();55 vi.resetModules();56 const v2 = await import("./vault");57 expect(await v2.restoreFromSession()).toBe(true);58 expect(v2.getEntries().some((e) => e.title === "Acct")).toBe(true);59 });6061 it("change_password_with_new_key_updates_session_escrow", async () => {62 const v1 = await import("./vault");63 await v1.createVault("orig-pass-abc123");64 await v1.addEntry({ type: "login", title: "Recovered", password: "pw" });6566 const newSalt = crypto.getRandomValues(new Uint8Array(16));67 const newKey = await deriveKey("reset-pass-xyz789", newSalt);68 await v1.changePasswordWithNewKey(newSalt, newKey);6970 await Promise.resolve();71 vi.resetModules();72 const v2 = await import("./vault");73 expect(await v2.restoreFromSession()).toBe(true);74 expect(v2.getEntries().some((e) => e.title === "Recovered")).toBe(true);75 });7677 it("restoreFromSession rehydrates rootKey so background sync survives a SW restart", async () => {78 79 80 81 82 83 84 85 86 const v1 = await import("./vault");87 await v1.createVault("strong-pass-abc123");88 89 90 91 await v1.unlock("strong-pass-abc123");92 const rootBeforeDead = v1.getRootKeyHex();93 expect(rootBeforeDead).not.toBeNull();9495 96 97 await Promise.resolve();98 vi.resetModules();99 const v2 = await import("./vault");100 expect(await v2.restoreFromSession()).toBe(true);101 102 103 expect(v2.getRootKeyHex()).not.toBeNull();104 expect(v2.getRootKeyHex()).toBe(rootBeforeDead);105 });106107 it("lock() clears the MV3 session key so a manual lock survives a SW restart", async () => {108 109 110 111 112 113 114 115 116 117 const v1 = await import("./vault");118 await v1.createVault("strong-pass-abc123");119 await v1.unlock("strong-pass-abc123");120 const sessionKeyBeforeLock = await chrome.storage.session.get("peach_session_dk");121 expect(sessionKeyBeforeLock.peach_session_dk).toBeDefined();122123 124 v1.lock();125 expect(v1.isUnlocked()).toBe(false);126 127 128 await Promise.resolve();129 const sessionKeyAfterLock = await chrome.storage.session.get("peach_session_dk");130 expect(sessionKeyAfterLock.peach_session_dk).toBeUndefined();131132 133 134 vi.resetModules();135 const v2 = await import("./vault");136 expect(await v2.restoreFromSession()).toBe(false);137 expect(v2.isUnlocked()).toBe(false);138 expect(v2.getRootKeyHex()).toBeNull();139 });140141 it("a lock invalidates a restore paused while reading the session key", async () => {142 const v1 = await import("./vault");143 await v1.createVault("strong-pass-abc123");144 await v1.unlock("strong-pass-abc123");145 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());146147 const staleSessionKey = session.peach_session_dk;148 const readStarted = deferred();149 const releaseRead = deferred();150 const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;151 sessionGet.mockImplementation(async (key?: string | string[]) => {152 if (key === "peach_session_dk") {153 readStarted.resolve();154 await releaseRead.promise;155 return { peach_session_dk: staleSessionKey };156 }157 if (!key) return session;158 const keys = Array.isArray(key) ? key : [key];159 return keys.reduce<StorageShape>((result, entry) => {160 result[entry] = session[entry];161 return result;162 }, {});163 });164165 vi.resetModules();166 const v2 = await import("./vault");167 const restoring = v2.restoreFromSession();168 await readStarted.promise;169170 await v2.lock();171 releaseRead.resolve();172173 await expect(restoring).resolves.toBe(false);174 expect(v2.isUnlocked()).toBe(false);175 expect(v2.getRootKeyHex()).toBeNull();176 });177178 it("keeps decrypted restore state private until the final epoch-checked commit", async () => {179 const v1 = await import("./vault");180 await v1.createVault("strong-pass-abc123");181 await v1.unlock("strong-pass-abc123");182 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());183184 const settingsReadStarted = deferred();185 const releaseSettingsRead = deferred();186 const localGet = chrome.storage.local.get as unknown as ReturnType<typeof vi.fn>;187 localGet.mockImplementation(async (key?: string | string[]) => {188 if (key === "peach_settings") {189 settingsReadStarted.resolve();190 await releaseSettingsRead.promise;191 }192 if (!key) return local;193 const keys = Array.isArray(key) ? key : [key];194 return keys.reduce<StorageShape>((result, entry) => {195 result[entry] = local[entry];196 return result;197 }, {});198 });199200 vi.resetModules();201 const v2 = await import("./vault");202 const restoring = v2.restoreFromSession();203 await settingsReadStarted.promise;204205 try {206 expect(v2.isUnlocked()).toBe(false);207 const locking = Promise.resolve(v2.lock());208 expect(v2.isUnlocked()).toBe(false);209 releaseSettingsRead.resolve();210 await locking;211212 await expect(restoring).resolves.toBe(false);213 expect(v2.isUnlocked()).toBe(false);214 expect(v2.getRootKeyHex()).toBeNull();215 } finally {216 releaseSettingsRead.resolve();217 await Promise.allSettled([restoring]);218 }219 });220221 it("does not resolve lock until session escrow and recovery confirmation are absent", async () => {222 const vault = await import("./vault");223 const pending = await import("./recovery-pending");224 await vault.createVault("strong-pass-abc123");225 await vault.unlock("strong-pass-abc123");226 await pending.setRecoveryConfirmPending();227 await vi.waitFor(() => {228 expect(session.peach_session_dk).toBeDefined();229 expect(session.peach_recovery_pending).toBeDefined();230 });231232 const removalStarted = deferred();233 const releaseRemoval = deferred();234 const sessionRemove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;235 sessionRemove.mockImplementation(async (keys: string | string[]) => {236 removalStarted.resolve();237 await releaseRemoval.promise;238 for (const key of Array.isArray(keys) ? keys : [keys]) delete session[key];239 });240241 let settled = false;242 const locking = Promise.resolve(vault.lock()).then(() => {243 settled = true;244 });245 expect(vault.isUnlocked()).toBe(false);246 await removalStarted.promise;247248 try {249 await Promise.resolve();250 expect(settled).toBe(false);251 } finally {252 releaseRemoval.resolve();253 await locking;254 }255256 expect(session.peach_session_dk).toBeUndefined();257 expect(session.peach_recovery_pending).toBeUndefined();258 });259260 it("disabled escrow clears the stored key and blocks save and restore", async () => {261 const v1 = await import("./vault");262 await v1.createVault("strong-pass-abc123");263 await v1.unlock("strong-pass-abc123");264 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());265 const parked = session.peach_session_dk;266267 await v1.applyStorageHardening(false);268 expect(session.peach_session_dk).toBeUndefined();269270 271 await v1.unlock("strong-pass-abc123");272 await Promise.resolve();273 expect(session.peach_session_dk).toBeUndefined();274275 276 277 session.peach_session_dk = parked;278 vi.resetModules();279 const v2 = await import("./vault");280 expect(await v2.restoreFromSession()).toBe(true);281282 vi.resetModules();283 const v3 = await import("./vault");284 await v3.applyStorageHardening(false);285 expect(session.peach_session_dk).toBeUndefined();286 expect(await v3.restoreFromSession()).toBe(false);287 expect(v3.isUnlocked()).toBe(false);288 });289290 it("deduplicates simultaneous restores and runs the unlock hook once", async () => {291 const v1 = await import("./vault");292 await v1.createVault("strong-pass-abc123");293 await v1.unlock("strong-pass-abc123");294 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());295296 vi.resetModules();297 const v2 = await import("./vault");298 const afterUnlock = vi.fn(async () => undefined);299 v2.setLockedSyncHooks({300 refresh: vi.fn(async () => undefined),301 afterUnlock,302 });303 const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;304 sessionGet.mockClear();305306 await expect(Promise.all([307 v2.restoreFromSession(),308 v2.restoreFromSession(),309 ])).resolves.toEqual([true, true]);310311 expect(sessionGet).toHaveBeenCalledTimes(1);312 expect(afterUnlock).toHaveBeenCalledTimes(1);313 });314});