recovery-words.test.ts

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