1234567891011121314import { 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;252627const 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 175 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 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 250 251 252 253 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 883 expect(({} as any).polluted).toBeUndefined();884 });885 });886887 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 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 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 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 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 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 1103 if (r.ok === true) {1104 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 11191120 describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {1121 1122 1123 1124 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 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});