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}1920describe("vault session escrow refresh (PCH-01-008)", () => {21 let local: StorageShape;22 let session: StorageShape;2324 beforeEach(() => {25 vi.resetModules();26 local = {};27 session = {};28 const area = (store: StorageShape) => ({29 get: vi.fn(async (key?: string | string[]) => {30 if (!key) return store;31 if (typeof key === "string") return { [key]: store[key] };32 return key.reduce<Record<string, unknown>>((r, k) => { r[k] = store[k]; return r; }, {});33 }),34 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),35 remove: vi.fn(async (keys: string | string[]) => {36 for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];37 }),38 });39 (globalThis as { chrome?: typeof chrome }).chrome = {40 storage: { local: area(local), session: area(session) },41 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },42 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },43 } as unknown as typeof chrome;44 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));45 });4647 it("change_password_updates_session_escrow", async () => {48 const v1 = await import("./vault");49 await v1.createVault("old-pass-abc123");50 await v1.addEntry({ type: "login", title: "Acct", password: "pw" });51 expect(await v1.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);5253 54 55 await Promise.resolve();56 vi.resetModules();57 const v2 = await import("./vault");58 expect(await v2.restoreFromSession()).toBe(true);59 expect(v2.getEntries().some((e) => e.title === "Acct")).toBe(true);60 });6162 it("change_password_with_new_key_updates_session_escrow", async () => {63 const v1 = await import("./vault");64 await v1.createVault("orig-pass-abc123");65 await v1.addEntry({ type: "login", title: "Recovered", password: "pw" });6667 const newSalt = crypto.getRandomValues(new Uint8Array(16));68 const newKey = await deriveKey("reset-pass-xyz789", newSalt);69 await v1.changePasswordWithNewKey(newSalt, newKey);7071 await Promise.resolve();72 vi.resetModules();73 const v2 = await import("./vault");74 expect(await v2.restoreFromSession()).toBe(true);75 expect(v2.getEntries().some((e) => e.title === "Recovered")).toBe(true);76 });7778 it("restoreFromSession rehydrates rootKey so background sync survives a SW restart", async () => {79 80 81 82 83 84 85 86 87 const v1 = await import("./vault");88 await v1.createVault("strong-pass-abc123");89 90 91 92 await v1.unlock("strong-pass-abc123");93 const rootBeforeDead = v1.getRootKeyHex();94 expect(rootBeforeDead).not.toBeNull();9596 97 98 await Promise.resolve();99 vi.resetModules();100 const v2 = await import("./vault");101 expect(await v2.restoreFromSession()).toBe(true);102 103 104 expect(v2.getRootKeyHex()).not.toBeNull();105 expect(v2.getRootKeyHex()).toBe(rootBeforeDead);106 });107108 it("lock() clears the MV3 session key so a manual lock survives a SW restart", async () => {109 110 111 112 113 114 115 116 117 118 const v1 = await import("./vault");119 await v1.createVault("strong-pass-abc123");120 await v1.unlock("strong-pass-abc123");121 const sessionKeyBeforeLock = await chrome.storage.session.get("peach_session_dk");122 expect(sessionKeyBeforeLock.peach_session_dk).toBeDefined();123124 125 v1.lock();126 expect(v1.isUnlocked()).toBe(false);127 128 129 await Promise.resolve();130 const sessionKeyAfterLock = await chrome.storage.session.get("peach_session_dk");131 expect(sessionKeyAfterLock.peach_session_dk).toBeUndefined();132133 134 135 vi.resetModules();136 const v2 = await import("./vault");137 expect(await v2.restoreFromSession()).toBe(false);138 expect(v2.isUnlocked()).toBe(false);139 expect(v2.getRootKeyHex()).toBeNull();140 });141142 it("a lock invalidates a restore paused while reading the session key", async () => {143 const v1 = await import("./vault");144 await v1.createVault("strong-pass-abc123");145 await v1.unlock("strong-pass-abc123");146 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());147148 const staleSessionKey = session.peach_session_dk;149 const readStarted = deferred();150 const releaseRead = deferred();151 const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;152 sessionGet.mockImplementation(async (key?: string | string[]) => {153 if (key === "peach_session_dk") {154 readStarted.resolve();155 await releaseRead.promise;156 return { peach_session_dk: staleSessionKey };157 }158 if (!key) return session;159 const keys = Array.isArray(key) ? key : [key];160 return keys.reduce<StorageShape>((result, entry) => {161 result[entry] = session[entry];162 return result;163 }, {});164 });165166 vi.resetModules();167 const v2 = await import("./vault");168 const restoring = v2.restoreFromSession();169 await readStarted.promise;170171 await v2.lock();172 releaseRead.resolve();173174 await expect(restoring).resolves.toBe(false);175 expect(v2.isUnlocked()).toBe(false);176 expect(v2.getRootKeyHex()).toBeNull();177 });178179 it("keeps decrypted restore state private until the final epoch-checked commit", async () => {180 const v1 = await import("./vault");181 await v1.createVault("strong-pass-abc123");182 await v1.unlock("strong-pass-abc123");183 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());184185 const settingsReadStarted = deferred();186 const releaseSettingsRead = deferred();187 const localGet = chrome.storage.local.get as unknown as ReturnType<typeof vi.fn>;188 localGet.mockImplementation(async (key?: string | string[]) => {189 if (key === "peach_settings") {190 settingsReadStarted.resolve();191 await releaseSettingsRead.promise;192 }193 if (!key) return local;194 const keys = Array.isArray(key) ? key : [key];195 return keys.reduce<StorageShape>((result, entry) => {196 result[entry] = local[entry];197 return result;198 }, {});199 });200201 vi.resetModules();202 const v2 = await import("./vault");203 const restoring = v2.restoreFromSession();204 await settingsReadStarted.promise;205206 try {207 expect(v2.isUnlocked()).toBe(false);208 const locking = Promise.resolve(v2.lock());209 expect(v2.isUnlocked()).toBe(false);210 releaseSettingsRead.resolve();211 await locking;212213 await expect(restoring).resolves.toBe(false);214 expect(v2.isUnlocked()).toBe(false);215 expect(v2.getRootKeyHex()).toBeNull();216 } finally {217 releaseSettingsRead.resolve();218 await Promise.allSettled([restoring]);219 }220 });221222 it("does not resolve lock until session escrow and recovery confirmation are absent", async () => {223 const vault = await import("./vault");224 const pending = await import("./recovery-pending");225 await vault.createVault("strong-pass-abc123");226 await vault.unlock("strong-pass-abc123");227 await pending.setRecoveryConfirmPending();228 await vi.waitFor(() => {229 expect(session.peach_session_dk).toBeDefined();230 expect(session.peach_recovery_pending).toBeDefined();231 });232233 const removalStarted = deferred();234 const releaseRemoval = deferred();235 const sessionRemove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;236 sessionRemove.mockImplementation(async (keys: string | string[]) => {237 removalStarted.resolve();238 await releaseRemoval.promise;239 for (const key of Array.isArray(keys) ? keys : [keys]) delete session[key];240 });241242 let settled = false;243 const locking = Promise.resolve(vault.lock()).then(() => {244 settled = true;245 });246 expect(vault.isUnlocked()).toBe(false);247 await removalStarted.promise;248249 try {250 await Promise.resolve();251 expect(settled).toBe(false);252 } finally {253 releaseRemoval.resolve();254 await locking;255 }256257 expect(session.peach_session_dk).toBeUndefined();258 expect(session.peach_recovery_pending).toBeUndefined();259 });260261 it("disabled escrow clears the stored key and blocks save and restore", async () => {262 const v1 = await import("./vault");263 await v1.createVault("strong-pass-abc123");264 await v1.unlock("strong-pass-abc123");265 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());266 const parked = session.peach_session_dk;267268 await v1.applyStorageHardening(false);269 expect(session.peach_session_dk).toBeUndefined();270271 272 await v1.unlock("strong-pass-abc123");273 await Promise.resolve();274 expect(session.peach_session_dk).toBeUndefined();275276 277 278 session.peach_session_dk = parked;279 vi.resetModules();280 const v2 = await import("./vault");281 expect(await v2.restoreFromSession()).toBe(true);282283 vi.resetModules();284 const v3 = await import("./vault");285 await v3.applyStorageHardening(false);286 expect(session.peach_session_dk).toBeUndefined();287 expect(await v3.restoreFromSession()).toBe(false);288 expect(v3.isUnlocked()).toBe(false);289 });290291 it("deduplicates simultaneous restores and runs the unlock hook once", async () => {292 const v1 = await import("./vault");293 await v1.createVault("strong-pass-abc123");294 await v1.unlock("strong-pass-abc123");295 await vi.waitFor(() => expect(session.peach_session_dk).toBeDefined());296297 vi.resetModules();298 const v2 = await import("./vault");299 const afterUnlock = vi.fn(async () => undefined);300 v2.setLockedSyncHooks({301 refresh: vi.fn(async () => undefined),302 afterUnlock,303 });304 const sessionGet = chrome.storage.session.get as unknown as ReturnType<typeof vi.fn>;305 sessionGet.mockClear();306307 await expect(Promise.all([308 v2.restoreFromSession(),309 v2.restoreFromSession(),310 ])).resolves.toEqual([true, true]);311312 expect(sessionGet).toHaveBeenCalledTimes(1);313 expect(afterUnlock).toHaveBeenCalledTimes(1);314 });315});