recovery.adversarial.test.ts

19.3 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      // "qwerty" is deliberately not in the Peach recovery word list.204      const words = [...(await getWords()), "qwerty"];205      const r = await dispatch({206        type: "VALIDATE_RECOVERY_WORDS",207        payload: { words },208      });209      const data = (r as {210        data: { invalid: Array<{ index: number; word: string }> };211      }).data;212      expect(data.invalid.some((x) => x.word === "qwerty")).toBe(true);213    });214215    it("11 words (missing): validator returns ok", async () => {216      const words = (await getWords()).slice(0, 11);217      const r = await dispatch({218        type: "VALIDATE_RECOVERY_WORDS",219        payload: { words },220      });221      // Validator just checks word membership; length is enforced at recovery time.222      expect(r.ok).toBe(true);223    });224225    // Boundary: WORD_LIST length spot checks.226    for (let i = 0; i < 30; i++) {227      const sample = WORD_LIST[i * 50]!;228      it(`recognised single word ${sample}`, async () => {229        const r = await dispatch({230          type: "VALIDATE_RECOVERY_WORDS",231          payload: { words: [sample] },232        });233        expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(234          []235        );236      });237    }238239    // Adversarial: try every shape of garbage to confirm no crash.240    const adversarial = [241      null,242      undefined,243      "not-an-array",244      42,245      true,246      { words: "x" },247      [null, null],248      [{ word: "x" }, { word: "y" }],249      ["abandon", null as unknown as string, "abandon"],250    ];251    for (const [i, payload] of adversarial.entries()) {252      it(`adversarial payload #${i} does not crash`, async () => {253        const r = await dispatch({254          type: "VALIDATE_RECOVERY_WORDS",255          payload: payload as unknown as { words: string[] },256        });257        expect(r).toBeDefined();258      });259    }260  });261262  // ── RECOVER_WITH_WORDS adversarial ──────────────────────────────────────263264  describe("RECOVER_WITH_WORDS adversarial", () => {265    it("correct words in wrong order fails", async () => {266      await setupUnlockedVault();267      const words = await getWords();268      const vault = await import("./vault");269      vault.lock();270      const reversed = [...words].reverse();271      const r = await dispatch({272        type: "RECOVER_WITH_WORDS",273        payload: { words: reversed, newPassword: "new-pass" },274      });275      expect(r.ok).toBe(false);276    });277278    for (const pos of [0, 1, 2, 5, 7, 11]) {279      it(`one wrong word at position ${pos}: recovery fails`, async () => {280        await setupUnlockedVault();281        const words = await getWords();282        const vault = await import("./vault");283        vault.lock();284        const tampered = [...words];285        // Pick a word that is in the BIP39 list but not at this position.286        const alternative = WORD_LIST.find((w) => w !== words[pos])!;287        tampered[pos] = alternative;288        const r = await dispatch({289          type: "RECOVER_WITH_WORDS",290          payload: { words: tampered, newPassword: "recovered-pass" },291        });292        expect(r.ok).toBe(false);293      });294    }295296    it("all garbage words: recovery fails", async () => {297      await setupUnlockedVault();298      const vault = await import("./vault");299      vault.lock();300      const r = await dispatch({301        type: "RECOVER_WITH_WORDS",302        payload: {303          words: Array.from({ length: 12 }, () => "zzzzz"),304          newPassword: "recovered-pass",305        },306      });307      expect(r.ok).toBe(false);308    });309310    it("empty words array: recovery fails", async () => {311      await setupUnlockedVault();312      const vault = await import("./vault");313      vault.lock();314      const r = await dispatch({315        type: "RECOVER_WITH_WORDS",316        payload: { words: [], newPassword: "recovered-pass" },317      });318      expect(r.ok).toBe(false);319    });320321    it("correct words: recovery succeeds, new password works", async () => {322      await setupUnlockedVault();323      const words = await getWords();324      const vault = await import("./vault");325      vault.lock();326      const r = await dispatch({327        type: "RECOVER_WITH_WORDS",328        payload: { words, newPassword: "fresh-recovered-pass" },329      });330      expect(r.ok).toBe(true);331      vault.lock();332      const unlock = await dispatch({333        type: "UNLOCK",334        payload: { password: "fresh-recovered-pass" },335      });336      expect(unlock.ok).toBe(true);337    });338339    const newPasswordCases = [340      { name: "short", pw: "a" },341      { name: "long", pw: "x".repeat(500) },342      { name: "unicode", pw: "新パスワード🔑" },343      { name: "emoji", pw: "🔑🔒🌸" },344      { name: "spaces", pw: "        " },345      { name: "control chars", pw: "a\t\n\rb" },346      { name: "RTL", pw: "كلمة المرور" },347      { name: "punctuation", pw: "!@#$%^&*()" },348    ];349    for (const { name, pw } of newPasswordCases) {350      it(`recovery sets ${name} new password; unlock works`, async () => {351        await setupUnlockedVault();352        const words = await getWords();353        const vault = await import("./vault");354        vault.lock();355        const r = await dispatch({356          type: "RECOVER_WITH_WORDS",357          payload: { words, newPassword: pw },358        });359        expect(r.ok).toBe(true);360        vault.lock();361        const u = await dispatch({362          type: "UNLOCK",363          payload: { password: pw },364        });365        expect(u.ok).toBe(true);366      });367    }368369    it("recovery, then old password fails", async () => {370      await setupUnlockedVault();371      const words = await getWords();372      const vault = await import("./vault");373      vault.lock();374      await dispatch({375        type: "RECOVER_WITH_WORDS",376        payload: { words, newPassword: "fresh-pass-XYZ" },377      });378      vault.lock();379      const old = await dispatch({380        type: "UNLOCK",381        payload: { password: "test-pass-12345" },382      });383      expect(old.ok).toBe(false);384    });385386    it("when no vault exists, recovery returns no vault", async () => {387      const vault = await import("./vault");388      vault.lock();389      const r = await dispatch({390        type: "RECOVER_WITH_WORDS",391        payload: {392          words: Array.from({ length: 12 }, (_, i) => WORD_LIST[i]!),393          newPassword: "x",394        },395      });396      expect(r.ok).toBe(false);397    });398399    // No-crash adversarial payloads.400    const badPayloads = [401      null,402      undefined,403      {},404      { words: null },405      { words: [], newPassword: null },406      { words: "not-an-array", newPassword: "x" },407      { words: [42, 43, 44], newPassword: "x" },408    ];409    for (const [i, payload] of badPayloads.entries()) {410      it(`adversarial recovery payload #${i} does not crash`, async () => {411        await setupUnlockedVault();412        const vault = await import("./vault");413        vault.lock();414        const r = await dispatch({415          type: "RECOVER_WITH_WORDS",416          payload: payload as unknown as {417            words: string[];418            newPassword: string;419          },420        });421        expect(r).toBeDefined();422        expect(r.ok).toBe(false);423      });424    }425  });426427  // ── Wide BIP39 word coverage ────────────────────────────────────────────428429  describe("BIP39 membership sweep (parameterized)", () => {430    // Sample 100 words across the BIP39 list at fixed indices.431    for (let i = 0; i < 100; i++) {432      const idx = (i * 20) % WORD_LIST.length;433      const word = WORD_LIST[idx]!;434      it(`word at index ${idx} (${word}) validates clean`, async () => {435        await setupUnlockedVault();436        const r = await dispatch({437          type: "VALIDATE_RECOVERY_WORDS",438          payload: { words: [word] },439        });440        expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(441          []442        );443      });444    }445446    // Non-BIP39 words must always be flagged.447    const garbageWords = [448      "zzz",449      "qqq",450      "xyz",451      "123",452      "abc",453      "yyy",454      "fakeword",455      "notinlist",456      "garbagestring",457      "definitelynotaword",458    ];459    for (const word of garbageWords) {460      it(`garbage word "${word}" is flagged`, async () => {461        await setupUnlockedVault();462        const r = await dispatch({463          type: "VALIDATE_RECOVERY_WORDS",464          payload: { words: [word] },465        });466        const data = (467          r as { data: { invalid: Array<{ word: string }> } }468        ).data;469        expect(data.invalid.length).toBe(1);470      });471    }472  });473474  // ── Tamper one word at every position 0..11 ─────────────────────────────475476  describe("tamper coverage at every position", () => {477    for (let pos = 0; pos < 12; pos++) {478      it(`replace position ${pos} with garbage: recovery fails`, async () => {479        await setupUnlockedVault();480        const words = await getWords();481        const vault = await import("./vault");482        vault.lock();483        const tampered = [...words];484        tampered[pos] = "zzz";485        const r = await dispatch({486          type: "RECOVER_WITH_WORDS",487          payload: { words: tampered, newPassword: "new" },488        });489        expect(r.ok).toBe(false);490      });491492      it(`replace position ${pos} with valid-but-wrong word: recovery fails`, async () => {493        await setupUnlockedVault();494        const words = await getWords();495        const vault = await import("./vault");496        vault.lock();497        const tampered = [...words];498        tampered[pos] = WORD_LIST.find((w) => w !== words[pos])!;499        const r = await dispatch({500          type: "RECOVER_WITH_WORDS",501          payload: { words: tampered, newPassword: "new" },502        });503        expect(r.ok).toBe(false);504      });505    }506  });507508  // ── GET_RECOVERY_WORDS adversarial ──────────────────────────────────────509510  describe("GET_RECOVERY_WORDS adversarial", () => {511    it("returns ok false when locked", async () => {512      await setupUnlockedVault();513      const vault = await import("./vault");514      vault.lock();515      const r = await dispatch({ type: "GET_RECOVERY_WORDS" });516      expect(r.ok).toBe(false);517    });518519    it("returns exactly 12 words when unlocked", async () => {520      await setupUnlockedVault();521      const words = await getWords();522      expect(words).toHaveLength(12);523    });524525    it("returned words are all in BIP39", async () => {526      await setupUnlockedVault();527      const words = await getWords();528      for (const w of words) {529        expect(WORD_LIST).toContain(w);530      }531    });532533    for (let i = 0; i < 5; i++) {534      it(`deterministic across repeated calls (round ${i})`, async () => {535        await setupUnlockedVault();536        const a = await getWords();537        const b = await getWords();538        expect(a).toEqual(b);539      });540    }541  });542543  // ── Recovery survives password change cycle ─────────────────────────────544545  describe("recovery interaction with password change cycle", () => {546    // NOTE: Peach rotates the root key on every changePassword(), so the OLD547    // recovery words become invalid and the LATEST recovery words are the548    // only ones that work. These tests pin that behaviour.549550    it("old recovery words are invalid after a password change (root key rotated)", async () => {551      await setupUnlockedVault();552      const oldWords = await getWords();553      const vault = await import("./vault");554      await vault.changePassword("test-pass-12345", "second-pass-XYZ");555      vault.lock();556      const r = await dispatch({557        type: "RECOVER_WITH_WORDS",558        payload: { words: oldWords, newPassword: "should-not-work" },559      });560      expect(r.ok).toBe(false);561    });562563    for (const cycles of [1, 2, 3]) {564      it(`latest recovery words work after ${cycles} password change(s)`, async () => {565        await setupUnlockedVault();566        const vault = await import("./vault");567        let cur = "test-pass-12345";568        for (let i = 0; i < cycles; i++) {569          const next = `pass-cycle-${i}`;570          await vault.changePassword(cur, next);571          cur = next;572        }573        // Fetch the most recent recovery words.574        const latest = await getWords();575        vault.lock();576        const r = await dispatch({577          type: "RECOVER_WITH_WORDS",578          payload: { words: latest, newPassword: "after-cycles" },579        });580        expect(r.ok).toBe(true);581      });582    }583  });584});