recovery.adversarial.test.ts

19.2 KB · raw

1/**2 * recovery.adversarial.test.ts — Layer 3 §3.2 adversarial recovery words tests.3 *4 * Covers VALIDATE_RECOVERY_WORDS edge cases, RECOVER_WITH_WORDS adversarial5 * paths, and recovery-after-password-change cycles. Parameterized for >=1506 * tests.7 */89import { beforeEach, describe, expect, it, vi } from "vitest";1011import type { Message, MessageResponse } from "../shared/types";12import { WORD_LIST } from "../shared/recovery-word-list";1314type StorageShape = Record<string, unknown>;15type MessageListener = (16  msg: unknown,17  sender: chrome.runtime.MessageSender,18  sendResponse: (response: MessageResponse) => void19) => boolean;2021describe("recovery words adversarial", () => {22  let storage: StorageShape;23  let sessionStorage: StorageShape;24  let listener: MessageListener | undefined;2526  beforeEach(async () => {27    vi.resetModules();28    vi.useRealTimers();29    storage = { peach_settings: { breachMonitoring: false } };30    sessionStorage = {};31    listener = undefined;3233    vi.stubGlobal(34      "fetch",35      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))36    );37    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);3839    const chromeMock = {40      runtime: {41        id: "peach-extension",42        onMessage: {43          addListener: vi.fn((fn: MessageListener) => {44            listener = fn;45          }),46        },47        sendMessage: vi.fn(async () => ({ ok: true })),48      },49      tabs: {50        query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {51          if (typeof cb === "function") {52            cb([{ id: 1 }]);53            return;54          }55          return Promise.resolve([{ id: 1 }]);56        }),57        sendMessage: vi.fn(async () => ({ ok: true })),58      },59      storage: {60        local: {61          get: vi.fn(async (key?: string | string[]) => {62            if (!key) return storage;63            if (typeof key === "string") return { [key]: storage[key] };64            return key.reduce<Record<string, unknown>>((acc, k) => {65              acc[k] = storage[k];66              return acc;67            }, {});68          }),69          set: vi.fn(async (next: Record<string, unknown>) => {70            Object.assign(storage, next);71          }),72          remove: vi.fn(async (keys: string | string[]) => {73            const list = Array.isArray(keys) ? keys : [keys];74            for (const key of list) delete storage[key];75          }),76        },77        session: {78          get: vi.fn(async (key?: string | string[]) => {79            if (!key) return sessionStorage;80            if (typeof key === "string") return { [key]: sessionStorage[key] };81            return key.reduce<Record<string, unknown>>((acc, k) => {82              acc[k] = sessionStorage[k];83              return acc;84            }, {});85          }),86          set: vi.fn(async (next: Record<string, unknown>) => {87            Object.assign(sessionStorage, next);88          }),89          remove: vi.fn(async (keys: string | string[]) => {90            const list = Array.isArray(keys) ? keys : [keys];91            for (const key of list) delete sessionStorage[key];92          }),93          clear: vi.fn(async () => {94            for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];95          }),96        },97      },98      alarms: {99        clear: vi.fn(),100        create: vi.fn(),101        onAlarm: { addListener: vi.fn() },102      },103      idle: {104        setDetectionInterval: vi.fn(),105        onStateChanged: { addListener: vi.fn() },106      },107      scripting: { executeScript: vi.fn(async () => []) },108    } as unknown as typeof chrome;109110    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;111112    const messages = await import("./messages");113    messages.setupMessageHandler();114    const vault = await import("./vault");115    await vault.ensureSettingsLoaded();116  });117118  async function dispatch(message: Message): Promise<MessageResponse> {119    if (!listener) throw new Error("listener not registered");120    return new Promise<MessageResponse>((resolve) => {121      const keepOpen = listener!(122        message,123        { id: "peach-extension" } as chrome.runtime.MessageSender,124        resolve125      );126      expect(keepOpen).toBe(true);127    });128  }129130  async function setupUnlockedVault(password = "test-pass-12345") {131    const vault = await import("./vault");132    await vault.createVault(password);133  }134135  async function getWords(): Promise<string[]> {136    const r = await dispatch({ type: "GET_RECOVERY_WORDS" });137    expect(r.ok).toBe(true);138    return (r as { data: string[] }).data;139  }140141  // ── VALIDATE_RECOVERY_WORDS edge cases ──────────────────────────────────142143  describe("VALIDATE_RECOVERY_WORDS edge cases", () => {144    beforeEach(setupUnlockedVault);145146    it("words with leading/trailing whitespace are accepted", async () => {147      const words = await getWords();148      const padded = words.map((w) => "  " + w + "  ");149      const r = await dispatch({150        type: "VALIDATE_RECOVERY_WORDS",151        payload: { words: padded },152      });153      const data = (r as { data: { invalid: unknown[] } }).data;154      expect(data.invalid).toEqual([]);155    });156157    it("words in all uppercase are accepted (case-insensitive)", async () => {158      const words = await getWords();159      const r = await dispatch({160        type: "VALIDATE_RECOVERY_WORDS",161        payload: { words: words.map((w) => w.toUpperCase()) },162      });163      expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual([]);164    });165166    it("empty array yields ok response", async () => {167      const r = await dispatch({168        type: "VALIDATE_RECOVERY_WORDS",169        payload: { words: [] },170      });171      expect(r.ok).toBe(true);172    });173174    const invalidWordCases = [175      { name: "single garbage word", words: ["zzzzzz"] },176      { name: "many garbage words", words: ["aaa", "bbb", "ccc"] },177      { name: "empty string elements", words: ["", "", ""] },178      { name: "tabs only", words: ["\t", "\t\t"] },179      { name: "newlines only", words: ["\n", "\n\n"] },180      { name: "numeric strings", words: ["123", "456"] },181      { name: "special chars", words: ["@#$", "%^&"] },182      { name: "mixed valid+invalid", words: ["abandon", "notaword"] },183    ];184    for (const { name, words } of invalidWordCases) {185      it(`returns invalid markers for ${name}`, async () => {186        const r = await dispatch({187          type: "VALIDATE_RECOVERY_WORDS",188          payload: { words },189        });190        expect(r.ok).toBe(true);191        const data = (r as {192          data: { invalid: Array<{ index: number; word: string }> };193        }).data;194        // Every word that isn't in BIP39 should be flagged.195        const expected = words.filter(196          (w) => !WORD_LIST.includes((w ?? "").toLowerCase().trim())197        );198        expect(data.invalid.length).toBe(expected.length);199      });200    }201202    it("13 words: extra word is flagged as invalid", async () => {203      const words = [...(await getWords()), "extra"];204      const r = await dispatch({205        type: "VALIDATE_RECOVERY_WORDS",206        payload: { words },207      });208      const data = (r as {209        data: { invalid: Array<{ index: number; word: string }> };210      }).data;211      expect(data.invalid.some((x) => x.word === "extra")).toBe(true);212    });213214    it("11 words (missing): validator returns ok", async () => {215      const words = (await getWords()).slice(0, 11);216      const r = await dispatch({217        type: "VALIDATE_RECOVERY_WORDS",218        payload: { words },219      });220      // Validator just checks word membership; length is enforced at recovery time.221      expect(r.ok).toBe(true);222    });223224    // Boundary: WORD_LIST length spot checks.225    for (let i = 0; i < 30; i++) {226      const sample = WORD_LIST[i * 50]!;227      it(`recognised single word ${sample}`, async () => {228        const r = await dispatch({229          type: "VALIDATE_RECOVERY_WORDS",230          payload: { words: [sample] },231        });232        expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(233          []234        );235      });236    }237238    // Adversarial: try every shape of garbage to confirm no crash.239    const adversarial = [240      null,241      undefined,242      "not-an-array",243      42,244      true,245      { words: "x" },246      [null, null],247      [{ word: "x" }, { word: "y" }],248      ["abandon", null as unknown as string, "abandon"],249    ];250    for (const [i, payload] of adversarial.entries()) {251      it(`adversarial payload #${i} does not crash`, async () => {252        const r = await dispatch({253          type: "VALIDATE_RECOVERY_WORDS",254          payload: payload as unknown as { words: string[] },255        });256        expect(r).toBeDefined();257      });258    }259  });260261  // ── RECOVER_WITH_WORDS adversarial ──────────────────────────────────────262263  describe("RECOVER_WITH_WORDS adversarial", () => {264    it("correct words in wrong order fails", async () => {265      await setupUnlockedVault();266      const words = await getWords();267      const vault = await import("./vault");268      vault.lock();269      const reversed = [...words].reverse();270      const r = await dispatch({271        type: "RECOVER_WITH_WORDS",272        payload: { words: reversed, newPassword: "new-pass" },273      });274      expect(r.ok).toBe(false);275    });276277    for (const pos of [0, 1, 2, 5, 7, 11]) {278      it(`one wrong word at position ${pos}: recovery fails`, async () => {279        await setupUnlockedVault();280        const words = await getWords();281        const vault = await import("./vault");282        vault.lock();283        const tampered = [...words];284        // Pick a word that is in the BIP39 list but not at this position.285        const alternative = WORD_LIST.find((w) => w !== words[pos])!;286        tampered[pos] = alternative;287        const r = await dispatch({288          type: "RECOVER_WITH_WORDS",289          payload: { words: tampered, newPassword: "recovered-pass" },290        });291        expect(r.ok).toBe(false);292      });293    }294295    it("all garbage words: recovery fails", async () => {296      await setupUnlockedVault();297      const vault = await import("./vault");298      vault.lock();299      const r = await dispatch({300        type: "RECOVER_WITH_WORDS",301        payload: {302          words: Array.from({ length: 12 }, () => "zzzzz"),303          newPassword: "recovered-pass",304        },305      });306      expect(r.ok).toBe(false);307    });308309    it("empty words array: recovery fails", async () => {310      await setupUnlockedVault();311      const vault = await import("./vault");312      vault.lock();313      const r = await dispatch({314        type: "RECOVER_WITH_WORDS",315        payload: { words: [], newPassword: "recovered-pass" },316      });317      expect(r.ok).toBe(false);318    });319320    it("correct words: recovery succeeds, new password works", async () => {321      await setupUnlockedVault();322      const words = await getWords();323      const vault = await import("./vault");324      vault.lock();325      const r = await dispatch({326        type: "RECOVER_WITH_WORDS",327        payload: { words, newPassword: "fresh-recovered-pass" },328      });329      expect(r.ok).toBe(true);330      vault.lock();331      const unlock = await dispatch({332        type: "UNLOCK",333        payload: { password: "fresh-recovered-pass" },334      });335      expect(unlock.ok).toBe(true);336    });337338    const newPasswordCases = [339      { name: "short", pw: "a" },340      { name: "long", pw: "x".repeat(500) },341      { name: "unicode", pw: "新パスワード🔑" },342      { name: "emoji", pw: "🔑🔒🌸" },343      { name: "spaces", pw: "        " },344      { name: "control chars", pw: "a\t\n\rb" },345      { name: "RTL", pw: "كلمة المرور" },346      { name: "punctuation", pw: "!@#$%^&*()" },347    ];348    for (const { name, pw } of newPasswordCases) {349      it(`recovery sets ${name} new password; unlock works`, async () => {350        await setupUnlockedVault();351        const words = await getWords();352        const vault = await import("./vault");353        vault.lock();354        const r = await dispatch({355          type: "RECOVER_WITH_WORDS",356          payload: { words, newPassword: pw },357        });358        expect(r.ok).toBe(true);359        vault.lock();360        const u = await dispatch({361          type: "UNLOCK",362          payload: { password: pw },363        });364        expect(u.ok).toBe(true);365      });366    }367368    it("recovery, then old password fails", async () => {369      await setupUnlockedVault();370      const words = await getWords();371      const vault = await import("./vault");372      vault.lock();373      await dispatch({374        type: "RECOVER_WITH_WORDS",375        payload: { words, newPassword: "fresh-pass-XYZ" },376      });377      vault.lock();378      const old = await dispatch({379        type: "UNLOCK",380        payload: { password: "test-pass-12345" },381      });382      expect(old.ok).toBe(false);383    });384385    it("when no vault exists, recovery returns no vault", async () => {386      const vault = await import("./vault");387      vault.lock();388      const r = await dispatch({389        type: "RECOVER_WITH_WORDS",390        payload: {391          words: Array.from({ length: 12 }, (_, i) => WORD_LIST[i]!),392          newPassword: "x",393        },394      });395      expect(r.ok).toBe(false);396    });397398    // No-crash adversarial payloads.399    const badPayloads = [400      null,401      undefined,402      {},403      { words: null },404      { words: [], newPassword: null },405      { words: "not-an-array", newPassword: "x" },406      { words: [42, 43, 44], newPassword: "x" },407    ];408    for (const [i, payload] of badPayloads.entries()) {409      it(`adversarial recovery payload #${i} does not crash`, async () => {410        await setupUnlockedVault();411        const vault = await import("./vault");412        vault.lock();413        const r = await dispatch({414          type: "RECOVER_WITH_WORDS",415          payload: payload as unknown as {416            words: string[];417            newPassword: string;418          },419        });420        expect(r).toBeDefined();421        expect(r.ok).toBe(false);422      });423    }424  });425426  // ── Wide BIP39 word coverage ────────────────────────────────────────────427428  describe("BIP39 membership sweep (parameterized)", () => {429    // Sample 100 words across the BIP39 list at fixed indices.430    for (let i = 0; i < 100; i++) {431      const idx = (i * 20) % WORD_LIST.length;432      const word = WORD_LIST[idx]!;433      it(`word at index ${idx} (${word}) validates clean`, async () => {434        await setupUnlockedVault();435        const r = await dispatch({436          type: "VALIDATE_RECOVERY_WORDS",437          payload: { words: [word] },438        });439        expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(440          []441        );442      });443    }444445    // Non-BIP39 words must always be flagged.446    const garbageWords = [447      "zzz",448      "qqq",449      "xyz",450      "123",451      "abc",452      "yyy",453      "fakeword",454      "notinlist",455      "garbagestring",456      "definitelynotaword",457    ];458    for (const word of garbageWords) {459      it(`garbage word "${word}" is flagged`, async () => {460        await setupUnlockedVault();461        const r = await dispatch({462          type: "VALIDATE_RECOVERY_WORDS",463          payload: { words: [word] },464        });465        const data = (466          r as { data: { invalid: Array<{ word: string }> } }467        ).data;468        expect(data.invalid.length).toBe(1);469      });470    }471  });472473  // ── Tamper one word at every position 0..11 ─────────────────────────────474475  describe("tamper coverage at every position", () => {476    for (let pos = 0; pos < 12; pos++) {477      it(`replace position ${pos} with garbage: recovery fails`, async () => {478        await setupUnlockedVault();479        const words = await getWords();480        const vault = await import("./vault");481        vault.lock();482        const tampered = [...words];483        tampered[pos] = "zzz";484        const r = await dispatch({485          type: "RECOVER_WITH_WORDS",486          payload: { words: tampered, newPassword: "new" },487        });488        expect(r.ok).toBe(false);489      });490491      it(`replace position ${pos} with valid-but-wrong word: recovery fails`, async () => {492        await setupUnlockedVault();493        const words = await getWords();494        const vault = await import("./vault");495        vault.lock();496        const tampered = [...words];497        tampered[pos] = WORD_LIST.find((w) => w !== words[pos])!;498        const r = await dispatch({499          type: "RECOVER_WITH_WORDS",500          payload: { words: tampered, newPassword: "new" },501        });502        expect(r.ok).toBe(false);503      });504    }505  });506507  // ── GET_RECOVERY_WORDS adversarial ──────────────────────────────────────508509  describe("GET_RECOVERY_WORDS adversarial", () => {510    it("returns ok false when locked", async () => {511      await setupUnlockedVault();512      const vault = await import("./vault");513      vault.lock();514      const r = await dispatch({ type: "GET_RECOVERY_WORDS" });515      expect(r.ok).toBe(false);516    });517518    it("returns exactly 12 words when unlocked", async () => {519      await setupUnlockedVault();520      const words = await getWords();521      expect(words).toHaveLength(12);522    });523524    it("returned words are all in BIP39", async () => {525      await setupUnlockedVault();526      const words = await getWords();527      for (const w of words) {528        expect(WORD_LIST).toContain(w);529      }530    });531532    for (let i = 0; i < 5; i++) {533      it(`deterministic across repeated calls (round ${i})`, async () => {534        await setupUnlockedVault();535        const a = await getWords();536        const b = await getWords();537        expect(a).toEqual(b);538      });539    }540  });541542  // ── Recovery survives password change cycle ─────────────────────────────543544  describe("recovery interaction with password change cycle", () => {545    // NOTE: Peach rotates the root key on every changePassword(), so the OLD546    // recovery words become invalid and the LATEST recovery words are the547    // only ones that work. These tests pin that behaviour.548549    it("old recovery words are invalid after a password change (root key rotated)", async () => {550      await setupUnlockedVault();551      const oldWords = await getWords();552      const vault = await import("./vault");553      await vault.changePassword("test-pass-12345", "second-pass-XYZ");554      vault.lock();555      const r = await dispatch({556        type: "RECOVER_WITH_WORDS",557        payload: { words: oldWords, newPassword: "should-not-work" },558      });559      expect(r.ok).toBe(false);560    });561562    for (const cycles of [1, 2, 3]) {563      it(`latest recovery words work after ${cycles} password change(s)`, async () => {564        await setupUnlockedVault();565        const vault = await import("./vault");566        let cur = "test-pass-12345";567        for (let i = 0; i < cycles; i++) {568          const next = `pass-cycle-${i}`;569          await vault.changePassword(cur, next);570          cur = next;571        }572        // Fetch the most recent recovery words.573        const latest = await getWords();574        vault.lock();575        const r = await dispatch({576          type: "RECOVER_WITH_WORDS",577          payload: { words: latest, newPassword: "after-cycles" },578        });579        expect(r.ok).toBe(true);580      });581    }582  });583});