1234567891011121314import { 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;242526const 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 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 245 246 247 248 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 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 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 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 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 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 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 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 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 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 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 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 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 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 743 expect(({} as any).polluted).toBeUndefined();744 });745 });746747 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 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 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 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 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 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 965 if (r.ok === true) {966 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 981982 describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {983 984 985 986 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 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});