recovery.adversarial.test.ts

19.5 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    // LV-4: GET_RECOVERY_WORDS now requires a fresh password proof. Every137    // caller fetches words right after setupUnlockedVault()'s default password.138    const r = await dispatch({139      type: "GET_RECOVERY_WORDS",140      payload: { password: "test-pass-12345" },141    });142    expect(r.ok).toBe(true);143    return (r as { data: string[] }).data;144  }145146  // ── VALIDATE_RECOVERY_WORDS edge cases ──────────────────────────────────147148  describe("VALIDATE_RECOVERY_WORDS edge cases", () => {149    beforeEach(() => setupUnlockedVault());150151    it("words with leading/trailing whitespace are accepted", async () => {152      const words = await getWords();153      const padded = words.map((w) => "  " + w + "  ");154      const r = await dispatch({155        type: "VALIDATE_RECOVERY_WORDS",156        payload: { words: padded },157      });158      const data = (r as { data: { invalid: unknown[] } }).data;159      expect(data.invalid).toEqual([]);160    });161162    it("words in all uppercase are accepted (case-insensitive)", async () => {163      const words = await getWords();164      const r = await dispatch({165        type: "VALIDATE_RECOVERY_WORDS",166        payload: { words: words.map((w) => w.toUpperCase()) },167      });168      expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual([]);169    });170171    it("empty array yields ok response", async () => {172      const r = await dispatch({173        type: "VALIDATE_RECOVERY_WORDS",174        payload: { words: [] },175      });176      expect(r.ok).toBe(true);177    });178179    const invalidWordCases = [180      { name: "single garbage word", words: ["zzzzzz"] },181      { name: "many garbage words", words: ["aaa", "bbb", "ccc"] },182      { name: "empty string elements", words: ["", "", ""] },183      { name: "tabs only", words: ["\t", "\t\t"] },184      { name: "newlines only", words: ["\n", "\n\n"] },185      { name: "numeric strings", words: ["123", "456"] },186      { name: "special chars", words: ["@#$", "%^&"] },187      { name: "mixed valid+invalid", words: ["abandon", "notaword"] },188    ];189    for (const { name, words } of invalidWordCases) {190      it(`returns invalid markers for ${name}`, async () => {191        const r = await dispatch({192          type: "VALIDATE_RECOVERY_WORDS",193          payload: { words },194        });195        expect(r.ok).toBe(true);196        const data = (r as {197          data: { invalid: Array<{ index: number; word: string }> };198        }).data;199        // Every word that isn't in BIP39 should be flagged.200        const expected = words.filter(201          (w) => !WORD_LIST.includes((w ?? "").toLowerCase().trim())202        );203        expect(data.invalid.length).toBe(expected.length);204      });205    }206207    it("13 words: extra word is flagged as invalid", async () => {208      // "qwerty" is deliberately not in the Peach recovery word list.209      const words = [...(await getWords()), "qwerty"];210      const r = await dispatch({211        type: "VALIDATE_RECOVERY_WORDS",212        payload: { words },213      });214      const data = (r as {215        data: { invalid: Array<{ index: number; word: string }> };216      }).data;217      expect(data.invalid.some((x) => x.word === "qwerty")).toBe(true);218    });219220    it("11 words (missing): validator returns ok", async () => {221      const words = (await getWords()).slice(0, 11);222      const r = await dispatch({223        type: "VALIDATE_RECOVERY_WORDS",224        payload: { words },225      });226      // Validator just checks word membership; length is enforced at recovery time.227      expect(r.ok).toBe(true);228    });229230    // Boundary: WORD_LIST length spot checks.231    for (let i = 0; i < 30; i++) {232      const sample = WORD_LIST[i * 50]!;233      it(`recognised single word ${sample}`, async () => {234        const r = await dispatch({235          type: "VALIDATE_RECOVERY_WORDS",236          payload: { words: [sample] },237        });238        expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(239          []240        );241      });242    }243244    // Adversarial: try every shape of garbage to confirm no crash.245    const adversarial = [246      null,247      undefined,248      "not-an-array",249      42,250      true,251      { words: "x" },252      [null, null],253      [{ word: "x" }, { word: "y" }],254      ["abandon", null as unknown as string, "abandon"],255    ];256    for (const [i, payload] of adversarial.entries()) {257      it(`adversarial payload #${i} does not crash`, async () => {258        const r = await dispatch({259          type: "VALIDATE_RECOVERY_WORDS",260          payload: payload as unknown as { words: string[] },261        });262        expect(r).toBeDefined();263      });264    }265  });266267  // ── RECOVER_WITH_WORDS adversarial ──────────────────────────────────────268269  describe("RECOVER_WITH_WORDS adversarial", () => {270    it("correct words in wrong order fails", async () => {271      await setupUnlockedVault();272      const words = await getWords();273      const vault = await import("./vault");274      vault.lock();275      const reversed = [...words].reverse();276      const r = await dispatch({277        type: "RECOVER_WITH_WORDS",278        payload: { words: reversed, newPassword: "new-pass" },279      });280      expect(r.ok).toBe(false);281    });282283    for (const pos of [0, 1, 2, 5, 7, 11]) {284      it(`one wrong word at position ${pos}: recovery fails`, async () => {285        await setupUnlockedVault();286        const words = await getWords();287        const vault = await import("./vault");288        vault.lock();289        const tampered = [...words];290        // Pick a word that is in the BIP39 list but not at this position.291        const alternative = WORD_LIST.find((w) => w !== words[pos])!;292        tampered[pos] = alternative;293        const r = await dispatch({294          type: "RECOVER_WITH_WORDS",295          payload: { words: tampered, newPassword: "recovered-pass" },296        });297        expect(r.ok).toBe(false);298      });299    }300301    it("all garbage words: recovery fails", async () => {302      await setupUnlockedVault();303      const vault = await import("./vault");304      vault.lock();305      const r = await dispatch({306        type: "RECOVER_WITH_WORDS",307        payload: {308          words: Array.from({ length: 12 }, () => "zzzzz"),309          newPassword: "recovered-pass",310        },311      });312      expect(r.ok).toBe(false);313    });314315    it("empty words array: recovery fails", async () => {316      await setupUnlockedVault();317      const vault = await import("./vault");318      vault.lock();319      const r = await dispatch({320        type: "RECOVER_WITH_WORDS",321        payload: { words: [], newPassword: "recovered-pass" },322      });323      expect(r.ok).toBe(false);324    });325326    it("correct words: recovery succeeds, new password works", async () => {327      await setupUnlockedVault();328      const words = await getWords();329      const vault = await import("./vault");330      vault.lock();331      const r = await dispatch({332        type: "RECOVER_WITH_WORDS",333        payload: { words, newPassword: "fresh-recovered-pass" },334      });335      expect(r.ok).toBe(true);336      vault.lock();337      const unlock = await dispatch({338        type: "UNLOCK",339        payload: { password: "fresh-recovered-pass" },340      });341      expect(unlock.ok).toBe(true);342    });343344    const newPasswordCases = [345      { name: "short", pw: "a" },346      { name: "long", pw: "x".repeat(500) },347      { name: "unicode", pw: "新パスワード🔑" },348      { name: "emoji", pw: "🔑🔒🌸" },349      { name: "spaces", pw: "        " },350      { name: "control chars", pw: "a\t\n\rb" },351      { name: "RTL", pw: "كلمة المرور" },352      { name: "punctuation", pw: "!@#$%^&*()" },353    ];354    for (const { name, pw } of newPasswordCases) {355      it(`recovery sets ${name} new password; unlock works`, async () => {356        await setupUnlockedVault();357        const words = await getWords();358        const vault = await import("./vault");359        vault.lock();360        const r = await dispatch({361          type: "RECOVER_WITH_WORDS",362          payload: { words, newPassword: pw },363        });364        expect(r.ok).toBe(true);365        vault.lock();366        const u = await dispatch({367          type: "UNLOCK",368          payload: { password: pw },369        });370        expect(u.ok).toBe(true);371      });372    }373374    it("recovery, then old password fails", async () => {375      await setupUnlockedVault();376      const words = await getWords();377      const vault = await import("./vault");378      vault.lock();379      await dispatch({380        type: "RECOVER_WITH_WORDS",381        payload: { words, newPassword: "fresh-pass-XYZ" },382      });383      vault.lock();384      const old = await dispatch({385        type: "UNLOCK",386        payload: { password: "test-pass-12345" },387      });388      expect(old.ok).toBe(false);389    });390391    it("when no vault exists, recovery returns no vault", async () => {392      const vault = await import("./vault");393      vault.lock();394      const r = await dispatch({395        type: "RECOVER_WITH_WORDS",396        payload: {397          words: Array.from({ length: 12 }, (_, i) => WORD_LIST[i]!),398          newPassword: "x",399        },400      });401      expect(r.ok).toBe(false);402    });403404    // No-crash adversarial payloads.405    const badPayloads = [406      null,407      undefined,408      {},409      { words: null },410      { words: [], newPassword: null },411      { words: "not-an-array", newPassword: "x" },412      { words: [42, 43, 44], newPassword: "x" },413    ];414    for (const [i, payload] of badPayloads.entries()) {415      it(`adversarial recovery payload #${i} does not crash`, async () => {416        await setupUnlockedVault();417        const vault = await import("./vault");418        vault.lock();419        const r = await dispatch({420          type: "RECOVER_WITH_WORDS",421          payload: payload as unknown as {422            words: string[];423            newPassword: string;424          },425        });426        expect(r).toBeDefined();427        expect(r.ok).toBe(false);428      });429    }430  });431432  // ── Wide BIP39 word coverage ────────────────────────────────────────────433434  describe("BIP39 membership sweep (parameterized)", () => {435    // Sample 100 words across the BIP39 list at fixed indices.436    for (let i = 0; i < 100; i++) {437      const idx = (i * 20) % WORD_LIST.length;438      const word = WORD_LIST[idx]!;439      it(`word at index ${idx} (${word}) validates clean`, async () => {440        await setupUnlockedVault();441        const r = await dispatch({442          type: "VALIDATE_RECOVERY_WORDS",443          payload: { words: [word] },444        });445        expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(446          []447        );448      });449    }450451    // Non-BIP39 words must always be flagged.452    const garbageWords = [453      "zzz",454      "qqq",455      "xyz",456      "123",457      "abc",458      "yyy",459      "fakeword",460      "notinlist",461      "garbagestring",462      "definitelynotaword",463    ];464    for (const word of garbageWords) {465      it(`garbage word "${word}" is flagged`, async () => {466        await setupUnlockedVault();467        const r = await dispatch({468          type: "VALIDATE_RECOVERY_WORDS",469          payload: { words: [word] },470        });471        const data = (472          r as { data: { invalid: Array<{ word: string }> } }473        ).data;474        expect(data.invalid.length).toBe(1);475      });476    }477  });478479  // ── Tamper one word at every position 0..11 ─────────────────────────────480481  describe("tamper coverage at every position", () => {482    for (let pos = 0; pos < 12; pos++) {483      it(`replace position ${pos} with garbage: recovery fails`, async () => {484        await setupUnlockedVault();485        const words = await getWords();486        const vault = await import("./vault");487        vault.lock();488        const tampered = [...words];489        tampered[pos] = "zzz";490        const r = await dispatch({491          type: "RECOVER_WITH_WORDS",492          payload: { words: tampered, newPassword: "new" },493        });494        expect(r.ok).toBe(false);495      });496497      it(`replace position ${pos} with valid-but-wrong word: recovery fails`, async () => {498        await setupUnlockedVault();499        const words = await getWords();500        const vault = await import("./vault");501        vault.lock();502        const tampered = [...words];503        tampered[pos] = WORD_LIST.find((w) => w !== words[pos])!;504        const r = await dispatch({505          type: "RECOVER_WITH_WORDS",506          payload: { words: tampered, newPassword: "new" },507        });508        expect(r.ok).toBe(false);509      });510    }511  });512513  // ── GET_RECOVERY_WORDS adversarial ──────────────────────────────────────514515  describe("GET_RECOVERY_WORDS adversarial", () => {516    it("returns ok false when locked", async () => {517      await setupUnlockedVault();518      const vault = await import("./vault");519      vault.lock();520      const r = await dispatch({ type: "GET_RECOVERY_WORDS" });521      expect(r.ok).toBe(false);522    });523524    it("returns exactly 12 words when unlocked", async () => {525      await setupUnlockedVault();526      const words = await getWords();527      expect(words).toHaveLength(12);528    });529530    it("returned words are all in BIP39", async () => {531      await setupUnlockedVault();532      const words = await getWords();533      for (const w of words) {534        expect(WORD_LIST).toContain(w);535      }536    });537538    for (let i = 0; i < 5; i++) {539      it(`deterministic across repeated calls (round ${i})`, async () => {540        await setupUnlockedVault();541        const a = await getWords();542        const b = await getWords();543        expect(a).toEqual(b);544      });545    }546  });547548  // ── Recovery survives password change cycle ─────────────────────────────549550  describe("recovery interaction with password change cycle", () => {551    // H4/COR-002: changePassword re-wraps the EXISTING root key instead of552    // minting a new one, so the original recovery words remain valid across553    // any number of password rotations.554555    it("original recovery words still work after a password change", async () => {556      await setupUnlockedVault();557      const oldWords = await getWords();558      const vault = await import("./vault");559      await vault.changePassword("test-pass-12345", "second-pass-XYZ");560      vault.lock();561      const r = await dispatch({562        type: "RECOVER_WITH_WORDS",563        payload: { words: oldWords, newPassword: "post-change-pass" },564      });565      expect(r.ok).toBe(true);566    });567568    for (const cycles of [1, 2, 3]) {569      it(`original recovery words still work after ${cycles} password change(s)`, async () => {570        await setupUnlockedVault();571        const originalWords = await getWords();572        const vault = await import("./vault");573        let cur = "test-pass-12345";574        for (let i = 0; i < cycles; i++) {575          const next = `pass-cycle-${i}`;576          await vault.changePassword(cur, next);577          cur = next;578        }579        vault.lock();580        const r = await dispatch({581          type: "RECOVER_WITH_WORDS",582          payload: { words: originalWords, newPassword: "after-cycles" },583        });584        expect(r.ok).toBe(true);585      });586    }587  });588});