messages.adversarial.test.ts

43.1 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        getURL: (p: string) => `chrome-extension://peach-extension/${p}`,83        onMessage: {84          addListener: vi.fn((fn: MessageListener) => {85            listener = fn;86          }),87        },88        sendMessage: vi.fn(async () => ({ ok: true })),89      },90      tabs: {91        query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {92          if (typeof cb === "function") {93            cb([{ id: 1 }]);94            return;95          }96          return Promise.resolve([{ id: 1 }]);97        }),98        sendMessage: vi.fn(async () => ({ ok: true })),99      },100      storage: {101        local: {102          get: vi.fn(async (key?: string | string[]) => {103            if (!key) return storage;104            if (typeof key === "string") return { [key]: storage[key] };105            return key.reduce<Record<string, unknown>>((acc, k) => {106              acc[k] = storage[k];107              return acc;108            }, {});109          }),110          set: vi.fn(async (next: Record<string, unknown>) => {111            Object.assign(storage, next);112          }),113          remove: vi.fn(async (keys: string | string[]) => {114            const list = Array.isArray(keys) ? keys : [keys];115            for (const key of list) delete storage[key];116          }),117        },118        session: {119          get: vi.fn(async (key?: string | string[]) => {120            if (!key) return sessionStorage;121            if (typeof key === "string") return { [key]: sessionStorage[key] };122            return key.reduce<Record<string, unknown>>((acc, k) => {123              acc[k] = sessionStorage[k];124              return acc;125            }, {});126          }),127          set: vi.fn(async (next: Record<string, unknown>) => {128            Object.assign(sessionStorage, next);129          }),130          remove: vi.fn(async (keys: string | string[]) => {131            const list = Array.isArray(keys) ? keys : [keys];132            for (const key of list) delete sessionStorage[key];133          }),134          clear: vi.fn(async () => {135            for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];136          }),137        },138      },139      alarms: {140        clear: vi.fn(),141        create: vi.fn(),142        onAlarm: { addListener: vi.fn() },143      },144      idle: {145        setDetectionInterval: vi.fn(),146        onStateChanged: { addListener: vi.fn() },147      },148      scripting: { executeScript: vi.fn(async () => []) },149    } as unknown as typeof chrome;150151    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;152153    const messages = await import("./messages");154    messages.setupMessageHandler();155    const vault = await import("./vault");156    await vault.ensureSettingsLoaded();157  });158159  async function dispatch(160    message: Message,161    sender: chrome.runtime.MessageSender = { id: "peach-extension" } as chrome.runtime.MessageSender,162  ): Promise<MessageResponse> {163    if (!listener) throw new Error("listener not registered");164    return new Promise<MessageResponse>((resolve) => {165      const keepOpen = listener!(message, sender, resolve);166      expect(keepOpen).toBe(true);167    });168  }169170  // PCH-01-018: destructive ops require an extension-page sender; the adversarial171  // RESTORE_FROM_CODEX payload tests must use this to reach payload validation.172  const EXTENSION_PAGE_SENDER = {173    id: "peach-extension",174    url: "chrome-extension://peach-extension/popup.html",175  } as chrome.runtime.MessageSender;176177  async function setupUnlockedVault() {178    const vault = await import("./vault");179    await vault.createVault("test-pass-12345");180  }181182  // ── Null/undefined/empty/nested payload matrix ──────────────────────────183184  describe("payload no-crash matrix", () => {185    for (const type of PAYLOAD_HANDLERS) {186      it(`${type} with null payload does not crash`, async () => {187        const r = await dispatch({ type, payload: null } as unknown as Message);188        expect(r).toBeDefined();189      });190191      it(`${type} with undefined payload does not crash`, async () => {192        const r = await dispatch({193          type,194          payload: undefined,195        } as unknown as Message);196        expect(r).toBeDefined();197      });198199      it(`${type} with empty object payload does not crash`, async () => {200        const r = await dispatch({ type, payload: {} } as unknown as Message);201        expect(r).toBeDefined();202      });203204      it(`${type} with deeply nested payload does not crash`, async () => {205        const nested: Record<string, unknown> = {};206        let cur: Record<string, unknown> = nested;207        for (let i = 0; i < 50; i++) {208          const child: Record<string, unknown> = {};209          cur.child = child;210          cur = child;211        }212        const r = await dispatch({213          type,214          payload: nested,215        } as unknown as Message);216        expect(r).toBeDefined();217      });218219      it(`${type} with array payload does not crash`, async () => {220        const r = await dispatch({221          type,222          payload: [1, 2, 3],223        } as unknown as Message);224        expect(r).toBeDefined();225      });226227      it(`${type} with string payload does not crash`, async () => {228        const r = await dispatch({229          type,230          payload: "garbage",231        } as unknown as Message);232        expect(r).toBeDefined();233      });234235      it(`${type} with number payload does not crash`, async () => {236        const r = await dispatch({237          type,238          payload: 42,239        } as unknown as Message);240        expect(r).toBeDefined();241      });242    }243  });244245  // ── AUD-007: extended no-crash payload sweep ────────────────────────────246  //247  // Each payload variant runs against every handler in PAYLOAD_HANDLERS and248  // must produce a structured response — never a thrown exception. This249  // expands fuzz coverage to >3000 tests without changing handler behavior.250251  describe("AUD-007 extended payload no-crash sweep", () => {252    const extraPayloads: Array<{ name: string; payload: unknown }> = [253      { name: "boolean true", payload: true },254      { name: "boolean false", payload: false },255      { name: "negative number", payload: -1 },256      { name: "NaN", payload: NaN },257      { name: "Infinity", payload: Infinity },258      { name: "empty string", payload: "" },259      { name: "very long string", payload: "A".repeat(10_000) },260      { name: "unicode string", payload: "私のパスワード🔑" },261      { name: "RTL string", payload: "مفتاح‮" },262      { name: "null bytes in string", payload: "a\x00b\x00c" },263      { name: "html string", payload: "<script>alert(1)</script>" },264      { name: "sql string", payload: "'; DROP TABLE entries; --" },265      { name: "path traversal string", payload: "../../etc/passwd" },266      { name: "object with null prototype-ish", payload: { __proto__: null } },267      { name: "object with prototype pollution attempt", payload: { __proto__: { polluted: true } } },268      { name: "object with constructor pollution", payload: { constructor: { prototype: { polluted: true } } } },269      { name: "deeply nested array", payload: [[[[[[[[[[]]]]]]]]]] },270      { name: "long array", payload: new Array(1_000).fill(0) },271      { name: "mixed array", payload: [null, undefined, 1, "x", true, {}, []] },272      { name: "function-like object", payload: { call: () => 1, apply: () => 1 } },273      { name: "Date object", payload: new Date(0) },274      { name: "BigInt-ish payload", payload: { n: "9007199254740993" } },275      { name: "circular-safe placeholder", payload: { self: "[Circular]" } },276    ];277278    for (const { name, payload } of extraPayloads) {279      for (const type of PAYLOAD_HANDLERS) {280        it(`${type} with ${name} payload does not crash`, async () => {281          const r = await dispatch({ type, payload } as unknown as Message);282          expect(r).toBeDefined();283          expect(typeof r.ok).toBe("boolean");284        });285      }286    }287  });288289  // ── Unknown / malformed message types ───────────────────────────────────290291  describe("unknown message types", () => {292    const unknownTypes = [293      "TOTALLY_UNKNOWN_TYPE",294      "",295      "X",296      "lowercase_type",297      "with spaces in type",298      "with-dashes",299      "WITH.DOTS",300      "🌸emoji_type",301      "Unicode_üñïcödé",302      "IMPORT_PEACHSCRIPT",303      "RESTORE_FROM_PEACHSCRIPT",304      "DESTROY_VAULT_QUIETLY",305      "ESCALATE_PRIVILEGE",306    ];307308    for (const type of unknownTypes) {309      it(`unknown type "${type}" returns error response`, async () => {310        const r = await dispatch({ type } as unknown as Message);311        expect(r).toBeDefined();312        expect(r.ok).toBe(false);313      });314    }315316    it("missing type field does not crash", async () => {317      const r = await dispatch({} as unknown as Message);318      expect(r).toBeDefined();319    });320321    it("type=null does not crash", async () => {322      const r = await dispatch({ type: null } as unknown as Message);323      expect(r).toBeDefined();324    });325326    it("type=number does not crash", async () => {327      const r = await dispatch({ type: 42 } as unknown as Message);328      expect(r).toBeDefined();329    });330  });331332  // ── Sender validation ────────────────────────────────────────────────────333334  describe("sender validation", () => {335    it("foreign extension sender is rejected", async () => {336      if (!listener) throw new Error("listener not registered");337      const r = await new Promise<MessageResponse>((resolve) => {338        listener!(339          { type: "GET_ENTRIES" } as Message,340          { id: "foreign-extension" } as chrome.runtime.MessageSender,341          resolve342        );343      });344      expect(r.ok).toBe(false);345      expect(r.error).toBe("invalid_sender");346    });347348    it("sender with no id (extension UI) is accepted", async () => {349      if (!listener) throw new Error("listener not registered");350      await setupUnlockedVault();351      const r = await new Promise<MessageResponse>((resolve) => {352        listener!(353          { type: "GET_ENTRIES" } as Message,354          {} as chrome.runtime.MessageSender,355          resolve356        );357      });358      expect(r.ok).toBe(true);359    });360  });361362  // ── AUTOFILL_QUERY adversarial ──────────────────────────────────────────363364  describe("AUTOFILL_QUERY adversarial", () => {365    async function withEntries(n: number) {366      const vault = await import("./vault");367      await vault.createVault("test-pass-12345");368      for (let i = 0; i < n; i++) {369        await vault.addEntry({370          title: `E${i}`,371          username: `u${i}`,372          password: "p",373          url: `https://example${i}.com`,374          type: "login",375        });376      }377    }378379    it("empty domain returns empty results", async () => {380      await withEntries(3);381      const r = await dispatch({382        type: "AUTOFILL_QUERY",383        payload: { domain: "" },384      });385      expect(r.ok).toBe(true);386      expect((r as { data: unknown[] }).data).toEqual([]);387    });388389    const adversarialDomains = [390      "*",391      ".*",392      "%",393      "<script>alert(1)</script>",394      "' OR '1'='1",395      "../../../etc/passwd",396      "\x00github.com",397      "github.com\x00",398      "GITHUB.COM",399      "github.com/path",400      "https://github.com",401      "javascript:alert(1)",402      " ",403      "   ",404      "\t",405      "\n",406      String("a").repeat(5000),407    ];408    for (const domain of adversarialDomains) {409      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not crash`, async () => {410        await withEntries(3);411        const r = await dispatch({412          type: "AUTOFILL_QUERY",413          payload: { domain },414        });415        expect(r.ok).toBe(true);416      });417418      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not match all entries`, async () => {419        await withEntries(5);420        const r = await dispatch({421          type: "AUTOFILL_QUERY",422          payload: { domain },423        });424        const data = (r as { data: unknown[] }).data;425        // No adversarial wildcard should ever match every entry.426        expect(data.length).toBeLessThan(5);427      });428    }429430    it("locked vault: AUTOFILL_QUERY returns error", async () => {431      const vault = await import("./vault");432      await vault.createVault("test-pass-12345");433      vault.lock();434      const r = await dispatch({435        type: "AUTOFILL_QUERY",436        payload: { domain: "github.com" },437      });438      expect(r.ok).toBe(false);439    });440441    it("response never leaks privateKeyJwk", async () => {442      await withEntries(3);443      const r = await dispatch({444        type: "AUTOFILL_QUERY",445        payload: { domain: "example0.com" },446      });447      const data = (r as { data: Array<Record<string, unknown>> }).data;448      for (const entry of data) {449        expect("privateKeyJwk" in entry).toBe(false);450        expect("passkey" in entry).toBe(false);451      }452    });453454    it("kind=card returns card summaries only (no full PAN)", async () => {455      const vault = await import("./vault");456      await vault.createVault("test-pass-12345");457      await vault.addEntry({458        type: "card",459        title: "Visa",460        cardNumber: "4111111111111111",461        last4: "1111",462        cardholderName: "JUNE",463        expMonth: "12",464        expYear: "2030",465        cvc: "123",466      } as unknown as Parameters<typeof vault.addEntry>[0]);467      const r = await dispatch({468        type: "AUTOFILL_QUERY",469        payload: { domain: "any.com", kind: "card" },470      });471      const data = (r as { data: Array<Record<string, unknown>> }).data;472      for (const entry of data) {473        expect("cardNumber" in entry).toBe(false);474        expect("cvc" in entry).toBe(false);475      }476    });477  });478479  // ── RESTORE_FROM_CODEX adversarial ──────────────────────────────────────480481  describe("RESTORE_FROM_CODEX adversarial", () => {482    const malformed = [483      { name: "empty vaultBlob", overrides: { vaultBlob: "" } },484      { name: "garbage vaultBlob", overrides: { vaultBlob: "!!!not-base64!!!" } },485      { name: "wrong fingerprint", overrides: { vaultFingerprint: "0".repeat(16) } },486      { name: "wrong passphrase", overrides: { masterPassphrase: "definitely wrong" } },487      { name: "negative timestamp", overrides: { timestamp: -1 } },488      { name: "zero timestamp", overrides: { timestamp: 0 } },489      { name: "huge entryCount", overrides: { entryCount: 1_000_000 } },490      { name: "negative entryCount", overrides: { entryCount: -5 } },491    ];492    for (const { name, overrides } of malformed) {493      it(`malformed payload (${name}) returns ok=false without crash`, async () => {494        const payload = {495          vaultBlob: "ZGF0YQ==",496          timestamp: Date.now(),497          verifyCode: "PX-TEST-1234",498          vaultFingerprint: "ABCDEF1234567890",499          entryCount: 0,500          masterPassphrase: "secret",501          ...overrides,502        };503        const r = await dispatch({ type: "RESTORE_FROM_CODEX", payload }, EXTENSION_PAGE_SENDER);504        expect(r).toBeDefined();505        expect(r.ok).toBe(false);506      });507    }508509    it("null payload does not crash", async () => {510      const r = await dispatch({511        type: "RESTORE_FROM_CODEX",512        payload: null,513      } as unknown as Message, EXTENSION_PAGE_SENDER);514      expect(r).toBeDefined();515      expect(r.ok).toBe(false);516    });517  });518519  // ── COPY handler ────────────────────────────────────────────────────────520521  describe("COPY handler adversarial", () => {522    for (const text of ["", "x", "a".repeat(10000), "\x00\x00\x00", "🔑"]) {523      it(`COPY of ${JSON.stringify(text.slice(0, 20))} resolves`, async () => {524        const r = await dispatch({525          type: "COPY",526          payload: { text, label: "test", alreadyWritten: true },527        });528        expect(r).toBeDefined();529      });530    }531  });532533  // ── FIRE_CANARY_PING adversarial ────────────────────────────────────────534535  describe("FIRE_CANARY_PING webhook url validation", () => {536    const badUrls = [537      "javascript:alert(1)",538      "file:///etc/passwd",539      "data:text/plain,evil",540      "",541      "not a url",542      "ftp://example.com/hook",543    ];544    for (const url of badUrls) {545      it(`rejects bad webhook URL ${JSON.stringify(url)}`, async () => {546        const r = await dispatch({547          type: "FIRE_CANARY_PING",548          payload: {549            webhookUrl: url,550            entryTitle: "x",551            trigger: "test",552          },553        });554        expect(r.ok).toBe(false);555      });556    }557  });558559  // ── GET_ENTRY_FOR_FILL safety ───────────────────────────────────────────560561  describe("GET_ENTRY_FOR_FILL safety", () => {562    it("returns entry without passkey field for login entry", async () => {563      const vault = await import("./vault");564      await vault.createVault("test-pass-12345");565      const e = await vault.addEntry({566        title: "GitHub",567        username: "june",568        password: "x",569        url: "https://github.com",570        type: "login",571      });572      const r = await dispatch({573        type: "GET_ENTRY_FOR_FILL",574        payload: { id: e.id },575      });576      expect(r.ok).toBe(true);577      const data = (r as { data: Record<string, unknown> }).data;578      expect("passkey" in data).toBe(false);579      expect("privateKeyJwk" in data).toBe(false);580    });581582    it("missing id returns error", async () => {583      await setupUnlockedVault();584      const r = await dispatch({585        type: "GET_ENTRY_FOR_FILL",586        payload: { id: "not-a-real-id" },587      });588      expect(r.ok).toBe(false);589    });590591    it("locked vault returns error", async () => {592      const vault = await import("./vault");593      await vault.createVault("test-pass-12345");594      vault.lock();595      const r = await dispatch({596        type: "GET_ENTRY_FOR_FILL",597        payload: { id: "x" },598      });599      expect(r.ok).toBe(false);600    });601  });602603  // ── GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006) ────────────────604605  describe("GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006)", () => {606    async function dispatchFrom(607      message: Message,608      sender: chrome.runtime.MessageSender,609    ): Promise<MessageResponse> {610      if (!listener) throw new Error("listener not registered");611      return new Promise<MessageResponse>((resolve) => {612        listener!(message, sender, resolve);613      });614    }615616    async function addGithubEntry() {617      const vault = await import("./vault");618      await vault.createVault("test-pass-12345");619      return vault.addEntry({620        title: "GitHub",621        username: "june",622        password: "x",623        url: "https://github.com",624        type: "login",625      });626    }627628    it("tab-context request on the entry's domain returns the full entry", async () => {629      const e = await addGithubEntry();630      const r = await dispatchFrom(631        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,632        { id: "peach-extension", tab: { url: "https://github.com/login" } } as chrome.runtime.MessageSender,633      );634      expect(r.ok).toBe(true);635      expect((r as { data: Record<string, unknown> }).data.username).toBe("june");636    });637638    it("tab-context request on a mismatched domain returns entry_not_found with no leak", async () => {639      const e = await addGithubEntry();640      const r = await dispatchFrom(641        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,642        { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,643      );644      expect(r.ok).toBe(false);645      expect(r.error).toBe("entry_not_found");646      expect((r as { data?: unknown }).data).toBeUndefined();647    });648649    it("explicitSummon allows a cross-domain tab-context request", async () => {650      const e = await addGithubEntry();651      const r = await dispatchFrom(652        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id, explicitSummon: true } } as Message,653        { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,654      );655      expect(r.ok).toBe(true);656      expect((r as { data: Record<string, unknown> }).data.username).toBe("june");657    });658659    it("popup context (no tab/url) skips the origin check and returns the entry", async () => {660      const e = await addGithubEntry();661      const r = await dispatchFrom(662        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,663        { id: "peach-extension" } as chrome.runtime.MessageSender,664      );665      expect(r.ok).toBe(true);666      expect((r as { data: Record<string, unknown> }).data.username).toBe("june");667    });668  });669670  // ── Destructive-op sender gate (PCH-01-018) ─────────────────────────────671672  describe("destructive ops gated to extension-page senders (PCH-01-018)", () => {673    async function dispatchFrom(674      message: Message,675      sender: chrome.runtime.MessageSender,676    ): Promise<MessageResponse> {677      if (!listener) throw new Error("listener not registered");678      return new Promise<MessageResponse>((resolve) => {679        listener!(message, sender, resolve);680      });681    }682    const DESTRUCTIVE = [683      "DESTROY_VAULT",684      "CLEAR_VAULT_ENTRIES",685      "RESTORE_VAULT_SNAPSHOT",686      "RESTORE_FROM_CODEX",687    ] as const;688    const SENSITIVE = [689      "GET_ENTRIES",690      "GET_RECOVERY_WORDS",691      "RESTORE_ENTRIES",692      "UNPAIR_DEVICE",693      "CONFIRM_VOUCHED_DEVICE",694      "ROTATE_SYNC_IDENTITY",695    ] as const;696    const contentScript = {697      id: "peach-extension",698      url: "https://evil.test/",699    } as chrome.runtime.MessageSender;700    const extensionPage = {701      id: "peach-extension",702      url: "chrome-extension://peach-extension/popup.html",703    } as chrome.runtime.MessageSender;704705    for (const type of DESTRUCTIVE) {706      it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {707        await setupUnlockedVault();708        const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);709        expect(r).toEqual({ ok: false, error: "invalid_sender" });710      });711712      it(`${type} from an extension-page sender passes the origin gate`, async () => {713        await setupUnlockedVault();714        const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);715        // It may still fail downstream validation, but never on the origin gate.716        expect(r.error).not.toBe("invalid_sender");717      });718    }719720    for (const type of SENSITIVE) {721      it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {722        const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);723        expect(r).toEqual({ ok: false, error: "invalid_sender" });724      });725726      it(`${type} from an extension-page sender reaches the handler`, async () => {727        const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);728        expect(r.error).not.toBe("invalid_sender");729      });730    }731732    it("GET_LOCAL_IDENTITY from a content-script sender is rejected (audit 2026-08-20 #4)", async () => {733      const r = await dispatchFrom({ type: "GET_LOCAL_IDENTITY", payload: {} } as Message, contentScript);734      expect(r).toEqual({ ok: false, error: "invalid_sender" });735    });736  });737738  // ── ADD_ENTRY adversarial when locked ───────────────────────────────────739740  describe("ADD_ENTRY locked-state safety", () => {741    it("locked vault: ADD_ENTRY returns vault locked", async () => {742      const r = await dispatch({743        type: "ADD_ENTRY",744        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },745      });746      expect(r.ok).toBe(false);747    });748749    it("ADD_ENTRY with no password and no passkey succeeds for email-only accounts", async () => {750      await setupUnlockedVault();751      const r = await dispatch({752        type: "ADD_ENTRY",753        payload: { title: "x", username: "u", password: "", url: "https://e.com" },754      });755      expect(r.ok).toBe(true);756    });757  });758759  // ── DELETE_ENTRY adversarial ────────────────────────────────────────────760761  describe("DELETE_ENTRY adversarial", () => {762    const badIds = [763      "",764      "   ",765      "not-a-uuid",766      String("a").repeat(10000),767      "00000000-0000-0000-0000-000000000000",768      "<script>",769      "\x00",770    ];771    for (const id of badIds) {772      it(`unknown id ${JSON.stringify(id.slice(0, 30))} returns error`, async () => {773        await setupUnlockedVault();774        const r = await dispatch({775          type: "DELETE_ENTRY",776          payload: { id },777        });778        expect(r.ok).toBe(false);779      });780    }781  });782783  // ── BATCH_ADD_ENTRIES adversarial ───────────────────────────────────────784785  describe("BATCH_ADD_ENTRIES adversarial", () => {786    it("empty array does not crash", async () => {787      await setupUnlockedVault();788      const r = await dispatch({789        type: "BATCH_ADD_ENTRIES",790        payload: { entries: [] },791      });792      expect(r).toBeDefined();793    });794795    it("non-array entries does not crash", async () => {796      await setupUnlockedVault();797      const r = await dispatch({798        type: "BATCH_ADD_ENTRIES",799        payload: { entries: "not-an-array" as unknown as unknown[] },800      });801      expect(r).toBeDefined();802    });803804    it("mostly-malformed entries does not crash", async () => {805      await setupUnlockedVault();806      const r = await dispatch({807        type: "BATCH_ADD_ENTRIES",808        payload: {809          entries: [810            null,811            undefined,812            42,813            "string",814            { title: "ok", username: "u", password: "p", url: "https://e.com" },815          ] as unknown as unknown[],816        },817      });818      expect(r).toBeDefined();819    });820  });821822  // ── SEARCH_VAULT adversarial ────────────────────────────────────────────823824  describe("SEARCH_VAULT adversarial", () => {825    const queries = [826      "",827      " ",828      "x",829      "a".repeat(1000),830      "🔑",831      "<script>",832      "' OR '1'='1",833      "../../",834      "\x00",835    ];836    for (const query of queries) {837      it(`query ${JSON.stringify(query.slice(0, 30))} does not crash`, async () => {838        await setupUnlockedVault();839        const r = await dispatch({840          type: "SEARCH_VAULT",841          payload: { query },842        });843        expect(r).toBeDefined();844      });845    }846  });847848  // ── UPDATE_SETTINGS adversarial ─────────────────────────────────────────849850  describe("UPDATE_SETTINGS adversarial", () => {851    const patches = [852      { name: "empty", patch: {} },853      { name: "junk keys", patch: { __proto__: { polluted: true } } },854      { name: "very long autolock", patch: { autoLockMinutes: 99999999 } },855      { name: "negative autolock", patch: { autoLockMinutes: -5 } },856      { name: "string autolock", patch: { autoLockMinutes: "five" } },857      { name: "unknown nested", patch: { foo: { bar: { baz: 1 } } } },858    ];859    for (const { name, patch } of patches) {860      it(`patch ${name} does not crash`, async () => {861        await setupUnlockedVault();862        const r = await dispatch({863          type: "UPDATE_SETTINGS",864          payload: patch as Record<string, unknown>,865        });866        expect(r).toBeDefined();867      });868    }869870    it("prototype pollution check: Object.prototype.polluted stays unset", async () => {871      await setupUnlockedVault();872      await dispatch({873        type: "UPDATE_SETTINGS",874        payload: JSON.parse(875          '{"__proto__": {"polluted": "yes"}}'876        ) as Record<string, unknown>,877      });878      // eslint-disable-next-line @typescript-eslint/no-explicit-any879      expect(({} as any).polluted).toBeUndefined();880    });881  });882883  // ── Wider adversarial payload matrix per handler ────────────────────────884885  describe("wide adversarial payload matrix", () => {886    const adversarialPayloads: Array<{ name: string; payload: unknown }> = [887      { name: "true", payload: true },888      { name: "false", payload: false },889      { name: "NaN", payload: NaN },890      { name: "Infinity", payload: Infinity },891      { name: "-Infinity", payload: -Infinity },892      { name: "BigInt-like", payload: { type: "bigint" } },893      { name: "Date", payload: new Date() },894      { name: "RegExp", payload: /pattern/ },895      { name: "Symbol-string", payload: Symbol("x").toString() },896      { name: "circular reference", payload: (() => {897        const o: Record<string, unknown> = {};898        o.self = o;899        return o;900      })() },901    ];902903    const fastHandlers = [904      "ADD_ENTRY",905      "UPDATE_ENTRY",906      "DELETE_ENTRY",907      "AUTOFILL_QUERY",908      "GET_ENTRY_FOR_FILL",909      "UPDATE_SETTINGS",910      "SEARCH_VAULT",911      "SAVE_NOTE",912      "DELETE_NOTE",913      "PURGE_ENTRY",914      "RESTORE_ENTRY",915      "PURGE_EXPIRED_TRASH",916      "ADD_ADDITIONAL_URL",917      "BATCH_ADD_ENTRIES",918      "RESTORE_ENTRIES",919      "ACTIVATE_LICENSE",920      "APPROVE_PASSKEY",921      "DENY_PASSKEY",922      "SET_PASSKEY_PROVIDER_ENABLED",923      "RESTORE_VAULT_SNAPSHOT",924      "VERIFY_MAIN_PASSWORD",925      "SETUP_DURESS",926    ];927928    for (const handler of fastHandlers) {929      for (const { name, payload } of adversarialPayloads) {930        it(`${handler} + ${name} payload resolves without crash`, async () => {931          const r = await dispatch({ type: handler, payload } as unknown as Message);932          expect(r).toBeDefined();933        });934      }935    }936  });937938  // ── Foreign sender × handler matrix ─────────────────────────────────────939940  describe("foreign sender rejection matrix", () => {941    const safeQuickHandlers = [942      "GET_ENTRIES",943      "IS_UNLOCKED",944      "HAS_VAULT",945      "GET_VAULT_STATUS",946      "GET_SETTINGS",947      "GET_DELETED_ENTRIES",948      "COUNT_DUPLICATES",949      "GET_VAULT_HISTORY",950      "ADD_ENTRY",951      "DELETE_ENTRY",952      "AUTOFILL_QUERY",953      "GET_ENTRY_FOR_FILL",954      "SAVE_NOTE",955      "DELETE_NOTE",956      "GET_NOTES",957      "SEARCH_VAULT",958      "GET_LICENSE",959      "GET_LICENSE_KEY",960      "GET_RECOVERY_WORDS",961      "VALIDATE_RECOVERY_WORDS",962      "RECOVER_WITH_WORDS",963      "RESTORE_FROM_CODEX",964      "GET_CODEX_VAULT_DATA",965      "ESTIMATE_CODEX_PAGES",966    ];967    for (const handler of safeQuickHandlers) {968      it(`${handler} from foreign sender returns invalid sender`, async () => {969        if (!listener) throw new Error("listener not registered");970        const r = await new Promise<MessageResponse>((resolve) => {971          listener!(972            { type: handler, payload: {} } as Message,973            { id: "foreign-extension-id" } as chrome.runtime.MessageSender,974            resolve975          );976        });977        expect(r.ok).toBe(false);978        expect(r.error).toBe("invalid_sender");979      });980    }981  });982983  // ── ADD_ENTRY payload-shape robustness ──────────────────────────────────984985  describe("ADD_ENTRY payload shape robustness", () => {986    const shapes = [987      { name: "no title", body: { username: "u", password: "p", url: "https://e.com" } },988      { name: "no url", body: { title: "t", username: "u", password: "p" } },989      { name: "title only", body: { title: "t", password: "p" } },990      { name: "spurious type", body: { type: "login", title: "t", password: "p" } },991      { name: "unknown type", body: { type: "wat", title: "t", password: "p" } },992      { name: "with extra field", body: { title: "t", password: "p", extra: "ignored" } },993      { name: "url with port", body: { title: "t", password: "p", url: "https://e.com:8080" } },994      { name: "url with path", body: { title: "t", password: "p", url: "https://e.com/a/b/c" } },995      { name: "username as number", body: { title: "t", username: 42, password: "p", url: "https://e.com" } },996      { name: "password as number", body: { title: "t", password: 12345, url: "https://e.com" } },997      { name: "title as null", body: { title: null, password: "p", url: "https://e.com" } },998      { name: "title as object", body: { title: { wat: 1 }, password: "p", url: "https://e.com" } },999    ];1000    for (const { name, body } of shapes) {1001      it(`ADD_ENTRY shape ${name} resolves without crash`, async () => {1002        await setupUnlockedVault();1003        const r = await dispatch({1004          type: "ADD_ENTRY",1005          payload: body as Parameters<typeof dispatch>[0]["payload"],1006        } as Message);1007        expect(r).toBeDefined();1008      });1009    }1010  });10111012  // ── UPDATE_ENTRY patch-shape robustness ─────────────────────────────────10131014  describe("UPDATE_ENTRY patch shape robustness", () => {1015    const patches = [1016      { name: "title only", patch: { title: "New" } },1017      { name: "password only", patch: { password: "newpass" } },1018      { name: "url only", patch: { url: "https://e2.com" } },1019      { name: "many fields", patch: { title: "N", username: "u2", password: "p2", url: "https://e2.com" } },1020      { name: "unknown field", patch: { totallyMadeUp: "x" } },1021      { name: "deletedAt forbidden", patch: { deletedAt: Date.now() } },1022      { name: "createdAt forbidden", patch: { createdAt: 0 } },1023      { name: "modifiedAt patch", patch: { modifiedAt: Date.now() } },1024      { name: "id patch (should reject or ignore)", patch: { id: "new-id" } },1025    ];1026    for (const { name, patch } of patches) {1027      it(`UPDATE_ENTRY patch ${name} resolves without crash`, async () => {1028        const vault = await import("./vault");1029        await vault.createVault("test-pass-12345");1030        const e = await vault.addEntry({1031          title: "x",1032          username: "u",1033          password: "p",1034          url: "https://e.com",1035          type: "login",1036        });1037        const r = await dispatch({1038          type: "UPDATE_ENTRY",1039          payload: { id: e.id, patch },1040        });1041        expect(r).toBeDefined();1042      });1043    }1044  });10451046  // ── Sequence: vault life-cycle adversarial ──────────────────────────────10471048  describe("vault lifecycle adversarial sequences", () => {1049    for (let i = 0; i < 20; i++) {1050      it(`random message sequence #${i} does not crash`, async () => {1051        const vault = await import("./vault");1052        await vault.createVault("test-pass-12345");1053        const handlers = [1054          { type: "GET_ENTRIES" },1055          { type: "IS_UNLOCKED" },1056          { type: "GET_VAULT_STATUS" },1057          { type: "HAS_VAULT" },1058          { type: "GET_DELETED_ENTRIES" },1059          { type: "GET_SETTINGS" },1060          { type: "COUNT_DUPLICATES" },1061          { type: "GET_VAULT_HISTORY" },1062        ] as Array<Message>;1063        for (const h of handlers) {1064          const r = await dispatch(h);1065          expect(r).toBeDefined();1066        }1067      });1068    }1069  });10701071  // ── Read-only handlers when locked ──────────────────────────────────────10721073  describe("read-only handlers when locked", () => {1074    const safeWhenLocked = [1075      "IS_UNLOCKED",1076      "HAS_VAULT",1077      "GET_VAULT_STATUS",1078      "GET_SETTINGS",1079    ];1080    for (const type of safeWhenLocked) {1081      it(`${type} works without crash when locked`, async () => {1082        const r = await dispatch({ type } as unknown as Message);1083        expect(r).toBeDefined();1084      });1085    }10861087    const gatedWhenLocked = [1088      "GET_ENTRIES",1089      "GET_DELETED_ENTRIES",1090      "COUNT_DUPLICATES",1091      "GET_IDENTITIES",1092      "GET_IDENTITY_SUGGESTIONS",1093    ];1094    for (const type of gatedWhenLocked) {1095      it(`${type} returns ok=false when locked`, async () => {1096        const r = await dispatch({ type } as unknown as Message);1097        expect(r).toBeDefined();1098        // Some handlers return ok:false explicitly; others may throw.1099        if (r.ok === true) {1100          // Accept ok:true only if data is empty.1101          const data = (r as { data: unknown }).data;1102          expect(1103            data == null ||1104              (Array.isArray(data) && data.length === 0) ||1105              data === 01106          ).toBe(true);1107        } else {1108          expect(r.ok).toBe(false);1109        }1110      });1111    }1112  });11131114  // ── M2/QUA-002: payload validation at trust boundary ────────────────────11151116  describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {1117    // Each entry: handler type + payloads that must produce ok:false.1118    // The accepted error codes include 'invalid_payload' (the new validator)1119    // or 'vault_locked' (handlers that lock-check before validating, which1120    // is still a correct rejection of the request).1121    const cases: Array<{ type: string; bad: unknown[] }> = [1122      {1123        type: "ADD_ENTRY",1124        bad: [null, "string", 42, [], true],1125      },1126      {1127        type: "BATCH_ADD_ENTRIES",1128        bad: [null, { entries: "not-array" }, { entries: null }, { entries: 42 }, []],1129      },1130      {1131        type: "UPDATE_ENTRY",1132        bad: [null, {}, { id: 123 }, { id: "x" }, { id: "x", patch: "no" }, { id: "x", patch: null }, []],1133      },1134      {1135        type: "RESTORE_ENTRIES",1136        bad: [null, {}, { entries: "no" }, { entries: [], mode: "wrong" }, []],1137      },1138      {1139        type: "UPDATE_SETTINGS",1140        bad: [null, "string", 42, []],1141      },1142    ];11431144    for (const { type, bad } of cases) {1145      for (const payload of bad) {1146        const label = JSON.stringify(payload);1147        it(`${type} rejects malformed payload: ${label}`, async () => {1148          await setupUnlockedVault();1149          const r = await dispatch({ type, payload } as unknown as Message);1150          expect(r.ok).toBe(false);1151          const err = (r as { error?: string }).error ?? "";1152          expect(err).toMatch(/invalid_payload|vault[_ ]locked/);1153        });1154      }1155    }1156  });11571158  // ── AUD-002: payload validation for previously raw-cast handlers ────────11591160  describe("AUD-002 payload validation", () => {1161    describe("SEARCH_VAULT", () => {1162      const badQueries: Array<[string, unknown]> = [1163        ["null", null],1164        ["numeric", 42],1165        ["boolean", true],1166        ["object", { evil: true }],1167        ["array", []],1168        ["missing", undefined],1169      ];1170      for (const [name, query] of badQueries) {1171        it(`rejects ${name} query`, async () => {1172          await setupUnlockedVault();1173          const r = await dispatch({1174            type: "SEARCH_VAULT",1175            payload: { query },1176          } as unknown as Message);1177          expect(r.ok).toBe(false);1178          expect((r as { error?: string }).error).toBe("invalid_payload");1179        });1180      }11811182      it("accepts valid string query", async () => {1183        await setupUnlockedVault();1184        const r = await dispatch({1185          type: "SEARCH_VAULT",1186          payload: { query: "github" },1187        });1188        expect(r.ok).toBe(true);1189      });1190    });11911192    describe("COPY", () => {1193      const bads: Array<[string, unknown, unknown]> = [1194        ["null text", null, "pw"],1195        ["numeric text", 42, "pw"],1196        ["object text", { evil: true }, "pw"],1197        ["missing text", undefined, "pw"],1198        ["null label", "secret", null],1199        ["numeric label", "secret", 42],1200      ];1201      for (const [name, text, label] of bads) {1202        it(`rejects ${name}`, async () => {1203          await setupUnlockedVault();1204          const r = await dispatch({1205            type: "COPY",1206            payload: { text, label },1207          } as unknown as Message);1208          expect(r.ok).toBe(false);1209          expect((r as { error?: string }).error).toBe("invalid_payload");1210        });1211      }1212    });12131214    describe("ML_PREDICT_BATCH", () => {1215      const bads: Array<[string, unknown]> = [1216        ["null", null],1217        ["object", { not: "array" }],1218        ["string", "features"],1219        ["number", 42],1220        ["missing", undefined],1221      ];1222      for (const [name, featuresArray] of bads) {1223        it(`rejects ${name} featuresArray`, async () => {1224          const r = await dispatch({1225            type: "ML_PREDICT_BATCH",1226            payload: { featuresArray },1227          } as unknown as Message);1228          expect(r.ok).toBe(false);1229          expect((r as { error?: string }).error).toBe("invalid_payload");1230        });1231      }12321233      it("accepts empty array featuresArray", async () => {1234        const r = await dispatch({1235          type: "ML_PREDICT_BATCH",1236          payload: { featuresArray: [] },1237        } as unknown as Message);1238        expect((r as { error?: string }).error).not.toBe("invalid_payload");1239      });1240    });1241  });1242});