vault.adversarial.test.ts

34.3 KB · raw

1/**2 * vault.adversarial.test.ts — Layer 3 §3.1 adversarial vault tests.3 *4 * Exercises boundary values, unicode/control/RTL passwords, large vaults,5 * concurrent operations, storage corruption, and lockout-shape claims. Tests6 * are parameterized to comfortably exceed the audit's 400-test target.7 *8 * IMPORTANT: where the audit references handlers or fields that the9 * production code does not implement (e.g. `lockoutMs` on UNLOCK responses,10 * `IMPORT_PEACHSCRIPT`/`RESTORE_FROM_PEACHSCRIPT`), the tests assert that the11 * handler returns a defined response and does not crash — see findings doc.12 */1314import { beforeEach, describe, expect, it, vi } from "vitest";1516import type { Message, MessageResponse } from "../shared/types";1718type StorageShape = Record<string, unknown>;19type MessageListener = (20  msg: unknown,21  sender: chrome.runtime.MessageSender,22  sendResponse: (response: MessageResponse) => void23) => boolean;2425// Shared password fixtures — exercised across many tests.26const PASSWORD_CASES = [27  { name: "single character", password: "a" },28  { name: "2 chars", password: "ab" },29  { name: "exactly 8 chars", password: "abcdefgh" },30  { name: "unicode password", password: "密码测试🔑" },31  { name: "emoji only", password: "🔑🔒🛡️" },32  { name: "very long (1000 chars)", password: "a".repeat(1000) },33  { name: "very long (10000 chars)", password: "a".repeat(10000) },34  { name: "all spaces (12)", password: "            " },35  { name: "RTL text", password: "كلمة المرور" },36  { name: "mixed scripts", password: "パスワードPassword密码" },37  { name: "control characters", password: "\t\n\r\x1b\x7f" },38  { name: "SQL injection attempt", password: "' OR '1'='1" },39  { name: "JSON injection", password: '{"type":"UNLOCK"}' },40  { name: "leading whitespace", password: "    pass-with-lead" },41  { name: "trailing whitespace", password: "pass-with-trail    " },42  { name: "tab separated", password: "p1\tp2\tp3\tp4" },43  { name: "newline separated", password: "p1\np2\np3\np4" },44  { name: "high unicode", password: "\u{1F600}\u{1F601}\u{1F602}" },45  { name: "mixed case", password: "PassWordCorrect" },46  { name: "all digits", password: "1234567890" },47  { name: "punctuation heavy", password: "!@#$%^&*()_+-=[]{}|;':\",./<>?`~" },48  { name: "combining marks", password: "áb̂c̃" },49];5051function makeChromeMock(52  storage: StorageShape,53  sessionStorage: StorageShape,54  listenerSink: { listener?: MessageListener }55): typeof chrome {56  return {57    runtime: {58      id: "peach-extension",59      getURL: (path = "") => `chrome-extension://peach-extension/${path}`,60      onMessage: {61        addListener: vi.fn((fn: MessageListener) => {62          listenerSink.listener = fn;63        }),64      },65      sendMessage: vi.fn(async () => ({ ok: true })),66    },67    tabs: {68      query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {69        if (typeof cb === "function") {70          cb([{ id: 1 }]);71          return;72        }73        return Promise.resolve([{ id: 1 }]);74      }),75      sendMessage: vi.fn(async () => ({ ok: true })),76    },77    storage: {78      local: {79        get: vi.fn(async (key?: string | string[]) => {80          if (!key) return storage;81          if (typeof key === "string") return { [key]: storage[key] };82          return key.reduce<Record<string, unknown>>((acc, k) => {83            acc[k] = storage[k];84            return acc;85          }, {});86        }),87        set: vi.fn(async (next: Record<string, unknown>) => {88          Object.assign(storage, 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 storage[key];93        }),94      },95      session: {96        get: vi.fn(async (key?: string | string[]) => {97          if (!key) return sessionStorage;98          if (typeof key === "string") return { [key]: sessionStorage[key] };99          return key.reduce<Record<string, unknown>>((acc, k) => {100            acc[k] = sessionStorage[k];101            return acc;102          }, {});103        }),104        set: vi.fn(async (next: Record<string, unknown>) => {105          Object.assign(sessionStorage, next);106        }),107        remove: vi.fn(async (keys: string | string[]) => {108          const list = Array.isArray(keys) ? keys : [keys];109          for (const key of list) delete sessionStorage[key];110        }),111        clear: vi.fn(async () => {112          for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];113        }),114      },115    },116    alarms: {117      clear: vi.fn(),118      create: vi.fn(),119      onAlarm: { addListener: vi.fn() },120    },121    idle: {122      setDetectionInterval: vi.fn(),123      onStateChanged: { addListener: vi.fn() },124    },125    scripting: { executeScript: vi.fn(async () => []) },126  } as unknown as typeof chrome;127}128129describe("vault adversarial inputs", () => {130  let storage: StorageShape;131  let sessionStorage: StorageShape;132  const sink: { listener?: MessageListener } = {};133134  beforeEach(async () => {135    vi.resetModules();136    vi.useRealTimers();137    storage = { peach_settings: { breachMonitoring: false } };138    sessionStorage = {};139    sink.listener = undefined;140141    vi.stubGlobal(142      "fetch",143      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))144    );145    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);146147    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(148      storage,149      sessionStorage,150      sink151    );152153    const messages = await import("./messages");154    messages.setupMessageHandler();155    const vault = await import("./vault");156    await vault.ensureSettingsLoaded();157  });158159  async function dispatch(message: Message): Promise<MessageResponse> {160    if (!sink.listener) throw new Error("listener not registered");161    return new Promise<MessageResponse>((resolve) => {162      const keepOpen = sink.listener!(163        message,164        {165          id: "peach-extension",166          url: "chrome-extension://peach-extension/popup.html",167        } as chrome.runtime.MessageSender,168        resolve169      );170      expect(keepOpen).toBe(true);171    });172  }173174  // ── Password boundary values ────────────────────────────────────────────175176  describe("password boundary values — CREATE_VAULT + UNLOCK round-trips", () => {177    for (const { name, password } of PASSWORD_CASES) {178      it(`CREATE_VAULT with ${name} produces a vault unlockable with same password`, async () => {179        const vault = await import("./vault");180        await vault.createVault(password);181        vault.lock();182        const result = await dispatch({183          type: "UNLOCK",184          payload: { password },185        });186        expect(result.ok).toBe(true);187      });188189      it(`UNLOCK with wrong password after ${name} creation fails (and no crash)`, async () => {190        const vault = await import("./vault");191        await vault.createVault(password);192        vault.lock();193        const wrong = password + "x";194        const result = await dispatch({195          type: "UNLOCK",196          payload: { password: wrong },197        });198        expect(result.ok).toBe(false);199      });200201      it(`UNLOCK with empty after ${name} creation fails`, async () => {202        const vault = await import("./vault");203        await vault.createVault(password);204        vault.lock();205        const result = await dispatch({206          type: "UNLOCK",207          payload: { password: "" },208        });209        // Empty when actual password is non-empty -> false; equal -> true.210        expect(result.ok).toBe(password === "");211      });212213      it(`re-locking after ${name} unlock cleans up state (IS_UNLOCKED=false)`, async () => {214        const vault = await import("./vault");215        await vault.createVault(password);216        await dispatch({ type: "LOCK" });217        const r = await dispatch({ type: "IS_UNLOCKED" });218        expect(r.ok).toBe(true);219        expect((r as { data: boolean }).data).toBe(false);220      });221    }222  });223224  // ── Entry boundary values ───────────────────────────────────────────────225226  describe("entry boundary values", () => {227    async function setupUnlocked() {228      const vault = await import("./vault");229      await vault.createVault("test-pass-12345");230    }231232    it("entry with empty title is rejected by sanitizeEntryDraft", async () => {233      // Product decision: an entry with no title is unusable in the UI list,234      // so sanitizeEntryDraft rejects it before persistence.235      await setupUnlocked();236      const r = await dispatch({237        type: "ADD_ENTRY",238        payload: { title: "", username: "u", password: "test", url: "https://e.com" },239      });240      expect(r.ok).toBe(false);241    });242243    // PCH-01-016: the batch-add path was skipping the MV-6 canary/tags whitelist.244    it("batch_add_drops_non_https_canary", async () => {245      const vault = await import("./vault");246      await vault.createVault("test-pass-12345");247      const [entry] = await vault.batchAddEntries([248        {249          type: "login",250          title: "Acct",251          username: "u",252          password: "p",253          url: "https://e.com",254          canary: { webhookUrl: "http://evil.example" },255        } as unknown as Parameters<typeof vault.batchAddEntries>[0][number],256      ]);257      expect(entry.canary).toBeUndefined();258    });259260    it("batch_add_filters_oversized_and_nonstring_tags", async () => {261      const vault = await import("./vault");262      await vault.createVault("test-pass-12345");263      const tags = [123, "  ", "x".repeat(5000), ...Array(1000).fill("t")];264      const [entry] = await vault.batchAddEntries([265        {266          type: "login",267          title: "Tagged",268          username: "u",269          password: "p",270          url: "https://e.com",271          tags,272        } as unknown as Parameters<typeof vault.batchAddEntries>[0][number],273      ]);274      expect(entry.tags).toBeDefined();275      expect(entry.tags!.length).toBeLessThanOrEqual(255);276      expect(entry.tags!.every((t) => typeof t === "string")).toBe(true);277      expect(entry.tags!.every((t) => t.length <= 256)).toBe(true);278      // 123 and "  " dropped; "x".repeat(5000) truncated to 256 chars.279      expect(entry.tags).toContain("x".repeat(256));280    });281282    const titleCases = [283      { name: "1 char title", title: "x" },284      { name: "100 char title", title: "x".repeat(100) },285      { name: "1000 char title", title: "x".repeat(1000) },286      { name: "10000 char title", title: "x".repeat(10000) },287      { name: "unicode title", title: "日本語 🔑 Ünïcödé" },288      { name: "emoji title", title: "🔑🔒🛡️🌸" },289      { name: "RTL title", title: "كلمة المرور" },290      { name: "tab in title", title: "Tab\there" },291      { name: "newline in title", title: "Line1\nLine2" },292      { name: "html-looking title", title: "<script>alert(1)</script>" },293      { name: "sql-looking title", title: "' OR '1'='1" },294      { name: "null character in title", title: "before\x00after" },295      { name: "control chars title", title: "\x01\x02\x03\x04" },296    ];297298    for (const { name, title } of titleCases) {299      it(`entry with ${name} is handled without crash`, async () => {300        await setupUnlocked();301        const r = await dispatch({302          type: "ADD_ENTRY",303          payload: { title, username: "u", password: "p", url: "https://e.com" },304        });305        expect(r).toBeDefined();306      });307308      it(`entry with ${name} round-trips title via GET_ENTRIES`, async () => {309        await setupUnlocked();310        await dispatch({311          type: "ADD_ENTRY",312          payload: { title, username: "u", password: "p", url: "https://e.com" },313        });314        const list = await dispatch({ type: "GET_ENTRIES" });315        expect(list.ok).toBe(true);316        const entries = (list as { data: Array<{ title: string }> }).data;317        expect(entries.length).toBe(1);318        // Title is preserved as-is (no sanitisation/escaping).319        expect(entries[0].title).toBe(title);320      });321    }322323    const urlCases = [324      { name: "https URL", url: "https://github.com" },325      { name: "http URL", url: "http://example.com" },326      { name: "javascript URL", url: "javascript:alert('xss')" },327      { name: "data URL", url: "data:text/html,<script>alert(1)</script>" },328      { name: "file URL", url: "file:///etc/passwd" },329      { name: "empty URL", url: "" },330      { name: "spaces URL", url: "   " },331      { name: "ftp URL", url: "ftp://example.com" },332      { name: "IPv6 URL", url: "https://[::1]" },333      { name: "IDN URL", url: "https://日本.jp" },334      { name: "very long URL", url: "https://example.com/" + "a".repeat(5000) },335    ];336337    for (const { name, url } of urlCases) {338      it(`entry with ${name} stored as-is`, async () => {339        await setupUnlocked();340        const r = await dispatch({341          type: "ADD_ENTRY",342          payload: { title: "t", username: "u", password: "p", url },343        });344        expect(r.ok).toBe(true);345      });346    }347348    it("100 entries: count consistent", async () => {349      await setupUnlocked();350      for (let i = 0; i < 100; i++) {351        await dispatch({352          type: "ADD_ENTRY",353          payload: { title: `E${i}`, username: "u", password: `p${i}`, url: "https://e.com" },354        });355      }356      const list = await dispatch({ type: "GET_ENTRIES" });357      expect((list as { data: unknown[] }).data.length).toBe(100);358    });359360    it("vault remains unlockable after many entries", async () => {361      await setupUnlocked();362      const vault = await import("./vault");363      for (let i = 0; i < 50; i++) {364        await vault.addEntry({365          title: `E${i}`,366          username: "u",367          password: `p${i}`,368          url: "https://e.com",369          type: "login",370        });371      }372      vault.lock();373      const unlock = await dispatch({374        type: "UNLOCK",375        payload: { password: "test-pass-12345" },376      });377      expect(unlock.ok).toBe(true);378      const list = await dispatch({ type: "GET_ENTRIES" });379      expect((list as { data: unknown[] }).data.length).toBe(50);380    });381382    it("add then delete leaves no live entries", async () => {383      await setupUnlocked();384      const r = await dispatch({385        type: "ADD_ENTRY",386        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },387      });388      const id = (r as { data: { id: string } }).data.id;389      await dispatch({ type: "DELETE_ENTRY", payload: { id } });390      const list = await dispatch({ type: "GET_ENTRIES" });391      expect((list as { data: unknown[] }).data.length).toBe(0);392    });393  });394395  // ── Concurrent operations ───────────────────────────────────────────────396397  describe("concurrent operations", () => {398    async function setupLocked() {399      const vault = await import("./vault");400      await vault.createVault("test-pass-12345");401      vault.lock();402    }403404    it("simultaneous UNLOCK calls leave vault unlocked", async () => {405      await setupLocked();406      const [r1, r2] = await Promise.all([407        dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),408        dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),409      ]);410      expect(r1.ok || r2.ok).toBe(true);411      const status = await dispatch({ type: "IS_UNLOCKED" });412      expect((status as { data: boolean }).data).toBe(true);413    });414415    it("simultaneous wrong UNLOCKs leave vault locked", async () => {416      await setupLocked();417      await Promise.all([418        dispatch({ type: "UNLOCK", payload: { password: "wrong-1" } }),419        dispatch({ type: "UNLOCK", payload: { password: "wrong-2" } }),420      ]);421      const status = await dispatch({ type: "IS_UNLOCKED" });422      expect((status as { data: boolean }).data).toBe(false);423    });424425    it("ADD_ENTRY during LOCK does not corrupt vault", async () => {426      const vault = await import("./vault");427      await vault.createVault("test-pass-12345");428      await Promise.all([429        dispatch({430          type: "ADD_ENTRY",431          payload: { title: "t", username: "u", password: "p", url: "https://e.com" },432        }).catch(() => null),433        dispatch({ type: "LOCK" }),434      ]);435      const unlock = await dispatch({436        type: "UNLOCK",437        payload: { password: "test-pass-12345" },438      });439      expect(unlock.ok).toBe(true);440    });441442    for (const count of [5, 10, 20, 50]) {443      it(`simultaneous ADD_ENTRY x ${count} produce that many entries`, async () => {444        const vault = await import("./vault");445        await vault.createVault("test-pass-12345");446        await Promise.all(447          Array.from({ length: count }, (_, i) =>448            dispatch({449              type: "ADD_ENTRY",450              payload: {451                title: `E${i}`,452                username: "u",453                password: "p",454                url: "https://e.com",455              },456            })457          )458        );459        const list = await dispatch({ type: "GET_ENTRIES" });460        expect((list as { data: unknown[] }).data.length).toBe(count);461      });462    }463464    it("interleaved ADD + UPDATE keeps entry counts stable", async () => {465      const vault = await import("./vault");466      await vault.createVault("test-pass-12345");467      const r = await dispatch({468        type: "ADD_ENTRY",469        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },470      });471      const id = (r as { data: { id: string } }).data.id;472      await Promise.all([473        dispatch({474          type: "ADD_ENTRY",475          payload: { title: "y", username: "u", password: "p", url: "https://e.com" },476        }),477        dispatch({ type: "UPDATE_ENTRY", payload: { id, patch: { username: "v" } } }),478        dispatch({479          type: "ADD_ENTRY",480          payload: { title: "z", username: "u", password: "p", url: "https://e.com" },481        }),482      ]);483      const list = await dispatch({ type: "GET_ENTRIES" });484      expect((list as { data: unknown[] }).data.length).toBe(3);485    });486  });487488  // ── Storage corruption ──────────────────────────────────────────────────489490  describe("storage corruption", () => {491    async function setupVaultInStorage() {492      const vault = await import("./vault");493      await vault.createVault("test-pass-12345");494      vault.lock();495    }496497    it("non-base64 slot A: unlock fails gracefully", async () => {498      await setupVaultInStorage();499      storage.peach_vault_a = "not-a-valid-stored-vault" as unknown;500      const r = await dispatch({501        type: "UNLOCK",502        payload: { password: "test-pass-12345" },503      });504      expect(r.ok).toBe(false);505    });506507    it("missing slot A: unlock fails", async () => {508      await setupVaultInStorage();509      delete storage.peach_vault_a;510      const r = await dispatch({511        type: "UNLOCK",512        payload: { password: "test-pass-12345" },513      });514      expect(r.ok).toBe(false);515    });516517    it("missing slot B: unlock still works from slot A", async () => {518      await setupVaultInStorage();519      delete storage.peach_vault_b;520      const r = await dispatch({521        type: "UNLOCK",522        payload: { password: "test-pass-12345" },523      });524      expect(r.ok).toBe(true);525    });526527    it("corrupt ciphertext in slot A: unlock fails", async () => {528      await setupVaultInStorage();529      const a = storage.peach_vault_a as { ciphertext: string };530      storage.peach_vault_a = { ...a, ciphertext: "AAAA" + a.ciphertext.slice(4) };531      const r = await dispatch({532        type: "UNLOCK",533        payload: { password: "test-pass-12345" },534      });535      expect(r.ok).toBe(false);536    });537538    it("corrupt salt: unlock fails", async () => {539      await setupVaultInStorage();540      const a = storage.peach_vault_a as { salt: string };541      storage.peach_vault_a = { ...a, salt: "00".repeat(16) };542      const r = await dispatch({543        type: "UNLOCK",544        payload: { password: "test-pass-12345" },545      });546      expect(r.ok).toBe(false);547    });548549    it("corrupt nonce: unlock fails", async () => {550      await setupVaultInStorage();551      const a = storage.peach_vault_a as { nonce: string };552      storage.peach_vault_a = { ...a, nonce: "ff".repeat(12) };553      const r = await dispatch({554        type: "UNLOCK",555        payload: { password: "test-pass-12345" },556      });557      expect(r.ok).toBe(false);558    });559560    it("corrupt tag: unlock fails", async () => {561      await setupVaultInStorage();562      const a = storage.peach_vault_a as { tag: string };563      storage.peach_vault_a = { ...a, tag: "00".repeat(16) };564      const r = await dispatch({565        type: "UNLOCK",566        payload: { password: "test-pass-12345" },567      });568      expect(r.ok).toBe(false);569    });570571    it("truncated ciphertext: unlock fails", async () => {572      await setupVaultInStorage();573      const a = storage.peach_vault_a as { ciphertext: string };574      storage.peach_vault_a = { ...a, ciphertext: a.ciphertext.slice(0, 20) };575      const r = await dispatch({576        type: "UNLOCK",577        payload: { password: "test-pass-12345" },578      });579      expect(r.ok).toBe(false);580    });581582    it("empty ciphertext: unlock fails", async () => {583      await setupVaultInStorage();584      const a = storage.peach_vault_a as { ciphertext: string };585      storage.peach_vault_a = { ...a, ciphertext: "" };586      const r = await dispatch({587        type: "UNLOCK",588        payload: { password: "test-pass-12345" },589      });590      expect(r.ok).toBe(false);591    });592593    for (const flipPos of [0, 4, 8, 16, 32, 64]) {594      it(`bit flip in ciphertext at offset ${flipPos}: unlock fails`, async () => {595        await setupVaultInStorage();596        const a = storage.peach_vault_a as { ciphertext: string };597        if (a.ciphertext.length <= flipPos + 1) return;598        const flipped =599          a.ciphertext.slice(0, flipPos) +600          (a.ciphertext[flipPos] === "0" ? "f" : "0") +601          a.ciphertext.slice(flipPos + 1);602        storage.peach_vault_a = { ...a, ciphertext: flipped };603        const r = await dispatch({604          type: "UNLOCK",605          payload: { password: "test-pass-12345" },606        });607        expect(r.ok).toBe(false);608      });609    }610611    it("after corruption, original storage record still exists (no wipe)", async () => {612      await setupVaultInStorage();613      const a = storage.peach_vault_a as { ciphertext: string };614      storage.peach_vault_a = { ...a, ciphertext: "00".repeat(64) };615      await dispatch({616        type: "UNLOCK",617        payload: { password: "test-pass-12345" },618      });619      expect(storage.peach_vault_a).toBeDefined();620    });621  });622623  // ── Unlock attempts / response shape ────────────────────────────────────624625  describe("repeated wrong unlock attempts", () => {626    async function setupLocked() {627      const vault = await import("./vault");628      await vault.createVault("test-pass-12345");629      vault.lock();630    }631632    for (let attempt = 1; attempt <= 10; attempt++) {633      it(`attempt ${attempt}: wrong password returns ok=false`, async () => {634        await setupLocked();635        for (let i = 0; i < attempt - 1; i++) {636          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });637        }638        const r = await dispatch({639          type: "UNLOCK",640          payload: { password: "wrong" },641        });642        expect(r.ok).toBe(false);643      });644    }645646    it(647      "vault data is preserved across 25 wrong attempts",648      async () => {649        await setupLocked();650        for (let i = 0; i < 25; i++) {651          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });652        }653        expect(storage.peach_vault_a).toBeTruthy();654        // Correct password still works.655        const ok = await dispatch({656          type: "UNLOCK",657          payload: { password: "test-pass-12345" },658        });659        expect(ok.ok).toBe(true);660      },661      60_000,662    );663664    it(665      "vault data is preserved across 50 wrong attempts",666      async () => {667        await setupLocked();668        for (let i = 0; i < 50; i++) {669          await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });670        }671        expect(storage.peach_vault_a).toBeTruthy();672      },673      120_000,674    );675676    it("successful unlock after wrong attempts produces unlocked state", async () => {677      await setupLocked();678      for (let i = 0; i < 5; i++) {679        await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });680      }681      const r = await dispatch({682        type: "UNLOCK",683        payload: { password: "test-pass-12345" },684      });685      expect(r.ok).toBe(true);686      const status = await dispatch({ type: "IS_UNLOCKED" });687      expect((status as { data: boolean }).data).toBe(true);688    });689690    it("incorrect-then-correct: state is unlocked", async () => {691      await setupLocked();692      await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });693      const r = await dispatch({694        type: "UNLOCK",695        payload: { password: "test-pass-12345" },696      });697      expect(r.ok).toBe(true);698    });699  });700701  // ── Generated random password set ───────────────────────────────────────702703  describe("generated random password fuzz round-trips", () => {704    // 50 deterministic randomly-shaped passwords; each must round-trip.705    const seeded: string[] = [];706    let rng = 0xdeadbeef;707    function next() {708      rng = (rng * 1103515245 + 12345) & 0x7fffffff;709      return rng;710    }711    for (let i = 0; i < 50; i++) {712      const len = (next() % 30) + 4;713      let s = "";714      for (let j = 0; j < len; j++) {715        s += String.fromCharCode((next() % 94) + 33);716      }717      seeded.push(s);718    }719    for (const [i, password] of seeded.entries()) {720      it(`fuzz password #${i} round-trips`, async () => {721        const vault = await import("./vault");722        await vault.createVault(password);723        vault.lock();724        const r = await dispatch({ type: "UNLOCK", payload: { password } });725        expect(r.ok).toBe(true);726      });727728      it(`fuzz password #${i} wrong rejects`, async () => {729        const vault = await import("./vault");730        await vault.createVault(password);731        vault.lock();732        const r = await dispatch({733          type: "UNLOCK",734          payload: { password: password.slice(0, -1) + "Z" },735        });736        expect(r.ok).toBe(false);737      });738    }739  });740741  // ── ADD/DELETE/UPDATE high-volume parameterized ─────────────────────────742743  describe("entry lifecycle parameterized", () => {744    for (const n of [1, 2, 3, 5, 10, 25, 50]) {745      it(`add ${n} entries then delete all leaves no live entries`, async () => {746        const vault = await import("./vault");747        await vault.createVault("test-pass-12345");748        const ids: string[] = [];749        for (let i = 0; i < n; i++) {750          const r = await dispatch({751            type: "ADD_ENTRY",752            payload: {753              title: `E${i}`,754              username: "u",755              password: "p",756              url: "https://e.com",757            },758          });759          ids.push((r as { data: { id: string } }).data.id);760        }761        for (const id of ids) {762          await dispatch({ type: "DELETE_ENTRY", payload: { id } });763        }764        const list = await dispatch({ type: "GET_ENTRIES" });765        expect((list as { data: unknown[] }).data.length).toBe(0);766      });767768      it(`add ${n} entries then update each title`, async () => {769        const vault = await import("./vault");770        await vault.createVault("test-pass-12345");771        const ids: string[] = [];772        for (let i = 0; i < n; i++) {773          const r = await dispatch({774            type: "ADD_ENTRY",775            payload: {776              title: `E${i}`,777              username: "u",778              password: "p",779              url: "https://e.com",780            },781          });782          ids.push((r as { data: { id: string } }).data.id);783        }784        for (const [i, id] of ids.entries()) {785          await dispatch({786            type: "UPDATE_ENTRY",787            payload: { id, patch: { title: `Updated${i}` } },788          });789        }790        const list = await dispatch({ type: "GET_ENTRIES" });791        const titles = (792          list as { data: Array<{ title: string }> }793        ).data.map((e) => e.title);794        for (let i = 0; i < n; i++) {795          expect(titles).toContain(`Updated${i}`);796        }797      });798    }799  });800801  // ── lock/unlock churn ───────────────────────────────────────────────────802803  describe("lock/unlock churn", () => {804    for (const cycles of [1, 2, 3, 5, 10]) {805      it(`${cycles} lock/unlock cycles preserve entries`, async () => {806        const vault = await import("./vault");807        await vault.createVault("test-pass-12345");808        await vault.addEntry({809          title: "Persistent",810          username: "u",811          password: "p",812          url: "https://e.com",813          type: "login",814        });815        for (let i = 0; i < cycles; i++) {816          vault.lock();817          const r = await dispatch({818            type: "UNLOCK",819            payload: { password: "test-pass-12345" },820          });821          expect(r.ok).toBe(true);822        }823        const list = await dispatch({ type: "GET_ENTRIES" });824        expect((list as { data: unknown[] }).data.length).toBe(1);825      });826    }827  });828829  // ── Storage shape adversarial — every slot field corrupted ──────────────830831  describe("per-field corruption matrix", () => {832    async function setup() {833      const vault = await import("./vault");834      await vault.createVault("test-pass-12345");835      vault.lock();836    }837838    const fields = [839      "salt",840      "nonce",841      "ciphertext",842      "tag",843      "version",844    ];845    const corruptions = [846      { name: "empty", value: "" },847      { name: "garbage hex", value: "zzzz" },848      { name: "wrong length 1", value: "ab" },849      { name: "wrong length long", value: "ab".repeat(200) },850      { name: "non-string number", value: 42 as unknown as string },851      { name: "non-string null", value: null as unknown as string },852    ];853    for (const field of fields) {854      for (const { name, value } of corruptions) {855        it(`field=${field} corruption=${name}: unlock fails or response defined`, async () => {856          await setup();857          const a = storage.peach_vault_a as Record<string, unknown>;858          storage.peach_vault_a = { ...a, [field]: value };859          const r = await dispatch({860            type: "UNLOCK",861            payload: { password: "test-pass-12345" },862          });863          expect(r).toBeDefined();864          // Cannot unlock unless the field was already correct.865          if (866            field !== "version" ||867            (typeof value !== "string" && value !== null)868          ) {869            expect(r.ok).toBe(false);870          }871        });872      }873    }874  });875876  // ── Corruption distinguished from wrong password (M7/COR-007) ───────────877878  describe("vault corruption surfaces vault_corrupted error", () => {879    async function setup() {880      const vault = await import("./vault");881      await vault.createVault("test-pass-12345");882      vault.lock();883    }884885    it("schema-invalid slot (version=42) returns vault_corrupted with canRestoreFromHistory", async () => {886      await setup();887      const a = storage.peach_vault_a as Record<string, unknown>;888      storage.peach_vault_a = { ...a, version: 42 };889      const r = await dispatch({890        type: "UNLOCK",891        payload: { password: "test-pass-12345" },892      });893      expect(r.ok).toBe(false);894      expect((r as { error?: string }).error).toBe("vault_corrupted");895      expect((r as { canRestoreFromHistory?: boolean }).canRestoreFromHistory).toBe(true);896    });897898    it("wrong password (correct schema) does NOT return vault_corrupted", async () => {899      await setup();900      const r = await dispatch({901        type: "UNLOCK",902        payload: { password: "wrong-password" },903      });904      expect(r.ok).toBe(false);905      expect((r as { error?: string }).error).not.toBe("vault_corrupted");906    });907  });908909  // ── Many "add then lock then unlock" sequences ──────────────────────────910911  describe("add-then-relock churn", () => {912    for (let seed = 0; seed < 20; seed++) {913      it(`seed ${seed}: add 3 entries with seeded titles survives lock/unlock`, async () => {914        const vault = await import("./vault");915        await vault.createVault("test-pass-12345");916        for (let i = 0; i < 3; i++) {917          await vault.addEntry({918            title: `seed-${seed}-${i}`,919            username: `u${i}`,920            password: `p${i}`,921            url: `https://e${seed}.com`,922            type: "login",923          });924        }925        vault.lock();926        const r = await dispatch({927          type: "UNLOCK",928          payload: { password: "test-pass-12345" },929        });930        expect(r.ok).toBe(true);931        const list = await dispatch({ type: "GET_ENTRIES" });932        expect((list as { data: unknown[] }).data.length).toBe(3);933      });934    }935  });936937  // ── Sanity / no-crash checks across many invocations ────────────────────938939  describe("UNLOCK no-crash matrix", () => {940    const adversarialPayloads = [941      { name: "null password", payload: { password: null as unknown as string } },942      { name: "undefined password", payload: { password: undefined as unknown as string } },943      { name: "number password", payload: { password: 12345 as unknown as string } },944      { name: "bool password", payload: { password: true as unknown as string } },945      { name: "array password", payload: { password: ["a", "b"] as unknown as string } },946      { name: "object password", payload: { password: { x: 1 } as unknown as string } },947      { name: "missing password field", payload: {} as { password: string } },948      { name: "empty password", payload: { password: "" } },949    ];950951    for (const { name, payload } of adversarialPayloads) {952      it(`UNLOCK with ${name} resolves without crash`, async () => {953        const vault = await import("./vault");954        await vault.createVault("test-pass-12345");955        vault.lock();956        const r = await dispatch({ type: "UNLOCK", payload });957        expect(r).toBeDefined();958      });959    }960  });961});