messages.adversarial.test.ts

43.2 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 = {162      id: "peach-extension",163      url: "chrome-extension://peach-extension/popup.html",164    } as chrome.runtime.MessageSender,165  ): Promise<MessageResponse> {166    if (!listener) throw new Error("listener not registered");167    return new Promise<MessageResponse>((resolve) => {168      const keepOpen = listener!(message, sender, resolve);169      expect(keepOpen).toBe(true);170    });171  }172173  // PCH-01-018: destructive ops require an extension-page sender; the adversarial174  // RESTORE_FROM_CODEX payload tests must use this to reach payload validation.175  const EXTENSION_PAGE_SENDER = {176    id: "peach-extension",177    url: "chrome-extension://peach-extension/popup.html",178  } as chrome.runtime.MessageSender;179180  async function setupUnlockedVault() {181    const vault = await import("./vault");182    await vault.createVault("test-pass-12345");183  }184185  // ── Null/undefined/empty/nested payload matrix ──────────────────────────186187  describe("payload no-crash matrix", () => {188    for (const type of PAYLOAD_HANDLERS) {189      it(`${type} with null payload does not crash`, async () => {190        const r = await dispatch({ type, payload: null } as unknown as Message);191        expect(r).toBeDefined();192      });193194      it(`${type} with undefined payload does not crash`, async () => {195        const r = await dispatch({196          type,197          payload: undefined,198        } as unknown as Message);199        expect(r).toBeDefined();200      });201202      it(`${type} with empty object payload does not crash`, async () => {203        const r = await dispatch({ type, payload: {} } as unknown as Message);204        expect(r).toBeDefined();205      });206207      it(`${type} with deeply nested payload does not crash`, async () => {208        const nested: Record<string, unknown> = {};209        let cur: Record<string, unknown> = nested;210        for (let i = 0; i < 50; i++) {211          const child: Record<string, unknown> = {};212          cur.child = child;213          cur = child;214        }215        const r = await dispatch({216          type,217          payload: nested,218        } as unknown as Message);219        expect(r).toBeDefined();220      });221222      it(`${type} with array payload does not crash`, async () => {223        const r = await dispatch({224          type,225          payload: [1, 2, 3],226        } as unknown as Message);227        expect(r).toBeDefined();228      });229230      it(`${type} with string payload does not crash`, async () => {231        const r = await dispatch({232          type,233          payload: "garbage",234        } as unknown as Message);235        expect(r).toBeDefined();236      });237238      it(`${type} with number payload does not crash`, async () => {239        const r = await dispatch({240          type,241          payload: 42,242        } as unknown as Message);243        expect(r).toBeDefined();244      });245    }246  });247248  // ── AUD-007: extended no-crash payload sweep ────────────────────────────249  //250  // Each payload variant runs against every handler in PAYLOAD_HANDLERS and251  // must produce a structured response — never a thrown exception. This252  // expands fuzz coverage to >3000 tests without changing handler behavior.253254  describe("AUD-007 extended payload no-crash sweep", () => {255    const extraPayloads: Array<{ name: string; payload: unknown }> = [256      { name: "boolean true", payload: true },257      { name: "boolean false", payload: false },258      { name: "negative number", payload: -1 },259      { name: "NaN", payload: NaN },260      { name: "Infinity", payload: Infinity },261      { name: "empty string", payload: "" },262      { name: "very long string", payload: "A".repeat(10_000) },263      { name: "unicode string", payload: "私のパスワード🔑" },264      { name: "RTL string", payload: "مفتاح‮" },265      { name: "null bytes in string", payload: "a\x00b\x00c" },266      { name: "html string", payload: "<script>alert(1)</script>" },267      { name: "sql string", payload: "'; DROP TABLE entries; --" },268      { name: "path traversal string", payload: "../../etc/passwd" },269      { name: "object with null prototype-ish", payload: { __proto__: null } },270      { name: "object with prototype pollution attempt", payload: { __proto__: { polluted: true } } },271      { name: "object with constructor pollution", payload: { constructor: { prototype: { polluted: true } } } },272      { name: "deeply nested array", payload: [[[[[[[[[[]]]]]]]]]] },273      { name: "long array", payload: new Array(1_000).fill(0) },274      { name: "mixed array", payload: [null, undefined, 1, "x", true, {}, []] },275      { name: "function-like object", payload: { call: () => 1, apply: () => 1 } },276      { name: "Date object", payload: new Date(0) },277      { name: "BigInt-ish payload", payload: { n: "9007199254740993" } },278      { name: "circular-safe placeholder", payload: { self: "[Circular]" } },279    ];280281    for (const { name, payload } of extraPayloads) {282      for (const type of PAYLOAD_HANDLERS) {283        it(`${type} with ${name} payload does not crash`, async () => {284          const r = await dispatch({ type, payload } as unknown as Message);285          expect(r).toBeDefined();286          expect(typeof r.ok).toBe("boolean");287        });288      }289    }290  });291292  // ── Unknown / malformed message types ───────────────────────────────────293294  describe("unknown message types", () => {295    const unknownTypes = [296      "TOTALLY_UNKNOWN_TYPE",297      "",298      "X",299      "lowercase_type",300      "with spaces in type",301      "with-dashes",302      "WITH.DOTS",303      "🌸emoji_type",304      "Unicode_üñïcödé",305      "IMPORT_PEACHSCRIPT",306      "RESTORE_FROM_PEACHSCRIPT",307      "DESTROY_VAULT_QUIETLY",308      "ESCALATE_PRIVILEGE",309    ];310311    for (const type of unknownTypes) {312      it(`unknown type "${type}" returns error response`, async () => {313        const r = await dispatch({ type } as unknown as Message);314        expect(r).toBeDefined();315        expect(r.ok).toBe(false);316      });317    }318319    it("missing type field does not crash", async () => {320      const r = await dispatch({} as unknown as Message);321      expect(r).toBeDefined();322    });323324    it("type=null does not crash", async () => {325      const r = await dispatch({ type: null } as unknown as Message);326      expect(r).toBeDefined();327    });328329    it("type=number does not crash", async () => {330      const r = await dispatch({ type: 42 } as unknown as Message);331      expect(r).toBeDefined();332    });333  });334335  // ── Sender validation ────────────────────────────────────────────────────336337  describe("sender validation", () => {338    it("foreign extension sender is rejected", async () => {339      if (!listener) throw new Error("listener not registered");340      const r = await new Promise<MessageResponse>((resolve) => {341        listener!(342          { type: "GET_ENTRIES" } as Message,343          { id: "foreign-extension" } as chrome.runtime.MessageSender,344          resolve345        );346      });347      expect(r.ok).toBe(false);348      expect(r.error).toBe("invalid_sender");349    });350351    it("sender with no URL is rejected", async () => {352      if (!listener) throw new Error("listener not registered");353      await setupUnlockedVault();354      const r = await new Promise<MessageResponse>((resolve) => {355        listener!(356          { type: "GET_ENTRIES" } as Message,357          {} as chrome.runtime.MessageSender,358          resolve359        );360      });361      expect(r).toMatchObject({ ok: false, error: "invalid_sender" });362    });363  });364365  // ── AUTOFILL_QUERY adversarial ──────────────────────────────────────────366367  describe("AUTOFILL_QUERY adversarial", () => {368    async function withEntries(n: number) {369      const vault = await import("./vault");370      await vault.createVault("test-pass-12345");371      for (let i = 0; i < n; i++) {372        await vault.addEntry({373          title: `E${i}`,374          username: `u${i}`,375          password: "p",376          url: `https://example${i}.com`,377          type: "login",378        });379      }380    }381382    it("empty domain returns empty results", async () => {383      await withEntries(3);384      const r = await dispatch({385        type: "AUTOFILL_QUERY",386        payload: { domain: "" },387      });388      expect(r.ok).toBe(true);389      expect((r as { data: unknown[] }).data).toEqual([]);390    });391392    const adversarialDomains = [393      "*",394      ".*",395      "%",396      "<script>alert(1)</script>",397      "' OR '1'='1",398      "../../../etc/passwd",399      "\x00github.com",400      "github.com\x00",401      "GITHUB.COM",402      "github.com/path",403      "https://github.com",404      "javascript:alert(1)",405      " ",406      "   ",407      "\t",408      "\n",409      String("a").repeat(5000),410    ];411    for (const domain of adversarialDomains) {412      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not crash`, async () => {413        await withEntries(3);414        const r = await dispatch({415          type: "AUTOFILL_QUERY",416          payload: { domain },417        });418        expect(r.ok).toBe(true);419      });420421      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not match all entries`, async () => {422        await withEntries(5);423        const r = await dispatch({424          type: "AUTOFILL_QUERY",425          payload: { domain },426        });427        const data = (r as { data: unknown[] }).data;428        // No adversarial wildcard should ever match every entry.429        expect(data.length).toBeLessThan(5);430      });431    }432433    it("locked vault: AUTOFILL_QUERY returns error", async () => {434      const vault = await import("./vault");435      await vault.createVault("test-pass-12345");436      vault.lock();437      const r = await dispatch({438        type: "AUTOFILL_QUERY",439        payload: { domain: "github.com" },440      });441      expect(r.ok).toBe(false);442    });443444    it("response never leaks privateKeyJwk", async () => {445      await withEntries(3);446      const r = await dispatch({447        type: "AUTOFILL_QUERY",448        payload: { domain: "example0.com" },449      });450      const data = (r as { data: Array<Record<string, unknown>> }).data;451      for (const entry of data) {452        expect("privateKeyJwk" in entry).toBe(false);453        expect("passkey" in entry).toBe(false);454      }455    });456457    it("kind=card returns card summaries only (no full PAN)", async () => {458      const vault = await import("./vault");459      await vault.createVault("test-pass-12345");460      await vault.addEntry({461        type: "card",462        title: "Visa",463        cardNumber: "4111111111111111",464        last4: "1111",465        cardholderName: "JUNE",466        expMonth: "12",467        expYear: "2030",468        cvc: "123",469      } as unknown as Parameters<typeof vault.addEntry>[0]);470      const r = await dispatch({471        type: "AUTOFILL_QUERY",472        payload: { domain: "any.com", kind: "card" },473      });474      const data = (r as { data: Array<Record<string, unknown>> }).data;475      for (const entry of data) {476        expect("cardNumber" in entry).toBe(false);477        expect("cvc" in entry).toBe(false);478      }479    });480  });481482  // ── RESTORE_FROM_CODEX adversarial ──────────────────────────────────────483484  describe("RESTORE_FROM_CODEX adversarial", () => {485    const malformed = [486      { name: "empty vaultBlob", overrides: { vaultBlob: "" } },487      { name: "garbage vaultBlob", overrides: { vaultBlob: "!!!not-base64!!!" } },488      { name: "wrong fingerprint", overrides: { vaultFingerprint: "0".repeat(16) } },489      { name: "wrong passphrase", overrides: { masterPassphrase: "definitely wrong" } },490      { name: "negative timestamp", overrides: { timestamp: -1 } },491      { name: "zero timestamp", overrides: { timestamp: 0 } },492      { name: "huge entryCount", overrides: { entryCount: 1_000_000 } },493      { name: "negative entryCount", overrides: { entryCount: -5 } },494    ];495    for (const { name, overrides } of malformed) {496      it(`malformed payload (${name}) returns ok=false without crash`, async () => {497        const payload = {498          vaultBlob: "ZGF0YQ==",499          timestamp: Date.now(),500          verifyCode: "PX-TEST-1234",501          vaultFingerprint: "ABCDEF1234567890",502          entryCount: 0,503          masterPassphrase: "secret",504          ...overrides,505        };506        const r = await dispatch({ type: "RESTORE_FROM_CODEX", payload }, EXTENSION_PAGE_SENDER);507        expect(r).toBeDefined();508        expect(r.ok).toBe(false);509      });510    }511512    it("null payload does not crash", async () => {513      const r = await dispatch({514        type: "RESTORE_FROM_CODEX",515        payload: null,516      } as unknown as Message, EXTENSION_PAGE_SENDER);517      expect(r).toBeDefined();518      expect(r.ok).toBe(false);519    });520  });521522  // ── COPY handler ────────────────────────────────────────────────────────523524  describe("COPY handler adversarial", () => {525    for (const text of ["", "x", "a".repeat(10000), "\x00\x00\x00", "🔑"]) {526      it(`COPY of ${JSON.stringify(text.slice(0, 20))} resolves`, async () => {527        const r = await dispatch({528          type: "COPY",529          payload: { text, label: "test", alreadyWritten: true },530        });531        expect(r).toBeDefined();532      });533    }534  });535536  // ── FIRE_CANARY_PING adversarial ────────────────────────────────────────537538  describe("FIRE_CANARY_PING webhook url validation", () => {539    const badUrls = [540      "javascript:alert(1)",541      "file:///etc/passwd",542      "data:text/plain,evil",543      "",544      "not a url",545      "ftp://example.com/hook",546    ];547    for (const url of badUrls) {548      it(`rejects bad webhook URL ${JSON.stringify(url)}`, async () => {549        const r = await dispatch({550          type: "FIRE_CANARY_PING",551          payload: {552            webhookUrl: url,553            entryTitle: "x",554            trigger: "test",555          },556        });557        expect(r.ok).toBe(false);558      });559    }560  });561562  // ── GET_ENTRY_FOR_FILL safety ───────────────────────────────────────────563564  describe("GET_ENTRY_FOR_FILL safety", () => {565    it("returns entry without passkey field for login entry", async () => {566      const vault = await import("./vault");567      await vault.createVault("test-pass-12345");568      const e = await vault.addEntry({569        title: "GitHub",570        username: "june",571        password: "x",572        url: "https://github.com",573        type: "login",574      });575      const r = await dispatch(576        { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },577        { id: "peach-extension", url: "https://github.com/login" },578      );579      expect(r.ok).toBe(true);580      const data = (r as { data: Record<string, unknown> }).data;581      expect("passkey" in data).toBe(false);582      expect("privateKeyJwk" in data).toBe(false);583    });584585    it("missing id returns error", async () => {586      await setupUnlockedVault();587      const r = await dispatch({588        type: "GET_ENTRY_FOR_FILL",589        payload: { id: "not-a-real-id" },590      });591      expect(r.ok).toBe(false);592    });593594    it("locked vault returns error", async () => {595      const vault = await import("./vault");596      await vault.createVault("test-pass-12345");597      vault.lock();598      const r = await dispatch({599        type: "GET_ENTRY_FOR_FILL",600        payload: { id: "x" },601      });602      expect(r.ok).toBe(false);603    });604  });605606  // ── GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006) ────────────────607608  describe("GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006)", () => {609    async function dispatchFrom(610      message: Message,611      sender: chrome.runtime.MessageSender,612    ): Promise<MessageResponse> {613      if (!listener) throw new Error("listener not registered");614      return new Promise<MessageResponse>((resolve) => {615        listener!(message, sender, resolve);616      });617    }618619    async function addGithubEntry() {620      const vault = await import("./vault");621      await vault.createVault("test-pass-12345");622      return vault.addEntry({623        title: "GitHub",624        username: "june",625        password: "x",626        url: "https://github.com",627        type: "login",628      });629    }630631    it("PB-26-201: tab-context GET_ENTRY_FOR_EDIT is denied", 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://github.com/login" } } as chrome.runtime.MessageSender,636      );637      expect(r.ok).toBe(false);638      expect(r.error).toBe("invalid_sender");639    });640641    it("PB-26-201: foreign tab cannot query edit data", async () => {642      const e = await addGithubEntry();643      const r = await dispatchFrom(644        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,645        { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,646      );647      expect(r.ok).toBe(false);648      expect(r.error).toBe("invalid_sender");649      expect((r as { data?: unknown }).data).toBeUndefined();650    });651652    it("PB-26-201: explicitSummon cannot authorize edit data", async () => {653      const e = await addGithubEntry();654      const r = await dispatchFrom(655        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id, explicitSummon: true } } as Message,656        { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,657      );658      expect(r.ok).toBe(false);659      expect(r.error).toBe("invalid_sender");660    });661662    it("PB-26-201: extension popup remains authorized for edit data", async () => {663      const e = await addGithubEntry();664      const r = await dispatchFrom(665        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,666        { id: "peach-extension", url: "chrome-extension://peach-extension/src/popup/index.html" } as chrome.runtime.MessageSender,667      );668      expect(r.ok).toBe(true);669      expect((r as { data: Record<string, unknown> }).data.username).toBe("june");670    });671  });672673  // ── Destructive-op sender gate (PCH-01-018) ─────────────────────────────674675  describe("destructive ops gated to extension-page senders (PCH-01-018)", () => {676    async function dispatchFrom(677      message: Message,678      sender: chrome.runtime.MessageSender,679    ): Promise<MessageResponse> {680      if (!listener) throw new Error("listener not registered");681      return new Promise<MessageResponse>((resolve) => {682        listener!(message, sender, resolve);683      });684    }685    const DESTRUCTIVE = [686      "DESTROY_VAULT",687      "CLEAR_VAULT_ENTRIES",688      "RESTORE_VAULT_SNAPSHOT",689      "RESTORE_FROM_CODEX",690    ] as const;691    const SENSITIVE = [692      "GET_ENTRIES",693      "GET_RECOVERY_WORDS",694      "RESTORE_ENTRIES",695      "UNPAIR_DEVICE",696      "CONFIRM_VOUCHED_DEVICE",697      "ROTATE_SYNC_IDENTITY",698    ] as const;699    const contentScript = {700      id: "peach-extension",701      url: "https://evil.test/",702    } as chrome.runtime.MessageSender;703    const extensionPage = {704      id: "peach-extension",705      url: "chrome-extension://peach-extension/popup.html",706    } as chrome.runtime.MessageSender;707708    for (const type of DESTRUCTIVE) {709      it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {710        await setupUnlockedVault();711        const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);712        expect(r).toEqual({ ok: false, error: "invalid_sender" });713      });714715      it(`${type} from an extension-page sender passes the origin gate`, async () => {716        await setupUnlockedVault();717        const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);718        // It may still fail downstream validation, but never on the origin gate.719        expect(r.error).not.toBe("invalid_sender");720      });721    }722723    for (const type of SENSITIVE) {724      it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {725        const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);726        expect(r).toEqual({ ok: false, error: "invalid_sender" });727      });728729      it(`${type} from an extension-page sender reaches the handler`, async () => {730        const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);731        expect(r.error).not.toBe("invalid_sender");732      });733    }734735    it("GET_LOCAL_IDENTITY from a content-script sender is rejected (audit 2026-08-20 #4)", async () => {736      const r = await dispatchFrom({ type: "GET_LOCAL_IDENTITY", payload: {} } as Message, contentScript);737      expect(r).toEqual({ ok: false, error: "invalid_sender" });738    });739  });740741  // ── ADD_ENTRY adversarial when locked ───────────────────────────────────742743  describe("ADD_ENTRY locked-state safety", () => {744    it("locked vault: ADD_ENTRY returns vault locked", async () => {745      const r = await dispatch({746        type: "ADD_ENTRY",747        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },748      });749      expect(r.ok).toBe(false);750    });751752    it("ADD_ENTRY with no password and no passkey succeeds for email-only accounts", async () => {753      await setupUnlockedVault();754      const r = await dispatch({755        type: "ADD_ENTRY",756        payload: { title: "x", username: "u", password: "", url: "https://e.com" },757      });758      expect(r.ok).toBe(true);759    });760  });761762  // ── DELETE_ENTRY adversarial ────────────────────────────────────────────763764  describe("DELETE_ENTRY adversarial", () => {765    const badIds = [766      "",767      "   ",768      "not-a-uuid",769      String("a").repeat(10000),770      "00000000-0000-0000-0000-000000000000",771      "<script>",772      "\x00",773    ];774    for (const id of badIds) {775      it(`unknown id ${JSON.stringify(id.slice(0, 30))} returns error`, async () => {776        await setupUnlockedVault();777        const r = await dispatch({778          type: "DELETE_ENTRY",779          payload: { id },780        });781        expect(r.ok).toBe(false);782      });783    }784  });785786  // ── BATCH_ADD_ENTRIES adversarial ───────────────────────────────────────787788  describe("BATCH_ADD_ENTRIES adversarial", () => {789    it("empty array does not crash", async () => {790      await setupUnlockedVault();791      const r = await dispatch({792        type: "BATCH_ADD_ENTRIES",793        payload: { entries: [] },794      });795      expect(r).toBeDefined();796    });797798    it("non-array entries does not crash", async () => {799      await setupUnlockedVault();800      const r = await dispatch({801        type: "BATCH_ADD_ENTRIES",802        payload: { entries: "not-an-array" as unknown as unknown[] },803      });804      expect(r).toBeDefined();805    });806807    it("mostly-malformed entries does not crash", async () => {808      await setupUnlockedVault();809      const r = await dispatch({810        type: "BATCH_ADD_ENTRIES",811        payload: {812          entries: [813            null,814            undefined,815            42,816            "string",817            { title: "ok", username: "u", password: "p", url: "https://e.com" },818          ] as unknown as unknown[],819        },820      });821      expect(r).toBeDefined();822    });823  });824825  // ── SEARCH_VAULT adversarial ────────────────────────────────────────────826827  describe("SEARCH_VAULT adversarial", () => {828    const queries = [829      "",830      " ",831      "x",832      "a".repeat(1000),833      "🔑",834      "<script>",835      "' OR '1'='1",836      "../../",837      "\x00",838    ];839    for (const query of queries) {840      it(`query ${JSON.stringify(query.slice(0, 30))} does not crash`, async () => {841        await setupUnlockedVault();842        const r = await dispatch({843          type: "SEARCH_VAULT",844          payload: { query },845        });846        expect(r).toBeDefined();847      });848    }849  });850851  // ── UPDATE_SETTINGS adversarial ─────────────────────────────────────────852853  describe("UPDATE_SETTINGS adversarial", () => {854    const patches = [855      { name: "empty", patch: {} },856      { name: "junk keys", patch: { __proto__: { polluted: true } } },857      { name: "very long autolock", patch: { autoLockMinutes: 99999999 } },858      { name: "negative autolock", patch: { autoLockMinutes: -5 } },859      { name: "string autolock", patch: { autoLockMinutes: "five" } },860      { name: "unknown nested", patch: { foo: { bar: { baz: 1 } } } },861    ];862    for (const { name, patch } of patches) {863      it(`patch ${name} does not crash`, async () => {864        await setupUnlockedVault();865        const r = await dispatch({866          type: "UPDATE_SETTINGS",867          payload: patch as Record<string, unknown>,868        });869        expect(r).toBeDefined();870      });871    }872873    it("prototype pollution check: Object.prototype.polluted stays unset", async () => {874      await setupUnlockedVault();875      await dispatch({876        type: "UPDATE_SETTINGS",877        payload: JSON.parse(878          '{"__proto__": {"polluted": "yes"}}'879        ) as Record<string, unknown>,880      });881      // eslint-disable-next-line @typescript-eslint/no-explicit-any882      expect(({} as any).polluted).toBeUndefined();883    });884  });885886  // ── Wider adversarial payload matrix per handler ────────────────────────887888  describe("wide adversarial payload matrix", () => {889    const adversarialPayloads: Array<{ name: string; payload: unknown }> = [890      { name: "true", payload: true },891      { name: "false", payload: false },892      { name: "NaN", payload: NaN },893      { name: "Infinity", payload: Infinity },894      { name: "-Infinity", payload: -Infinity },895      { name: "BigInt-like", payload: { type: "bigint" } },896      { name: "Date", payload: new Date() },897      { name: "RegExp", payload: /pattern/ },898      { name: "Symbol-string", payload: Symbol("x").toString() },899      { name: "circular reference", payload: (() => {900        const o: Record<string, unknown> = {};901        o.self = o;902        return o;903      })() },904    ];905906    const fastHandlers = [907      "ADD_ENTRY",908      "UPDATE_ENTRY",909      "DELETE_ENTRY",910      "AUTOFILL_QUERY",911      "GET_ENTRY_FOR_FILL",912      "UPDATE_SETTINGS",913      "SEARCH_VAULT",914      "SAVE_NOTE",915      "DELETE_NOTE",916      "PURGE_ENTRY",917      "RESTORE_ENTRY",918      "PURGE_EXPIRED_TRASH",919      "ADD_ADDITIONAL_URL",920      "BATCH_ADD_ENTRIES",921      "RESTORE_ENTRIES",922      "ACTIVATE_LICENSE",923      "APPROVE_PASSKEY",924      "DENY_PASSKEY",925      "SET_PASSKEY_PROVIDER_ENABLED",926      "RESTORE_VAULT_SNAPSHOT",927      "VERIFY_MAIN_PASSWORD",928      "SETUP_DURESS",929    ];930931    for (const handler of fastHandlers) {932      for (const { name, payload } of adversarialPayloads) {933        it(`${handler} + ${name} payload resolves without crash`, async () => {934          const r = await dispatch({ type: handler, payload } as unknown as Message);935          expect(r).toBeDefined();936        });937      }938    }939  });940941  // ── Foreign sender × handler matrix ─────────────────────────────────────942943  describe("foreign sender rejection matrix", () => {944    const safeQuickHandlers = [945      "GET_ENTRIES",946      "IS_UNLOCKED",947      "HAS_VAULT",948      "GET_VAULT_STATUS",949      "GET_SETTINGS",950      "GET_DELETED_ENTRIES",951      "COUNT_DUPLICATES",952      "GET_VAULT_HISTORY",953      "ADD_ENTRY",954      "DELETE_ENTRY",955      "AUTOFILL_QUERY",956      "GET_ENTRY_FOR_FILL",957      "SAVE_NOTE",958      "DELETE_NOTE",959      "GET_NOTES",960      "SEARCH_VAULT",961      "GET_LICENSE",962      "GET_LICENSE_KEY",963      "GET_RECOVERY_WORDS",964      "VALIDATE_RECOVERY_WORDS",965      "RECOVER_WITH_WORDS",966      "RESTORE_FROM_CODEX",967      "GET_CODEX_VAULT_DATA",968      "ESTIMATE_CODEX_PAGES",969    ];970    for (const handler of safeQuickHandlers) {971      it(`${handler} from foreign sender returns invalid sender`, async () => {972        if (!listener) throw new Error("listener not registered");973        const r = await new Promise<MessageResponse>((resolve) => {974          listener!(975            { type: handler, payload: {} } as Message,976            { id: "foreign-extension-id" } as chrome.runtime.MessageSender,977            resolve978          );979        });980        expect(r.ok).toBe(false);981        expect(r.error).toBe("invalid_sender");982      });983    }984  });985986  // ── ADD_ENTRY payload-shape robustness ──────────────────────────────────987988  describe("ADD_ENTRY payload shape robustness", () => {989    const shapes = [990      { name: "no title", body: { username: "u", password: "p", url: "https://e.com" } },991      { name: "no url", body: { title: "t", username: "u", password: "p" } },992      { name: "title only", body: { title: "t", password: "p" } },993      { name: "spurious type", body: { type: "login", title: "t", password: "p" } },994      { name: "unknown type", body: { type: "wat", title: "t", password: "p" } },995      { name: "with extra field", body: { title: "t", password: "p", extra: "ignored" } },996      { name: "url with port", body: { title: "t", password: "p", url: "https://e.com:8080" } },997      { name: "url with path", body: { title: "t", password: "p", url: "https://e.com/a/b/c" } },998      { name: "username as number", body: { title: "t", username: 42, password: "p", url: "https://e.com" } },999      { name: "password as number", body: { title: "t", password: 12345, url: "https://e.com" } },1000      { name: "title as null", body: { title: null, password: "p", url: "https://e.com" } },1001      { name: "title as object", body: { title: { wat: 1 }, password: "p", url: "https://e.com" } },1002    ];1003    for (const { name, body } of shapes) {1004      it(`ADD_ENTRY shape ${name} resolves without crash`, async () => {1005        await setupUnlockedVault();1006        const r = await dispatch({1007          type: "ADD_ENTRY",1008          payload: body as Parameters<typeof dispatch>[0]["payload"],1009        } as Message);1010        expect(r).toBeDefined();1011      });1012    }1013  });10141015  // ── UPDATE_ENTRY patch-shape robustness ─────────────────────────────────10161017  describe("UPDATE_ENTRY patch shape robustness", () => {1018    const patches = [1019      { name: "title only", patch: { title: "New" } },1020      { name: "password only", patch: { password: "newpass" } },1021      { name: "url only", patch: { url: "https://e2.com" } },1022      { name: "many fields", patch: { title: "N", username: "u2", password: "p2", url: "https://e2.com" } },1023      { name: "unknown field", patch: { totallyMadeUp: "x" } },1024      { name: "deletedAt forbidden", patch: { deletedAt: Date.now() } },1025      { name: "createdAt forbidden", patch: { createdAt: 0 } },1026      { name: "modifiedAt patch", patch: { modifiedAt: Date.now() } },1027      { name: "id patch (should reject or ignore)", patch: { id: "new-id" } },1028    ];1029    for (const { name, patch } of patches) {1030      it(`UPDATE_ENTRY patch ${name} resolves without crash`, async () => {1031        const vault = await import("./vault");1032        await vault.createVault("test-pass-12345");1033        const e = await vault.addEntry({1034          title: "x",1035          username: "u",1036          password: "p",1037          url: "https://e.com",1038          type: "login",1039        });1040        const r = await dispatch({1041          type: "UPDATE_ENTRY",1042          payload: { id: e.id, patch },1043        });1044        expect(r).toBeDefined();1045      });1046    }1047  });10481049  // ── Sequence: vault life-cycle adversarial ──────────────────────────────10501051  describe("vault lifecycle adversarial sequences", () => {1052    for (let i = 0; i < 20; i++) {1053      it(`random message sequence #${i} does not crash`, async () => {1054        const vault = await import("./vault");1055        await vault.createVault("test-pass-12345");1056        const handlers = [1057          { type: "GET_ENTRIES" },1058          { type: "IS_UNLOCKED" },1059          { type: "GET_VAULT_STATUS" },1060          { type: "HAS_VAULT" },1061          { type: "GET_DELETED_ENTRIES" },1062          { type: "GET_SETTINGS" },1063          { type: "COUNT_DUPLICATES" },1064          { type: "GET_VAULT_HISTORY" },1065        ] as Array<Message>;1066        for (const h of handlers) {1067          const r = await dispatch(h);1068          expect(r).toBeDefined();1069        }1070      });1071    }1072  });10731074  // ── Read-only handlers when locked ──────────────────────────────────────10751076  describe("read-only handlers when locked", () => {1077    const safeWhenLocked = [1078      "IS_UNLOCKED",1079      "HAS_VAULT",1080      "GET_VAULT_STATUS",1081      "GET_SETTINGS",1082    ];1083    for (const type of safeWhenLocked) {1084      it(`${type} works without crash when locked`, async () => {1085        const r = await dispatch({ type } as unknown as Message);1086        expect(r).toBeDefined();1087      });1088    }10891090    const gatedWhenLocked = [1091      "GET_ENTRIES",1092      "GET_DELETED_ENTRIES",1093      "COUNT_DUPLICATES",1094      "GET_IDENTITIES",1095      "GET_IDENTITY_SUGGESTIONS",1096    ];1097    for (const type of gatedWhenLocked) {1098      it(`${type} returns ok=false when locked`, async () => {1099        const r = await dispatch({ type } as unknown as Message);1100        expect(r).toBeDefined();1101        // Some handlers return ok:false explicitly; others may throw.1102        if (r.ok === true) {1103          // Accept ok:true only if data is empty.1104          const data = (r as { data: unknown }).data;1105          expect(1106            data == null ||1107              (Array.isArray(data) && data.length === 0) ||1108              data === 01109          ).toBe(true);1110        } else {1111          expect(r.ok).toBe(false);1112        }1113      });1114    }1115  });11161117  // ── M2/QUA-002: payload validation at trust boundary ────────────────────11181119  describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {1120    // Each entry: handler type + payloads that must produce ok:false.1121    // The accepted error codes include 'invalid_payload' (the new validator)1122    // or 'vault_locked' (handlers that lock-check before validating, which1123    // is still a correct rejection of the request).1124    const cases: Array<{ type: string; bad: unknown[] }> = [1125      {1126        type: "ADD_ENTRY",1127        bad: [null, "string", 42, [], true],1128      },1129      {1130        type: "BATCH_ADD_ENTRIES",1131        bad: [null, { entries: "not-array" }, { entries: null }, { entries: 42 }, []],1132      },1133      {1134        type: "UPDATE_ENTRY",1135        bad: [null, {}, { id: 123 }, { id: "x" }, { id: "x", patch: "no" }, { id: "x", patch: null }, []],1136      },1137      {1138        type: "RESTORE_ENTRIES",1139        bad: [null, {}, { entries: "no" }, { entries: [], mode: "wrong" }, []],1140      },1141      {1142        type: "UPDATE_SETTINGS",1143        bad: [null, "string", 42, []],1144      },1145    ];11461147    for (const { type, bad } of cases) {1148      for (const payload of bad) {1149        const label = JSON.stringify(payload);1150        it(`${type} rejects malformed payload: ${label}`, async () => {1151          await setupUnlockedVault();1152          const r = await dispatch({ type, payload } as unknown as Message);1153          expect(r.ok).toBe(false);1154          const err = (r as { error?: string }).error ?? "";1155          expect(err).toMatch(/invalid_payload|vault[_ ]locked/);1156        });1157      }1158    }1159  });11601161  // ── AUD-002: payload validation for previously raw-cast handlers ────────11621163  describe("AUD-002 payload validation", () => {1164    describe("SEARCH_VAULT", () => {1165      const badQueries: Array<[string, unknown]> = [1166        ["null", null],1167        ["numeric", 42],1168        ["boolean", true],1169        ["object", { evil: true }],1170        ["array", []],1171        ["missing", undefined],1172      ];1173      for (const [name, query] of badQueries) {1174        it(`rejects ${name} query`, async () => {1175          await setupUnlockedVault();1176          const r = await dispatch({1177            type: "SEARCH_VAULT",1178            payload: { query },1179          } as unknown as Message);1180          expect(r.ok).toBe(false);1181          expect((r as { error?: string }).error).toBe("invalid_payload");1182        });1183      }11841185      it("accepts valid string query", async () => {1186        await setupUnlockedVault();1187        const r = await dispatch({1188          type: "SEARCH_VAULT",1189          payload: { query: "github" },1190        });1191        expect(r.ok).toBe(true);1192      });1193    });11941195    describe("COPY", () => {1196      const bads: Array<[string, unknown, unknown]> = [1197        ["null text", null, "pw"],1198        ["numeric text", 42, "pw"],1199        ["object text", { evil: true }, "pw"],1200        ["missing text", undefined, "pw"],1201        ["null label", "secret", null],1202        ["numeric label", "secret", 42],1203      ];1204      for (const [name, text, label] of bads) {1205        it(`rejects ${name}`, async () => {1206          await setupUnlockedVault();1207          const r = await dispatch({1208            type: "COPY",1209            payload: { text, label },1210          } as unknown as Message);1211          expect(r.ok).toBe(false);1212          expect((r as { error?: string }).error).toBe("invalid_payload");1213        });1214      }1215    });12161217    describe("ML_PREDICT_BATCH", () => {1218      const bads: Array<[string, unknown]> = [1219        ["null", null],1220        ["object", { not: "array" }],1221        ["string", "features"],1222        ["number", 42],1223        ["missing", undefined],1224      ];1225      for (const [name, featuresArray] of bads) {1226        it(`rejects ${name} featuresArray`, async () => {1227          const r = await dispatch({1228            type: "ML_PREDICT_BATCH",1229            payload: { featuresArray },1230          } as unknown as Message);1231          expect(r.ok).toBe(false);1232          expect((r as { error?: string }).error).toBe("invalid_payload");1233        });1234      }12351236      it("accepts empty array featuresArray", async () => {1237        const r = await dispatch({1238          type: "ML_PREDICT_BATCH",1239          payload: { featuresArray: [] },1240        } as unknown as Message);1241        expect((r as { error?: string }).error).not.toBe("invalid_payload");1242      });1243    });1244  });1245});