recovery-words.test.ts

19.4 KB · raw

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';67// Every unlock/GET_RECOVERY_WORDS here runs real Argon2id (m=64MiB, t=3, p=4).8// The lock/unlock-cycle cases do seven derivations, which lands just past9// vitest's 5s default once the full suite is competing for CPU — the failures10// clustered at 5.0-5.1s, not at random points. Raised for the whole file11// because every case in it is Argon2-bound, not just the ones that flaked.12vi.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        getURL: (path = '') => `chrome-extension://peach-extension/${path}`,39        onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },40        sendMessage: vi.fn(async () => ({ ok: true })),41      },42      tabs: {43        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {44          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }45          return Promise.resolve([{ id: 1 }]);46        }),47        sendMessage: vi.fn(async () => ({ ok: true })),48      },49      storage: {50        local: {51          get: vi.fn(async (key?: string | string[]) => {52            if (!key) return storage;53            if (typeof key === 'string') return { [key]: storage[key] };54            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});55          }),56          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),57          remove: vi.fn(async (keys: string | string[]) => {58            const list = Array.isArray(keys) ? keys : [keys];59            for (const key of list) delete storage[key];60          }),61        },62        session: {63          get: vi.fn(async (key?: string | string[]) => {64            if (!key) return sessionStorage;65            if (typeof key === 'string') return { [key]: sessionStorage[key] };66            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});67          }),68          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),69          remove: vi.fn(async (keys: string | string[]) => {70            const list = Array.isArray(keys) ? keys : [keys];71            for (const key of list) delete sessionStorage[key];72          }),73          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),74        },75      },76      alarms: {77        clear: vi.fn(), create: vi.fn(),78        onAlarm: { addListener: vi.fn() },79      },80      idle: {81        setDetectionInterval: vi.fn(),82        onStateChanged: { addListener: vi.fn() },83      },84      scripting: { executeScript: vi.fn(async () => []) },85    } as unknown as typeof chrome;8687    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8889    const messages = await import('./messages');90    messages.setupMessageHandler();91    const vault = await import('./vault');92    await vault.ensureSettingsLoaded();93  });9495  async function dispatch(message: Message): Promise<MessageResponse> {96    return new Promise<MessageResponse>((resolve) => {97      const keepOpen = listener(message, {98        id: 'peach-extension',99        url: 'chrome-extension://peach-extension/popup.html',100      }, resolve);101      expect(keepOpen).toBe(true);102    });103  }104105  async function setupUnlockedVault(): Promise<void> {106    const vault = await import('./vault');107    await vault.createVault('test-pass-12345');108  }109110  async function lockVault(): Promise<void> {111    const vault = await import('./vault');112    vault.lock();113  }114115  function rawSlotA(): StoredVault {116    const slot = storage.peach_vault_a as StoredVault | undefined;117    if (!slot) throw new Error('missing slot A');118    return slot;119  }120121  async function mockNoPro(): Promise<void> {122    const license = await import('./license');123    vi.spyOn(license, 'isProUser').mockResolvedValue(false);124  }125126  describe('GET_RECOVERY_WORDS handler', () => {127    beforeEach(async () => { await setupUnlockedVault(); });128129    it('returns 12 words', async () => {130      const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });131      expect(result.ok).toBe(true);132      const words = (result as { data: string[] }).data;133      expect(words).toHaveLength(12);134    });135136    it('all words are from the BIP39 word list', async () => {137      const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });138      const words = (result as { data: string[] }).data;139      for (const word of words) {140        expect(WORD_LIST).toContain(word);141      }142    });143144    it('returns same words on subsequent calls (deterministic)', async () => {145      const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });146      const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });147      expect((r1 as { data: string[] }).data).toEqual((r2 as { data: string[] }).data);148    });149150    it('returns error when vault is locked', async () => {151      await lockVault();152      const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });153      expect(result.ok).toBe(false);154    });155  });156157  describe('VALIDATE_RECOVERY_WORDS handler', () => {158    beforeEach(async () => { await setupUnlockedVault(); });159160    it('returns no invalid words for correct list', async () => {161      const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });162      const words = (r as { data: string[] }).data;163      const result = await dispatch({164        type: 'VALIDATE_RECOVERY_WORDS',165        payload: { words },166      });167      const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;168      expect(data.invalid).toEqual([]);169    });170171    it('returns invalid words for unknown words', async () => {172      const result = await dispatch({173        type: 'VALIDATE_RECOVERY_WORDS',174        payload: { words: ['notaword', 'alsonotaword'] },175      });176      const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;177      const invalidWords = data.invalid.map((x) => x.word);178      expect(invalidWords).toContain('notaword');179      expect(invalidWords).toContain('alsonotaword');180    });181182    it('is case-insensitive for validation', async () => {183      const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });184      const words = (r as { data: string[] }).data;185      const uppercased = words.map((w: string) => w.toUpperCase());186      const result = await dispatch({187        type: 'VALIDATE_RECOVERY_WORDS',188        payload: { words: uppercased },189      });190      const data = (result as { data: { invalid: Array<{ index: number; word: string }> } }).data;191      expect(data.invalid).toEqual([]);192    });193  });194195  describe('RECOVER_WITH_WORDS handler', () => {196    it('recovery succeeds with correct words and new passphrase', async () => {197      await setupUnlockedVault();198      const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });199      const words = (r as { data: string[] }).data;200      await lockVault();201202      const result = await dispatch({203        type: 'RECOVER_WITH_WORDS',204        payload: { words, newPassword: 'new-passphrase' },205      });206      expect(result.ok).toBe(true);207      const unlocked = await dispatch({ type: 'IS_UNLOCKED' });208      expect((unlocked as { data: boolean }).data).toBe(true);209    });210211    it('fails with wrong recovery words', async () => {212      await setupUnlockedVault();213      await lockVault();214      const fakeWords = Array(12).fill('abandon');215      const result = await dispatch({216        type: 'RECOVER_WITH_WORDS',217        payload: { words: fakeWords, newPassword: 'new-pass' },218      });219      expect(result.ok).toBe(false);220      expect((result as { error: string }).error).toMatch(/invalid/);221    });222223    it('fails with partial word list (fewer than 12)', async () => {224      await setupUnlockedVault();225      const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });226      const words = (r as { data: string[] }).data;227      await lockVault();228      const partial = words.slice(0, 6);229      const result = await dispatch({230        type: 'RECOVER_WITH_WORDS',231        payload: { words: partial, newPassword: 'new-pass' },232      });233      expect(result.ok).toBe(false);234    });235236    it('new passphrase works after recovery', async () => {237      await setupUnlockedVault();238      const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });239      const words = (r as { data: string[] }).data;240      await lockVault();241      await dispatch({242        type: 'RECOVER_WITH_WORDS',243        payload: { words, newPassword: 'brand-new-password' },244      });245      await lockVault();246      const unlock = await dispatch({247        type: 'UNLOCK',248        payload: { password: 'brand-new-password' },249      });250      expect(unlock.ok).toBe(true);251    });252253    it('old passphrase does not work after recovery', async () => {254      await setupUnlockedVault();255      const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });256      const words = (r as { data: string[] }).data;257      await lockVault();258      await dispatch({259        type: 'RECOVER_WITH_WORDS',260        payload: { words, newPassword: 'brand-new-password' },261      });262      await lockVault();263      const unlock = await dispatch({264        type: 'UNLOCK',265        payload: { password: 'test-pass-12345' },266      });267      expect(unlock.ok).toBe(false);268    });269270    it('preserves recovery escrow fields after recovery password rewrap', async () => {271      await setupUnlockedVault();272      const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });273      const words = (r as { data: string[] }).data;274      const before = { ...rawSlotA() };275      await lockVault();276277      const result = await dispatch({278        type: 'RECOVER_WITH_WORDS',279        payload: { words, newPassword: 'new-passphrase' },280      });281      expect(result.ok).toBe(true);282283      const after = rawSlotA();284      expect(after.recovery_wrapped_root_key).toBe(before.recovery_wrapped_root_key);285      expect(after.recovery_nonce).toBe(before.recovery_nonce);286      expect(after.recovery_tag).toBe(before.recovery_tag);287      expect(after.recovery_salt).toBe(before.recovery_salt);288      expect(after.ciphertext).not.toBe(before.ciphertext);289      expect(after.salt).not.toBe(before.salt);290    });291292    it('same recovery words succeed on a second recovery', async () => {293      await setupUnlockedVault();294      const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });295      const words = (r as { data: string[] }).data;296297      await lockVault();298      const first = await dispatch({299        type: 'RECOVER_WITH_WORDS',300        payload: { words, newPassword: 'pass-1' },301      });302      expect(first.ok).toBe(true);303304      await lockVault();305      const second = await dispatch({306        type: 'RECOVER_WITH_WORDS',307        payload: { words, newPassword: 'pass-2' },308      });309      expect(second.ok).toBe(true);310311      await lockVault();312      const unlock = await dispatch({313        type: 'UNLOCK',314        payload: { password: 'pass-2' },315      });316      expect(unlock.ok).toBe(true);317    });318319    it('same recovery words succeed on a third recovery', async () => {320      await setupUnlockedVault();321      const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });322      const words = (r as { data: string[] }).data;323324      for (let i = 0; i < 3; i += 1) {325        await lockVault();326        const result = await dispatch({327          type: 'RECOVER_WITH_WORDS',328          payload: { words, newPassword: `pass-${i}` },329        });330        expect(result.ok).toBe(true);331      }332    });333334    it('vault remains usable after recovery', async () => {335      await setupUnlockedVault();336      const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });337      const words = (r as { data: string[] }).data;338339      await lockVault();340      const recovered = await dispatch({341        type: 'RECOVER_WITH_WORDS',342        payload: { words, newPassword: 'new-passphrase' },343      });344      expect(recovered.ok).toBe(true);345346      await dispatch({347        type: 'ADD_ENTRY',348        payload: { title: 'Check me', username: '', password: 'p', url: '' },349      });350      const entries = (await dispatch({ type: 'GET_ENTRIES' }) as { data: Array<{ title: string }> }).data;351      expect(entries.some((entry) => entry.title === 'Check me')).toBe(true);352    });353354    // H4/COR-002: changePassword MUST preserve the root key so the user's355    // existing recovery words continue to work. Rotating the root key on356    // every password change would silently brick recovery.357    it('recovery words are preserved across CHANGE_PASSWORD', async () => {358      await setupUnlockedVault();359      const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;360      await dispatch({361        type: 'CHANGE_PASSWORD',362        payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-67890' },363      });364      const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'new-pass-67890' } }) as { data: string[] }).data;365      expect(after).toEqual(before);366    });367368    it('recovery is not Pro-gated', async () => {369      await setupUnlockedVault();370      await mockNoPro();371      const r = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });372      const words = (r as { data: string[] }).data;373      await lockVault();374      const result = await dispatch({375        type: 'RECOVER_WITH_WORDS',376        payload: { words, newPassword: 'new-pass' },377      });378      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);379    });380  });381382  // H4/COR-002 — changePassword must re-wrap the existing root key, not mint383  // a new one. The recovery words derived from rootKey must remain valid384  // after any password change.385  describe('changePassword preserves root key and recovery words', () => {386    it('recovery words work to unlock after password change', async () => {387      await setupUnlockedVault();388      const words = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;389      const ch = await dispatch({390        type: 'CHANGE_PASSWORD',391        payload: { currentPassword: 'test-pass-12345', newPassword: 'new-pass-aaa' },392      });393      expect(ch.ok).toBe(true);394      await lockVault();395      const r = await dispatch({396        type: 'RECOVER_WITH_WORDS',397        payload: { words, newPassword: 'recovered-pass' },398      });399      expect(r.ok).toBe(true);400    });401402    it('recovery words are identical before and after password change', async () => {403      await setupUnlockedVault();404      const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;405      await dispatch({406        type: 'CHANGE_PASSWORD',407        payload: { currentPassword: 'test-pass-12345', newPassword: 'another-pass-bbb' },408      });409      const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'another-pass-bbb' } }) as { data: string[] }).data;410      expect(after).toEqual(before);411    });412413    it('vault unlocks with the new password after change', async () => {414      await setupUnlockedVault();415      await dispatch({416        type: 'CHANGE_PASSWORD',417        payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' },418      });419      await lockVault();420      const r = await dispatch({421        type: 'UNLOCK',422        payload: { password: 'brand-new-pass' },423      });424      expect(r.ok).toBe(true);425    });426427    it('the old password no longer unlocks after change', async () => {428      await setupUnlockedVault();429      await dispatch({430        type: 'CHANGE_PASSWORD',431        payload: { currentPassword: 'test-pass-12345', newPassword: 'brand-new-pass' },432      });433      await lockVault();434      const r = await dispatch({435        type: 'UNLOCK',436        payload: { password: 'test-pass-12345' },437      });438      expect(r.ok).toBe(false);439    });440441    it('GET_RECOVERY_WORDS still works after a password change + lock + unlock', async () => {442      await setupUnlockedVault();443      const before = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;444      await dispatch({445        type: 'CHANGE_PASSWORD',446        payload: { currentPassword: 'test-pass-12345', newPassword: 'pass-after-cycle' },447      });448      await lockVault();449      await dispatch({450        type: 'UNLOCK',451        payload: { password: 'pass-after-cycle' },452      });453      const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'pass-after-cycle' } }) as { data: string[] }).data;454      expect(after).toEqual(before);455    });456  });457458  // H2/COR-006/SEC-006/CRYPTO-11 — rootKey must survive lock/unlock cycles459  // so the user's 12 recovery words are stable across sessions. The fix is460  // a pw_root_* second wrapping under the password-derived key.461  describe('deriveRootKeyFromDerivedKey and recovery persistence', () => {462    it('GET_RECOVERY_WORDS works after lock and re-unlock', async () => {463      await setupUnlockedVault();464      const r1 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });465      const words1 = (r1 as { data: string[] }).data;466      expect(words1).toHaveLength(12);467      await lockVault();468      const unlocked = await dispatch({469        type: 'UNLOCK',470        payload: { password: 'test-pass-12345' },471      });472      expect(unlocked.ok).toBe(true);473      const r2 = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });474      const words2 = (r2 as { data: string[] }).data;475      expect(words2).toEqual(words1);476    });477478    it('GET_RECOVERY_WORDS works after multiple lock/unlock cycles', async () => {479      await setupUnlockedVault();480      const original = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;481      for (let i = 0; i < 5; i++) {482        await lockVault();483        await dispatch({484          type: 'UNLOCK',485          payload: { password: 'test-pass-12345' },486        });487      }488      const after = (await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } }) as { data: string[] }).data;489      expect(after).toEqual(original);490    });491492  });493});