1import { describe, it, expect, beforeEach } from 'vitest';2import { vi } from 'vitest';34import type { Message, MessageResponse, StoredVault } from '../shared/types';5import { WORD_LIST } from '../shared/recovery-word-list';67type StorageShape = Record<string, unknown>;8type MessageListener = (9 msg: unknown,10 sender: chrome.runtime.MessageSender,11 sendResponse: (response: MessageResponse) => void12) => boolean;1314describe('recovery words handlers', () => {15 let storage: StorageShape;16 let sessionStorage: StorageShape;17 let listener: MessageListener;1819 beforeEach(async () => {20 vi.resetModules();21 vi.useRealTimers();22 storage = { peach_settings: { breachMonitoring: false } };23 sessionStorage = {};2425 vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));26 vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);2728 const chromeMock = {29 runtime: {30 id: 'peach-extension',31 onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },32 sendMessage: vi.fn(async () => ({ ok: true })),33 },34 tabs: {35 query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {36 if (typeof cb === 'function') { cb([{ id: 1 }]); return; }37 return Promise.resolve([{ id: 1 }]);38 }),39 sendMessage: vi.fn(async () => ({ ok: true })),40 },41 storage: {42 local: {43 get: vi.fn(async (key?: string | string[]) => {44 if (!key) return storage;45 if (typeof key === 'string') return { [key]: storage[key] };46 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});47 }),48 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),49 remove: vi.fn(async (keys: string | string[]) => {50 const list = Array.isArray(keys) ? keys : [keys];51 for (const key of list) delete storage[key];52 }),53 },54 session: {55 get: vi.fn(async (key?: string | string[]) => {56 if (!key) return sessionStorage;57 if (typeof key === 'string') return { [key]: sessionStorage[key] };58 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});59 }),60 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),61 remove: vi.fn(async (keys: string | string[]) => {62 const list = Array.isArray(keys) ? keys : [keys];63 for (const key of list) delete sessionStorage[key];64 }),65 clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),66 },67 },68 alarms: {69 clear: vi.fn(), create: vi.fn(),70 onAlarm: { addListener: vi.fn() },71 },72 idle: {73 setDetectionInterval: vi.fn(),74 onStateChanged: { addListener: vi.fn() },75 },76 scripting: { executeScript: vi.fn(async () => []) },77 } as unknown as typeof chrome;7879 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8081 const messages = await import('./messages');82 messages.setupMessageHandler();83 const vault = await import('./vault');84 await vault.ensureSettingsLoaded();85 });8687 async function dispatch(message: Message): Promise<MessageResponse> {88 return new Promise<MessageResponse>((resolve) => {89 const keepOpen = listener(message, { id: 'peach-extension' }, resolve);90 expect(keepOpen).toBe(true);91 });92 }9394 async function setupUnlockedVault(): Promise<void> {95 const vault = await import('./vault');96 await vault.createVault('test-pass-12345');97 }9899 async function lockVault(): Promise<void> {100 const vault = await import('./vault');101 vault.lock();102 }103104 function rawSlotA(): StoredVault {105 const slot = storage.peach_vault_a as StoredVault | undefined;106 if (!slot) throw new Error('missing slot A');107 return slot;108 }109110 async function mockNoPro(): Promise<void> {111 const license = await import('./license');112 vi.spyOn(license, 'isProUser').mockResolvedValue(false);113 }114115 describe('GET_RECOVERY_WORDS handler', () => {116 beforeEach(async () => { await setupUnlockedVault(); });117118 it('returns 12 words', async () => {119 const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });120 expect(result.ok).toBe(true);121 const words = (result as { data: string[] }).data;122 expect(words).toHaveLength(12);123 });124125 it('all words are from the BIP39 word list', async () => {126 const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });127 const words = (result as { data: string[] }).data;128 for (const word of words) {129 expect(WORD_LIST).toContain(word);130 }131 });132133 it('returns same words on subsequent calls (deterministic)', async () => {134 const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });135 const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });136 expect((r1 as { data: string[] }).data).toEqual((r2 as { data: string[] }).data);137 });138139 it('returns error when vault is locked', async () => {140 await lockVault();141 const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });142 expect(result.ok).toBe(false);143 });144 });145146 describe('VALIDATE_RECOVERY_WORDS handler', () => {147 beforeEach(async () => { await setupUnlockedVault(); });148149 it('returns no invalid words for correct list', async () => {150 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });151 const words = (r as { data: string[] }).data;152 const result = await dispatch({153 type: 'VALIDATE_RECOVERY_WORDS',154 payload: { words },155 });156 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;157 expect(data.invalid).toEqual([]);158 });159160 it('returns invalid words for unknown words', async () => {161 const result = await dispatch({162 type: 'VALIDATE_RECOVERY_WORDS',163 payload: { words: ['notaword', 'alsonotaword'] },164 });165 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;166 const invalidWords = data.invalid.map((x) => x.word);167 expect(invalidWords).toContain('notaword');168 expect(invalidWords).toContain('alsonotaword');169 });170171 it('is case-insensitive for validation', async () => {172 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });173 const words = (r as { data: string[] }).data;174 const uppercased = words.map((w: string) => w.toUpperCase());175 const result = await dispatch({176 type: 'VALIDATE_RECOVERY_WORDS',177 payload: { words: uppercased },178 });179 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;180 expect(data.invalid).toEqual([]);181 });182 });183184 describe('RECOVER_WITH_WORDS handler', () => {185 it('recovery succeeds with correct words and new passphrase', async () => {186 await setupUnlockedVault();187 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });188 const words = (r as { data: string[] }).data;189 await lockVault();190191 const result = await dispatch({192 type: 'RECOVER_WITH_WORDS',193 payload: { words, newPassword: 'new-passphrase' },194 });195 expect(result.ok).toBe(true);196 const unlocked = await dispatch({ type: 'IS_UNLOCKED' });197 expect((unlocked as { data: boolean }).data).toBe(true);198 });199200 it('fails with wrong recovery words', async () => {201 await setupUnlockedVault();202 await lockVault();203 const fakeWords = Array(12).fill('abandon');204 const result = await dispatch({205 type: 'RECOVER_WITH_WORDS',206 payload: { words: fakeWords, newPassword: 'new-pass' },207 });208 expect(result.ok).toBe(false);209 expect((result as { error: string }).error).toMatch(/invalid/);210 });211212 it('fails with partial word list (fewer than 12)', async () => {213 await setupUnlockedVault();214 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });215 const words = (r as { data: string[] }).data;216 await lockVault();217 const partial = words.slice(0, 6);218 const result = await dispatch({219 type: 'RECOVER_WITH_WORDS',220 payload: { words: partial, newPassword: 'new-pass' },221 });222 expect(result.ok).toBe(false);223 });224225 it('new passphrase works after recovery', async () => {226 await setupUnlockedVault();227 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });228 const words = (r as { data: string[] }).data;229 await lockVault();230 await dispatch({231 type: 'RECOVER_WITH_WORDS',232 payload: { words, newPassword: 'brand-new-password' },233 });234 await lockVault();235 const unlock = await dispatch({236 type: 'UNLOCK',237 payload: { password: 'brand-new-password' },238 });239 expect(unlock.ok).toBe(true);240 });241242 it('old passphrase does not work after recovery', async () => {243 await setupUnlockedVault();244 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });245 const words = (r as { data: string[] }).data;246 await lockVault();247 await dispatch({248 type: 'RECOVER_WITH_WORDS',249 payload: { words, newPassword: 'brand-new-password' },250 });251 await lockVault();252 const unlock = await dispatch({253 type: 'UNLOCK',254 payload: { password: 'test-pass-12345' },255 });256 expect(unlock.ok).toBe(false);257 });258259 it('preserves recovery escrow fields after recovery password rewrap', async () => {260 await setupUnlockedVault();261 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });262 const words = (r as { data: string[] }).data;263 const before = { ...rawSlotA() };264 await lockVault();265266 const result = await dispatch({267 type: 'RECOVER_WITH_WORDS',268 payload: { words, newPassword: 'new-passphrase' },269 });270 expect(result.ok).toBe(true);271272 const after = rawSlotA();273 expect(after.recovery_wrapped_root_key).toBe(before.recovery_wrapped_root_key);274 expect(after.recovery_nonce).toBe(before.recovery_nonce);275 expect(after.recovery_tag).toBe(before.recovery_tag);276 expect(after.recovery_salt).toBe(before.recovery_salt);277 expect(after.ciphertext).not.toBe(before.ciphertext);278 expect(after.salt).not.toBe(before.salt);279 });280281 it('same recovery words succeed on a second recovery', async () => {282 await setupUnlockedVault();283 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });284 const words = (r as { data: string[] }).data;285286 await lockVault();287 const first = await dispatch({288 type: 'RECOVER_WITH_WORDS',289 payload: { words, newPassword: 'pass-1' },290 });291 expect(first.ok).toBe(true);292293 await lockVault();294 const second = await dispatch({295 type: 'RECOVER_WITH_WORDS',296 payload: { words, newPassword: 'pass-2' },297 });298 expect(second.ok).toBe(true);299300 await lockVault();301 const unlock = await dispatch({302 type: 'UNLOCK',303 payload: { password: 'pass-2' },304 });305 expect(unlock.ok).toBe(true);306 });307308 it('same recovery words succeed on a third recovery', async () => {309 await setupUnlockedVault();310 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });311 const words = (r as { data: string[] }).data;312313 for (let i = 0; i < 3; i += 1) {314 await lockVault();315 const result = await dispatch({316 type: 'RECOVER_WITH_WORDS',317 payload: { words, newPassword: `pass-${i}` },318 });319 expect(result.ok).toBe(true);320 }321 });322323 it('vault remains usable after recovery', async () => {324 await setupUnlockedVault();325 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });326 const words = (r as { data: string[] }).data;327328 await lockVault();329 const recovered = await dispatch({330 type: 'RECOVER_WITH_WORDS',331 payload: { words, newPassword: 'new-passphrase' },332 });333 expect(recovered.ok).toBe(true);334335 await dispatch({336 type: 'ADD_ENTRY',337 payload: { title: 'Check me', username: '', password: 'p', url: '' },338 });339 const entries = (await dispatch({ type: 'GET_ENTRIES' }) as { data: Array<{ title: string }> }).data;340 expect(entries.some((entry) => entry.title === 'Check me')).toBe(true);341 });342343 344 345 346 it('recovery words are preserved across CHANGE_PASSWORD', async () => {347 await setupUnlockedVault();348 const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;349 await dispatch({350 type: 'CHANGE_PASSWORD',351 payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-67890' },352 });353 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'new-pass-67890' } }) as { data: string[] }).data;354 expect(after).toEqual(before);355 });356357 it('recovery is not Pro-gated', async () => {358 await setupUnlockedVault();359 await mockNoPro();360 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });361 const words = (r as { data: string[] }).data;362 await lockVault();363 const result = await dispatch({364 type: 'RECOVER_WITH_WORDS',365 payload: { words, newPassword: 'new-pass' },366 });367 expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);368 });369 });370371 372 373 374 describe('changePassword preserves root key and recovery words', () => {375 it('recovery words work to unlock after password change', async () => {376 await setupUnlockedVault();377 const words = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;378 const ch = await dispatch({379 type: 'CHANGE_PASSWORD',380 payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-aaa' },381 });382 expect(ch.ok).toBe(true);383 await lockVault();384 const r = await dispatch({385 type: 'RECOVER_WITH_WORDS',386 payload: { words, newPassword: 'recovered-pass' },387 });388 expect(r.ok).toBe(true);389 });390391 it('recovery words are identical before and after password change', async () => {392 await setupUnlockedVault();393 const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;394 await dispatch({395 type: 'CHANGE_PASSWORD',396 payload: { currentPassword: 'test-pass-12345', newPassword: 'another-pass-bbb' },397 });398 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'another-pass-bbb' } }) as { data: string[] }).data;399 expect(after).toEqual(before);400 });401402 it('vault unlocks with the new password after change', async () => {403 await setupUnlockedVault();404 await dispatch({405 type: 'CHANGE_PASSWORD',406 payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' },407 });408 await lockVault();409 const r = await dispatch({410 type: 'UNLOCK',411 payload: { password: 'brand-new-pass' },412 });413 expect(r.ok).toBe(true);414 });415416 it('the old password no longer unlocks after change', async () => {417 await setupUnlockedVault();418 await dispatch({419 type: 'CHANGE_PASSWORD',420 payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' },421 });422 await lockVault();423 const r = await dispatch({424 type: 'UNLOCK',425 payload: { password: 'test-pass-12345' },426 });427 expect(r.ok).toBe(false);428 });429430 it('GET_RECOVERY_WORDS still works after a password change + lock + unlock', async () => {431 await setupUnlockedVault();432 const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;433 await dispatch({434 type: 'CHANGE_PASSWORD',435 payload: { currentPassword: 'test-pass-12345', newPassword: 'pass-after-cycle' },436 });437 await lockVault();438 await dispatch({439 type: 'UNLOCK',440 payload: { password: 'pass-after-cycle' },441 });442 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'pass-after-cycle' } }) as { data: string[] }).data;443 expect(after).toEqual(before);444 });445 });446447 448 449 450 describe('deriveRootKeyFromDerivedKey and recovery persistence', () => {451 it('GET_RECOVERY_WORDS works after lock and re-unlock', async () => {452 await setupUnlockedVault();453 const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });454 const words1 = (r1 as { data: string[] }).data;455 expect(words1).toHaveLength(12);456 await lockVault();457 const unlocked = await dispatch({458 type: 'UNLOCK',459 payload: { password: 'test-pass-12345' },460 });461 expect(unlocked.ok).toBe(true);462 const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });463 const words2 = (r2 as { data: string[] }).data;464 expect(words2).toEqual(words1);465 });466467 it('GET_RECOVERY_WORDS works after multiple lock/unlock cycles', async () => {468 await setupUnlockedVault();469 const original = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;470 for (let i = 0; i < 5; i++) {471 await lockVault();472 await dispatch({473 type: 'UNLOCK',474 payload: { password: 'test-pass-12345' },475 });476 }477 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;478 expect(after).toEqual(original);479 });480481 it('pre-v2.12 vault (no recovery escrow) gets recovery words after first unlock', async () => {482 483 484 await setupUnlockedVault();485 const slot = rawSlotA();486 const legacy: StoredVault = {487 version: slot.version,488 salt: slot.salt,489 nonce: slot.nonce,490 ciphertext: slot.ciphertext,491 tag: slot.tag,492 };493 storage.peach_vault_a = legacy;494 await lockVault();495496 const unlocked = await dispatch({497 type: 'UNLOCK',498 payload: { password: 'test-pass-12345' },499 });500 expect(unlocked.ok).toBe(true);501502 const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });503 expect(result.ok).toBe(true);504 const words = (result as { data: string[] }).data;505 expect(words).toHaveLength(12);506507 508 509 expect(storage.peach_recovery_pending).toBe('1');510511 512 const slotAfter = storage.peach_vault_a as StoredVault;513 expect(slotAfter.recovery_salt).toBeTruthy();514 expect(slotAfter.recovery_wrapped_root_key).toBeTruthy();515 expect(slotAfter.pw_root_ciphertext).toBeTruthy();516 });517 });518});