1import { beforeEach, describe, expect, it, vi } from "vitest";2import { deriveKey } from "../crypto/argon2";345678910type StorageShape = Record<string, unknown>;1112function deferred(): { promise: Promise<void>; resolve: () => void } {13 let resolve!: () => void;14 const promise = new Promise<void>((done) => {15 resolve = done;16 });17 return { promise, resolve };18}1920function pauseSessionKeyWrite(session: StorageShape): {21 started: Promise<void>;22 release: () => void;23} {24 const started = deferred();25 const release = deferred();26 const sessionSet = chrome.storage.session.set as unknown as ReturnType<typeof vi.fn>;27 sessionSet.mockImplementation(async (next: Record<string, unknown>) => {28 if ("peach_session_dk" in next) {29 started.resolve();30 await release.promise;31 }32 Object.assign(session, next);33 });34 return { started: started.promise, release: release.resolve };35}3637describe("vault session escrow refresh (PCH-01-008)", () => {38 let local: StorageShape;39 let session: StorageShape;4041 beforeEach(() => {42 vi.resetModules();43 local = {};44 session = {};45 const area = (store: StorageShape) => ({46 get: vi.fn(async (key?: string | string[]) => {47 if (!key) return store;48 if (typeof key === "string") return { [key]: store[key] };49 return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});50 }),51 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),52 remove: vi.fn(async (keys: string | string[]) => {53 for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];54 }),55 });56 (globalThis as { chrome?: typeof chrome }).chrome = {57 storage: { local: area(local), session: area(session) },58 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },59 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },60 } as unknown as typeof chrome;61 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));62 });6364 it("change_password_updates_session_escrow", async () => {65 const v1 = await import("./vault");66 await v1.createVault("old-pass-abc123");67 await v1.addEntry({ type: "login", title: "Acct", password: "pw" });68 expect(await v1.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);6970 71 72 vi.resetModules();73 const v2 = await import("./vault");74 expect(await v2.restoreFromSession()).toBe(true);75 expect(v2.getEntries().some((e) => e.title === "Acct")).toBe(true);76 });7778 it("change_password_with_new_key_updates_session_escrow", async () => {79 const v1 = await import("./vault");80 await v1.createVault("orig-pass-abc123");81 await v1.addEntry({ type: "login", title: "Recovered", password: "pw" });8283 const newSalt = crypto.getRandomValues(new Uint8Array(16));84 const newKey = await deriveKey("reset-pass-xyz789", newSalt);85 await v1.changePasswordWithNewKey(newSalt, newKey);8687 vi.resetModules();88 const v2 = await import("./vault");89 expect(await v2.restoreFromSession()).toBe(true);90 expect(v2.getEntries().some((e) => e.title === "Recovered")).toBe(true);91 });9293 it("restoreFromSession rehydrates rootKey so background sync survives a SW restart", async () => {94 95 96 97 98 99 100 101 102 const v1 = await import("./vault");103 await v1.createVault("strong-pass-abc123");104 105 106 107 await v1.unlock("strong-pass-abc123");108 const rootBeforeDead = v1.getRootKeyHex();109 expect(rootBeforeDead).not.toBeNull();110111 112 113 await Promise.resolve();114 vi.resetModules();115 const v2 = await import("./vault");116 expect(await v2.restoreFromSession()).toBe(true);117 118 119 expect(v2.getRootKeyHex()).not.toBeNull();120 expect(v2.getRootKeyHex()).toBe(rootBeforeDead);121 });122123 it("lock() clears the MV3 session key so a manual lock survives a SW restart", async () => {124 125 126 127 128 129 130 131 132 133 const v1 = await import("./vault");134 await v1.createVault("strong-pass-abc123");135 await v1.unlock("strong-pass-abc123");136 const sessionKeyBeforeLock = await chrome.storage.session.get("peach_session_dk");137 expect(sessionKeyBeforeLock.peach_session_dk).toBeDefined();138139 140 v1.lock();141 expect(v1.isUnlocked()).toBe(false);142 143 144 await Promise.resolve();145 const sessionKeyAfterLock = await chrome.storage.session.get("peach_session_dk");146 expect(sessionKeyAfterLock.peach_session_dk).toBeUndefined();147148 149 150 vi.resetModules();151 const v2 = await import("./vault");152 expect(await v2.restoreFromSession()).toBe(false);153 expect(v2.isUnlocked()).toBe(false);154 expect(v2.getRootKeyHex()).toBeNull();155 });156157 it("a lock invalidates a real unlock paused while writing session escrow", async () => {158 const vault = await import("./vault");159 await vault.createVault("strong-pass-abc123");160 await vault.lock();161162 const write = pauseSessionKeyWrite(session);163 const unlocking = vault.unlock("strong-pass-abc123");164 await write.started;165166 let lockSettled = false;167 const locking = vault.lock().then(() => {168 lockSettled = true;169 });170 await new Promise((resolve) => setTimeout(resolve, 0));171 const lockSettledBeforeWrite = lockSettled;172 write.release();173174 expect(lockSettledBeforeWrite).toBe(false);175 await expect(unlocking).resolves.toBe(false);176 await locking;177 expect(vault.isUnlocked()).toBe(false);178 expect(session.peach_session_dk).toBeUndefined();179180 vi.resetModules();181 const restarted = await import("./vault");182 expect(await restarted.restoreFromSession()).toBe(false);183 });184185 it("a lock invalidates a duress unlock paused while writing session escrow", async () => {186 const vault = await import("./vault");187 await vault.createVault("real-pass-abc123");188 await vault.setupDuress("duress-pass-xyz789");189 await vault.lock();190191 const write = pauseSessionKeyWrite(session);192 const unlocking = vault.unlock("duress-pass-xyz789");193 await write.started;194195 const locking = vault.lock();196 await Promise.resolve();197 write.release();198199 await expect(unlocking).resolves.toBe(false);200 await locking;201 expect(vault.isUnlocked()).toBe(false);202 expect(session.peach_session_dk).toBeUndefined();203 });204205 it("a lock invalidates a key unlock paused while writing session escrow", async () => {206 const vault = await import("./vault");207 await vault.createVault("strong-pass-abc123");208 const keyHex = vault.getDerivedKeyHex();209 await vault.lock();210211 const write = pauseSessionKeyWrite(session);212 const unlocking = vault.unlockWithKey(keyHex);213 await write.started;214215 const locking = vault.lock();216 await Promise.resolve();217 write.release();218219 await expect(unlocking).resolves.toEqual({ ok: false, error: "invalid_key" });220 await locking;221 expect(vault.isUnlocked()).toBe(false);222 expect(session.peach_session_dk).toBeUndefined();223 });224225 it("a lock invalidates a password change paused while writing session escrow", async () => {226 const vault = await import("./vault");227 await vault.createVault("old-pass-abc123");228229 const write = pauseSessionKeyWrite(session);230 const changing = vault.changePassword("old-pass-abc123", "new-pass-xyz789");231 await write.started;232233 const locking = vault.lock();234 await Promise.resolve();235 write.release();236237 await expect(changing).resolves.toBe(false);238 await locking;239 expect(vault.isUnlocked()).toBe(false);240 expect(session.peach_session_dk).toBeUndefined();241242 vi.resetModules();243 const restarted = await import("./vault");244 expect(await restarted.restoreFromSession()).toBe(false);245 });246247 it("a lock clears a recovery re-key paused while writing session escrow", async () => {248 const vault = await import("./vault");249 await vault.createVault("old-pass-abc123");250 const newSalt = crypto.getRandomValues(new Uint8Array(16));251 const newKey = await deriveKey("new-pass-xyz789", newSalt);252253 const write = pauseSessionKeyWrite(session);254 const changing = vault.changePasswordWithNewKey(newSalt, newKey);255 await write.started;256257 let lockSettled = false;258 const locking = vault.lock().then(() => {259 lockSettled = true;260 });261 await new Promise((resolve) => setTimeout(resolve, 0));262 const lockSettledBeforeWrite = lockSettled;263 write.release();264265 expect(lockSettledBeforeWrite).toBe(false);266 await expect(changing).rejects.toThrow("vault_locked");267 await locking;268 expect(vault.isUnlocked()).toBe(false);269 expect(session.peach_session_dk).toBeUndefined();270271 vi.resetModules();272 const restarted = await import("./vault");273 expect(await restarted.restoreFromSession()).toBe(false);274 });275276 it("a lock invalidates a restore paused while reading the session key", async () => {277 const v1 = await import("./vault");278 await v1.createVault("strong-pass-abc123");279 await v1.unlock("strong-pass-abc123");280 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());281282 const staleSessionKey = session.peach_session_dk;283 const readStarted = deferred();284 const releaseRead = deferred();285 const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;286 sessionGet.mockImplementation(async (key?: string | string[]) => {287 if (key === "peach_session_dk") {288 readStarted.resolve();289 await releaseRead.promise;290 return { peach_session_dk: staleSessionKey };291 }292 if (!key) return session;293 const keys = Array.isArray(key) ? key : [key];294 return keys.reduce<StorageShape>((result, entry) => {295 result[entry] = session[entry];296 return result;297 }, {});298 });299300 vi.resetModules();301 const v2 = await import("./vault");302 const restoring = v2.restoreFromSession();303 await readStarted.promise;304305 await v2.lock();306 releaseRead.resolve();307308 await expect(restoring).resolves.toBe(false);309 expect(v2.isUnlocked()).toBe(false);310 expect(v2.getRootKeyHex()).toBeNull();311 });312313 it("keeps decrypted restore state private until the final epoch-checked commit", async () => {314 const v1 = await import("./vault");315 await v1.createVault("strong-pass-abc123");316 await v1.unlock("strong-pass-abc123");317 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());318319 const settingsReadStarted = deferred();320 const releaseSettingsRead = deferred();321 const localGet = chrome.storage.local.get as unknown as ReturnType<typeof vi.fn>;322 localGet.mockImplementation(async (key?: string | string[]) => {323 if (key === "peach_settings") {324 settingsReadStarted.resolve();325 await releaseSettingsRead.promise;326 }327 if (!key) return local;328 const keys = Array.isArray(key) ? key : [key];329 return keys.reduce<StorageShape>((result, entry) => {330 result[entry] = local[entry];331 return result;332 }, {});333 });334335 vi.resetModules();336 const v2 = await import("./vault");337 const restoring = v2.restoreFromSession();338 await settingsReadStarted.promise;339340 try {341 expect(v2.isUnlocked()).toBe(false);342 const locking = Promise.resolve(v2.lock());343 expect(v2.isUnlocked()).toBe(false);344 releaseSettingsRead.resolve();345 await locking;346347 await expect(restoring).resolves.toBe(false);348 expect(v2.isUnlocked()).toBe(false);349 expect(v2.getRootKeyHex()).toBeNull();350 } finally {351 releaseSettingsRead.resolve();352 await Promise.allSettled([restoring]);353 }354 });355356 it("does not resolve lock until session escrow is absent", async () => {357 const vault = await import("./vault");358 const pending = await import("./recovery-pending");359 await vault.createVault("strong-pass-abc123");360 await vault.unlock("strong-pass-abc123");361 await pending.setRecoveryConfirmPending();362 await vi.waitFor(() => {363 expect(session.peach_session_dk).toBeDefined();364 expect(session.peach_recovery_pending).toBeDefined();365 });366367 const removalStarted = deferred();368 const releaseRemoval = deferred();369 const sessionRemove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;370 sessionRemove.mockImplementation(async (keys: string | string[]) => {371 removalStarted.resolve();372 await releaseRemoval.promise;373 for (const key of Array.isArray(keys) ? keys : [keys]) delete session[key];374 });375376 let settled = false;377 const locking = Promise.resolve(vault.lock()).then(() => {378 settled = true;379 });380 expect(vault.isUnlocked()).toBe(false);381 await removalStarted.promise;382383 try {384 await Promise.resolve();385 expect(settled).toBe(false);386 } finally {387 releaseRemoval.resolve();388 await locking;389 }390391 expect(session.peach_session_dk).toBeUndefined();392 expect(session.peach_recovery_pending).toBeDefined();393 });394395 it("disabled escrow clears the stored key and blocks save and restore", async () => {396 const v1 = await import("./vault");397 await v1.createVault("strong-pass-abc123");398 await v1.unlock("strong-pass-abc123");399 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());400 const parked = session.peach_session_dk;401402 await v1.applyStorageHardening(false);403 expect(session.peach_session_dk).toBeUndefined();404405 406 await v1.unlock("strong-pass-abc123");407 await Promise.resolve();408 expect(session.peach_session_dk).toBeUndefined();409410 411 412 session.peach_session_dk = parked;413 vi.resetModules();414 const v2 = await import("./vault");415 expect(await v2.restoreFromSession()).toBe(true);416417 vi.resetModules();418 const v3 = await import("./vault");419 await v3.applyStorageHardening(false);420 expect(session.peach_session_dk).toBeUndefined();421 expect(await v3.restoreFromSession()).toBe(false);422 expect(v3.isUnlocked()).toBe(false);423 });424425 it("deduplicates simultaneous restores and runs the unlock hook once", async () => {426 const v1 = await import("./vault");427 await v1.createVault("strong-pass-abc123");428 await v1.unlock("strong-pass-abc123");429 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());430431 vi.resetModules();432 const v2 = await import("./vault");433 const afterUnlock = vi.fn(async () => undefined);434 v2.setLockedSyncHooks({435 refresh: vi.fn(async () => undefined),436 afterUnlock,437 });438 const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;439 sessionGet.mockClear();440441 await expect(Promise.all([442 v2.restoreFromSession(),443 v2.restoreFromSession(),444 ])).resolves.toEqual([true, true]);445446 expect(sessionGet).toHaveBeenCalledTimes(1);447 expect(afterUnlock).toHaveBeenCalledTimes(1);448 });449});