messages.adversarial.test.ts

44.9 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";17import { ML_FEATURE_KEYS } from "./ml";1819type StorageShape = Record<string, unknown>;20type MessageListener = (21  msg: unknown,22  sender: chrome.runtime.MessageSender,23  sendResponse: (response: MessageResponse) => void24) => boolean;2526// Payload-bearing handlers we exercise with adversarial payloads.27const PAYLOAD_HANDLERS = [28  "UNLOCK",29  "CREATE_VAULT",30  "ADD_ENTRY",31  "UPDATE_ENTRY",32  "DELETE_ENTRY",33  "BATCH_ADD_ENTRIES",34  "RESTORE_ENTRIES",35  "ADD_ADDITIONAL_URL",36  "RESTORE_ENTRY",37  "PURGE_ENTRY",38  "PURGE_EXPIRED_TRASH",39  "SAVE_NOTE",40  "DELETE_NOTE",41  "RECOVER_WITH_WORDS",42  "VALIDATE_RECOVERY_WORDS",43  "RESTORE_FROM_CODEX",44  "AUTOFILL_QUERY",45  "GET_ENTRY_FOR_FILL",46  "COPY",47  "UPDATE_SETTINGS",48  "APPROVE_PASSKEY",49  "DENY_PASSKEY",50  "SET_PASSKEY_PROVIDER_ENABLED",51  "ACTIVATE_LICENSE",52  "RESTORE_VAULT_SNAPSHOT",53  "CHANGE_PASSWORD",54  "SETUP_DURESS",55  "VERIFY_MAIN_PASSWORD",56  "FIRE_CANARY_PING",57  "ML_PREDICT_BATCH",58  "SEARCH_VAULT",59  "GET_PASSKEY_FOR_DOMAIN",60];6162describe("message handler adversarial inputs", () => {63  let storage: StorageShape;64  let sessionStorage: StorageShape;65  let listener: MessageListener | undefined;6667  beforeEach(async () => {68    vi.resetModules();69    vi.useRealTimers();70    storage = { peach_settings: { breachMonitoring: false } };71    sessionStorage = {};72    listener = undefined;7374    vi.stubGlobal(75      "fetch",76      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))77    );78    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);7980    const chromeMock = {81      runtime: {82        id: "peach-extension",83        getURL: (p: string) => `chrome-extension://peach-extension/${p}`,84        onMessage: {85          addListener: vi.fn((fn: MessageListener) => {86            listener = fn;87          }),88        },89        sendMessage: vi.fn(async () => ({ ok: true })),90      },91      tabs: {92        query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {93          if (typeof cb === "function") {94            cb([{ id: 1 }]);95            return;96          }97          return Promise.resolve([{ id: 1 }]);98        }),99        sendMessage: vi.fn(async () => ({ ok: true })),100      },101      storage: {102        local: {103          get: vi.fn(async (key?: string | string[]) => {104            if (!key) return storage;105            if (typeof key === "string") return { [key]: storage[key] };106            return key.reduce<Record<string, unknown>>((acc, k) => {107              acc[k] = storage[k];108              return acc;109            }, {});110          }),111          set: vi.fn(async (next: Record<string, unknown>) => {112            Object.assign(storage, next);113          }),114          remove: vi.fn(async (keys: string | string[]) => {115            const list = Array.isArray(keys) ? keys : [keys];116            for (const key of list) delete storage[key];117          }),118        },119        session: {120          get: vi.fn(async (key?: string | string[]) => {121            if (!key) return sessionStorage;122            if (typeof key === "string") return { [key]: sessionStorage[key] };123            return key.reduce<Record<string, unknown>>((acc, k) => {124              acc[k] = sessionStorage[k];125              return acc;126            }, {});127          }),128          set: vi.fn(async (next: Record<string, unknown>) => {129            Object.assign(sessionStorage, next);130          }),131          remove: vi.fn(async (keys: string | string[]) => {132            const list = Array.isArray(keys) ? keys : [keys];133            for (const key of list) delete sessionStorage[key];134          }),135          clear: vi.fn(async () => {136            for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];137          }),138        },139      },140      alarms: {141        clear: vi.fn(),142        create: vi.fn(),143        onAlarm: { addListener: vi.fn() },144      },145      idle: {146        setDetectionInterval: vi.fn(),147        onStateChanged: { addListener: vi.fn() },148      },149      scripting: { executeScript: vi.fn(async () => []) },150    } as unknown as typeof chrome;151152    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;153154    const messages = await import("./messages");155    messages.setupMessageHandler();156    const vault = await import("./vault");157    await vault.ensureSettingsLoaded();158  });159160  async function dispatch(161    message: Message,162    sender: chrome.runtime.MessageSender = {163      id: "peach-extension",164      url: "chrome-extension://peach-extension/popup.html",165    } as chrome.runtime.MessageSender,166  ): Promise<MessageResponse> {167    if (!listener) throw new Error("listener not registered");168    return new Promise<MessageResponse>((resolve) => {169      const keepOpen = listener!(message, sender, resolve);170      expect(keepOpen).toBe(true);171    });172  }173174  // PCH-01-018: destructive ops require an extension-page sender; the adversarial175  // RESTORE_FROM_CODEX payload tests must use this to reach payload validation.176  const EXTENSION_PAGE_SENDER = {177    id: "peach-extension",178    url: "chrome-extension://peach-extension/popup.html",179  } as chrome.runtime.MessageSender;180181  async function setupUnlockedVault() {182    const vault = await import("./vault");183    await vault.createVault("test-pass-12345");184  }185186  // ── Null/undefined/empty/nested payload matrix ──────────────────────────187188  describe("payload no-crash matrix", () => {189    for (const type of PAYLOAD_HANDLERS) {190      it(`${type} with null payload does not crash`, async () => {191        const r = await dispatch({ type, payload: null } as unknown as Message);192        expect(r).toBeDefined();193      });194195      it(`${type} with undefined payload does not crash`, async () => {196        const r = await dispatch({197          type,198          payload: undefined,199        } as unknown as Message);200        expect(r).toBeDefined();201      });202203      it(`${type} with empty object payload does not crash`, async () => {204        const r = await dispatch({ type, payload: {} } as unknown as Message);205        expect(r).toBeDefined();206      });207208      it(`${type} with deeply nested payload does not crash`, async () => {209        const nested: Record<string, unknown> = {};210        let cur: Record<string, unknown> = nested;211        for (let i = 0; i < 50; i++) {212          const child: Record<string, unknown> = {};213          cur.child = child;214          cur = child;215        }216        const r = await dispatch({217          type,218          payload: nested,219        } as unknown as Message);220        expect(r).toBeDefined();221      });222223      it(`${type} with array payload does not crash`, async () => {224        const r = await dispatch({225          type,226          payload: [1, 2, 3],227        } as unknown as Message);228        expect(r).toBeDefined();229      });230231      it(`${type} with string payload does not crash`, async () => {232        const r = await dispatch({233          type,234          payload: "garbage",235        } as unknown as Message);236        expect(r).toBeDefined();237      });238239      it(`${type} with number payload does not crash`, async () => {240        const r = await dispatch({241          type,242          payload: 42,243        } as unknown as Message);244        expect(r).toBeDefined();245      });246    }247  });248249  // ── AUD-007: extended no-crash payload sweep ────────────────────────────250  //251  // Each payload variant runs against every handler in PAYLOAD_HANDLERS and252  // must produce a structured response — never a thrown exception. This253  // expands fuzz coverage to >3000 tests without changing handler behavior.254255  describe("AUD-007 extended payload no-crash sweep", () => {256    const extraPayloads: Array<{ name: string; payload: unknown }> = [257      { name: "boolean true", payload: true },258      { name: "boolean false", payload: false },259      { name: "negative number", payload: -1 },260      { name: "NaN", payload: NaN },261      { name: "Infinity", payload: Infinity },262      { name: "empty string", payload: "" },263      { name: "very long string", payload: "A".repeat(10_000) },264      { name: "unicode string", payload: "私のパスワード🔑" },265      { name: "RTL string", payload: "مفتاح‮" },266      { name: "null bytes in string", payload: "a\x00b\x00c" },267      { name: "html string", payload: "<script>alert(1)</script>" },268      { name: "sql string", payload: "'; DROP TABLE entries; --" },269      { name: "path traversal string", payload: "../../etc/passwd" },270      { name: "object with null prototype-ish", payload: { __proto__: null } },271      { name: "object with prototype pollution attempt", payload: { __proto__: { polluted: true } } },272      { name: "object with constructor pollution", payload: { constructor: { prototype: { polluted: true } } } },273      { name: "deeply nested array", payload: [[[[[[[[[[]]]]]]]]]] },274      { name: "long array", payload: new Array(1_000).fill(0) },275      { name: "mixed array", payload: [null, undefined, 1, "x", true, {}, []] },276      { name: "function-like object", payload: { call: () => 1, apply: () => 1 } },277      { name: "Date object", payload: new Date(0) },278      { name: "BigInt-ish payload", payload: { n: "9007199254740993" } },279      { name: "circular-safe placeholder", payload: { self: "[Circular]" } },280    ];281282    for (const { name, payload } of extraPayloads) {283      for (const type of PAYLOAD_HANDLERS) {284        it(`${type} with ${name} payload does not crash`, async () => {285          const r = await dispatch({ type, payload } as unknown as Message);286          expect(r).toBeDefined();287          expect(typeof r.ok).toBe("boolean");288        });289      }290    }291  });292293  // ── Unknown / malformed message types ───────────────────────────────────294295  describe("unknown message types", () => {296    const unknownTypes = [297      "TOTALLY_UNKNOWN_TYPE",298      "",299      "X",300      "lowercase_type",301      "with spaces in type",302      "with-dashes",303      "WITH.DOTS",304      "🌸emoji_type",305      "Unicode_üñïcödé",306      "IMPORT_PEACHSCRIPT",307      "RESTORE_FROM_PEACHSCRIPT",308      "DESTROY_VAULT_QUIETLY",309      "ESCALATE_PRIVILEGE",310    ];311312    for (const type of unknownTypes) {313      it(`unknown type "${type}" returns error response`, async () => {314        const r = await dispatch({ type } as unknown as Message);315        expect(r).toBeDefined();316        expect(r.ok).toBe(false);317      });318    }319320    it("missing type field does not crash", async () => {321      const r = await dispatch({} as unknown as Message);322      expect(r).toBeDefined();323    });324325    it("type=null does not crash", async () => {326      const r = await dispatch({ type: null } as unknown as Message);327      expect(r).toBeDefined();328    });329330    it("type=number does not crash", async () => {331      const r = await dispatch({ type: 42 } as unknown as Message);332      expect(r).toBeDefined();333    });334  });335336  // ── Sender validation ────────────────────────────────────────────────────337338  describe("sender validation", () => {339    it("foreign extension sender is rejected", async () => {340      if (!listener) throw new Error("listener not registered");341      const r = await new Promise<MessageResponse>((resolve) => {342        listener!(343          { type: "GET_ENTRIES" } as Message,344          { id: "foreign-extension" } as chrome.runtime.MessageSender,345          resolve346        );347      });348      expect(r.ok).toBe(false);349      expect(r.error).toBe("invalid_sender");350    });351352    it("sender with no URL is rejected", async () => {353      if (!listener) throw new Error("listener not registered");354      await setupUnlockedVault();355      const r = await new Promise<MessageResponse>((resolve) => {356        listener!(357          { type: "GET_ENTRIES" } as Message,358          {} as chrome.runtime.MessageSender,359          resolve360        );361      });362      expect(r).toMatchObject({ ok: false, error: "invalid_sender" });363    });364  });365366  // ── AUTOFILL_QUERY adversarial ──────────────────────────────────────────367368  describe("AUTOFILL_QUERY adversarial", () => {369    async function withEntries(n: number) {370      const vault = await import("./vault");371      await vault.createVault("test-pass-12345");372      for (let i = 0; i < n; i++) {373        await vault.addEntry({374          title: `E${i}`,375          username: `u${i}`,376          password: "p",377          url: `https://example${i}.com`,378          type: "login",379        });380      }381    }382383    it("empty domain returns empty results", async () => {384      await withEntries(3);385      const r = await dispatch({386        type: "AUTOFILL_QUERY",387        payload: { domain: "" },388      });389      expect(r.ok).toBe(true);390      expect((r as { data: unknown[] }).data).toEqual([]);391    });392393    const adversarialDomains = [394      "*",395      ".*",396      "%",397      "<script>alert(1)</script>",398      "' OR '1'='1",399      "../../../etc/passwd",400      "\x00github.com",401      "github.com\x00",402      "GITHUB.COM",403      "github.com/path",404      "https://github.com",405      "javascript:alert(1)",406      " ",407      "   ",408      "\t",409      "\n",410      String("a").repeat(5000),411    ];412    for (const domain of adversarialDomains) {413      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not crash`, async () => {414        await withEntries(3);415        const r = await dispatch({416          type: "AUTOFILL_QUERY",417          payload: { domain },418        });419        expect(r.ok).toBe(true);420      });421422      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not match all entries`, async () => {423        await withEntries(5);424        const r = await dispatch({425          type: "AUTOFILL_QUERY",426          payload: { domain },427        });428        const data = (r as { data: unknown[] }).data;429        // No adversarial wildcard should ever match every entry.430        expect(data.length).toBeLessThan(5);431      });432    }433434    it("locked vault: AUTOFILL_QUERY returns error", async () => {435      const vault = await import("./vault");436      await vault.createVault("test-pass-12345");437      vault.lock();438      const r = await dispatch({439        type: "AUTOFILL_QUERY",440        payload: { domain: "github.com" },441      });442      expect(r.ok).toBe(false);443    });444445    it("response never leaks privateKeyJwk", async () => {446      await withEntries(3);447      const r = await dispatch({448        type: "AUTOFILL_QUERY",449        payload: { domain: "example0.com" },450      });451      const data = (r as { data: Array<Record<string, unknown>> }).data;452      for (const entry of data) {453        expect("privateKeyJwk" in entry).toBe(false);454        expect("passkey" in entry).toBe(false);455      }456    });457458    it("kind=card returns card summaries only (no full PAN)", async () => {459      const vault = await import("./vault");460      await vault.createVault("test-pass-12345");461      await vault.addEntry({462        type: "card",463        title: "Visa",464        cardNumber: "4111111111111111",465        last4: "1111",466        cardholderName: "JUNE",467        expMonth: "12",468        expYear: "2030",469        cvc: "123",470      } as unknown as Parameters<typeof vault.addEntry>[0]);471      const r = await dispatch({472        type: "AUTOFILL_QUERY",473        payload: { domain: "any.com", kind: "card" },474      });475      const data = (r as { data: Array<Record<string, unknown>> }).data;476      for (const entry of data) {477        expect("cardNumber" in entry).toBe(false);478        expect("cvc" in entry).toBe(false);479      }480    });481  });482483  // ── RESTORE_FROM_CODEX adversarial ──────────────────────────────────────484485  describe("RESTORE_FROM_CODEX adversarial", () => {486    const malformed = [487      { name: "empty vaultBlob", overrides: { vaultBlob: "" } },488      { name: "garbage vaultBlob", overrides: { vaultBlob: "!!!not-base64!!!" } },489      { name: "wrong fingerprint", overrides: { vaultFingerprint: "0".repeat(16) } },490      { name: "wrong passphrase", overrides: { masterPassphrase: "definitely wrong" } },491      { name: "negative timestamp", overrides: { timestamp: -1 } },492      { name: "zero timestamp", overrides: { timestamp: 0 } },493      { name: "huge entryCount", overrides: { entryCount: 1_000_000 } },494      { name: "negative entryCount", overrides: { entryCount: -5 } },495    ];496    for (const { name, overrides } of malformed) {497      it(`malformed payload (${name}) returns ok=false without crash`, async () => {498        const payload = {499          vaultBlob: "ZGF0YQ==",500          timestamp: Date.now(),501          verifyCode: "PX-TEST-1234",502          vaultFingerprint: "ABCDEF1234567890",503          entryCount: 0,504          masterPassphrase: "secret",505          ...overrides,506        };507        const r = await dispatch({ type: "RESTORE_FROM_CODEX", payload }, EXTENSION_PAGE_SENDER);508        expect(r).toBeDefined();509        expect(r.ok).toBe(false);510      });511    }512513    it("null payload does not crash", async () => {514      const r = await dispatch({515        type: "RESTORE_FROM_CODEX",516        payload: null,517      } as unknown as Message, EXTENSION_PAGE_SENDER);518      expect(r).toBeDefined();519      expect(r.ok).toBe(false);520    });521  });522523  // ── COPY handler ────────────────────────────────────────────────────────524525  describe("COPY handler adversarial", () => {526    for (const text of ["", "x", "a".repeat(10000), "\x00\x00\x00", "🔑"]) {527      it(`COPY of ${JSON.stringify(text.slice(0, 20))} resolves`, async () => {528        const r = await dispatch({529          type: "COPY",530          payload: { text, label: "test", alreadyWritten: true },531        });532        expect(r).toBeDefined();533      });534    }535  });536537  // ── FIRE_CANARY_PING adversarial ────────────────────────────────────────538539  describe("FIRE_CANARY_PING webhook url validation", () => {540    const badUrls = [541      "javascript:alert(1)",542      "file:///etc/passwd",543      "data:text/plain,evil",544      "",545      "not a url",546      "ftp://example.com/hook",547    ];548    for (const url of badUrls) {549      it(`rejects bad webhook URL ${JSON.stringify(url)}`, async () => {550        const r = await dispatch({551          type: "FIRE_CANARY_PING",552          payload: {553            webhookUrl: url,554            entryTitle: "x",555            trigger: "test",556          },557        });558        expect(r.ok).toBe(false);559      });560    }561  });562563  // ── GET_ENTRY_FOR_FILL safety ───────────────────────────────────────────564565  describe("GET_ENTRY_FOR_FILL safety", () => {566    it("returns entry without passkey field for login entry", async () => {567      const vault = await import("./vault");568      await vault.createVault("test-pass-12345");569      const e = await vault.addEntry({570        title: "GitHub",571        username: "june",572        password: "x",573        url: "https://github.com",574        type: "login",575      });576      const r = await dispatch(577        { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },578        { id: "peach-extension", url: "https://github.com/login" },579      );580      expect(r.ok).toBe(true);581      const data = (r as { data: Record<string, unknown> }).data;582      expect("passkey" in data).toBe(false);583      expect("privateKeyJwk" in data).toBe(false);584    });585586    it("missing id returns error", async () => {587      await setupUnlockedVault();588      const r = await dispatch({589        type: "GET_ENTRY_FOR_FILL",590        payload: { id: "not-a-real-id" },591      });592      expect(r.ok).toBe(false);593    });594595    it("locked vault returns error", async () => {596      const vault = await import("./vault");597      await vault.createVault("test-pass-12345");598      vault.lock();599      const r = await dispatch({600        type: "GET_ENTRY_FOR_FILL",601        payload: { id: "x" },602      });603      expect(r.ok).toBe(false);604    });605  });606607  // ── GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006) ────────────────608609  describe("GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006)", () => {610    async function dispatchFrom(611      message: Message,612      sender: chrome.runtime.MessageSender,613    ): Promise<MessageResponse> {614      if (!listener) throw new Error("listener not registered");615      return new Promise<MessageResponse>((resolve) => {616        listener!(message, sender, resolve);617      });618    }619620    async function addGithubEntry() {621      const vault = await import("./vault");622      await vault.createVault("test-pass-12345");623      return vault.addEntry({624        title: "GitHub",625        username: "june",626        password: "x",627        url: "https://github.com",628        type: "login",629      });630    }631632    it("PB-26-201: tab-context GET_ENTRY_FOR_EDIT is denied", async () => {633      const e = await addGithubEntry();634      const r = await dispatchFrom(635        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,636        { id: "peach-extension", tab: { url: "https://github.com/login" } } as chrome.runtime.MessageSender,637      );638      expect(r.ok).toBe(false);639      expect(r.error).toBe("invalid_sender");640    });641642    it("PB-26-201: foreign tab cannot query edit data", async () => {643      const e = await addGithubEntry();644      const r = await dispatchFrom(645        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,646        { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,647      );648      expect(r.ok).toBe(false);649      expect(r.error).toBe("invalid_sender");650      expect((r as { data?: unknown }).data).toBeUndefined();651    });652653    it("PB-26-201: explicitSummon cannot authorize edit data", async () => {654      const e = await addGithubEntry();655      const r = await dispatchFrom(656        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id, explicitSummon: true } } as Message,657        { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,658      );659      expect(r.ok).toBe(false);660      expect(r.error).toBe("invalid_sender");661    });662663    it("PB-26-201: extension popup remains authorized for edit data", async () => {664      const e = await addGithubEntry();665      const r = await dispatchFrom(666        { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,667        { id: "peach-extension", url: "chrome-extension://peach-extension/src/popup/index.html" } as chrome.runtime.MessageSender,668      );669      expect(r.ok).toBe(true);670      expect((r as { data: Record<string, unknown> }).data.username).toBe("june");671    });672  });673674  // ── Destructive-op sender gate (PCH-01-018) ─────────────────────────────675676  describe("destructive ops gated to extension-page senders (PCH-01-018)", () => {677    async function dispatchFrom(678      message: Message,679      sender: chrome.runtime.MessageSender,680    ): Promise<MessageResponse> {681      if (!listener) throw new Error("listener not registered");682      return new Promise<MessageResponse>((resolve) => {683        listener!(message, sender, resolve);684      });685    }686    const DESTRUCTIVE = [687      "DESTROY_VAULT",688      "CLEAR_VAULT_ENTRIES",689      "RESTORE_VAULT_SNAPSHOT",690      "RESTORE_FROM_CODEX",691    ] as const;692    const SENSITIVE = [693      "GET_ENTRIES",694      "GET_RECOVERY_WORDS",695      "RESTORE_ENTRIES",696      "UNPAIR_DEVICE",697      "CONFIRM_VOUCHED_DEVICE",698      "ROTATE_SYNC_IDENTITY",699    ] as const;700    const contentScript = {701      id: "peach-extension",702      url: "https://evil.test/",703    } as chrome.runtime.MessageSender;704    const extensionPage = {705      id: "peach-extension",706      url: "chrome-extension://peach-extension/popup.html",707    } as chrome.runtime.MessageSender;708709    for (const type of DESTRUCTIVE) {710      it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {711        await setupUnlockedVault();712        const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);713        expect(r).toEqual({ ok: false, error: "invalid_sender" });714      });715716      it(`${type} from an extension-page sender passes the origin gate`, async () => {717        await setupUnlockedVault();718        const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);719        // It may still fail downstream validation, but never on the origin gate.720        expect(r.error).not.toBe("invalid_sender");721      });722    }723724    for (const type of SENSITIVE) {725      it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {726        const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);727        expect(r).toEqual({ ok: false, error: "invalid_sender" });728      });729730      it(`${type} from an extension-page sender reaches the handler`, async () => {731        const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);732        expect(r.error).not.toBe("invalid_sender");733      });734    }735736    it("GET_LOCAL_IDENTITY from a content-script sender is rejected (audit 2026-08-20 #4)", async () => {737      const r = await dispatchFrom({ type: "GET_LOCAL_IDENTITY", payload: {} } as Message, contentScript);738      expect(r).toEqual({ ok: false, error: "invalid_sender" });739    });740  });741742  // ── ADD_ENTRY adversarial when locked ───────────────────────────────────743744  describe("ADD_ENTRY locked-state safety", () => {745    it("locked vault: ADD_ENTRY returns vault locked", async () => {746      const r = await dispatch({747        type: "ADD_ENTRY",748        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },749      });750      expect(r.ok).toBe(false);751    });752753    it("ADD_ENTRY with no password and no passkey succeeds for email-only accounts", async () => {754      await setupUnlockedVault();755      const r = await dispatch({756        type: "ADD_ENTRY",757        payload: { title: "x", username: "u", password: "", url: "https://e.com" },758      });759      expect(r.ok).toBe(true);760    });761  });762763  // ── DELETE_ENTRY adversarial ────────────────────────────────────────────764765  describe("DELETE_ENTRY adversarial", () => {766    const badIds = [767      "",768      "   ",769      "not-a-uuid",770      String("a").repeat(10000),771      "00000000-0000-0000-0000-000000000000",772      "<script>",773      "\x00",774    ];775    for (const id of badIds) {776      it(`unknown id ${JSON.stringify(id.slice(0, 30))} returns error`, async () => {777        await setupUnlockedVault();778        const r = await dispatch({779          type: "DELETE_ENTRY",780          payload: { id },781        });782        expect(r.ok).toBe(false);783      });784    }785  });786787  // ── BATCH_ADD_ENTRIES adversarial ───────────────────────────────────────788789  describe("BATCH_ADD_ENTRIES adversarial", () => {790    it("empty array does not crash", async () => {791      await setupUnlockedVault();792      const r = await dispatch({793        type: "BATCH_ADD_ENTRIES",794        payload: { entries: [] },795      });796      expect(r).toBeDefined();797    });798799    it("non-array entries does not crash", async () => {800      await setupUnlockedVault();801      const r = await dispatch({802        type: "BATCH_ADD_ENTRIES",803        payload: { entries: "not-an-array" as unknown as unknown[] },804      });805      expect(r).toBeDefined();806    });807808    it("mostly-malformed entries does not crash", async () => {809      await setupUnlockedVault();810      const r = await dispatch({811        type: "BATCH_ADD_ENTRIES",812        payload: {813          entries: [814            null,815            undefined,816            42,817            "string",818            { title: "ok", username: "u", password: "p", url: "https://e.com" },819          ] as unknown as unknown[],820        },821      });822      expect(r).toBeDefined();823    });824  });825826  // ── SEARCH_VAULT adversarial ────────────────────────────────────────────827828  describe("SEARCH_VAULT adversarial", () => {829    const queries = [830      "",831      " ",832      "x",833      "a".repeat(1000),834      "🔑",835      "<script>",836      "' OR '1'='1",837      "../../",838      "\x00",839    ];840    for (const query of queries) {841      it(`query ${JSON.stringify(query.slice(0, 30))} does not crash`, async () => {842        await setupUnlockedVault();843        const r = await dispatch({844          type: "SEARCH_VAULT",845          payload: { query },846        });847        expect(r).toBeDefined();848      });849    }850  });851852  // ── UPDATE_SETTINGS adversarial ─────────────────────────────────────────853854  describe("UPDATE_SETTINGS adversarial", () => {855    const patches = [856      { name: "empty", patch: {} },857      { name: "junk keys", patch: { __proto__: { polluted: true } } },858      { name: "very long autolock", patch: { autoLockMinutes: 99999999 } },859      { name: "negative autolock", patch: { autoLockMinutes: -5 } },860      { name: "string autolock", patch: { autoLockMinutes: "five" } },861      { name: "unknown nested", patch: { foo: { bar: { baz: 1 } } } },862    ];863    for (const { name, patch } of patches) {864      it(`patch ${name} does not crash`, async () => {865        await setupUnlockedVault();866        const r = await dispatch({867          type: "UPDATE_SETTINGS",868          payload: patch as Record<string, unknown>,869        });870        expect(r).toBeDefined();871      });872    }873874    it("prototype pollution check: Object.prototype.polluted stays unset", async () => {875      await setupUnlockedVault();876      await dispatch({877        type: "UPDATE_SETTINGS",878        payload: JSON.parse(879          '{"__proto__": {"polluted": "yes"}}'880        ) as Record<string, unknown>,881      });882      // eslint-disable-next-line @typescript-eslint/no-explicit-any883      expect(({} as any).polluted).toBeUndefined();884    });885  });886887  // ── Wider adversarial payload matrix per handler ────────────────────────888889  describe("wide adversarial payload matrix", () => {890    const adversarialPayloads: Array<{ name: string; payload: unknown }> = [891      { name: "true", payload: true },892      { name: "false", payload: false },893      { name: "NaN", payload: NaN },894      { name: "Infinity", payload: Infinity },895      { name: "-Infinity", payload: -Infinity },896      { name: "BigInt-like", payload: { type: "bigint" } },897      { name: "Date", payload: new Date() },898      { name: "RegExp", payload: /pattern/ },899      { name: "Symbol-string", payload: Symbol("x").toString() },900      { name: "circular reference", payload: (() => {901        const o: Record<string, unknown> = {};902        o.self = o;903        return o;904      })() },905    ];906907    const fastHandlers = [908      "ADD_ENTRY",909      "UPDATE_ENTRY",910      "DELETE_ENTRY",911      "AUTOFILL_QUERY",912      "GET_ENTRY_FOR_FILL",913      "UPDATE_SETTINGS",914      "SEARCH_VAULT",915      "SAVE_NOTE",916      "DELETE_NOTE",917      "PURGE_ENTRY",918      "RESTORE_ENTRY",919      "PURGE_EXPIRED_TRASH",920      "ADD_ADDITIONAL_URL",921      "BATCH_ADD_ENTRIES",922      "RESTORE_ENTRIES",923      "ACTIVATE_LICENSE",924      "APPROVE_PASSKEY",925      "DENY_PASSKEY",926      "SET_PASSKEY_PROVIDER_ENABLED",927      "RESTORE_VAULT_SNAPSHOT",928      "VERIFY_MAIN_PASSWORD",929      "SETUP_DURESS",930    ];931932    for (const handler of fastHandlers) {933      for (const { name, payload } of adversarialPayloads) {934        it(`${handler} + ${name} payload resolves without crash`, async () => {935          const r = await dispatch({ type: handler, payload } as unknown as Message);936          expect(r).toBeDefined();937        });938      }939    }940  });941942  // ── Foreign sender × handler matrix ─────────────────────────────────────943944  describe("foreign sender rejection matrix", () => {945    const safeQuickHandlers = [946      "GET_ENTRIES",947      "IS_UNLOCKED",948      "HAS_VAULT",949      "GET_VAULT_STATUS",950      "GET_SETTINGS",951      "GET_DELETED_ENTRIES",952      "COUNT_DUPLICATES",953      "GET_VAULT_HISTORY",954      "ADD_ENTRY",955      "DELETE_ENTRY",956      "AUTOFILL_QUERY",957      "GET_ENTRY_FOR_FILL",958      "SAVE_NOTE",959      "DELETE_NOTE",960      "GET_NOTES",961      "SEARCH_VAULT",962      "GET_LICENSE",963      "GET_LICENSE_KEY",964      "GET_RECOVERY_WORDS",965      "VALIDATE_RECOVERY_WORDS",966      "RECOVER_WITH_WORDS",967      "RESTORE_FROM_CODEX",968      "GET_CODEX_VAULT_DATA",969      "ESTIMATE_CODEX_PAGES",970    ];971    for (const handler of safeQuickHandlers) {972      it(`${handler} from foreign sender returns invalid sender`, async () => {973        if (!listener) throw new Error("listener not registered");974        const r = await new Promise<MessageResponse>((resolve) => {975          listener!(976            { type: handler, payload: {} } as Message,977            { id: "foreign-extension-id" } as chrome.runtime.MessageSender,978            resolve979          );980        });981        expect(r.ok).toBe(false);982        expect(r.error).toBe("invalid_sender");983      });984    }985  });986987  // ── ADD_ENTRY payload-shape robustness ──────────────────────────────────988989  describe("ADD_ENTRY payload shape robustness", () => {990    const shapes = [991      { name: "no title", body: { username: "u", password: "p", url: "https://e.com" } },992      { name: "no url", body: { title: "t", username: "u", password: "p" } },993      { name: "title only", body: { title: "t", password: "p" } },994      { name: "spurious type", body: { type: "login", title: "t", password: "p" } },995      { name: "unknown type", body: { type: "wat", title: "t", password: "p" } },996      { name: "with extra field", body: { title: "t", password: "p", extra: "ignored" } },997      { name: "url with port", body: { title: "t", password: "p", url: "https://e.com:8080" } },998      { name: "url with path", body: { title: "t", password: "p", url: "https://e.com/a/b/c" } },999      { name: "username as number", body: { title: "t", username: 42, password: "p", url: "https://e.com" } },1000      { name: "password as number", body: { title: "t", password: 12345, url: "https://e.com" } },1001      { name: "title as null", body: { title: null, password: "p", url: "https://e.com" } },1002      { name: "title as object", body: { title: { wat: 1 }, password: "p", url: "https://e.com" } },1003    ];1004    for (const { name, body } of shapes) {1005      it(`ADD_ENTRY shape ${name} resolves without crash`, async () => {1006        await setupUnlockedVault();1007        const r = await dispatch({1008          type: "ADD_ENTRY",1009          payload: body as Parameters<typeof dispatch>[0]["payload"],1010        } as Message);1011        expect(r).toBeDefined();1012      });1013    }1014  });10151016  // ── UPDATE_ENTRY patch-shape robustness ─────────────────────────────────10171018  describe("UPDATE_ENTRY patch shape robustness", () => {1019    const patches = [1020      { name: "title only", patch: { title: "New" } },1021      { name: "password only", patch: { password: "newpass" } },1022      { name: "url only", patch: { url: "https://e2.com" } },1023      { name: "many fields", patch: { title: "N", username: "u2", password: "p2", url: "https://e2.com" } },1024      { name: "unknown field", patch: { totallyMadeUp: "x" } },1025      { name: "deletedAt forbidden", patch: { deletedAt: Date.now() } },1026      { name: "createdAt forbidden", patch: { createdAt: 0 } },1027      { name: "modifiedAt patch", patch: { modifiedAt: Date.now() } },1028      { name: "id patch (should reject or ignore)", patch: { id: "new-id" } },1029    ];1030    for (const { name, patch } of patches) {1031      it(`UPDATE_ENTRY patch ${name} resolves without crash`, async () => {1032        const vault = await import("./vault");1033        await vault.createVault("test-pass-12345");1034        const e = await vault.addEntry({1035          title: "x",1036          username: "u",1037          password: "p",1038          url: "https://e.com",1039          type: "login",1040        });1041        const r = await dispatch({1042          type: "UPDATE_ENTRY",1043          payload: { id: e.id, patch },1044        });1045        expect(r).toBeDefined();1046      });1047    }1048  });10491050  // ── Sequence: vault life-cycle adversarial ──────────────────────────────10511052  describe("vault lifecycle adversarial sequences", () => {1053    for (let i = 0; i < 20; i++) {1054      it(`random message sequence #${i} does not crash`, async () => {1055        const vault = await import("./vault");1056        await vault.createVault("test-pass-12345");1057        const handlers = [1058          { type: "GET_ENTRIES" },1059          { type: "IS_UNLOCKED" },1060          { type: "GET_VAULT_STATUS" },1061          { type: "HAS_VAULT" },1062          { type: "GET_DELETED_ENTRIES" },1063          { type: "GET_SETTINGS" },1064          { type: "COUNT_DUPLICATES" },1065          { type: "GET_VAULT_HISTORY" },1066        ] as Array<Message>;1067        for (const h of handlers) {1068          const r = await dispatch(h);1069          expect(r).toBeDefined();1070        }1071      });1072    }1073  });10741075  // ── Read-only handlers when locked ──────────────────────────────────────10761077  describe("read-only handlers when locked", () => {1078    const safeWhenLocked = [1079      "IS_UNLOCKED",1080      "HAS_VAULT",1081      "GET_VAULT_STATUS",1082      "GET_SETTINGS",1083    ];1084    for (const type of safeWhenLocked) {1085      it(`${type} works without crash when locked`, async () => {1086        const r = await dispatch({ type } as unknown as Message);1087        expect(r).toBeDefined();1088      });1089    }10901091    const gatedWhenLocked = [1092      "GET_ENTRIES",1093      "GET_DELETED_ENTRIES",1094      "COUNT_DUPLICATES",1095      "GET_IDENTITIES",1096      "GET_IDENTITY_SUGGESTIONS",1097    ];1098    for (const type of gatedWhenLocked) {1099      it(`${type} returns ok=false when locked`, async () => {1100        const r = await dispatch({ type } as unknown as Message);1101        expect(r).toBeDefined();1102        // Some handlers return ok:false explicitly; others may throw.1103        if (r.ok === true) {1104          // Accept ok:true only if data is empty.1105          const data = (r as { data: unknown }).data;1106          expect(1107            data == null ||1108              (Array.isArray(data) && data.length === 0) ||1109              data === 01110          ).toBe(true);1111        } else {1112          expect(r.ok).toBe(false);1113        }1114      });1115    }1116  });11171118  // ── M2/QUA-002: payload validation at trust boundary ────────────────────11191120  describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {1121    // Each entry: handler type + payloads that must produce ok:false.1122    // The accepted error codes include 'invalid_payload' (the new validator)1123    // or 'vault_locked' (handlers that lock-check before validating, which1124    // is still a correct rejection of the request).1125    const cases: Array<{ type: string; bad: unknown[] }> = [1126      {1127        type: "ADD_ENTRY",1128        bad: [null, "string", 42, [], true],1129      },1130      {1131        type: "BATCH_ADD_ENTRIES",1132        bad: [null, { entries: "not-array" }, { entries: null }, { entries: 42 }, []],1133      },1134      {1135        type: "UPDATE_ENTRY",1136        bad: [null, {}, { id: 123 }, { id: "x" }, { id: "x", patch: "no" }, { id: "x", patch: null }, []],1137      },1138      {1139        type: "RESTORE_ENTRIES",1140        bad: [null, {}, { entries: "no" }, { entries: [], mode: "wrong" }, []],1141      },1142      {1143        type: "UPDATE_SETTINGS",1144        bad: [null, "string", 42, []],1145      },1146    ];11471148    for (const { type, bad } of cases) {1149      for (const payload of bad) {1150        const label = JSON.stringify(payload);1151        it(`${type} rejects malformed payload: ${label}`, async () => {1152          await setupUnlockedVault();1153          const r = await dispatch({ type, payload } as unknown as Message);1154          expect(r.ok).toBe(false);1155          const err = (r as { error?: string }).error ?? "";1156          expect(err).toMatch(/invalid_payload|vault[_ ]locked/);1157        });1158      }1159    }1160  });11611162  // ── AUD-002: payload validation for previously raw-cast handlers ────────11631164  describe("AUD-002 payload validation", () => {1165    describe("SEARCH_VAULT", () => {1166      const badQueries: Array<[string, unknown]> = [1167        ["null", null],1168        ["numeric", 42],1169        ["boolean", true],1170        ["object", { evil: true }],1171        ["array", []],1172        ["missing", undefined],1173      ];1174      for (const [name, query] of badQueries) {1175        it(`rejects ${name} query`, async () => {1176          await setupUnlockedVault();1177          const r = await dispatch({1178            type: "SEARCH_VAULT",1179            payload: { query },1180          } as unknown as Message);1181          expect(r.ok).toBe(false);1182          expect((r as { error?: string }).error).toBe("invalid_payload");1183        });1184      }11851186      it("accepts valid string query", async () => {1187        await setupUnlockedVault();1188        const r = await dispatch({1189          type: "SEARCH_VAULT",1190          payload: { query: "github" },1191        });1192        expect(r.ok).toBe(true);1193      });1194    });11951196    describe("COPY", () => {1197      const bads: Array<[string, unknown, unknown]> = [1198        ["null text", null, "pw"],1199        ["numeric text", 42, "pw"],1200        ["object text", { evil: true }, "pw"],1201        ["missing text", undefined, "pw"],1202        ["null label", "secret", null],1203        ["numeric label", "secret", 42],1204      ];1205      for (const [name, text, label] of bads) {1206        it(`rejects ${name}`, async () => {1207          await setupUnlockedVault();1208          const r = await dispatch({1209            type: "COPY",1210            payload: { text, label },1211          } as unknown as Message);1212          expect(r.ok).toBe(false);1213          expect((r as { error?: string }).error).toBe("invalid_payload");1214        });1215      }1216    });12171218    describe("ML_PREDICT_BATCH", () => {1219      const validFeatures = Object.fromEntries(ML_FEATURE_KEYS.map((key) => [key, 0]));1220      const bads: Array<[string, unknown]> = [1221        ["null", null],1222        ["object", { not: "array" }],1223        ["string", "features"],1224        ["number", 42],1225        ["missing", undefined],1226      ];1227      for (const [name, featuresArray] of bads) {1228        it(`rejects ${name} featuresArray`, async () => {1229          const r = await dispatch({1230            type: "ML_PREDICT_BATCH",1231            payload: { featuresArray },1232          } as unknown as Message);1233          expect(r.ok).toBe(false);1234          expect((r as { error?: string }).error).toBe("invalid_payload");1235        });1236      }12371238      it("accepts empty array featuresArray", async () => {1239        const r = await dispatch({1240          type: "ML_PREDICT_BATCH",1241          payload: { featuresArray: [] },1242        } as unknown as Message);1243        expect((r as { error?: string }).error).not.toBe("invalid_payload");1244      });12451246      it("rejects oversized and non-finite feature batches before inference", async () => {1247        const oversized = await dispatch({1248          type: "ML_PREDICT_BATCH",1249          payload: { featuresArray: Array.from({ length: 129 }, () => validFeatures) },1250        } as unknown as Message);1251        expect(oversized).toEqual({ ok: false, error: "invalid_payload" });12521253        const malformed = await dispatch({1254          type: "ML_PREDICT_BATCH",1255          payload: { featuresArray: [{ ...validFeatures, type_text: Number.NaN }] },1256        } as unknown as Message);1257        expect(malformed).toEqual({ ok: false, error: "invalid_payload" });1258      });12591260      it("rate-limits repeated bounded work per browser-attested sender", async () => {1261        const sender = {1262          id: "peach-extension",1263          url: "https://login.example.test/frame",1264          origin: "https://login.example.test",1265          tab: { id: 7, url: "https://login.example.test/" },1266          frameId: 0,1267        } as chrome.runtime.MessageSender;1268        const batch = Array.from({ length: 128 }, () => validFeatures);1269        expect((await dispatch({ type: "ML_PREDICT_BATCH", payload: { featuresArray: batch } } as unknown as Message, sender)).ok).toBe(true);1270        expect((await dispatch({ type: "ML_PREDICT_BATCH", payload: { featuresArray: batch } } as unknown as Message, sender)).ok).toBe(true);1271        expect(await dispatch({ type: "ML_PREDICT_BATCH", payload: { featuresArray: [validFeatures] } } as unknown as Message, sender)).toEqual({1272          ok: false,1273          error: "rate_limited",1274        });1275      });1276    });1277  });1278});