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';6789101112vi.setConfig({ testTimeout: 30_000 });1314type StorageShape = Record<string, unknown>;15type MessageListener = (16 msg: unknown,17 sender: chrome.runtime.MessageSender,18 sendResponse: (response: MessageResponse) => void19) => boolean;2021describe('recovery words handlers', () => {22 let storage: StorageShape;23 let sessionStorage: StorageShape;24 let listener: MessageListener;2526 beforeEach(async () => {27 vi.resetModules();28 vi.useRealTimers();29 storage = { peach_settings: { breachMonitoring: false } };30 sessionStorage = {};3132 vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));33 vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);3435 const chromeMock = {36 runtime: {37 id: 'peach-extension',38 onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },39 sendMessage: vi.fn(async () => ({ ok: true })),40 },41 tabs: {42 query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {43 if (typeof cb === 'function') { cb([{ id: 1 }]); return; }44 return Promise.resolve([{ id: 1 }]);45 }),46 sendMessage: vi.fn(async () => ({ ok: true })),47 },48 storage: {49 local: {50 get: vi.fn(async (key?: string | string[]) => {51 if (!key) return storage;52 if (typeof key === 'string') return { [key]: storage[key] };53 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});54 }),55 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),56 remove: vi.fn(async (keys: string | string[]) => {57 const list = Array.isArray(keys) ? keys : [keys];58 for (const key of list) delete storage[key];59 }),60 },61 session: {62 get: vi.fn(async (key?: string | string[]) => {63 if (!key) return sessionStorage;64 if (typeof key === 'string') return { [key]: sessionStorage[key] };65 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});66 }),67 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),68 remove: vi.fn(async (keys: string | string[]) => {69 const list = Array.isArray(keys) ? keys : [keys];70 for (const key of list) delete sessionStorage[key];71 }),72 clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),73 },74 },75 alarms: {76 clear: vi.fn(), create: vi.fn(),77 onAlarm: { addListener: vi.fn() },78 },79 idle: {80 setDetectionInterval: vi.fn(),81 onStateChanged: { addListener: vi.fn() },82 },83 scripting: { executeScript: vi.fn(async () => []) },84 } as unknown as typeof chrome;8586 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8788 const messages = await import('./messages');89 messages.setupMessageHandler();90 const vault = await import('./vault');91 await vault.ensureSettingsLoaded();92 });9394 async function dispatch(message: Message): Promise<MessageResponse> {95 return new Promise<MessageResponse>((resolve) => {96 const keepOpen = listener(message, { id: 'peach-extension' }, resolve);97 expect(keepOpen).toBe(true);98 });99 }100101 async function setupUnlockedVault(): Promise<void> {102 const vault = await import('./vault');103 await vault.createVault('test-pass-12345');104 }105106 async function lockVault(): Promise<void> {107 const vault = await import('./vault');108 vault.lock();109 }110111 function rawSlotA(): StoredVault {112 const slot = storage.peach_vault_a as StoredVault | undefined;113 if (!slot) throw new Error('missing slot A');114 return slot;115 }116117 async function mockNoPro(): Promise<void> {118 const license = await import('./license');119 vi.spyOn(license, 'isProUser').mockResolvedValue(false);120 }121122 describe('GET_RECOVERY_WORDS handler', () => {123 beforeEach(async () => { await setupUnlockedVault(); });124125 it('returns 12 words', async () => {126 const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });127 expect(result.ok).toBe(true);128 const words = (result as { data: string[] }).data;129 expect(words).toHaveLength(12);130 });131132 it('all words are from the BIP39 word list', async () => {133 const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });134 const words = (result as { data: string[] }).data;135 for (const word of words) {136 expect(WORD_LIST).toContain(word);137 }138 });139140 it('returns same words on subsequent calls (deterministic)', async () => {141 const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });142 const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });143 expect((r1 as { data: string[] }).data).toEqual((r2 as { data: string[] }).data);144 });145146 it('returns error when vault is locked', async () => {147 await lockVault();148 const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });149 expect(result.ok).toBe(false);150 });151 });152153 describe('VALIDATE_RECOVERY_WORDS handler', () => {154 beforeEach(async () => { await setupUnlockedVault(); });155156 it('returns no invalid words for correct list', async () => {157 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });158 const words = (r as { data: string[] }).data;159 const result = await dispatch({160 type: 'VALIDATE_RECOVERY_WORDS',161 payload: { words },162 });163 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;164 expect(data.invalid).toEqual([]);165 });166167 it('returns invalid words for unknown words', async () => {168 const result = await dispatch({169 type: 'VALIDATE_RECOVERY_WORDS',170 payload: { words: ['notaword', 'alsonotaword'] },171 });172 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;173 const invalidWords = data.invalid.map((x) => x.word);174 expect(invalidWords).toContain('notaword');175 expect(invalidWords).toContain('alsonotaword');176 });177178 it('is case-insensitive for validation', async () => {179 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });180 const words = (r as { data: string[] }).data;181 const uppercased = words.map((w: string) => w.toUpperCase());182 const result = await dispatch({183 type: 'VALIDATE_RECOVERY_WORDS',184 payload: { words: uppercased },185 });186 const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;187 expect(data.invalid).toEqual([]);188 });189 });190191 describe('RECOVER_WITH_WORDS handler', () => {192 it('recovery succeeds with correct words and new passphrase', async () => {193 await setupUnlockedVault();194 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });195 const words = (r as { data: string[] }).data;196 await lockVault();197198 const result = await dispatch({199 type: 'RECOVER_WITH_WORDS',200 payload: { words, newPassword: 'new-passphrase' },201 });202 expect(result.ok).toBe(true);203 const unlocked = await dispatch({ type: 'IS_UNLOCKED' });204 expect((unlocked as { data: boolean }).data).toBe(true);205 });206207 it('fails with wrong recovery words', async () => {208 await setupUnlockedVault();209 await lockVault();210 const fakeWords = Array(12).fill('abandon');211 const result = await dispatch({212 type: 'RECOVER_WITH_WORDS',213 payload: { words: fakeWords, newPassword: 'new-pass' },214 });215 expect(result.ok).toBe(false);216 expect((result as { error: string }).error).toMatch(/invalid/);217 });218219 it('fails with partial word list (fewer than 12)', async () => {220 await setupUnlockedVault();221 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });222 const words = (r as { data: string[] }).data;223 await lockVault();224 const partial = words.slice(0, 6);225 const result = await dispatch({226 type: 'RECOVER_WITH_WORDS',227 payload: { words: partial, newPassword: 'new-pass' },228 });229 expect(result.ok).toBe(false);230 });231232 it('new passphrase works after recovery', async () => {233 await setupUnlockedVault();234 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });235 const words = (r as { data: string[] }).data;236 await lockVault();237 await dispatch({238 type: 'RECOVER_WITH_WORDS',239 payload: { words, newPassword: 'brand-new-password' },240 });241 await lockVault();242 const unlock = await dispatch({243 type: 'UNLOCK',244 payload: { password: 'brand-new-password' },245 });246 expect(unlock.ok).toBe(true);247 });248249 it('old passphrase does not work after recovery', async () => {250 await setupUnlockedVault();251 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });252 const words = (r as { data: string[] }).data;253 await lockVault();254 await dispatch({255 type: 'RECOVER_WITH_WORDS',256 payload: { words, newPassword: 'brand-new-password' },257 });258 await lockVault();259 const unlock = await dispatch({260 type: 'UNLOCK',261 payload: { password: 'test-pass-12345' },262 });263 expect(unlock.ok).toBe(false);264 });265266 it('preserves recovery escrow fields after recovery password rewrap', async () => {267 await setupUnlockedVault();268 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });269 const words = (r as { data: string[] }).data;270 const before = { ...rawSlotA() };271 await lockVault();272273 const result = await dispatch({274 type: 'RECOVER_WITH_WORDS',275 payload: { words, newPassword: 'new-passphrase' },276 });277 expect(result.ok).toBe(true);278279 const after = rawSlotA();280 expect(after.recovery_wrapped_root_key).toBe(before.recovery_wrapped_root_key);281 expect(after.recovery_nonce).toBe(before.recovery_nonce);282 expect(after.recovery_tag).toBe(before.recovery_tag);283 expect(after.recovery_salt).toBe(before.recovery_salt);284 expect(after.ciphertext).not.toBe(before.ciphertext);285 expect(after.salt).not.toBe(before.salt);286 });287288 it('same recovery words succeed on a second recovery', async () => {289 await setupUnlockedVault();290 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });291 const words = (r as { data: string[] }).data;292293 await lockVault();294 const first = await dispatch({295 type: 'RECOVER_WITH_WORDS',296 payload: { words, newPassword: 'pass-1' },297 });298 expect(first.ok).toBe(true);299300 await lockVault();301 const second = await dispatch({302 type: 'RECOVER_WITH_WORDS',303 payload: { words, newPassword: 'pass-2' },304 });305 expect(second.ok).toBe(true);306307 await lockVault();308 const unlock = await dispatch({309 type: 'UNLOCK',310 payload: { password: 'pass-2' },311 });312 expect(unlock.ok).toBe(true);313 });314315 it('same recovery words succeed on a third recovery', async () => {316 await setupUnlockedVault();317 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });318 const words = (r as { data: string[] }).data;319320 for (let i = 0; i < 3; i += 1) {321 await lockVault();322 const result = await dispatch({323 type: 'RECOVER_WITH_WORDS',324 payload: { words, newPassword: `pass-${i}` },325 });326 expect(result.ok).toBe(true);327 }328 });329330 it('vault remains usable after recovery', async () => {331 await setupUnlockedVault();332 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });333 const words = (r as { data: string[] }).data;334335 await lockVault();336 const recovered = await dispatch({337 type: 'RECOVER_WITH_WORDS',338 payload: { words, newPassword: 'new-passphrase' },339 });340 expect(recovered.ok).toBe(true);341342 await dispatch({343 type: 'ADD_ENTRY',344 payload: { title: 'Check me', username: '', password: 'p', url: '' },345 });346 const entries = (await dispatch({ type: 'GET_ENTRIES' }) as { data: Array<{ title: string }> }).data;347 expect(entries.some((entry) => entry.title === 'Check me')).toBe(true);348 });349350 351 352 353 it('recovery words are preserved across CHANGE_PASSWORD', async () => {354 await setupUnlockedVault();355 const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;356 await dispatch({357 type: 'CHANGE_PASSWORD',358 payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-67890' },359 });360 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'new-pass-67890' } }) as { data: string[] }).data;361 expect(after).toEqual(before);362 });363364 it('recovery is not Pro-gated', async () => {365 await setupUnlockedVault();366 await mockNoPro();367 const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });368 const words = (r as { data: string[] }).data;369 await lockVault();370 const result = await dispatch({371 type: 'RECOVER_WITH_WORDS',372 payload: { words, newPassword: 'new-pass' },373 });374 expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);375 });376 });377378 379 380 381 describe('changePassword preserves root key and recovery words', () => {382 it('recovery words work to unlock after password change', async () => {383 await setupUnlockedVault();384 const words = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;385 const ch = await dispatch({386 type: 'CHANGE_PASSWORD',387 payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-aaa' },388 });389 expect(ch.ok).toBe(true);390 await lockVault();391 const r = await dispatch({392 type: 'RECOVER_WITH_WORDS',393 payload: { words, newPassword: 'recovered-pass' },394 });395 expect(r.ok).toBe(true);396 });397398 it('recovery words are identical before and after password change', async () => {399 await setupUnlockedVault();400 const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;401 await dispatch({402 type: 'CHANGE_PASSWORD',403 payload: { currentPassword: 'test-pass-12345', newPassword: 'another-pass-bbb' },404 });405 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'another-pass-bbb' } }) as { data: string[] }).data;406 expect(after).toEqual(before);407 });408409 it('vault unlocks with the new password after change', async () => {410 await setupUnlockedVault();411 await dispatch({412 type: 'CHANGE_PASSWORD',413 payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' },414 });415 await lockVault();416 const r = await dispatch({417 type: 'UNLOCK',418 payload: { password: 'brand-new-pass' },419 });420 expect(r.ok).toBe(true);421 });422423 it('the old password no longer unlocks after change', async () => {424 await setupUnlockedVault();425 await dispatch({426 type: 'CHANGE_PASSWORD',427 payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' },428 });429 await lockVault();430 const r = await dispatch({431 type: 'UNLOCK',432 payload: { password: 'test-pass-12345' },433 });434 expect(r.ok).toBe(false);435 });436437 it('GET_RECOVERY_WORDS still works after a password change + lock + unlock', async () => {438 await setupUnlockedVault();439 const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;440 await dispatch({441 type: 'CHANGE_PASSWORD',442 payload: { currentPassword: 'test-pass-12345', newPassword: 'pass-after-cycle' },443 });444 await lockVault();445 await dispatch({446 type: 'UNLOCK',447 payload: { password: 'pass-after-cycle' },448 });449 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'pass-after-cycle' } }) as { data: string[] }).data;450 expect(after).toEqual(before);451 });452 });453454 455 456 457 describe('deriveRootKeyFromDerivedKey and recovery persistence', () => {458 it('GET_RECOVERY_WORDS works after lock and re-unlock', async () => {459 await setupUnlockedVault();460 const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });461 const words1 = (r1 as { data: string[] }).data;462 expect(words1).toHaveLength(12);463 await lockVault();464 const unlocked = await dispatch({465 type: 'UNLOCK',466 payload: { password: 'test-pass-12345' },467 });468 expect(unlocked.ok).toBe(true);469 const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });470 const words2 = (r2 as { data: string[] }).data;471 expect(words2).toEqual(words1);472 });473474 it('GET_RECOVERY_WORDS works after multiple lock/unlock cycles', async () => {475 await setupUnlockedVault();476 const original = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;477 for (let i = 0; i < 5; i++) {478 await lockVault();479 await dispatch({480 type: 'UNLOCK',481 payload: { password: 'test-pass-12345' },482 });483 }484 const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;485 expect(after).toEqual(original);486 });487488 it('pre-v2.12 vault (no recovery escrow) gets recovery words after first unlock', async () => {489 490 491 await setupUnlockedVault();492 const slot = rawSlotA();493 const legacy: StoredVault = {494 version: slot.version,495 salt: slot.salt,496 nonce: slot.nonce,497 ciphertext: slot.ciphertext,498 tag: slot.tag,499 };500 storage.peach_vault_a = legacy;501 await lockVault();502503 const unlocked = await dispatch({504 type: 'UNLOCK',505 payload: { password: 'test-pass-12345' },506 });507 expect(unlocked.ok).toBe(true);508509 const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });510 expect(result.ok).toBe(true);511 const words = (result as { data: string[] }).data;512 expect(words).toHaveLength(12);513514 515 516 expect(storage.peach_recovery_pending).toBe('1');517518 519 const slotAfter = storage.peach_vault_a as StoredVault;520 expect(slotAfter.recovery_salt).toBeTruthy();521 expect(slotAfter.recovery_wrapped_root_key).toBeTruthy();522 expect(slotAfter.pw_root_ciphertext).toBeTruthy();523 });524 });525});