messages.adversarial.test.ts

37.6 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  "SCAN_PAIRING_QR",44  "ACCEPT_PAIRING",45  "REMOVE_PAIRED_DEVICE",46  "REMOVE_TRUSTED_NETWORK",47  "AUTOFILL_QUERY",48  "GET_ENTRY_FOR_FILL",49  "COPY",50  "UPDATE_SETTINGS",51  "APPROVE_PASSKEY",52  "DENY_PASSKEY",53  "SET_PASSKEY_PROVIDER_ENABLED",54  "ACTIVATE_LICENSE",55  "RESPOND_INCOMING_PAIRING",56  "DEBUG_INJECT_INCOMING_PAIRING",57  "RESTORE_VAULT_SNAPSHOT",58  "CHANGE_PASSWORD",59  "SETUP_DURESS",60  "VERIFY_MAIN_PASSWORD",61  "FIRE_CANARY_PING",62  "ML_PREDICT_BATCH",63  "SEARCH_VAULT",64  "GET_PASSKEY_FOR_DOMAIN",65];6667describe("message handler adversarial inputs", () => {68  let storage: StorageShape;69  let sessionStorage: StorageShape;70  let listener: MessageListener | undefined;7172  beforeEach(async () => {73    vi.resetModules();74    vi.useRealTimers();75    storage = { peach_settings: { breachMonitoring: false } };76    sessionStorage = {};77    listener = undefined;7879    vi.stubGlobal(80      "fetch",81      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))82    );83    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);8485    const chromeMock = {86      runtime: {87        id: "peach-extension",88        onMessage: {89          addListener: vi.fn((fn: MessageListener) => {90            listener = fn;91          }),92        },93        sendMessage: vi.fn(async () => ({ ok: true })),94      },95      tabs: {96        query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {97          if (typeof cb === "function") {98            cb([{ id: 1 }]);99            return;100          }101          return Promise.resolve([{ id: 1 }]);102        }),103        sendMessage: vi.fn(async () => ({ ok: true })),104      },105      storage: {106        local: {107          get: vi.fn(async (key?: string | string[]) => {108            if (!key) return storage;109            if (typeof key === "string") return { [key]: storage[key] };110            return key.reduce<Record<string, unknown>>((acc, k) => {111              acc[k] = storage[k];112              return acc;113            }, {});114          }),115          set: vi.fn(async (next: Record<string, unknown>) => {116            Object.assign(storage, next);117          }),118          remove: vi.fn(async (keys: string | string[]) => {119            const list = Array.isArray(keys) ? keys : [keys];120            for (const key of list) delete storage[key];121          }),122        },123        session: {124          get: vi.fn(async (key?: string | string[]) => {125            if (!key) return sessionStorage;126            if (typeof key === "string") return { [key]: sessionStorage[key] };127            return key.reduce<Record<string, unknown>>((acc, k) => {128              acc[k] = sessionStorage[k];129              return acc;130            }, {});131          }),132          set: vi.fn(async (next: Record<string, unknown>) => {133            Object.assign(sessionStorage, next);134          }),135          remove: vi.fn(async (keys: string | string[]) => {136            const list = Array.isArray(keys) ? keys : [keys];137            for (const key of list) delete sessionStorage[key];138          }),139          clear: vi.fn(async () => {140            for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];141          }),142        },143      },144      alarms: {145        clear: vi.fn(),146        create: vi.fn(),147        onAlarm: { addListener: vi.fn() },148      },149      idle: {150        setDetectionInterval: vi.fn(),151        onStateChanged: { addListener: vi.fn() },152      },153      scripting: { executeScript: vi.fn(async () => []) },154    } as unknown as typeof chrome;155156    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;157158    const messages = await import("./messages");159    messages.setupMessageHandler();160    const vault = await import("./vault");161    await vault.ensureSettingsLoaded();162  });163164  async function dispatch(message: Message): Promise<MessageResponse> {165    if (!listener) throw new Error("listener not registered");166    return new Promise<MessageResponse>((resolve) => {167      const keepOpen = listener!(168        message,169        { id: "peach-extension" } as chrome.runtime.MessageSender,170        resolve171      );172      expect(keepOpen).toBe(true);173    });174  }175176  async function setupUnlockedVault() {177    const vault = await import("./vault");178    await vault.createVault("test-pass-12345");179  }180181  // ── Null/undefined/empty/nested payload matrix ──────────────────────────182183  describe("payload no-crash matrix", () => {184    for (const type of PAYLOAD_HANDLERS) {185      it(`${type} with null payload does not crash`, async () => {186        const r = await dispatch({ type, payload: null } as unknown as Message);187        expect(r).toBeDefined();188      });189190      it(`${type} with undefined payload does not crash`, async () => {191        const r = await dispatch({192          type,193          payload: undefined,194        } as unknown as Message);195        expect(r).toBeDefined();196      });197198      it(`${type} with empty object payload does not crash`, async () => {199        const r = await dispatch({ type, payload: {} } as unknown as Message);200        expect(r).toBeDefined();201      });202203      it(`${type} with deeply nested payload does not crash`, async () => {204        const nested: Record<string, unknown> = {};205        let cur: Record<string, unknown> = nested;206        for (let i = 0; i < 50; i++) {207          const child: Record<string, unknown> = {};208          cur.child = child;209          cur = child;210        }211        const r = await dispatch({212          type,213          payload: nested,214        } as unknown as Message);215        expect(r).toBeDefined();216      });217218      it(`${type} with array payload does not crash`, async () => {219        const r = await dispatch({220          type,221          payload: [1, 2, 3],222        } as unknown as Message);223        expect(r).toBeDefined();224      });225226      it(`${type} with string payload does not crash`, async () => {227        const r = await dispatch({228          type,229          payload: "garbage",230        } as unknown as Message);231        expect(r).toBeDefined();232      });233234      it(`${type} with number payload does not crash`, async () => {235        const r = await dispatch({236          type,237          payload: 42,238        } as unknown as Message);239        expect(r).toBeDefined();240      });241    }242  });243244  // ── AUD-007: extended no-crash payload sweep ────────────────────────────245  //246  // Each payload variant runs against every handler in PAYLOAD_HANDLERS and247  // must produce a structured response — never a thrown exception. This248  // expands fuzz coverage to >3000 tests without changing handler behavior.249250  describe("AUD-007 extended payload no-crash sweep", () => {251    const extraPayloads: Array<{ name: string; payload: unknown }> = [252      { name: "boolean true", payload: true },253      { name: "boolean false", payload: false },254      { name: "negative number", payload: -1 },255      { name: "NaN", payload: NaN },256      { name: "Infinity", payload: Infinity },257      { name: "empty string", payload: "" },258      { name: "very long string", payload: "A".repeat(10_000) },259      { name: "unicode string", payload: "私のパスワード🔑" },260      { name: "RTL string", payload: "مفتاح‮" },261      { name: "null bytes in string", payload: "a\x00b\x00c" },262      { name: "html string", payload: "<script>alert(1)</script>" },263      { name: "sql string", payload: "'; DROP TABLE entries; --" },264      { name: "path traversal string", payload: "../../etc/passwd" },265      { name: "object with null prototype-ish", payload: { __proto__: null } },266      { name: "object with prototype pollution attempt", payload: { __proto__: { polluted: true } } },267      { name: "object with constructor pollution", payload: { constructor: { prototype: { polluted: true } } } },268      { name: "deeply nested array", payload: [[[[[[[[[[]]]]]]]]]] },269      { name: "long array", payload: new Array(1_000).fill(0) },270      { name: "mixed array", payload: [null, undefined, 1, "x", true, {}, []] },271      { name: "function-like object", payload: { call: () => 1, apply: () => 1 } },272      { name: "Date object", payload: new Date(0) },273      { name: "BigInt-ish payload", payload: { n: "9007199254740993" } },274      { name: "circular-safe placeholder", payload: { self: "[Circular]" } },275    ];276277    for (const { name, payload } of extraPayloads) {278      for (const type of PAYLOAD_HANDLERS) {279        it(`${type} with ${name} payload does not crash`, async () => {280          const r = await dispatch({ type, payload } as unknown as Message);281          expect(r).toBeDefined();282          expect(typeof r.ok).toBe("boolean");283        });284      }285    }286  });287288  // ── Unknown / malformed message types ───────────────────────────────────289290  describe("unknown message types", () => {291    const unknownTypes = [292      "TOTALLY_UNKNOWN_TYPE",293      "",294      "X",295      "lowercase_type",296      "with spaces in type",297      "with-dashes",298      "WITH.DOTS",299      "🌸emoji_type",300      "Unicode_üñïcödé",301      "IMPORT_PEACHSCRIPT",302      "RESTORE_FROM_PEACHSCRIPT",303      "DESTROY_VAULT_QUIETLY",304      "ESCALATE_PRIVILEGE",305    ];306307    for (const type of unknownTypes) {308      it(`unknown type "${type}" returns error response`, async () => {309        const r = await dispatch({ type } as unknown as Message);310        expect(r).toBeDefined();311        expect(r.ok).toBe(false);312      });313    }314315    it("missing type field does not crash", async () => {316      const r = await dispatch({} as unknown as Message);317      expect(r).toBeDefined();318    });319320    it("type=null does not crash", async () => {321      const r = await dispatch({ type: null } as unknown as Message);322      expect(r).toBeDefined();323    });324325    it("type=number does not crash", async () => {326      const r = await dispatch({ type: 42 } as unknown as Message);327      expect(r).toBeDefined();328    });329  });330331  // ── Sender validation ────────────────────────────────────────────────────332333  describe("sender validation", () => {334    it("foreign extension sender is rejected", async () => {335      if (!listener) throw new Error("listener not registered");336      const r = await new Promise<MessageResponse>((resolve) => {337        listener!(338          { type: "GET_ENTRIES" } as Message,339          { id: "foreign-extension" } as chrome.runtime.MessageSender,340          resolve341        );342      });343      expect(r.ok).toBe(false);344      expect(r.error).toBe("invalid_sender");345    });346347    it("sender with no id (extension UI) is accepted", async () => {348      if (!listener) throw new Error("listener not registered");349      await setupUnlockedVault();350      const r = await new Promise<MessageResponse>((resolve) => {351        listener!(352          { type: "GET_ENTRIES" } as Message,353          {} as chrome.runtime.MessageSender,354          resolve355        );356      });357      expect(r.ok).toBe(true);358    });359  });360361  // ── AUTOFILL_QUERY adversarial ──────────────────────────────────────────362363  describe("AUTOFILL_QUERY adversarial", () => {364    async function withEntries(n: number) {365      const vault = await import("./vault");366      await vault.createVault("test-pass-12345");367      for (let i = 0; i < n; i++) {368        await vault.addEntry({369          title: `E${i}`,370          username: `u${i}`,371          password: "p",372          url: `https://example${i}.com`,373          type: "login",374        });375      }376    }377378    it("empty domain returns empty results", async () => {379      await withEntries(3);380      const r = await dispatch({381        type: "AUTOFILL_QUERY",382        payload: { domain: "" },383      });384      expect(r.ok).toBe(true);385      expect((r as { data: unknown[] }).data).toEqual([]);386    });387388    const adversarialDomains = [389      "*",390      ".*",391      "%",392      "<script>alert(1)</script>",393      "' OR '1'='1",394      "../../../etc/passwd",395      "\x00github.com",396      "github.com\x00",397      "GITHUB.COM",398      "github.com/path",399      "https://github.com",400      "javascript:alert(1)",401      " ",402      "   ",403      "\t",404      "\n",405      String("a").repeat(5000),406    ];407    for (const domain of adversarialDomains) {408      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not crash`, async () => {409        await withEntries(3);410        const r = await dispatch({411          type: "AUTOFILL_QUERY",412          payload: { domain },413        });414        expect(r.ok).toBe(true);415      });416417      it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not match all entries`, async () => {418        await withEntries(5);419        const r = await dispatch({420          type: "AUTOFILL_QUERY",421          payload: { domain },422        });423        const data = (r as { data: unknown[] }).data;424        // No adversarial wildcard should ever match every entry.425        expect(data.length).toBeLessThan(5);426      });427    }428429    it("locked vault: AUTOFILL_QUERY returns error", async () => {430      const vault = await import("./vault");431      await vault.createVault("test-pass-12345");432      vault.lock();433      const r = await dispatch({434        type: "AUTOFILL_QUERY",435        payload: { domain: "github.com" },436      });437      expect(r.ok).toBe(false);438    });439440    it("response never leaks privateKeyJwk", async () => {441      await withEntries(3);442      const r = await dispatch({443        type: "AUTOFILL_QUERY",444        payload: { domain: "example0.com" },445      });446      const data = (r as { data: Array<Record<string, unknown>> }).data;447      for (const entry of data) {448        expect("privateKeyJwk" in entry).toBe(false);449        expect("passkey" in entry).toBe(false);450      }451    });452453    it("kind=card returns card summaries only (no full PAN)", async () => {454      const vault = await import("./vault");455      await vault.createVault("test-pass-12345");456      await vault.addEntry({457        type: "card",458        title: "Visa",459        cardNumber: "4111111111111111",460        last4: "1111",461        cardholderName: "JUNE",462        expMonth: "12",463        expYear: "2030",464        cvc: "123",465      } as unknown as Parameters<typeof vault.addEntry>[0]);466      const r = await dispatch({467        type: "AUTOFILL_QUERY",468        payload: { domain: "any.com", kind: "card" },469      });470      const data = (r as { data: Array<Record<string, unknown>> }).data;471      for (const entry of data) {472        expect("cardNumber" in entry).toBe(false);473        expect("cvc" in entry).toBe(false);474      }475    });476  });477478  // ── RESTORE_FROM_CODEX adversarial ──────────────────────────────────────479480  describe("RESTORE_FROM_CODEX adversarial", () => {481    const malformed = [482      { name: "empty vaultBlob", overrides: { vaultBlob: "" } },483      { name: "garbage vaultBlob", overrides: { vaultBlob: "!!!not-base64!!!" } },484      { name: "wrong fingerprint", overrides: { vaultFingerprint: "0".repeat(16) } },485      { name: "wrong passphrase", overrides: { masterPassphrase: "definitely wrong" } },486      { name: "negative timestamp", overrides: { timestamp: -1 } },487      { name: "zero timestamp", overrides: { timestamp: 0 } },488      { name: "huge entryCount", overrides: { entryCount: 1_000_000 } },489      { name: "negative entryCount", overrides: { entryCount: -5 } },490    ];491    for (const { name, overrides } of malformed) {492      it(`malformed payload (${name}) returns ok=false without crash`, async () => {493        const payload = {494          vaultBlob: "ZGF0YQ==",495          timestamp: Date.now(),496          verifyCode: "PX-TEST-1234",497          vaultFingerprint: "ABCDEF1234567890",498          entryCount: 0,499          masterPassphrase: "secret",500          ...overrides,501        };502        const r = await dispatch({ type: "RESTORE_FROM_CODEX", payload });503        expect(r).toBeDefined();504        expect(r.ok).toBe(false);505      });506    }507508    it("null payload does not crash", async () => {509      const r = await dispatch({510        type: "RESTORE_FROM_CODEX",511        payload: null,512      } as unknown as Message);513      expect(r).toBeDefined();514      expect(r.ok).toBe(false);515    });516  });517518  // ── COPY handler ────────────────────────────────────────────────────────519520  describe("COPY handler adversarial", () => {521    for (const text of ["", "x", "a".repeat(10000), "\x00\x00\x00", "🔑"]) {522      it(`COPY of ${JSON.stringify(text.slice(0, 20))} resolves`, async () => {523        const r = await dispatch({524          type: "COPY",525          payload: { text, label: "test", alreadyWritten: true },526        });527        expect(r).toBeDefined();528      });529    }530  });531532  // ── FIRE_CANARY_PING adversarial ────────────────────────────────────────533534  describe("FIRE_CANARY_PING webhook url validation", () => {535    const badUrls = [536      "javascript:alert(1)",537      "file:///etc/passwd",538      "data:text/plain,evil",539      "",540      "not a url",541      "ftp://example.com/hook",542    ];543    for (const url of badUrls) {544      it(`rejects bad webhook URL ${JSON.stringify(url)}`, async () => {545        const r = await dispatch({546          type: "FIRE_CANARY_PING",547          payload: {548            webhookUrl: url,549            entryTitle: "x",550            trigger: "test",551          },552        });553        expect(r.ok).toBe(false);554      });555    }556  });557558  // ── GET_ENTRY_FOR_FILL safety ───────────────────────────────────────────559560  describe("GET_ENTRY_FOR_FILL safety", () => {561    it("returns entry without passkey field for login entry", async () => {562      const vault = await import("./vault");563      await vault.createVault("test-pass-12345");564      const e = await vault.addEntry({565        title: "GitHub",566        username: "june",567        password: "x",568        url: "https://github.com",569        type: "login",570      });571      const r = await dispatch({572        type: "GET_ENTRY_FOR_FILL",573        payload: { id: e.id },574      });575      expect(r.ok).toBe(true);576      const data = (r as { data: Record<string, unknown> }).data;577      expect("passkey" in data).toBe(false);578      expect("privateKeyJwk" in data).toBe(false);579    });580581    it("missing id returns error", async () => {582      await setupUnlockedVault();583      const r = await dispatch({584        type: "GET_ENTRY_FOR_FILL",585        payload: { id: "not-a-real-id" },586      });587      expect(r.ok).toBe(false);588    });589590    it("locked vault returns error", async () => {591      const vault = await import("./vault");592      await vault.createVault("test-pass-12345");593      vault.lock();594      const r = await dispatch({595        type: "GET_ENTRY_FOR_FILL",596        payload: { id: "x" },597      });598      expect(r.ok).toBe(false);599    });600  });601602  // ── ADD_ENTRY adversarial when locked ───────────────────────────────────603604  describe("ADD_ENTRY locked-state safety", () => {605    it("locked vault: ADD_ENTRY returns vault locked", async () => {606      const r = await dispatch({607        type: "ADD_ENTRY",608        payload: { title: "x", username: "u", password: "p", url: "https://e.com" },609      });610      expect(r.ok).toBe(false);611    });612613    it("ADD_ENTRY with no password and no passkey returns validation error", async () => {614      await setupUnlockedVault();615      const r = await dispatch({616        type: "ADD_ENTRY",617        payload: { title: "x", username: "u", password: "", url: "https://e.com" },618      });619      expect(r.ok).toBe(false);620    });621  });622623  // ── DELETE_ENTRY adversarial ────────────────────────────────────────────624625  describe("DELETE_ENTRY adversarial", () => {626    const badIds = [627      "",628      "   ",629      "not-a-uuid",630      String("a").repeat(10000),631      "00000000-0000-0000-0000-000000000000",632      "<script>",633      "\x00",634    ];635    for (const id of badIds) {636      it(`unknown id ${JSON.stringify(id.slice(0, 30))} returns error`, async () => {637        await setupUnlockedVault();638        const r = await dispatch({639          type: "DELETE_ENTRY",640          payload: { id },641        });642        expect(r.ok).toBe(false);643      });644    }645  });646647  // ── BATCH_ADD_ENTRIES adversarial ───────────────────────────────────────648649  describe("BATCH_ADD_ENTRIES adversarial", () => {650    it("empty array does not crash", async () => {651      await setupUnlockedVault();652      const r = await dispatch({653        type: "BATCH_ADD_ENTRIES",654        payload: { entries: [] },655      });656      expect(r).toBeDefined();657    });658659    it("non-array entries does not crash", async () => {660      await setupUnlockedVault();661      const r = await dispatch({662        type: "BATCH_ADD_ENTRIES",663        payload: { entries: "not-an-array" as unknown as unknown[] },664      });665      expect(r).toBeDefined();666    });667668    it("mostly-malformed entries does not crash", async () => {669      await setupUnlockedVault();670      const r = await dispatch({671        type: "BATCH_ADD_ENTRIES",672        payload: {673          entries: [674            null,675            undefined,676            42,677            "string",678            { title: "ok", username: "u", password: "p", url: "https://e.com" },679          ] as unknown as unknown[],680        },681      });682      expect(r).toBeDefined();683    });684  });685686  // ── SEARCH_VAULT adversarial ────────────────────────────────────────────687688  describe("SEARCH_VAULT adversarial", () => {689    const queries = [690      "",691      " ",692      "x",693      "a".repeat(1000),694      "🔑",695      "<script>",696      "' OR '1'='1",697      "../../",698      "\x00",699    ];700    for (const query of queries) {701      it(`query ${JSON.stringify(query.slice(0, 30))} does not crash`, async () => {702        await setupUnlockedVault();703        const r = await dispatch({704          type: "SEARCH_VAULT",705          payload: { query },706        });707        expect(r).toBeDefined();708      });709    }710  });711712  // ── UPDATE_SETTINGS adversarial ─────────────────────────────────────────713714  describe("UPDATE_SETTINGS adversarial", () => {715    const patches = [716      { name: "empty", patch: {} },717      { name: "junk keys", patch: { __proto__: { polluted: true } } },718      { name: "very long autolock", patch: { autoLockMinutes: 99999999 } },719      { name: "negative autolock", patch: { autoLockMinutes: -5 } },720      { name: "string autolock", patch: { autoLockMinutes: "five" } },721      { name: "unknown nested", patch: { foo: { bar: { baz: 1 } } } },722    ];723    for (const { name, patch } of patches) {724      it(`patch ${name} does not crash`, async () => {725        await setupUnlockedVault();726        const r = await dispatch({727          type: "UPDATE_SETTINGS",728          payload: patch as Record<string, unknown>,729        });730        expect(r).toBeDefined();731      });732    }733734    it("prototype pollution check: Object.prototype.polluted stays unset", async () => {735      await setupUnlockedVault();736      await dispatch({737        type: "UPDATE_SETTINGS",738        payload: JSON.parse(739          '{"__proto__": {"polluted": "yes"}}'740        ) as Record<string, unknown>,741      });742      // eslint-disable-next-line @typescript-eslint/no-explicit-any743      expect(({} as any).polluted).toBeUndefined();744    });745  });746747  // ── Wider adversarial payload matrix per handler ────────────────────────748749  describe("wide adversarial payload matrix", () => {750    const adversarialPayloads: Array<{ name: string; payload: unknown }> = [751      { name: "true", payload: true },752      { name: "false", payload: false },753      { name: "NaN", payload: NaN },754      { name: "Infinity", payload: Infinity },755      { name: "-Infinity", payload: -Infinity },756      { name: "BigInt-like", payload: { type: "bigint" } },757      { name: "Date", payload: new Date() },758      { name: "RegExp", payload: /pattern/ },759      { name: "Symbol-string", payload: Symbol("x").toString() },760      { name: "circular reference", payload: (() => {761        const o: Record<string, unknown> = {};762        o.self = o;763        return o;764      })() },765    ];766767    const fastHandlers = [768      "ADD_ENTRY",769      "UPDATE_ENTRY",770      "DELETE_ENTRY",771      "AUTOFILL_QUERY",772      "GET_ENTRY_FOR_FILL",773      "UPDATE_SETTINGS",774      "SEARCH_VAULT",775      "SAVE_NOTE",776      "DELETE_NOTE",777      "PURGE_ENTRY",778      "RESTORE_ENTRY",779      "PURGE_EXPIRED_TRASH",780      "ADD_ADDITIONAL_URL",781      "BATCH_ADD_ENTRIES",782      "RESTORE_ENTRIES",783      "ACTIVATE_LICENSE",784      "APPROVE_PASSKEY",785      "DENY_PASSKEY",786      "SET_PASSKEY_PROVIDER_ENABLED",787      "REMOVE_PAIRED_DEVICE",788      "RESTORE_VAULT_SNAPSHOT",789      "RESPOND_INCOMING_PAIRING",790      "VERIFY_MAIN_PASSWORD",791      "SETUP_DURESS",792    ];793794    for (const handler of fastHandlers) {795      for (const { name, payload } of adversarialPayloads) {796        it(`${handler} + ${name} payload resolves without crash`, async () => {797          const r = await dispatch({ type: handler, payload } as unknown as Message);798          expect(r).toBeDefined();799        });800      }801    }802  });803804  // ── Foreign sender × handler matrix ─────────────────────────────────────805806  describe("foreign sender rejection matrix", () => {807    const safeQuickHandlers = [808      "GET_ENTRIES",809      "IS_UNLOCKED",810      "HAS_VAULT",811      "GET_VAULT_STATUS",812      "GET_SETTINGS",813      "GET_DELETED_ENTRIES",814      "COUNT_DUPLICATES",815      "GET_VAULT_HISTORY",816      "ADD_ENTRY",817      "DELETE_ENTRY",818      "AUTOFILL_QUERY",819      "GET_ENTRY_FOR_FILL",820      "SAVE_NOTE",821      "DELETE_NOTE",822      "GET_NOTES",823      "SEARCH_VAULT",824      "GET_LICENSE",825      "GET_LICENSE_KEY",826      "GET_RECOVERY_WORDS",827      "VALIDATE_RECOVERY_WORDS",828      "RECOVER_WITH_WORDS",829      "RESTORE_FROM_CODEX",830      "GET_CODEX_VAULT_DATA",831      "ESTIMATE_CODEX_PAGES",832    ];833    for (const handler of safeQuickHandlers) {834      it(`${handler} from foreign sender returns invalid sender`, async () => {835        if (!listener) throw new Error("listener not registered");836        const r = await new Promise<MessageResponse>((resolve) => {837          listener!(838            { type: handler, payload: {} } as Message,839            { id: "foreign-extension-id" } as chrome.runtime.MessageSender,840            resolve841          );842        });843        expect(r.ok).toBe(false);844        expect(r.error).toBe("invalid_sender");845      });846    }847  });848849  // ── ADD_ENTRY payload-shape robustness ──────────────────────────────────850851  describe("ADD_ENTRY payload shape robustness", () => {852    const shapes = [853      { name: "no title", body: { username: "u", password: "p", url: "https://e.com" } },854      { name: "no url", body: { title: "t", username: "u", password: "p" } },855      { name: "title only", body: { title: "t", password: "p" } },856      { name: "spurious type", body: { type: "login", title: "t", password: "p" } },857      { name: "unknown type", body: { type: "wat", title: "t", password: "p" } },858      { name: "with extra field", body: { title: "t", password: "p", extra: "ignored" } },859      { name: "url with port", body: { title: "t", password: "p", url: "https://e.com:8080" } },860      { name: "url with path", body: { title: "t", password: "p", url: "https://e.com/a/b/c" } },861      { name: "username as number", body: { title: "t", username: 42, password: "p", url: "https://e.com" } },862      { name: "password as number", body: { title: "t", password: 12345, url: "https://e.com" } },863      { name: "title as null", body: { title: null, password: "p", url: "https://e.com" } },864      { name: "title as object", body: { title: { wat: 1 }, password: "p", url: "https://e.com" } },865    ];866    for (const { name, body } of shapes) {867      it(`ADD_ENTRY shape ${name} resolves without crash`, async () => {868        await setupUnlockedVault();869        const r = await dispatch({870          type: "ADD_ENTRY",871          payload: body as Parameters<typeof dispatch>[0]["payload"],872        } as Message);873        expect(r).toBeDefined();874      });875    }876  });877878  // ── UPDATE_ENTRY patch-shape robustness ─────────────────────────────────879880  describe("UPDATE_ENTRY patch shape robustness", () => {881    const patches = [882      { name: "title only", patch: { title: "New" } },883      { name: "password only", patch: { password: "newpass" } },884      { name: "url only", patch: { url: "https://e2.com" } },885      { name: "many fields", patch: { title: "N", username: "u2", password: "p2", url: "https://e2.com" } },886      { name: "unknown field", patch: { totallyMadeUp: "x" } },887      { name: "deletedAt forbidden", patch: { deletedAt: Date.now() } },888      { name: "createdAt forbidden", patch: { createdAt: 0 } },889      { name: "modifiedAt patch", patch: { modifiedAt: Date.now() } },890      { name: "id patch (should reject or ignore)", patch: { id: "new-id" } },891    ];892    for (const { name, patch } of patches) {893      it(`UPDATE_ENTRY patch ${name} resolves without crash`, async () => {894        const vault = await import("./vault");895        await vault.createVault("test-pass-12345");896        const e = await vault.addEntry({897          title: "x",898          username: "u",899          password: "p",900          url: "https://e.com",901          type: "login",902        });903        const r = await dispatch({904          type: "UPDATE_ENTRY",905          payload: { id: e.id, patch },906        });907        expect(r).toBeDefined();908      });909    }910  });911912  // ── Sequence: vault life-cycle adversarial ──────────────────────────────913914  describe("vault lifecycle adversarial sequences", () => {915    for (let i = 0; i < 20; i++) {916      it(`random message sequence #${i} does not crash`, async () => {917        const vault = await import("./vault");918        await vault.createVault("test-pass-12345");919        const handlers = [920          { type: "GET_ENTRIES" },921          { type: "IS_UNLOCKED" },922          { type: "GET_VAULT_STATUS" },923          { type: "HAS_VAULT" },924          { type: "GET_DELETED_ENTRIES" },925          { type: "GET_SETTINGS" },926          { type: "COUNT_DUPLICATES" },927          { type: "GET_VAULT_HISTORY" },928        ] as Array<Message>;929        for (const h of handlers) {930          const r = await dispatch(h);931          expect(r).toBeDefined();932        }933      });934    }935  });936937  // ── Read-only handlers when locked ──────────────────────────────────────938939  describe("read-only handlers when locked", () => {940    const safeWhenLocked = [941      "IS_UNLOCKED",942      "HAS_VAULT",943      "GET_VAULT_STATUS",944      "GET_SETTINGS",945    ];946    for (const type of safeWhenLocked) {947      it(`${type} works without crash when locked`, async () => {948        const r = await dispatch({ type } as unknown as Message);949        expect(r).toBeDefined();950      });951    }952953    const gatedWhenLocked = [954      "GET_ENTRIES",955      "GET_DELETED_ENTRIES",956      "COUNT_DUPLICATES",957      "GET_IDENTITIES",958      "GET_IDENTITY_SUGGESTIONS",959    ];960    for (const type of gatedWhenLocked) {961      it(`${type} returns ok=false when locked`, async () => {962        const r = await dispatch({ type } as unknown as Message);963        expect(r).toBeDefined();964        // Some handlers return ok:false explicitly; others may throw.965        if (r.ok === true) {966          // Accept ok:true only if data is empty.967          const data = (r as { data: unknown }).data;968          expect(969            data == null ||970              (Array.isArray(data) && data.length === 0) ||971              data === 0972          ).toBe(true);973        } else {974          expect(r.ok).toBe(false);975        }976      });977    }978  });979980  // ── M2/QUA-002: payload validation at trust boundary ────────────────────981982  describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {983    // Each entry: handler type + payloads that must produce ok:false.984    // The accepted error codes include 'invalid_payload' (the new validator)985    // or 'vault_locked' (handlers that lock-check before validating, which986    // is still a correct rejection of the request).987    const cases: Array<{ type: string; bad: unknown[] }> = [988      {989        type: "ADD_ENTRY",990        bad: [null, "string", 42, [], true],991      },992      {993        type: "BATCH_ADD_ENTRIES",994        bad: [null, { entries: "not-array" }, { entries: null }, { entries: 42 }, []],995      },996      {997        type: "UPDATE_ENTRY",998        bad: [null, {}, { id: 123 }, { id: "x" }, { id: "x", patch: "no" }, { id: "x", patch: null }, []],999      },1000      {1001        type: "RESTORE_ENTRIES",1002        bad: [null, {}, { entries: "no" }, { entries: [], mode: "wrong" }, []],1003      },1004      {1005        type: "UPDATE_SETTINGS",1006        bad: [null, "string", 42, []],1007      },1008      {1009        type: "REMOVE_TRUSTED_NETWORK",1010        bad: [null, "string", 42, []],1011      },1012    ];10131014    for (const { type, bad } of cases) {1015      for (const payload of bad) {1016        const label = JSON.stringify(payload);1017        it(`${type} rejects malformed payload: ${label}`, async () => {1018          await setupUnlockedVault();1019          const r = await dispatch({ type, payload } as unknown as Message);1020          expect(r.ok).toBe(false);1021          const err = (r as { error?: string }).error ?? "";1022          expect(err).toMatch(/invalid_payload|vault[_ ]locked/);1023        });1024      }1025    }1026  });10271028  // ── AUD-002: payload validation for previously raw-cast handlers ────────10291030  describe("AUD-002 payload validation", () => {1031    describe("SEARCH_VAULT", () => {1032      const badQueries: Array<[string, unknown]> = [1033        ["null", null],1034        ["numeric", 42],1035        ["boolean", true],1036        ["object", { evil: true }],1037        ["array", []],1038        ["missing", undefined],1039      ];1040      for (const [name, query] of badQueries) {1041        it(`rejects ${name} query`, async () => {1042          await setupUnlockedVault();1043          const r = await dispatch({1044            type: "SEARCH_VAULT",1045            payload: { query },1046          } as unknown as Message);1047          expect(r.ok).toBe(false);1048          expect((r as { error?: string }).error).toBe("invalid_payload");1049        });1050      }10511052      it("accepts valid string query", async () => {1053        await setupUnlockedVault();1054        const r = await dispatch({1055          type: "SEARCH_VAULT",1056          payload: { query: "github" },1057        });1058        expect(r.ok).toBe(true);1059      });1060    });10611062    describe("COPY", () => {1063      const bads: Array<[string, unknown, unknown]> = [1064        ["null text", null, "pw"],1065        ["numeric text", 42, "pw"],1066        ["object text", { evil: true }, "pw"],1067        ["missing text", undefined, "pw"],1068        ["null label", "secret", null],1069        ["numeric label", "secret", 42],1070      ];1071      for (const [name, text, label] of bads) {1072        it(`rejects ${name}`, async () => {1073          await setupUnlockedVault();1074          const r = await dispatch({1075            type: "COPY",1076            payload: { text, label },1077          } as unknown as Message);1078          expect(r.ok).toBe(false);1079          expect((r as { error?: string }).error).toBe("invalid_payload");1080        });1081      }1082    });10831084    describe("ML_PREDICT_BATCH", () => {1085      const bads: Array<[string, unknown]> = [1086        ["null", null],1087        ["object", { not: "array" }],1088        ["string", "features"],1089        ["number", 42],1090        ["missing", undefined],1091      ];1092      for (const [name, featuresArray] of bads) {1093        it(`rejects ${name} featuresArray`, async () => {1094          const r = await dispatch({1095            type: "ML_PREDICT_BATCH",1096            payload: { featuresArray },1097          } as unknown as Message);1098          expect(r.ok).toBe(false);1099          expect((r as { error?: string }).error).toBe("invalid_payload");1100        });1101      }11021103      it("accepts empty array featuresArray", async () => {1104        const r = await dispatch({1105          type: "ML_PREDICT_BATCH",1106          payload: { featuresArray: [] },1107        } as unknown as Message);1108        expect((r as { error?: string }).error).not.toBe("invalid_payload");1109      });1110    });1111  });1112});