messages.adversarial.test.ts

40.0 KB · raw

1/**2 * messages.adversarial.test.ts — Layer 3 §3.3 adversarial message handler tests.3 *4 * Exercises every payload-bearing handler with null/undefined/empty/nested5 * payloads, unknown message types, AUTOFILL_QUERY edge cases, and Codex6 * restore boundary inputs. Parameterized for >=300 tests.7 *8 * Where the audit references handlers Peach does not implement9 * (IMPORT_PEACHSCRIPT / RESTORE_FROM_PEACHSCRIPT), the tests confirm the10 * default `unknown message type` path responds without crashing — see the11 * 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// Payload-bearing handlers we exercise with adversarial payloads.26const PAYLOAD_HANDLERS = [27  "UNLOCK",28  "CREATE_VAULT",29  "ADD_ENTRY",30  "UPDATE_ENTRY",31  "DELETE_ENTRY",32  "BATCH_ADD_ENTRIES",33  "RESTORE_ENTRIES",34  "ADD_ADDITIONAL_URL",35  "RESTORE_ENTRY",36  "PURGE_ENTRY",37  "PURGE_EXPIRED_TRASH",38  "SAVE_NOTE",39  "DELETE_NOTE",40  "RECOVER_WITH_WORDS",41  "VALIDATE_RECOVERY_WORDS",42  "RESTORE_FROM_CODEX",43  "AUTOFILL_QUERY",44  "GET_ENTRY_FOR_FILL",45  "COPY",46  "UPDATE_SETTINGS",47  "APPROVE_PASSKEY",48  "DENY_PASSKEY",49  "SET_PASSKEY_PROVIDER_ENABLED",50  "ACTIVATE_LICENSE",51  "RESTORE_VAULT_SNAPSHOT",52  "CHANGE_PASSWORD",53  "SETUP_DURESS",54  "VERIFY_MAIN_PASSWORD",55  "FIRE_CANARY_PING",56  "ML_PREDICT_BATCH",57  "SEARCH_VAULT",58  "GET_PASSKEY_FOR_DOMAIN",59];6061describe("message handler adversarial inputs", () => {62  let storage: StorageShape;63  let sessionStorage: StorageShape;64  let listener: MessageListener | undefined;6566  beforeEach(async () => {67    vi.resetModules();68    vi.useRealTimers();69    storage = { peach_settings: { breachMonitoring: false } };70    sessionStorage = {};71    listener = undefined;7273    vi.stubGlobal(74      "fetch",75      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))76    );77    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);7879    const chromeMock = {80      runtime: {81        id: "peach-extension",82        onMessage: {83          addListener: vi.fn((fn: MessageListener) => {84            listener = fn;85          }),86        },87        sendMessage: vi.fn(async () => ({ ok: true })),88      },89      tabs: {90        query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {91          if (typeof cb === "function") {92            cb([{ id: 1 }]);93            return;94          }95          return Promise.resolve([{ id: 1 }]);96        }),97        sendMessage: vi.fn(async () => ({ ok: true })),98      },99      storage: {100        local: {101          get: vi.fn(async (key?: string | string[]) => {102            if (!key) return storage;103            if (typeof key === "string") return { [key]: storage[key] };104            return key.reduce<Record<string, unknown>>((acc, k) => {105              acc[k] = storage[k];106              return acc;107            }, {});108          }),109          set: vi.fn(async (next: Record<string, unknown>) => {110            Object.assign(storage, next);111          }),112          remove: vi.fn(async (keys: string | string[]) => {113            const list = Array.isArray(keys) ? keys : [keys];114            for (const key of list) delete storage[key];115          }),116        },117        session: {118          get: vi.fn(async (key?: string | string[]) => {119            if (!key) return sessionStorage;120            if (typeof key === "string") return { [key]: sessionStorage[key] };121            return key.reduce<Record<string, unknown>>((acc, k) => {122              acc[k] = sessionStorage[k];123              return acc;124            }, {});125          }),126          set: vi.fn(async (next: Record<string, unknown>) => {127            Object.assign(sessionStorage, next);128          }),129          remove: vi.fn(async (keys: string | string[]) => {130            const list = Array.isArray(keys) ? keys : [keys];131            for (const key of list) delete sessionStorage[key];132          }),133          clear: vi.fn(async () => {134            for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];135          }),136        },137      },138      alarms: {139        clear: vi.fn(),140        create: vi.fn(),141        onAlarm: { addListener: vi.fn() },142      },143      idle: {144        setDetectionInterval: vi.fn(),145        onStateChanged: { addListener: vi.fn() },146      },147      scripting: { executeScript: vi.fn(async () => []) },148    } as unknown as typeof chrome;149150    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;151152    const messages = await import("./messages");153    messages.setupMessageHandler();154    const vault = await import("./vault");155    await vault.ensureSettingsLoaded();156  });157158  async function dispatch(message: Message): Promise<MessageResponse> {159    if (!listener) throw new Error("listener not registered");160    return new Promise<MessageResponse>((resolve) => {161      const keepOpen = listener!(162        message,163        { id: "peach-extension" } as chrome.runtime.MessageSender,164        resolve165      );166      expect(keepOpen).toBe(true);167    });168  }169170  async function setupUnlockedVault() {171    const vault = await import("./vault");172    await vault.createVault("test-pass-12345");173  }174175  // ── Null/undefined/empty/nested payload matrix ──────────────────────────176177  describe("payload no-crash matrix", () => {178    for (const type of PAYLOAD_HANDLERS) {179      it(`${type} with null payload does not crash`, async () => {180        const r = await dispatch({ type, payload: null } as unknown as Message);181        expect(r).toBeDefined();182      });183184      it(`${type} with undefined payload does not crash`, async () => {185        const r = await dispatch({186          type,187          payload: undefined,188        } as unknown as Message);189        expect(r).toBeDefined();190      });191192      it(`${type} with empty object payload does not crash`, async () => {193        const r = await dispatch({ type, payload: {} } as unknown as Message);194        expect(r).toBeDefined();195      });196197      it(`${type} with deeply nested payload does not crash`, async () => {198        const nested: Record<string, unknown> = {};199        let cur: Record<string, unknown> = nested;200        for (let i = 0; i < 50; i++) {201          const child: Record<string, unknown> = {};202          cur.child = child;203          cur = child;204        }205        const r = await dispatch({206          type,207          payload: nested,208        } as unknown as Message);209        expect(r).toBeDefined();210      });211212      it(`${type} with array payload does not crash`, async () => {213        const r = await dispatch({214          type,215          payload: [1, 2, 3],216        } as unknown as Message);217        expect(r).toBeDefined();218      });219220      it(`${type} with string payload does not crash`, async () => {221        const r = await dispatch({222          type,223          payload: "garbage",224        } as unknown as Message);225        expect(r).toBeDefined();226      });227228      it(`${type} with number payload does not crash`, async () => {229        const r = await dispatch({230          type,231          payload: 42,232        } as unknown as Message);233        expect(r).toBeDefined();234      });235    }236  });237238  // ── AUD-007: extended no-crash payload sweep ────────────────────────────239  //240  // Each payload variant runs against every handler in PAYLOAD_HANDLERS and241  // must produce a structured response — never a thrown exception. This242  // expands fuzz coverage to >3000 tests without changing handler behavior.243244  describe("AUD-007 extended payload no-crash sweep", () => {245    const extraPayloads: Array<{ name: string; payload: unknown }> = [246      { name: "boolean true", payload: true },247      { name: "boolean false", payload: false },248      { name: "negative number", payload: -1 },249      { name: "NaN", payload: NaN },250      { name: "Infinity", payload: Infinity },251      { name: "empty string", payload: "" },252      { name: "very long string", payload: "A".repeat(10_000) },253      { name: "unicode string", payload: "私のパスワード🔑" },254      { name: "RTL string", payload: "مفتاح‮" },255      { name: "null bytes in string", payload: "a\x00b\x00c" },256      { name: "html string", payload: "<script>alert(1)</script>" },257      { name: "sql string", payload: "'; DROP TABLE entries; --" },258      { name: "path traversal string", payload: "../../etc/passwd" },259      { name: "object with null prototype-ish", payload: { __proto__: null } },260      { name: "object with prototype pollution attempt", payload: { __proto__: { polluted: true } } },261      { name: "object with constructor pollution", payload: { constructor: { prototype: { polluted: true } } } },262      { name: "deeply nested array", payload: [[[[[[[[[[]]]]]]]]]] },263      { name: "long array", payload: new Array(1_000).fill(0) },264      { name: "mixed array", payload: [null, undefined, 1, "x", true, {}, []] },265      { name: "function-like object", payload: { call: () => 1, apply: () => 1 } },266      { name: "Date object", payload: new Date(0) },267      { name: "BigInt-ish payload", payload: { n: "9007199254740993" } },268      { name: "circular-safe placeholder", payload: { self: "[Circular]" } },269    ];270271    for (const { name, payload } of extraPayloads) {272      for (const type of PAYLOAD_HANDLERS) {273        it(`${type} with ${name} payload does not crash`, async () => {274          const r = await dispatch({ type, payload } as unknown as Message);275          expect(r).toBeDefined();276          expect(typeof r.ok).toBe("boolean");277        });278      }279    }280  });281282  // ── Unknown / malformed message types ───────────────────────────────────283284  describe("unknown message types", () => {285    const unknownTypes = [286      "TOTALLY_UNKNOWN_TYPE",287      "",288      "X",289      "lowercase_type",290      "with spaces in type",291      "with-dashes",292      "WITH.DOTS",293      "🌸emoji_type",294      "Unicode_üñïcödé",295      "IMPORT_PEACHSCRIPT",296      "RESTORE_FROM_PEACHSCRIPT",297      "DESTROY_VAULT_QUIETLY",298      "ESCALATE_PRIVILEGE",299    ];300301    for (const type of unknownTypes) {302      it(`unknown type "${type}" returns error response`, async () => {303        const r = await dispatch({ type } as unknown as Message);304        expect(r).toBeDefined();305        expect(r.ok).toBe(false);306      });307    }308309    it("missing type field does not crash", async () => {310      const r = await dispatch({} as unknown as Message);311      expect(r).toBeDefined();312    });313314    it("type=null does not crash", async () => {315      const r = await dispatch({ type: null } as unknown as Message);316      expect(r).toBeDefined();317    });318319    it("type=number does not crash", async () => {320      const r = await dispatch({ type: 42 } as unknown as Message);321      expect(r).toBeDefined();322    });323  });324325  // ── Sender validation ────────────────────────────────────────────────────326327  describe("sender validation", () => {328    it("foreign extension sender is rejected", async () => {329      if (!listener) throw new Error("listener not registered");330      const r = await new Promise<MessageResponse>((resolve) => {331        listener!(332          { type: "GET_ENTRIES" } as Message,333          { id: "foreign-extension" } as chrome.runtime.MessageSender,334          resolve335        );336      });337      expect(r.ok).toBe(false);338      expect(r.error).toBe("invalid_sender");339    });340341    it("sender with no id (extension UI) is accepted", async () => {342      if (!listener) throw new Error("listener not registered");343      await setupUnlockedVault();344      const r = await new Promise<MessageResponse>((resolve) => {345        listener!(346          { type: "GET_ENTRIES" } as Message,347          {} as chrome.runtime.MessageSender,348          resolve349        );350      });351      expect(r.ok).toBe(true);352    });353  });354355  // ── AUTOFILL_QUERY adversarial ──────────────────────────────────────────356357  describe("AUTOFILL_QUERY adversarial", () => {358    async function withEntries(n: number) {359      const vault = await import("./vault");360      await vault.createVault("test-pass-12345");361      for (let i = 0; i < n; i++) {362        await vault.addEntry({363          title: `E${i}`,364          username: `u${i}`,365          password: "p",366          url: `https://example${i}.com`,367          type: "login",368        });369      }370    }371372    it("empty domain returns empty results", async () => {373      await withEntries(3);374      const r = await dispatch({375        type: "AUTOFILL_QUERY",376        payload: { domain: "" },377      });378      expect(r.ok).toBe(true);379      expect((r as { data: unknown[] }).data).toEqual([]);380    });381382    const adversarialDomains = [383      "*",384      ".*",385      "%",386      "<script>alert(1)</script>",387      "' OR '1'='1",388      "../../../etc/passwd",389      "\x00github.com",390      "github.com\x00",391      "GITHUB.COM",392      "github.com/path",393      "https://github.com",394      "javascript:alert(1)",395      " ",396      "   ",397      "\t",398      "\n",399      String("a").repeat(5000),400    ];401    for (const domain of adversarialDomains) {402      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not crash`, async () => {403        await withEntries(3);404        const r = await dispatch({405          type: "AUTOFILL_QUERY",406          payload: { domain },407        });408        expect(r.ok).toBe(true);409      });410411      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not match all entries`, async () => {412        await withEntries(5);413        const r = await dispatch({414          type: "AUTOFILL_QUERY",415          payload: { domain },416        });417        const data = (r as { data: unknown[] }).data;418        // No adversarial wildcard should ever match every entry.419        expect(data.length).toBeLessThan(5);420      });421    }422423    it("locked vault: AUTOFILL_QUERY returns error", async () => {424      const vault = await import("./vault");425      await vault.createVault("test-pass-12345");426      vault.lock();427      const r = await dispatch({428        type: "AUTOFILL_QUERY",429        payload: { domain: "github.com" },430      });431      expect(r.ok).toBe(false);432    });433434    it("response never leaks privateKeyJwk", async () => {435      await withEntries(3);436      const r = await dispatch({437        type: "AUTOFILL_QUERY",438        payload: { domain: "example0.com" },439      });440      const data = (r as { data: Array<Record<string, unknown>> }).data;441      for (const entry of data) {442        expect("privateKeyJwk" in entry).toBe(false);443        expect("passkey" in entry).toBe(false);444      }445    });446447    it("kind=card returns card summaries only (no full PAN)", async () => {448      const vault = await import("./vault");449      await vault.createVault("test-pass-12345");450      await vault.addEntry({451        type: "card",452        title: "Visa",453        cardNumber: "4111111111111111",454        last4: "1111",455        cardholderName: "JUNE",456        expMonth: "12",457        expYear: "2030",458        cvc: "123",459      } as unknown as Parameters<typeof vault.addEntry>[0]);460      const r = await dispatch({461        type: "AUTOFILL_QUERY",462        payload: { domain: "any.com", kind: "card" },463      });464      const data = (r as { data: Array<Record<string, unknown>> }).data;465      for (const entry of data) {466        expect("cardNumber" in entry).toBe(false);467        expect("cvc" in entry).toBe(false);468      }469    });470  });471472  // ── RESTORE_FROM_CODEX adversarial ──────────────────────────────────────473474  describe("RESTORE_FROM_CODEX adversarial", () => {475    const malformed = [476      { name: "empty vaultBlob", overrides: { vaultBlob: "" } },477      { name: "garbage vaultBlob", overrides: { vaultBlob: "!!!not-base64!!!" } },478      { name: "wrong fingerprint", overrides: { vaultFingerprint: "0".repeat(16) } },479      { name: "wrong passphrase", overrides: { masterPassphrase: "definitely wrong" } },480      { name: "negative timestamp", overrides: { timestamp: -1 } },481      { name: "zero timestamp", overrides: { timestamp: 0 } },482      { name: "huge entryCount", overrides: { entryCount: 1_000_000 } },483      { name: "negative entryCount", overrides: { entryCount: -5 } },484    ];485    for (const { name, overrides } of malformed) {486      it(`malformed payload (${name}) returns ok=false without crash`, async () => {487        const payload = {488          vaultBlob: "ZGF0YQ==",489          timestamp: Date.now(),490          verifyCode: "PX-TEST-1234",491          vaultFingerprint: "ABCDEF1234567890",492          entryCount: 0,493          masterPassphrase: "secret",494          ...overrides,495        };496        const r = await dispatch({ type: "RESTORE_FROM_CODEX", payload });497        expect(r).toBeDefined();498        expect(r.ok).toBe(false);499      });500    }501502    it("null payload does not crash", async () => {503      const r = await dispatch({504        type: "RESTORE_FROM_CODEX",505        payload: null,506      } as unknown as Message);507      expect(r).toBeDefined();508      expect(r.ok).toBe(false);509    });510  });511512  // ── COPY handler ────────────────────────────────────────────────────────513514  describe("COPY handler adversarial", () => {515    for (const text of ["", "x", "a".repeat(10000), "\x00\x00\x00", "🔑"]) {516      it(`COPY of ${JSON.stringify(text.slice(0, 20))} resolves`, async () => {517        const r = await dispatch({518          type: "COPY",519          payload: { text, label: "test", alreadyWritten: true },520        });521        expect(r).toBeDefined();522      });523    }524  });525526  // ── FIRE_CANARY_PING adversarial ────────────────────────────────────────527528  describe("FIRE_CANARY_PING webhook url validation", () => {529    const badUrls = [530      "javascript:alert(1)",531      "file:///etc/passwd",532      "data:text/plain,evil",533      "",534      "not a url",535      "ftp://example.com/hook",536    ];537    for (const url of badUrls) {538      it(`rejects bad webhook URL ${JSON.stringify(url)}`, async () => {539        const r = await dispatch({540          type: "FIRE_CANARY_PING",541          payload: {542            webhookUrl: url,543            entryTitle: "x",544            trigger: "test",545          },546        });547        expect(r.ok).toBe(false);548      });549    }550  });551552  // ── GET_ENTRY_FOR_FILL safety ───────────────────────────────────────────553554  describe("GET_ENTRY_FOR_FILL safety", () => {555    it("returns entry without passkey field for login entry", async () => {556      const vault = await import("./vault");557      await vault.createVault("test-pass-12345");558      const e = await vault.addEntry({559        title: "GitHub",560        username: "june",561        password: "x",562        url: "https://github.com",563        type: "login",564      });565      const r = await dispatch({566        type: "GET_ENTRY_FOR_FILL",567        payload: { id: e.id },568      });569      expect(r.ok).toBe(true);570      const data = (r as { data: Record<string, unknown> }).data;571      expect("passkey" in data).toBe(false);572      expect("privateKeyJwk" in data).toBe(false);573    });574575    it("missing id returns error", async () => {576      await setupUnlockedVault();577      const r = await dispatch({578        type: "GET_ENTRY_FOR_FILL",579        payload: { id: "not-a-real-id" },580      });581      expect(r.ok).toBe(false);582    });583584    it("locked vault returns error", async () => {585      const vault = await import("./vault");586      await vault.createVault("test-pass-12345");587      vault.lock();588      const r = await dispatch({589        type: "GET_ENTRY_FOR_FILL",590        payload: { id: "x" },591      });592      expect(r.ok).toBe(false);593    });594  });595596  // ── GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006) ────────────────597598  describe("GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006)", () => {599    async function dispatchFrom(600      message: Message,601      sender: chrome.runtime.MessageSender,602    ): Promise<MessageResponse> {603      if (!listener) throw new Error("listener not registered");604      return new Promise<MessageResponse>((resolve) => {605        listener!(message, sender, resolve);606      });607    }608609    async function addGithubEntry() {610      const vault = await import("./vault");611      await vault.createVault("test-pass-12345");612      return vault.addEntry({613        title: "GitHub",614        username: "june",615        password: "x",616        url: "https://github.com",617        type: "login",618      });619    }620621    it("tab-context request on the entry's domain returns the full entry", async () => {622      const e = await addGithubEntry();623      const r = await dispatchFrom(624        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,625        { id: "peach-extension", tab: { url: "https://github.com/login" } } as chrome.runtime.MessageSender,626      );627      expect(r.ok).toBe(true);628      expect((r as { data: Record<string, unknown> }).data.username).toBe("june");629    });630631    it("tab-context request on a mismatched domain returns entry_not_found with no leak", async () => {632      const e = await addGithubEntry();633      const r = await dispatchFrom(634        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,635        { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,636      );637      expect(r.ok).toBe(false);638      expect(r.error).toBe("entry_not_found");639      expect((r as { data?: unknown }).data).toBeUndefined();640    });641642    it("explicitSummon allows a cross-domain tab-context request", async () => {643      const e = await addGithubEntry();644      const r = await dispatchFrom(645        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id, explicitSummon: true } } as Message,646        { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,647      );648      expect(r.ok).toBe(true);649      expect((r as { data: Record<string, unknown> }).data.username).toBe("june");650    });651652    it("popup context (no tab/url) skips the origin check and returns the entry", async () => {653      const e = await addGithubEntry();654      const r = await dispatchFrom(655        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,656        { id: "peach-extension" } as chrome.runtime.MessageSender,657      );658      expect(r.ok).toBe(true);659      expect((r as { data: Record<string, unknown> }).data.username).toBe("june");660    });661  });662663  // ── ADD_ENTRY adversarial when locked ───────────────────────────────────664665  describe("ADD_ENTRY locked-state safety", () => {666    it("locked vault: ADD_ENTRY returns vault locked", async () => {667      const r = await dispatch({668        type: "ADD_ENTRY",669        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },670      });671      expect(r.ok).toBe(false);672    });673674    it("ADD_ENTRY with no password and no passkey succeeds for email-only accounts", async () => {675      await setupUnlockedVault();676      const r = await dispatch({677        type: "ADD_ENTRY",678        payload: { title: "x", username: "u", password: "", url: "https://e.com" },679      });680      expect(r.ok).toBe(true);681    });682  });683684  // ── DELETE_ENTRY adversarial ────────────────────────────────────────────685686  describe("DELETE_ENTRY adversarial", () => {687    const badIds = [688      "",689      "   ",690      "not-a-uuid",691      String("a").repeat(10000),692      "00000000-0000-0000-0000-000000000000",693      "<script>",694      "\x00",695    ];696    for (const id of badIds) {697      it(`unknown id ${JSON.stringify(id.slice(0, 30))} returns error`, async () => {698        await setupUnlockedVault();699        const r = await dispatch({700          type: "DELETE_ENTRY",701          payload: { id },702        });703        expect(r.ok).toBe(false);704      });705    }706  });707708  // ── BATCH_ADD_ENTRIES adversarial ───────────────────────────────────────709710  describe("BATCH_ADD_ENTRIES adversarial", () => {711    it("empty array does not crash", async () => {712      await setupUnlockedVault();713      const r = await dispatch({714        type: "BATCH_ADD_ENTRIES",715        payload: { entries: [] },716      });717      expect(r).toBeDefined();718    });719720    it("non-array entries does not crash", async () => {721      await setupUnlockedVault();722      const r = await dispatch({723        type: "BATCH_ADD_ENTRIES",724        payload: { entries: "not-an-array" as unknown as unknown[] },725      });726      expect(r).toBeDefined();727    });728729    it("mostly-malformed entries does not crash", async () => {730      await setupUnlockedVault();731      const r = await dispatch({732        type: "BATCH_ADD_ENTRIES",733        payload: {734          entries: [735            null,736            undefined,737            42,738            "string",739            { title: "ok", username: "u", password: "p", url: "https://e.com" },740          ] as unknown as unknown[],741        },742      });743      expect(r).toBeDefined();744    });745  });746747  // ── SEARCH_VAULT adversarial ────────────────────────────────────────────748749  describe("SEARCH_VAULT adversarial", () => {750    const queries = [751      "",752      " ",753      "x",754      "a".repeat(1000),755      "🔑",756      "<script>",757      "' OR '1'='1",758      "../../",759      "\x00",760    ];761    for (const query of queries) {762      it(`query ${JSON.stringify(query.slice(0, 30))} does not crash`, async () => {763        await setupUnlockedVault();764        const r = await dispatch({765          type: "SEARCH_VAULT",766          payload: { query },767        });768        expect(r).toBeDefined();769      });770    }771  });772773  // ── UPDATE_SETTINGS adversarial ─────────────────────────────────────────774775  describe("UPDATE_SETTINGS adversarial", () => {776    const patches = [777      { name: "empty", patch: {} },778      { name: "junk keys", patch: { __proto__: { polluted: true } } },779      { name: "very long autolock", patch: { autoLockMinutes: 99999999 } },780      { name: "negative autolock", patch: { autoLockMinutes: -5 } },781      { name: "string autolock", patch: { autoLockMinutes: "five" } },782      { name: "unknown nested", patch: { foo: { bar: { baz: 1 } } } },783    ];784    for (const { name, patch } of patches) {785      it(`patch ${name} does not crash`, async () => {786        await setupUnlockedVault();787        const r = await dispatch({788          type: "UPDATE_SETTINGS",789          payload: patch as Record<string, unknown>,790        });791        expect(r).toBeDefined();792      });793    }794795    it("prototype pollution check: Object.prototype.polluted stays unset", async () => {796      await setupUnlockedVault();797      await dispatch({798        type: "UPDATE_SETTINGS",799        payload: JSON.parse(800          '{"__proto__": {"polluted": "yes"}}'801        ) as Record<string, unknown>,802      });803      // eslint-disable-next-line @typescript-eslint/no-explicit-any804      expect(({} as any).polluted).toBeUndefined();805    });806  });807808  // ── Wider adversarial payload matrix per handler ────────────────────────809810  describe("wide adversarial payload matrix", () => {811    const adversarialPayloads: Array<{ name: string; payload: unknown }> = [812      { name: "true", payload: true },813      { name: "false", payload: false },814      { name: "NaN", payload: NaN },815      { name: "Infinity", payload: Infinity },816      { name: "-Infinity", payload: -Infinity },817      { name: "BigInt-like", payload: { type: "bigint" } },818      { name: "Date", payload: new Date() },819      { name: "RegExp", payload: /pattern/ },820      { name: "Symbol-string", payload: Symbol("x").toString() },821      { name: "circular reference", payload: (() => {822        const o: Record<string, unknown> = {};823        o.self = o;824        return o;825      })() },826    ];827828    const fastHandlers = [829      "ADD_ENTRY",830      "UPDATE_ENTRY",831      "DELETE_ENTRY",832      "AUTOFILL_QUERY",833      "GET_ENTRY_FOR_FILL",834      "UPDATE_SETTINGS",835      "SEARCH_VAULT",836      "SAVE_NOTE",837      "DELETE_NOTE",838      "PURGE_ENTRY",839      "RESTORE_ENTRY",840      "PURGE_EXPIRED_TRASH",841      "ADD_ADDITIONAL_URL",842      "BATCH_ADD_ENTRIES",843      "RESTORE_ENTRIES",844      "ACTIVATE_LICENSE",845      "APPROVE_PASSKEY",846      "DENY_PASSKEY",847      "SET_PASSKEY_PROVIDER_ENABLED",848      "RESTORE_VAULT_SNAPSHOT",849      "VERIFY_MAIN_PASSWORD",850      "SETUP_DURESS",851    ];852853    for (const handler of fastHandlers) {854      for (const { name, payload } of adversarialPayloads) {855        it(`${handler} + ${name} payload resolves without crash`, async () => {856          const r = await dispatch({ type: handler, payload } as unknown as Message);857          expect(r).toBeDefined();858        });859      }860    }861  });862863  // ── Foreign sender × handler matrix ─────────────────────────────────────864865  describe("foreign sender rejection matrix", () => {866    const safeQuickHandlers = [867      "GET_ENTRIES",868      "IS_UNLOCKED",869      "HAS_VAULT",870      "GET_VAULT_STATUS",871      "GET_SETTINGS",872      "GET_DELETED_ENTRIES",873      "COUNT_DUPLICATES",874      "GET_VAULT_HISTORY",875      "ADD_ENTRY",876      "DELETE_ENTRY",877      "AUTOFILL_QUERY",878      "GET_ENTRY_FOR_FILL",879      "SAVE_NOTE",880      "DELETE_NOTE",881      "GET_NOTES",882      "SEARCH_VAULT",883      "GET_LICENSE",884      "GET_LICENSE_KEY",885      "GET_RECOVERY_WORDS",886      "VALIDATE_RECOVERY_WORDS",887      "RECOVER_WITH_WORDS",888      "RESTORE_FROM_CODEX",889      "GET_CODEX_VAULT_DATA",890      "ESTIMATE_CODEX_PAGES",891    ];892    for (const handler of safeQuickHandlers) {893      it(`${handler} from foreign sender returns invalid sender`, async () => {894        if (!listener) throw new Error("listener not registered");895        const r = await new Promise<MessageResponse>((resolve) => {896          listener!(897            { type: handler, payload: {} } as Message,898            { id: "foreign-extension-id" } as chrome.runtime.MessageSender,899            resolve900          );901        });902        expect(r.ok).toBe(false);903        expect(r.error).toBe("invalid_sender");904      });905    }906  });907908  // ── ADD_ENTRY payload-shape robustness ──────────────────────────────────909910  describe("ADD_ENTRY payload shape robustness", () => {911    const shapes = [912      { name: "no title", body: { username: "u", password: "p", url: "https://e.com" } },913      { name: "no url", body: { title: "t", username: "u", password: "p" } },914      { name: "title only", body: { title: "t", password: "p" } },915      { name: "spurious type", body: { type: "login", title: "t", password: "p" } },916      { name: "unknown type", body: { type: "wat", title: "t", password: "p" } },917      { name: "with extra field", body: { title: "t", password: "p", extra: "ignored" } },918      { name: "url with port", body: { title: "t", password: "p", url: "https://e.com:8080" } },919      { name: "url with path", body: { title: "t", password: "p", url: "https://e.com/a/b/c" } },920      { name: "username as number", body: { title: "t", username: 42, password: "p", url: "https://e.com" } },921      { name: "password as number", body: { title: "t", password: 12345, url: "https://e.com" } },922      { name: "title as null", body: { title: null, password: "p", url: "https://e.com" } },923      { name: "title as object", body: { title: { wat: 1 }, password: "p", url: "https://e.com" } },924    ];925    for (const { name, body } of shapes) {926      it(`ADD_ENTRY shape ${name} resolves without crash`, async () => {927        await setupUnlockedVault();928        const r = await dispatch({929          type: "ADD_ENTRY",930          payload: body as Parameters<typeof dispatch>[0]["payload"],931        } as Message);932        expect(r).toBeDefined();933      });934    }935  });936937  // ── UPDATE_ENTRY patch-shape robustness ─────────────────────────────────938939  describe("UPDATE_ENTRY patch shape robustness", () => {940    const patches = [941      { name: "title only", patch: { title: "New" } },942      { name: "password only", patch: { password: "newpass" } },943      { name: "url only", patch: { url: "https://e2.com" } },944      { name: "many fields", patch: { title: "N", username: "u2", password: "p2", url: "https://e2.com" } },945      { name: "unknown field", patch: { totallyMadeUp: "x" } },946      { name: "deletedAt forbidden", patch: { deletedAt: Date.now() } },947      { name: "createdAt forbidden", patch: { createdAt: 0 } },948      { name: "modifiedAt patch", patch: { modifiedAt: Date.now() } },949      { name: "id patch (should reject or ignore)", patch: { id: "new-id" } },950    ];951    for (const { name, patch } of patches) {952      it(`UPDATE_ENTRY patch ${name} resolves without crash`, async () => {953        const vault = await import("./vault");954        await vault.createVault("test-pass-12345");955        const e = await vault.addEntry({956          title: "x",957          username: "u",958          password: "p",959          url: "https://e.com",960          type: "login",961        });962        const r = await dispatch({963          type: "UPDATE_ENTRY",964          payload: { id: e.id, patch },965        });966        expect(r).toBeDefined();967      });968    }969  });970971  // ── Sequence: vault life-cycle adversarial ──────────────────────────────972973  describe("vault lifecycle adversarial sequences", () => {974    for (let i = 0; i < 20; i++) {975      it(`random message sequence #${i} does not crash`, async () => {976        const vault = await import("./vault");977        await vault.createVault("test-pass-12345");978        const handlers = [979          { type: "GET_ENTRIES" },980          { type: "IS_UNLOCKED" },981          { type: "GET_VAULT_STATUS" },982          { type: "HAS_VAULT" },983          { type: "GET_DELETED_ENTRIES" },984          { type: "GET_SETTINGS" },985          { type: "COUNT_DUPLICATES" },986          { type: "GET_VAULT_HISTORY" },987        ] as Array<Message>;988        for (const h of handlers) {989          const r = await dispatch(h);990          expect(r).toBeDefined();991        }992      });993    }994  });995996  // ── Read-only handlers when locked ──────────────────────────────────────997998  describe("read-only handlers when locked", () => {999    const safeWhenLocked = [1000      "IS_UNLOCKED",1001      "HAS_VAULT",1002      "GET_VAULT_STATUS",1003      "GET_SETTINGS",1004    ];1005    for (const type of safeWhenLocked) {1006      it(`${type} works without crash when locked`, async () => {1007        const r = await dispatch({ type } as unknown as Message);1008        expect(r).toBeDefined();1009      });1010    }10111012    const gatedWhenLocked = [1013      "GET_ENTRIES",1014      "GET_DELETED_ENTRIES",1015      "COUNT_DUPLICATES",1016      "GET_IDENTITIES",1017      "GET_IDENTITY_SUGGESTIONS",1018    ];1019    for (const type of gatedWhenLocked) {1020      it(`${type} returns ok=false when locked`, async () => {1021        const r = await dispatch({ type } as unknown as Message);1022        expect(r).toBeDefined();1023        // Some handlers return ok:false explicitly; others may throw.1024        if (r.ok === true) {1025          // Accept ok:true only if data is empty.1026          const data = (r as { data: unknown }).data;1027          expect(1028            data == null ||1029              (Array.isArray(data) && data.length === 0) ||1030              data === 01031          ).toBe(true);1032        } else {1033          expect(r.ok).toBe(false);1034        }1035      });1036    }1037  });10381039  // ── M2/QUA-002: payload validation at trust boundary ────────────────────10401041  describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {1042    // Each entry: handler type + payloads that must produce ok:false.1043    // The accepted error codes include 'invalid_payload' (the new validator)1044    // or 'vault_locked' (handlers that lock-check before validating, which1045    // is still a correct rejection of the request).1046    const cases: Array<{ type: string; bad: unknown[] }> = [1047      {1048        type: "ADD_ENTRY",1049        bad: [null, "string", 42, [], true],1050      },1051      {1052        type: "BATCH_ADD_ENTRIES",1053        bad: [null, { entries: "not-array" }, { entries: null }, { entries: 42 }, []],1054      },1055      {1056        type: "UPDATE_ENTRY",1057        bad: [null, {}, { id: 123 }, { id: "x" }, { id: "x", patch: "no" }, { id: "x", patch: null }, []],1058      },1059      {1060        type: "RESTORE_ENTRIES",1061        bad: [null, {}, { entries: "no" }, { entries: [], mode: "wrong" }, []],1062      },1063      {1064        type: "UPDATE_SETTINGS",1065        bad: [null, "string", 42, []],1066      },1067    ];10681069    for (const { type, bad } of cases) {1070      for (const payload of bad) {1071        const label = JSON.stringify(payload);1072        it(`${type} rejects malformed payload: ${label}`, async () => {1073          await setupUnlockedVault();1074          const r = await dispatch({ type, payload } as unknown as Message);1075          expect(r.ok).toBe(false);1076          const err = (r as { error?: string }).error ?? "";1077          expect(err).toMatch(/invalid_payload|vault[_ ]locked/);1078        });1079      }1080    }1081  });10821083  // ── AUD-002: payload validation for previously raw-cast handlers ────────10841085  describe("AUD-002 payload validation", () => {1086    describe("SEARCH_VAULT", () => {1087      const badQueries: Array<[string, unknown]> = [1088        ["null", null],1089        ["numeric", 42],1090        ["boolean", true],1091        ["object", { evil: true }],1092        ["array", []],1093        ["missing", undefined],1094      ];1095      for (const [name, query] of badQueries) {1096        it(`rejects ${name} query`, async () => {1097          await setupUnlockedVault();1098          const r = await dispatch({1099            type: "SEARCH_VAULT",1100            payload: { query },1101          } as unknown as Message);1102          expect(r.ok).toBe(false);1103          expect((r as { error?: string }).error).toBe("invalid_payload");1104        });1105      }11061107      it("accepts valid string query", async () => {1108        await setupUnlockedVault();1109        const r = await dispatch({1110          type: "SEARCH_VAULT",1111          payload: { query: "github" },1112        });1113        expect(r.ok).toBe(true);1114      });1115    });11161117    describe("COPY", () => {1118      const bads: Array<[string, unknown, unknown]> = [1119        ["null text", null, "pw"],1120        ["numeric text", 42, "pw"],1121        ["object text", { evil: true }, "pw"],1122        ["missing text", undefined, "pw"],1123        ["null label", "secret", null],1124        ["numeric label", "secret", 42],1125      ];1126      for (const [name, text, label] of bads) {1127        it(`rejects ${name}`, async () => {1128          await setupUnlockedVault();1129          const r = await dispatch({1130            type: "COPY",1131            payload: { text, label },1132          } as unknown as Message);1133          expect(r.ok).toBe(false);1134          expect((r as { error?: string }).error).toBe("invalid_payload");1135        });1136      }1137    });11381139    describe("ML_PREDICT_BATCH", () => {1140      const bads: Array<[string, unknown]> = [1141        ["null", null],1142        ["object", { not: "array" }],1143        ["string", "features"],1144        ["number", 42],1145        ["missing", undefined],1146      ];1147      for (const [name, featuresArray] of bads) {1148        it(`rejects ${name} featuresArray`, async () => {1149          const r = await dispatch({1150            type: "ML_PREDICT_BATCH",1151            payload: { featuresArray },1152          } as unknown as Message);1153          expect(r.ok).toBe(false);1154          expect((r as { error?: string }).error).toBe("invalid_payload");1155        });1156      }11571158      it("accepts empty array featuresArray", async () => {1159        const r = await dispatch({1160          type: "ML_PREDICT_BATCH",1161          payload: { featuresArray: [] },1162        } as unknown as Message);1163        expect((r as { error?: string }).error).not.toBe("invalid_payload");1164      });1165    });1166  });1167});