import { describe, it, expect, beforeEach } from 'vitest'; import { vi } from 'vitest'; import type { Message, MessageResponse, StoredVault } from '../shared/types'; import { WORD_LIST } from '../shared/recovery-word-list'; // Every unlock/GET_RECOVERY_WORDS here runs real Argon2id (m=64MiB, t=3, p=4). // The lock/unlock-cycle cases do seven derivations, which lands just past // vitest's 5s default once the full suite is competing for CPU — the failures // clustered at 5.0-5.1s, not at random points. Raised for the whole file // because every case in it is Argon2-bound, not just the ones that flaked. vi.setConfig({ testTimeout: 30_000 }); type StorageShape = Record; type MessageListener = ( msg: unknown, sender: chrome.runtime.MessageSender, sendResponse: (response: MessageResponse) => void ) => boolean; describe('recovery words handlers', () => { let storage: StorageShape; let sessionStorage: StorageShape; let listener: MessageListener; beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); storage = { peach_settings: { breachMonitoring: false } }; sessionStorage = {}; vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' }))); vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false); const chromeMock = { runtime: { id: 'peach-extension', onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) }, sendMessage: vi.fn(async () => ({ ok: true })), }, tabs: { query: vi.fn((_, cb?: (tabs: unknown[]) => void) => { if (typeof cb === 'function') { cb([{ id: 1 }]); return; } return Promise.resolve([{ id: 1 }]); }), sendMessage: vi.fn(async () => ({ ok: true })), }, storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === 'string') return { [key]: storage[key] }; return key.reduce>((acc, k) => { acc[k] = storage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete storage[key]; }), }, session: { get: vi.fn(async (key?: string | string[]) => { if (!key) return sessionStorage; if (typeof key === 'string') return { [key]: sessionStorage[key] }; return key.reduce>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(sessionStorage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete sessionStorage[key]; }), clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, scripting: { executeScript: vi.fn(async () => []) }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; const messages = await import('./messages'); messages.setupMessageHandler(); const vault = await import('./vault'); await vault.ensureSettingsLoaded(); }); async function dispatch(message: Message): Promise { return new Promise((resolve) => { const keepOpen = listener(message, { id: 'peach-extension' }, resolve); expect(keepOpen).toBe(true); }); } async function setupUnlockedVault(): Promise { const vault = await import('./vault'); await vault.createVault('test-pass-12345'); } async function lockVault(): Promise { const vault = await import('./vault'); vault.lock(); } function rawSlotA(): StoredVault { const slot = storage.peach_vault_a as StoredVault | undefined; if (!slot) throw new Error('missing slot A'); return slot; } async function mockNoPro(): Promise { const license = await import('./license'); vi.spyOn(license, 'isProUser').mockResolvedValue(false); } describe('GET_RECOVERY_WORDS handler', () => { beforeEach(async () => { await setupUnlockedVault(); }); it('returns 12 words', async () => { const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); expect(result.ok).toBe(true); const words = (result as { data: string[] }).data; expect(words).toHaveLength(12); }); it('all words are from the BIP39 word list', async () => { const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (result as { data: string[] }).data; for (const word of words) { expect(WORD_LIST).toContain(word); } }); it('returns same words on subsequent calls (deterministic)', async () => { const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); expect((r1 as { data: string[] }).data).toEqual((r2 as { data: string[] }).data); }); it('returns error when vault is locked', async () => { await lockVault(); const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); expect(result.ok).toBe(false); }); }); describe('VALIDATE_RECOVERY_WORDS handler', () => { beforeEach(async () => { await setupUnlockedVault(); }); it('returns no invalid words for correct list', async () => { const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (r as { data: string[] }).data; const result = await dispatch({ type: 'VALIDATE_RECOVERY_WORDS', payload: { words }, }); const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data; expect(data.invalid).toEqual([]); }); it('returns invalid words for unknown words', async () => { const result = await dispatch({ type: 'VALIDATE_RECOVERY_WORDS', payload: { words: ['notaword', 'alsonotaword'] }, }); const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data; const invalidWords = data.invalid.map((x) => x.word); expect(invalidWords).toContain('notaword'); expect(invalidWords).toContain('alsonotaword'); }); it('is case-insensitive for validation', async () => { const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (r as { data: string[] }).data; const uppercased = words.map((w: string) => w.toUpperCase()); const result = await dispatch({ type: 'VALIDATE_RECOVERY_WORDS', payload: { words: uppercased }, }); const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data; expect(data.invalid).toEqual([]); }); }); describe('RECOVER_WITH_WORDS handler', () => { it('recovery succeeds with correct words and new passphrase', async () => { await setupUnlockedVault(); const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (r as { data: string[] }).data; await lockVault(); const result = await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words, newPassword: 'new-passphrase' }, }); expect(result.ok).toBe(true); const unlocked = await dispatch({ type: 'IS_UNLOCKED' }); expect((unlocked as { data: boolean }).data).toBe(true); }); it('fails with wrong recovery words', async () => { await setupUnlockedVault(); await lockVault(); const fakeWords = Array(12).fill('abandon'); const result = await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words: fakeWords, newPassword: 'new-pass' }, }); expect(result.ok).toBe(false); expect((result as { error: string }).error).toMatch(/invalid/); }); it('fails with partial word list (fewer than 12)', async () => { await setupUnlockedVault(); const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (r as { data: string[] }).data; await lockVault(); const partial = words.slice(0, 6); const result = await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words: partial, newPassword: 'new-pass' }, }); expect(result.ok).toBe(false); }); it('new passphrase works after recovery', async () => { await setupUnlockedVault(); const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (r as { data: string[] }).data; await lockVault(); await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words, newPassword: 'brand-new-password' }, }); await lockVault(); const unlock = await dispatch({ type: 'UNLOCK', payload: { password: 'brand-new-password' }, }); expect(unlock.ok).toBe(true); }); it('old passphrase does not work after recovery', async () => { await setupUnlockedVault(); const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (r as { data: string[] }).data; await lockVault(); await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words, newPassword: 'brand-new-password' }, }); await lockVault(); const unlock = await dispatch({ type: 'UNLOCK', payload: { password: 'test-pass-12345' }, }); expect(unlock.ok).toBe(false); }); it('preserves recovery escrow fields after recovery password rewrap', async () => { await setupUnlockedVault(); const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (r as { data: string[] }).data; const before = { ...rawSlotA() }; await lockVault(); const result = await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words, newPassword: 'new-passphrase' }, }); expect(result.ok).toBe(true); const after = rawSlotA(); expect(after.recovery_wrapped_root_key).toBe(before.recovery_wrapped_root_key); expect(after.recovery_nonce).toBe(before.recovery_nonce); expect(after.recovery_tag).toBe(before.recovery_tag); expect(after.recovery_salt).toBe(before.recovery_salt); expect(after.ciphertext).not.toBe(before.ciphertext); expect(after.salt).not.toBe(before.salt); }); it('same recovery words succeed on a second recovery', async () => { await setupUnlockedVault(); const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (r as { data: string[] }).data; await lockVault(); const first = await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words, newPassword: 'pass-1' }, }); expect(first.ok).toBe(true); await lockVault(); const second = await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words, newPassword: 'pass-2' }, }); expect(second.ok).toBe(true); await lockVault(); const unlock = await dispatch({ type: 'UNLOCK', payload: { password: 'pass-2' }, }); expect(unlock.ok).toBe(true); }); it('same recovery words succeed on a third recovery', async () => { await setupUnlockedVault(); const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (r as { data: string[] }).data; for (let i = 0; i < 3; i += 1) { await lockVault(); const result = await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words, newPassword: `pass-${i}` }, }); expect(result.ok).toBe(true); } }); it('vault remains usable after recovery', async () => { await setupUnlockedVault(); const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (r as { data: string[] }).data; await lockVault(); const recovered = await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words, newPassword: 'new-passphrase' }, }); expect(recovered.ok).toBe(true); await dispatch({ type: 'ADD_ENTRY', payload: { title: 'Check me', username: '', password: 'p', url: '' }, }); const entries = (await dispatch({ type: 'GET_ENTRIES' }) as { data: Array<{ title: string }> }).data; expect(entries.some((entry) => entry.title === 'Check me')).toBe(true); }); // H4/COR-002: changePassword MUST preserve the root key so the user's // existing recovery words continue to work. Rotating the root key on // every password change would silently brick recovery. it('recovery words are preserved across CHANGE_PASSWORD', async () => { await setupUnlockedVault(); const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data; await dispatch({ type: 'CHANGE_PASSWORD', payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-67890' }, }); const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'new-pass-67890' } }) as { data: string[] }).data; expect(after).toEqual(before); }); it('recovery is not Pro-gated', async () => { await setupUnlockedVault(); await mockNoPro(); const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words = (r as { data: string[] }).data; await lockVault(); const result = await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words, newPassword: 'new-pass' }, }); expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/); }); }); // H4/COR-002 — changePassword must re-wrap the existing root key, not mint // a new one. The recovery words derived from rootKey must remain valid // after any password change. describe('changePassword preserves root key and recovery words', () => { it('recovery words work to unlock after password change', async () => { await setupUnlockedVault(); const words = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data; const ch = await dispatch({ type: 'CHANGE_PASSWORD', payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-aaa' }, }); expect(ch.ok).toBe(true); await lockVault(); const r = await dispatch({ type: 'RECOVER_WITH_WORDS', payload: { words, newPassword: 'recovered-pass' }, }); expect(r.ok).toBe(true); }); it('recovery words are identical before and after password change', async () => { await setupUnlockedVault(); const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data; await dispatch({ type: 'CHANGE_PASSWORD', payload: { currentPassword: 'test-pass-12345', newPassword: 'another-pass-bbb' }, }); const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'another-pass-bbb' } }) as { data: string[] }).data; expect(after).toEqual(before); }); it('vault unlocks with the new password after change', async () => { await setupUnlockedVault(); await dispatch({ type: 'CHANGE_PASSWORD', payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' }, }); await lockVault(); const r = await dispatch({ type: 'UNLOCK', payload: { password: 'brand-new-pass' }, }); expect(r.ok).toBe(true); }); it('the old password no longer unlocks after change', async () => { await setupUnlockedVault(); await dispatch({ type: 'CHANGE_PASSWORD', payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' }, }); await lockVault(); const r = await dispatch({ type: 'UNLOCK', payload: { password: 'test-pass-12345' }, }); expect(r.ok).toBe(false); }); it('GET_RECOVERY_WORDS still works after a password change + lock + unlock', async () => { await setupUnlockedVault(); const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data; await dispatch({ type: 'CHANGE_PASSWORD', payload: { currentPassword: 'test-pass-12345', newPassword: 'pass-after-cycle' }, }); await lockVault(); await dispatch({ type: 'UNLOCK', payload: { password: 'pass-after-cycle' }, }); const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'pass-after-cycle' } }) as { data: string[] }).data; expect(after).toEqual(before); }); }); // H2/COR-006/SEC-006/CRYPTO-11 — rootKey must survive lock/unlock cycles // so the user's 12 recovery words are stable across sessions. The fix is // a pw_root_* second wrapping under the password-derived key. describe('deriveRootKeyFromDerivedKey and recovery persistence', () => { it('GET_RECOVERY_WORDS works after lock and re-unlock', async () => { await setupUnlockedVault(); const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words1 = (r1 as { data: string[] }).data; expect(words1).toHaveLength(12); await lockVault(); const unlocked = await dispatch({ type: 'UNLOCK', payload: { password: 'test-pass-12345' }, }); expect(unlocked.ok).toBe(true); const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); const words2 = (r2 as { data: string[] }).data; expect(words2).toEqual(words1); }); it('GET_RECOVERY_WORDS works after multiple lock/unlock cycles', async () => { await setupUnlockedVault(); const original = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data; for (let i = 0; i < 5; i++) { await lockVault(); await dispatch({ type: 'UNLOCK', payload: { password: 'test-pass-12345' }, }); } const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data; expect(after).toEqual(original); }); it('pre-v2.12 vault (no recovery escrow) gets recovery words after first unlock', async () => { // Build a slot A that has *no* recovery_* fields and *no* pw_root_*. // The migration path in unlock() should populate both. await setupUnlockedVault(); const slot = rawSlotA(); const legacy: StoredVault = { version: slot.version, salt: slot.salt, nonce: slot.nonce, ciphertext: slot.ciphertext, tag: slot.tag, }; storage.peach_vault_a = legacy; await lockVault(); const unlocked = await dispatch({ type: 'UNLOCK', payload: { password: 'test-pass-12345' }, }); expect(unlocked.ok).toBe(true); const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }); expect(result.ok).toBe(true); const words = (result as { data: string[] }).data; expect(words).toHaveLength(12); // Migration must surface the recovery-pending flag so the popup // re-presents the words for confirmation. expect(storage.peach_recovery_pending).toBe('1'); // Slot A must now have both wrappings persisted. const slotAfter = storage.peach_vault_a as StoredVault; expect(slotAfter.recovery_salt).toBeTruthy(); expect(slotAfter.recovery_wrapped_root_key).toBeTruthy(); expect(slotAfter.pw_root_ciphertext).toBeTruthy(); }); }); });