recovery-words.test.ts

20.6 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        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    // H4/COR-002: changePassword MUST preserve the root key so the user's351    // existing recovery words continue to work. Rotating the root key on352    // every password change would silently brick recovery.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  // H4/COR-002 — changePassword must re-wrap the existing root key, not mint379  // a new one. The recovery words derived from rootKey must remain valid380  // after any password change.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  // H2/COR-006/SEC-006/CRYPTO-11 — rootKey must survive lock/unlock cycles455  // so the user's 12 recovery words are stable across sessions. The fix is456  // a pw_root_* second wrapping under the password-derived key.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      // Build a slot A that has *no* recovery_* fields and *no* pw_root_*.490      // The migration path in unlock() should populate both.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      // Migration must surface the recovery-pending flag so the popup515      // re-presents the words for confirmation.516      expect(storage.peach_recovery_pending).toBe('1');517518      // Slot A must now have both wrappings persisted.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});