recovery.adversarial.test.ts

19.6 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        getURL: (path = "") => `chrome-extension://peach-extension/${path}`,43        onMessage: {44          addListener: vi.fn((fn: MessageListener) => {45            listener = fn;46          }),47        },48        sendMessage: vi.fn(async () => ({ ok: true })),49      },50      tabs: {51        query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {52          if (typeof cb === "function") {53            cb([{ id: 1 }]);54            return;55          }56          return Promise.resolve([{ id: 1 }]);57        }),58        sendMessage: vi.fn(async () => ({ ok: true })),59      },60      storage: {61        local: {62          get: vi.fn(async (key?: string | string[]) => {63            if (!key) return storage;64            if (typeof key === "string") return { [key]: storage[key] };65            return key.reduce<Record<string, unknown>>((acc, k) => {66              acc[k] = storage[k];67              return acc;68            }, {});69          }),70          set: vi.fn(async (next: Record<string, unknown>) => {71            Object.assign(storage, next);72          }),73          remove: vi.fn(async (keys: string | string[]) => {74            const list = Array.isArray(keys) ? keys : [keys];75            for (const key of list) delete storage[key];76          }),77        },78        session: {79          get: vi.fn(async (key?: string | string[]) => {80            if (!key) return sessionStorage;81            if (typeof key === "string") return { [key]: sessionStorage[key] };82            return key.reduce<Record<string, unknown>>((acc, k) => {83              acc[k] = sessionStorage[k];84              return acc;85            }, {});86          }),87          set: vi.fn(async (next: Record<string, unknown>) => {88            Object.assign(sessionStorage, next);89          }),90          remove: vi.fn(async (keys: string | string[]) => {91            const list = Array.isArray(keys) ? keys : [keys];92            for (const key of list) delete sessionStorage[key];93          }),94          clear: vi.fn(async () => {95            for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];96          }),97        },98      },99      alarms: {100        clear: vi.fn(),101        create: vi.fn(),102        onAlarm: { addListener: vi.fn() },103      },104      idle: {105        setDetectionInterval: vi.fn(),106        onStateChanged: { addListener: vi.fn() },107      },108      scripting: { executeScript: vi.fn(async () => []) },109    } as unknown as typeof chrome;110111    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;112113    const messages = await import("./messages");114    messages.setupMessageHandler();115    const vault = await import("./vault");116    await vault.ensureSettingsLoaded();117  });118119  async function dispatch(message: Message): Promise<MessageResponse> {120    if (!listener) throw new Error("listener not registered");121    return new Promise<MessageResponse>((resolve) => {122      const keepOpen = listener!(123        message,124        {125          id: "peach-extension",126          url: "chrome-extension://peach-extension/popup.html",127        } as chrome.runtime.MessageSender,128        resolve129      );130      expect(keepOpen).toBe(true);131    });132  }133134  async function setupUnlockedVault(password = "test-pass-12345") {135    const vault = await import("./vault");136    await vault.createVault(password);137  }138139  async function getWords(): Promise<string[]> {140    // LV-4: GET_RECOVERY_WORDS now requires a fresh password proof. Every141    // caller fetches words right after setupUnlockedVault()'s default password.142    const r = await dispatch({143      type: "GET_RECOVERY_WORDS",144      payload: { password: "test-pass-12345" },145    });146    expect(r.ok).toBe(true);147    return (r as { data: string[] }).data;148  }149150  // ── VALIDATE_RECOVERY_WORDS edge cases ──────────────────────────────────151152  describe("VALIDATE_RECOVERY_WORDS edge cases", () => {153    beforeEach(() => setupUnlockedVault());154155    it("words with leading/trailing whitespace are accepted", async () => {156      const words = await getWords();157      const padded = words.map((w) => "  " + w + "  ");158      const r = await dispatch({159        type: "VALIDATE_RECOVERY_WORDS",160        payload: { words: padded },161      });162      const data = (r as { data: { invalid: unknown[] } }).data;163      expect(data.invalid).toEqual([]);164    });165166    it("words in all uppercase are accepted (case-insensitive)", async () => {167      const words = await getWords();168      const r = await dispatch({169        type: "VALIDATE_RECOVERY_WORDS",170        payload: { words: words.map((w) => w.toUpperCase()) },171      });172      expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual([]);173    });174175    it("empty array yields ok response", async () => {176      const r = await dispatch({177        type: "VALIDATE_RECOVERY_WORDS",178        payload: { words: [] },179      });180      expect(r.ok).toBe(true);181    });182183    const invalidWordCases = [184      { name: "single garbage word", words: ["zzzzzz"] },185      { name: "many garbage words", words: ["aaa", "bbb", "ccc"] },186      { name: "empty string elements", words: ["", "", ""] },187      { name: "tabs only", words: ["\t", "\t\t"] },188      { name: "newlines only", words: ["\n", "\n\n"] },189      { name: "numeric strings", words: ["123", "456"] },190      { name: "special chars", words: ["@#$", "%^&"] },191      { name: "mixed valid+invalid", words: ["abandon", "notaword"] },192    ];193    for (const { name, words } of invalidWordCases) {194      it(`returns invalid markers for ${name}`, async () => {195        const r = await dispatch({196          type: "VALIDATE_RECOVERY_WORDS",197          payload: { words },198        });199        expect(r.ok).toBe(true);200        const data = (r as {201          data: { invalid: Array<{ index: number; word: string }> };202        }).data;203        // Every word that isn't in BIP39 should be flagged.204        const expected = words.filter(205          (w) => !WORD_LIST.includes((w ?? "").toLowerCase().trim())206        );207        expect(data.invalid.length).toBe(expected.length);208      });209    }210211    it("13 words: extra word is flagged as invalid", async () => {212      // "qwerty" is deliberately not in the Peach recovery word list.213      const words = [...(await getWords()), "qwerty"];214      const r = await dispatch({215        type: "VALIDATE_RECOVERY_WORDS",216        payload: { words },217      });218      const data = (r as {219        data: { invalid: Array<{ index: number; word: string }> };220      }).data;221      expect(data.invalid.some((x) => x.word === "qwerty")).toBe(true);222    });223224    it("11 words (missing): validator returns ok", async () => {225      const words = (await getWords()).slice(0, 11);226      const r = await dispatch({227        type: "VALIDATE_RECOVERY_WORDS",228        payload: { words },229      });230      // Validator just checks word membership; length is enforced at recovery time.231      expect(r.ok).toBe(true);232    });233234    // Boundary: WORD_LIST length spot checks.235    for (let i = 0; i < 30; i++) {236      const sample = WORD_LIST[i * 50]!;237      it(`recognised single word ${sample}`, async () => {238        const r = await dispatch({239          type: "VALIDATE_RECOVERY_WORDS",240          payload: { words: [sample] },241        });242        expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(243          []244        );245      });246    }247248    // Adversarial: try every shape of garbage to confirm no crash.249    const adversarial = [250      null,251      undefined,252      "not-an-array",253      42,254      true,255      { words: "x" },256      [null, null],257      [{ word: "x" }, { word: "y" }],258      ["abandon", null as unknown as string, "abandon"],259    ];260    for (const [i, payload] of adversarial.entries()) {261      it(`adversarial payload #${i} does not crash`, async () => {262        const r = await dispatch({263          type: "VALIDATE_RECOVERY_WORDS",264          payload: payload as unknown as { words: string[] },265        });266        expect(r).toBeDefined();267      });268    }269  });270271  // ── RECOVER_WITH_WORDS adversarial ──────────────────────────────────────272273  describe("RECOVER_WITH_WORDS adversarial", () => {274    it("correct words in wrong order fails", async () => {275      await setupUnlockedVault();276      const words = await getWords();277      const vault = await import("./vault");278      vault.lock();279      const reversed = [...words].reverse();280      const r = await dispatch({281        type: "RECOVER_WITH_WORDS",282        payload: { words: reversed, newPassword: "new-pass" },283      });284      expect(r.ok).toBe(false);285    });286287    for (const pos of [0, 1, 2, 5, 7, 11]) {288      it(`one wrong word at position ${pos}: recovery fails`, async () => {289        await setupUnlockedVault();290        const words = await getWords();291        const vault = await import("./vault");292        vault.lock();293        const tampered = [...words];294        // Pick a word that is in the BIP39 list but not at this position.295        const alternative = WORD_LIST.find((w) => w !== words[pos])!;296        tampered[pos] = alternative;297        const r = await dispatch({298          type: "RECOVER_WITH_WORDS",299          payload: { words: tampered, newPassword: "recovered-pass" },300        });301        expect(r.ok).toBe(false);302      });303    }304305    it("all garbage words: recovery fails", async () => {306      await setupUnlockedVault();307      const vault = await import("./vault");308      vault.lock();309      const r = await dispatch({310        type: "RECOVER_WITH_WORDS",311        payload: {312          words: Array.from({ length: 12 }, () => "zzzzz"),313          newPassword: "recovered-pass",314        },315      });316      expect(r.ok).toBe(false);317    });318319    it("empty words array: recovery fails", async () => {320      await setupUnlockedVault();321      const vault = await import("./vault");322      vault.lock();323      const r = await dispatch({324        type: "RECOVER_WITH_WORDS",325        payload: { words: [], newPassword: "recovered-pass" },326      });327      expect(r.ok).toBe(false);328    });329330    it("correct words: recovery succeeds, new password works", async () => {331      await setupUnlockedVault();332      const words = await getWords();333      const vault = await import("./vault");334      vault.lock();335      const r = await dispatch({336        type: "RECOVER_WITH_WORDS",337        payload: { words, newPassword: "fresh-recovered-pass" },338      });339      expect(r.ok).toBe(true);340      vault.lock();341      const unlock = await dispatch({342        type: "UNLOCK",343        payload: { password: "fresh-recovered-pass" },344      });345      expect(unlock.ok).toBe(true);346    });347348    const newPasswordCases = [349      { name: "short", pw: "a" },350      { name: "long", pw: "x".repeat(500) },351      { name: "unicode", pw: "新パスワード🔑" },352      { name: "emoji", pw: "🔑🔒🌸" },353      { name: "spaces", pw: "        " },354      { name: "control chars", pw: "a\t\n\rb" },355      { name: "RTL", pw: "كلمة المرور" },356      { name: "punctuation", pw: "!@#$%^&*()" },357    ];358    for (const { name, pw } of newPasswordCases) {359      it(`recovery sets ${name} new password; unlock works`, async () => {360        await setupUnlockedVault();361        const words = await getWords();362        const vault = await import("./vault");363        vault.lock();364        const r = await dispatch({365          type: "RECOVER_WITH_WORDS",366          payload: { words, newPassword: pw },367        });368        expect(r.ok).toBe(true);369        vault.lock();370        const u = await dispatch({371          type: "UNLOCK",372          payload: { password: pw },373        });374        expect(u.ok).toBe(true);375      });376    }377378    it("recovery, then old password fails", async () => {379      await setupUnlockedVault();380      const words = await getWords();381      const vault = await import("./vault");382      vault.lock();383      await dispatch({384        type: "RECOVER_WITH_WORDS",385        payload: { words, newPassword: "fresh-pass-XYZ" },386      });387      vault.lock();388      const old = await dispatch({389        type: "UNLOCK",390        payload: { password: "test-pass-12345" },391      });392      expect(old.ok).toBe(false);393    });394395    it("when no vault exists, recovery returns no vault", async () => {396      const vault = await import("./vault");397      vault.lock();398      const r = await dispatch({399        type: "RECOVER_WITH_WORDS",400        payload: {401          words: Array.from({ length: 12 }, (_, i) => WORD_LIST[i]!),402          newPassword: "x",403        },404      });405      expect(r.ok).toBe(false);406    });407408    // No-crash adversarial payloads.409    const badPayloads = [410      null,411      undefined,412      {},413      { words: null },414      { words: [], newPassword: null },415      { words: "not-an-array", newPassword: "x" },416      { words: [42, 43, 44], newPassword: "x" },417    ];418    for (const [i, payload] of badPayloads.entries()) {419      it(`adversarial recovery payload #${i} does not crash`, async () => {420        await setupUnlockedVault();421        const vault = await import("./vault");422        vault.lock();423        const r = await dispatch({424          type: "RECOVER_WITH_WORDS",425          payload: payload as unknown as {426            words: string[];427            newPassword: string;428          },429        });430        expect(r).toBeDefined();431        expect(r.ok).toBe(false);432      });433    }434  });435436  // ── Wide BIP39 word coverage ────────────────────────────────────────────437438  describe("BIP39 membership sweep (parameterized)", () => {439    // Sample 100 words across the BIP39 list at fixed indices.440    for (let i = 0; i < 100; i++) {441      const idx = (i * 20) % WORD_LIST.length;442      const word = WORD_LIST[idx]!;443      it(`word at index ${idx} (${word}) validates clean`, async () => {444        await setupUnlockedVault();445        const r = await dispatch({446          type: "VALIDATE_RECOVERY_WORDS",447          payload: { words: [word] },448        });449        expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(450          []451        );452      });453    }454455    // Non-BIP39 words must always be flagged.456    const garbageWords = [457      "zzz",458      "qqq",459      "xyz",460      "123",461      "abc",462      "yyy",463      "fakeword",464      "notinlist",465      "garbagestring",466      "definitelynotaword",467    ];468    for (const word of garbageWords) {469      it(`garbage word "${word}" is flagged`, async () => {470        await setupUnlockedVault();471        const r = await dispatch({472          type: "VALIDATE_RECOVERY_WORDS",473          payload: { words: [word] },474        });475        const data = (476          r as { data: { invalid: Array<{ word: string }> } }477        ).data;478        expect(data.invalid.length).toBe(1);479      });480    }481  });482483  // ── Tamper one word at every position 0..11 ─────────────────────────────484485  describe("tamper coverage at every position", () => {486    for (let pos = 0; pos < 12; pos++) {487      it(`replace position ${pos} with garbage: recovery fails`, async () => {488        await setupUnlockedVault();489        const words = await getWords();490        const vault = await import("./vault");491        vault.lock();492        const tampered = [...words];493        tampered[pos] = "zzz";494        const r = await dispatch({495          type: "RECOVER_WITH_WORDS",496          payload: { words: tampered, newPassword: "new" },497        });498        expect(r.ok).toBe(false);499      });500501      it(`replace position ${pos} with valid-but-wrong word: recovery fails`, async () => {502        await setupUnlockedVault();503        const words = await getWords();504        const vault = await import("./vault");505        vault.lock();506        const tampered = [...words];507        tampered[pos] = WORD_LIST.find((w) => w !== words[pos])!;508        const r = await dispatch({509          type: "RECOVER_WITH_WORDS",510          payload: { words: tampered, newPassword: "new" },511        });512        expect(r.ok).toBe(false);513      });514    }515  });516517  // ── GET_RECOVERY_WORDS adversarial ──────────────────────────────────────518519  describe("GET_RECOVERY_WORDS adversarial", () => {520    it("returns ok false when locked", async () => {521      await setupUnlockedVault();522      const vault = await import("./vault");523      vault.lock();524      const r = await dispatch({ type: "GET_RECOVERY_WORDS" });525      expect(r.ok).toBe(false);526    });527528    it("returns exactly 12 words when unlocked", async () => {529      await setupUnlockedVault();530      const words = await getWords();531      expect(words).toHaveLength(12);532    });533534    it("returned words are all in BIP39", async () => {535      await setupUnlockedVault();536      const words = await getWords();537      for (const w of words) {538        expect(WORD_LIST).toContain(w);539      }540    });541542    for (let i = 0; i < 5; i++) {543      it(`deterministic across repeated calls (round ${i})`, async () => {544        await setupUnlockedVault();545        const a = await getWords();546        const b = await getWords();547        expect(a).toEqual(b);548      });549    }550  });551552  // ── Recovery survives password change cycle ─────────────────────────────553554  describe("recovery interaction with password change cycle", () => {555    // H4/COR-002: changePassword re-wraps the EXISTING root key instead of556    // minting a new one, so the original recovery words remain valid across557    // any number of password rotations.558559    it("original recovery words still work after a password change", async () => {560      await setupUnlockedVault();561      const oldWords = await getWords();562      const vault = await import("./vault");563      await vault.changePassword("test-pass-12345", "second-pass-XYZ");564      vault.lock();565      const r = await dispatch({566        type: "RECOVER_WITH_WORDS",567        payload: { words: oldWords, newPassword: "post-change-pass" },568      });569      expect(r.ok).toBe(true);570    });571572    for (const cycles of [1, 2, 3]) {573      it(`original recovery words still work after ${cycles} password change(s)`, async () => {574        await setupUnlockedVault();575        const originalWords = await getWords();576        const vault = await import("./vault");577        let cur = "test-pass-12345";578        for (let i = 0; i < cycles; i++) {579          const next = `pass-cycle-${i}`;580          await vault.changePassword(cur, next);581          cur = next;582        }583        vault.lock();584        const r = await dispatch({585          type: "RECOVER_WITH_WORDS",586          payload: { words: originalWords, newPassword: "after-cycles" },587        });588        expect(r.ok).toBe(true);589      });590    }591  });592});