/** * recovery.adversarial.test.ts — Layer 3 §3.2 adversarial recovery words tests. * * Covers VALIDATE_RECOVERY_WORDS edge cases, RECOVER_WITH_WORDS adversarial * paths, and recovery-after-password-change cycles. Parameterized for >=150 * tests. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Message, MessageResponse } from "../shared/types"; import { WORD_LIST } from "../shared/recovery-word-list"; type StorageShape = Record; type MessageListener = ( msg: unknown, sender: chrome.runtime.MessageSender, sendResponse: (response: MessageResponse) => void ) => boolean; describe("recovery words adversarial", () => { let storage: StorageShape; let sessionStorage: StorageShape; let listener: MessageListener | undefined; beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); storage = { peach_settings: { breachMonitoring: false } }; sessionStorage = {}; listener = undefined; vi.stubGlobal( "fetch", vi.fn(async () => ({ ok: true, status: 200, text: async () => "" })) ); vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false); const chromeMock = { runtime: { id: "peach-extension", getURL: (path = "") => `chrome-extension://peach-extension/${path}`, onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }), }, sendMessage: vi.fn(async () => ({ ok: true })), }, tabs: { query: vi.fn((_q: unknown, 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 { if (!listener) throw new Error("listener not registered"); return new Promise((resolve) => { const keepOpen = listener!( message, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html", } as chrome.runtime.MessageSender, resolve ); expect(keepOpen).toBe(true); }); } async function setupUnlockedVault(password = "test-pass-12345") { const vault = await import("./vault"); await vault.createVault(password); } async function getWords(): Promise { // LV-4: GET_RECOVERY_WORDS now requires a fresh password proof. Every // caller fetches words right after setupUnlockedVault()'s default password. const r = await dispatch({ type: "GET_RECOVERY_WORDS", payload: { password: "test-pass-12345" }, }); expect(r.ok).toBe(true); return (r as { data: string[] }).data; } // ── VALIDATE_RECOVERY_WORDS edge cases ────────────────────────────────── describe("VALIDATE_RECOVERY_WORDS edge cases", () => { beforeEach(() => setupUnlockedVault()); it("words with leading/trailing whitespace are accepted", async () => { const words = await getWords(); const padded = words.map((w) => " " + w + " "); const r = await dispatch({ type: "VALIDATE_RECOVERY_WORDS", payload: { words: padded }, }); const data = (r as { data: { invalid: unknown[] } }).data; expect(data.invalid).toEqual([]); }); it("words in all uppercase are accepted (case-insensitive)", async () => { const words = await getWords(); const r = await dispatch({ type: "VALIDATE_RECOVERY_WORDS", payload: { words: words.map((w) => w.toUpperCase()) }, }); expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual([]); }); it("empty array yields ok response", async () => { const r = await dispatch({ type: "VALIDATE_RECOVERY_WORDS", payload: { words: [] }, }); expect(r.ok).toBe(true); }); const invalidWordCases = [ { name: "single garbage word", words: ["zzzzzz"] }, { name: "many garbage words", words: ["aaa", "bbb", "ccc"] }, { name: "empty string elements", words: ["", "", ""] }, { name: "tabs only", words: ["\t", "\t\t"] }, { name: "newlines only", words: ["\n", "\n\n"] }, { name: "numeric strings", words: ["123", "456"] }, { name: "special chars", words: ["@#$", "%^&"] }, { name: "mixed valid+invalid", words: ["abandon", "notaword"] }, ]; for (const { name, words } of invalidWordCases) { it(`returns invalid markers for ${name}`, async () => { const r = await dispatch({ type: "VALIDATE_RECOVERY_WORDS", payload: { words }, }); expect(r.ok).toBe(true); const data = (r as { data: { invalid: Array<{ index: number; word: string }> }; }).data; // Every word that isn't in BIP39 should be flagged. const expected = words.filter( (w) => !WORD_LIST.includes((w ?? "").toLowerCase().trim()) ); expect(data.invalid.length).toBe(expected.length); }); } it("13 words: extra word is flagged as invalid", async () => { // "qwerty" is deliberately not in the Peach recovery word list. const words = [...(await getWords()), "qwerty"]; const r = await dispatch({ type: "VALIDATE_RECOVERY_WORDS", payload: { words }, }); const data = (r as { data: { invalid: Array<{ index: number; word: string }> }; }).data; expect(data.invalid.some((x) => x.word === "qwerty")).toBe(true); }); it("11 words (missing): validator returns ok", async () => { const words = (await getWords()).slice(0, 11); const r = await dispatch({ type: "VALIDATE_RECOVERY_WORDS", payload: { words }, }); // Validator just checks word membership; length is enforced at recovery time. expect(r.ok).toBe(true); }); // Boundary: WORD_LIST length spot checks. for (let i = 0; i < 30; i++) { const sample = WORD_LIST[i * 50]!; it(`recognised single word ${sample}`, async () => { const r = await dispatch({ type: "VALIDATE_RECOVERY_WORDS", payload: { words: [sample] }, }); expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual( [] ); }); } // Adversarial: try every shape of garbage to confirm no crash. const adversarial = [ null, undefined, "not-an-array", 42, true, { words: "x" }, [null, null], [{ word: "x" }, { word: "y" }], ["abandon", null as unknown as string, "abandon"], ]; for (const [i, payload] of adversarial.entries()) { it(`adversarial payload #${i} does not crash`, async () => { const r = await dispatch({ type: "VALIDATE_RECOVERY_WORDS", payload: payload as unknown as { words: string[] }, }); expect(r).toBeDefined(); }); } }); // ── RECOVER_WITH_WORDS adversarial ────────────────────────────────────── describe("RECOVER_WITH_WORDS adversarial", () => { it("correct words in wrong order fails", async () => { await setupUnlockedVault(); const words = await getWords(); const vault = await import("./vault"); vault.lock(); const reversed = [...words].reverse(); const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words: reversed, newPassword: "new-pass" }, }); expect(r.ok).toBe(false); }); for (const pos of [0, 1, 2, 5, 7, 11]) { it(`one wrong word at position ${pos}: recovery fails`, async () => { await setupUnlockedVault(); const words = await getWords(); const vault = await import("./vault"); vault.lock(); const tampered = [...words]; // Pick a word that is in the BIP39 list but not at this position. const alternative = WORD_LIST.find((w) => w !== words[pos])!; tampered[pos] = alternative; const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words: tampered, newPassword: "recovered-pass" }, }); expect(r.ok).toBe(false); }); } it("all garbage words: recovery fails", async () => { await setupUnlockedVault(); const vault = await import("./vault"); vault.lock(); const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words: Array.from({ length: 12 }, () => "zzzzz"), newPassword: "recovered-pass", }, }); expect(r.ok).toBe(false); }); it("empty words array: recovery fails", async () => { await setupUnlockedVault(); const vault = await import("./vault"); vault.lock(); const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words: [], newPassword: "recovered-pass" }, }); expect(r.ok).toBe(false); }); it("correct words: recovery succeeds, new password works", async () => { await setupUnlockedVault(); const words = await getWords(); const vault = await import("./vault"); vault.lock(); const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words, newPassword: "fresh-recovered-pass" }, }); expect(r.ok).toBe(true); vault.lock(); const unlock = await dispatch({ type: "UNLOCK", payload: { password: "fresh-recovered-pass" }, }); expect(unlock.ok).toBe(true); }); const newPasswordCases = [ { name: "short", pw: "a" }, { name: "long", pw: "x".repeat(500) }, { name: "unicode", pw: "新パスワード🔑" }, { name: "emoji", pw: "🔑🔒🌸" }, { name: "spaces", pw: " " }, { name: "control chars", pw: "a\t\n\rb" }, { name: "RTL", pw: "كلمة المرور" }, { name: "punctuation", pw: "!@#$%^&*()" }, ]; for (const { name, pw } of newPasswordCases) { it(`recovery sets ${name} new password; unlock works`, async () => { await setupUnlockedVault(); const words = await getWords(); const vault = await import("./vault"); vault.lock(); const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words, newPassword: pw }, }); expect(r.ok).toBe(true); vault.lock(); const u = await dispatch({ type: "UNLOCK", payload: { password: pw }, }); expect(u.ok).toBe(true); }); } it("recovery, then old password fails", async () => { await setupUnlockedVault(); const words = await getWords(); const vault = await import("./vault"); vault.lock(); await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words, newPassword: "fresh-pass-XYZ" }, }); vault.lock(); const old = await dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" }, }); expect(old.ok).toBe(false); }); it("when no vault exists, recovery returns no vault", async () => { const vault = await import("./vault"); vault.lock(); const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words: Array.from({ length: 12 }, (_, i) => WORD_LIST[i]!), newPassword: "x", }, }); expect(r.ok).toBe(false); }); // No-crash adversarial payloads. const badPayloads = [ null, undefined, {}, { words: null }, { words: [], newPassword: null }, { words: "not-an-array", newPassword: "x" }, { words: [42, 43, 44], newPassword: "x" }, ]; for (const [i, payload] of badPayloads.entries()) { it(`adversarial recovery payload #${i} does not crash`, async () => { await setupUnlockedVault(); const vault = await import("./vault"); vault.lock(); const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: payload as unknown as { words: string[]; newPassword: string; }, }); expect(r).toBeDefined(); expect(r.ok).toBe(false); }); } }); // ── Wide BIP39 word coverage ──────────────────────────────────────────── describe("BIP39 membership sweep (parameterized)", () => { // Sample 100 words across the BIP39 list at fixed indices. for (let i = 0; i < 100; i++) { const idx = (i * 20) % WORD_LIST.length; const word = WORD_LIST[idx]!; it(`word at index ${idx} (${word}) validates clean`, async () => { await setupUnlockedVault(); const r = await dispatch({ type: "VALIDATE_RECOVERY_WORDS", payload: { words: [word] }, }); expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual( [] ); }); } // Non-BIP39 words must always be flagged. const garbageWords = [ "zzz", "qqq", "xyz", "123", "abc", "yyy", "fakeword", "notinlist", "garbagestring", "definitelynotaword", ]; for (const word of garbageWords) { it(`garbage word "${word}" is flagged`, async () => { await setupUnlockedVault(); const r = await dispatch({ type: "VALIDATE_RECOVERY_WORDS", payload: { words: [word] }, }); const data = ( r as { data: { invalid: Array<{ word: string }> } } ).data; expect(data.invalid.length).toBe(1); }); } }); // ── Tamper one word at every position 0..11 ───────────────────────────── describe("tamper coverage at every position", () => { for (let pos = 0; pos < 12; pos++) { it(`replace position ${pos} with garbage: recovery fails`, async () => { await setupUnlockedVault(); const words = await getWords(); const vault = await import("./vault"); vault.lock(); const tampered = [...words]; tampered[pos] = "zzz"; const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words: tampered, newPassword: "new" }, }); expect(r.ok).toBe(false); }); it(`replace position ${pos} with valid-but-wrong word: recovery fails`, async () => { await setupUnlockedVault(); const words = await getWords(); const vault = await import("./vault"); vault.lock(); const tampered = [...words]; tampered[pos] = WORD_LIST.find((w) => w !== words[pos])!; const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words: tampered, newPassword: "new" }, }); expect(r.ok).toBe(false); }); } }); // ── GET_RECOVERY_WORDS adversarial ────────────────────────────────────── describe("GET_RECOVERY_WORDS adversarial", () => { it("returns ok false when locked", async () => { await setupUnlockedVault(); const vault = await import("./vault"); vault.lock(); const r = await dispatch({ type: "GET_RECOVERY_WORDS" }); expect(r.ok).toBe(false); }); it("returns exactly 12 words when unlocked", async () => { await setupUnlockedVault(); const words = await getWords(); expect(words).toHaveLength(12); }); it("returned words are all in BIP39", async () => { await setupUnlockedVault(); const words = await getWords(); for (const w of words) { expect(WORD_LIST).toContain(w); } }); for (let i = 0; i < 5; i++) { it(`deterministic across repeated calls (round ${i})`, async () => { await setupUnlockedVault(); const a = await getWords(); const b = await getWords(); expect(a).toEqual(b); }); } }); // ── Recovery survives password change cycle ───────────────────────────── describe("recovery interaction with password change cycle", () => { // H4/COR-002: changePassword re-wraps the EXISTING root key instead of // minting a new one, so the original recovery words remain valid across // any number of password rotations. it("original recovery words still work after a password change", async () => { await setupUnlockedVault(); const oldWords = await getWords(); const vault = await import("./vault"); await vault.changePassword("test-pass-12345", "second-pass-XYZ"); vault.lock(); const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words: oldWords, newPassword: "post-change-pass" }, }); expect(r.ok).toBe(true); }); for (const cycles of [1, 2, 3]) { it(`original recovery words still work after ${cycles} password change(s)`, async () => { await setupUnlockedVault(); const originalWords = await getWords(); const vault = await import("./vault"); let cur = "test-pass-12345"; for (let i = 0; i < cycles; i++) { const next = `pass-cycle-${i}`; await vault.changePassword(cur, next); cur = next; } vault.lock(); const r = await dispatch({ type: "RECOVER_WITH_WORDS", payload: { words: originalWords, newPassword: "after-cycles" }, }); expect(r.ok).toBe(true); }); } }); });