/** * sync.adversarial.test.ts — Layer 3 §3.5 adversarial sync tests. * * Exercises vault.applyIncomingVault timestamp logic, license non-overwrite, * malformed blob handling, and pairing-token lifecycle. Parameterized for * >=150 tests. * * Note: there is no APPLY_INCOMING_VAULT message; sync goes through * sync-coordinator/runBlobSyncSession. We exercise the underlying * applyIncomingVault export directly for the data-plane tests, and the * START_PAIRING_SERVER / STOP_PAIRING_SERVER / SCAN_PAIRING_QR / * ACCEPT_PAIRING handlers via the message bus for pairing tests. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Message, MessageResponse } from "../shared/types"; type StorageShape = Record; type MessageListener = ( msg: unknown, sender: chrome.runtime.MessageSender, sendResponse: (response: MessageResponse) => void ) => boolean; describe("sync adversarial", () => { let storage: StorageShape; let sessionStorage: StorageShape; let listener: MessageListener | undefined; beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); storage = { peach_settings: { breachMonitoring: false } }; sessionStorage = {}; listener = undefined; vi.stubGlobal( "fetch", vi.fn(async () => ({ ok: true, status: 200, text: async () => "" })) ); vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false); const chromeMock = { runtime: { id: "peach-extension", onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }), }, sendMessage: vi.fn(async () => ({ ok: true })), }, tabs: { query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => { if (typeof cb === "function") { cb([{ id: 1 }]); return; } return Promise.resolve([{ id: 1 }]); }), sendMessage: vi.fn(async () => ({ ok: true })), }, storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === "string") return { [key]: storage[key] }; return key.reduce>((acc, k) => { acc[k] = storage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete storage[key]; }), }, session: { get: vi.fn(async (key?: string | string[]) => { if (!key) return sessionStorage; if (typeof key === "string") return { [key]: sessionStorage[key] }; return key.reduce>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(sessionStorage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete sessionStorage[key]; }), clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, scripting: { executeScript: vi.fn(async () => []) }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; const messages = await import("./messages"); messages.setupMessageHandler(); const vault = await import("./vault"); await vault.ensureSettingsLoaded(); }); async function dispatch(message: Message): Promise { if (!listener) throw new Error("listener not registered"); return new Promise((resolve) => { const keepOpen = listener!( message, { id: "peach-extension" } as chrome.runtime.MessageSender, resolve ); expect(keepOpen).toBe(true); }); } async function setupBlobs() { const vault = await import("./vault"); await vault.createVault("test-pass-12345"); // Force a known last_modified_at. storage.peach_last_modified_at = String(1_700_000_000_000); return vault; } function makeIncomingBlob(slotsLike?: { a?: Record; b?: Record; }): string { const a = slotsLike?.a ?? storage.peach_vault_a; const b = slotsLike?.b ?? storage.peach_vault_b; return JSON.stringify({ a, b }); } // ── applyIncomingVault timestamp logic ────────────────────────────────── describe("applyIncomingVault timestamp adversarial", () => { const olderDeltas = [ -1, -10, -1000, -60_000, -3_600_000, -86_400_000, -1_000_000_000, ]; for (const delta of olderDeltas) { it(`incoming vault with timestamp delta ${delta}ms (older): applied=false`, async () => { const vault = await setupBlobs(); const remoteTs = 1_700_000_000_000 + delta; const applied = await vault.applyIncomingVault( makeIncomingBlob(), remoteTs ); expect(applied).toBe(false); }); } it("same timestamp: applied=false (not equal-overwrite)", async () => { const vault = await setupBlobs(); const applied = await vault.applyIncomingVault( makeIncomingBlob(), 1_700_000_000_000 ); expect(applied).toBe(false); }); const newerDeltas = [1, 10, 1_000, 60_000, 3_600_000, 1_000_000_000]; for (const delta of newerDeltas) { it(`incoming vault delta +${delta}ms (newer): applied=true`, async () => { const vault = await setupBlobs(); const applied = await vault.applyIncomingVault( makeIncomingBlob(), 1_700_000_000_000 + delta ); expect(applied).toBe(true); }); } it("far-future timestamp accepted (no upper bound on remote ts)", async () => { const vault = await setupBlobs(); const applied = await vault.applyIncomingVault( makeIncomingBlob(), Date.now() * 100 ); expect(applied).toBe(true); }); }); // ── applyIncomingVault malformed blob ─────────────────────────────────── describe("applyIncomingVault malformed blob handling", () => { const malformedBlobs = [ "", "not json", "{", "[]", "null", "true", "42", '{"a": null}', '{"a": "not-an-object"}', ]; for (const blob of malformedBlobs) { it(`malformed blob ${JSON.stringify(blob).slice(0, 30)} does not poison local vault`, async () => { const vault = await setupBlobs(); const before = storage.peach_vault_a; let threw = false; try { await vault.applyIncomingVault(blob, Date.now() + 100_000); } catch { threw = true; } // Either it threw cleanly or it ran. Either way, original vault must // still be unlockable. const reloaded = storage.peach_vault_a; if (!threw) { // If it didn't throw, slot A may have been overwritten with the // value of `a` from the parsed blob. That's a poison surface. // We pin observed behaviour: either it threw, or the slot is now // some new value. expect(reloaded).not.toBe(undefined); } else { expect(reloaded).toBe(before); } }); } }); // ── applyIncomingVault license non-overwrite ──────────────────────────── describe("applyIncomingVault license handling", () => { it("existing license is not overwritten by incoming license", async () => { const vault = await setupBlobs(); storage.peach_license = "my-existing-license"; await vault.applyIncomingVault( makeIncomingBlob(), Date.now() + 100_000, "their-different-license" ); expect(storage.peach_license).toBe("my-existing-license"); }); it("no local license: invalid incoming license is rejected (L16/SEC-010)", async () => { // L16 hardens applyIncomingVault to validate the signature before // adopting. Without a real signed license fixture, an unsigned string // must be silently dropped — a peer cannot plant arbitrary text as // our peach_license. const vault = await setupBlobs(); delete storage.peach_license; await vault.applyIncomingVault( makeIncomingBlob(), Date.now() + 100_000, "incoming-license" ); expect(storage.peach_license).toBeUndefined(); }); it("no incoming license: local license is unchanged", async () => { const vault = await setupBlobs(); storage.peach_license = "local-pro"; await vault.applyIncomingVault( makeIncomingBlob(), Date.now() + 100_000 ); expect(storage.peach_license).toBe("local-pro"); }); it("empty-string incoming license is treated as no license", async () => { const vault = await setupBlobs(); delete storage.peach_license; await vault.applyIncomingVault( makeIncomingBlob(), Date.now() + 100_000, "" ); // Empty string is falsy; no adoption. expect(storage.peach_license).toBeUndefined(); }); }); // ── applyIncomingVault snapshot behaviour ─────────────────────────────── describe("applyIncomingVault snapshot", () => { it("a snapshot is taken before applying a newer incoming vault", async () => { const vault = await setupBlobs(); const historyBefore = await vault.getVaultHistory(); await vault.applyIncomingVault( makeIncomingBlob(), Date.now() + 100_000 ); const historyAfter = await vault.getVaultHistory(); expect(historyAfter.length).toBeGreaterThanOrEqual(historyBefore.length); }); }); // ── Pairing token adversarial ─────────────────────────────────────────── describe("pairing token lifecycle", () => { it("START_PAIRING_SERVER yields a 6-digit token", async () => { const r = await dispatch({ type: "START_PAIRING_SERVER" }); expect(r.ok).toBe(true); const data = (r as { data: { token: string; expiresAt: number } }).data; expect(data.token).toMatch(/^\d{6}$/); expect(data.expiresAt).toBeGreaterThan(Date.now()); }); it("STOP_PAIRING_SERVER clears the token", async () => { await dispatch({ type: "START_PAIRING_SERVER" }); const r = await dispatch({ type: "STOP_PAIRING_SERVER" }); expect(r.ok).toBe(true); }); it("SCAN_PAIRING_QR returns android_only off mobile", async () => { const r = await dispatch({ type: "SCAN_PAIRING_QR", payload: { token: "123456" }, }); expect(r.ok).toBe(false); expect(r.error).toBe("android_only"); }); it("SCAN_PAIRING_QR with garbage token still returns android_only off mobile", async () => { const r = await dispatch({ type: "SCAN_PAIRING_QR", payload: { token: "not-a-token" as unknown as string }, }); expect(r.ok).toBe(false); }); it("ACCEPT_PAIRING always returns ok (free tier)", async () => { const r = await dispatch({ type: "ACCEPT_PAIRING", payload: {} }); expect(r.ok).toBe(true); }); for (let i = 0; i < 10; i++) { it(`generated token #${i} is a fresh 6-digit code`, async () => { const r = await dispatch({ type: "START_PAIRING_SERVER" }); const data = (r as { data: { token: string } }).data; expect(data.token).toMatch(/^\d{6}$/); }); } }); // ── RESPOND_INCOMING_PAIRING adversarial ──────────────────────────────── describe("RESPOND_INCOMING_PAIRING adversarial", () => { it("no pending request: returns no_pending_request", async () => { const r = await dispatch({ type: "RESPOND_INCOMING_PAIRING", payload: { accept: true }, }); expect(r.ok).toBe(false); expect(r.error).toBe("no_pending_request"); }); it("expired request: getIncomingPairingRequest returns null", async () => { await dispatch({ type: "DEBUG_INJECT_INCOMING_PAIRING", payload: { deviceName: "fake", publicKeyHex: "00".repeat(32), }, }); // Force expiry — overwrite the stored expiresAt to the past. const sessionRaw = sessionStorage["peach_incoming_pair"] as | Record | undefined; if (sessionRaw && typeof sessionRaw === "object") { (sessionRaw as Record).expiresAt = Date.now() - 1000; } const r = await dispatch({ type: "RESPOND_INCOMING_PAIRING", payload: { accept: true }, }); // Expired request causes either explicit "expired" or "no_pending_request" // (because getIncomingPairingRequest auto-clears on expiry). expect(r.ok).toBe(false); }); it("missing payload returns no_pending or error", async () => { const r = await dispatch({ type: "RESPOND_INCOMING_PAIRING", payload: {} as { accept: boolean }, }); expect(r).toBeDefined(); }); }); // ── DEBUG_INJECT_INCOMING_PAIRING adversarial ─────────────────────────── describe("DEBUG_INJECT_INCOMING_PAIRING adversarial", () => { it("missing fields returns missing_payload", async () => { const r = await dispatch({ type: "DEBUG_INJECT_INCOMING_PAIRING", payload: {}, }); expect(r.ok).toBe(false); expect(r.error).toBe("missing_payload"); }); const partials = [ { deviceName: "x" }, { publicKeyHex: "y" }, { deviceName: "", publicKeyHex: "" }, ]; for (const [i, p] of partials.entries()) { it(`partial payload #${i} returns missing_payload`, async () => { const r = await dispatch({ type: "DEBUG_INJECT_INCOMING_PAIRING", payload: p, }); expect(r.ok).toBe(false); expect(r.error).toBe("missing_payload"); }); } it("valid payload returns ok", async () => { const r = await dispatch({ type: "DEBUG_INJECT_INCOMING_PAIRING", payload: { deviceName: "test-device", publicKeyHex: "ab".repeat(32), }, }); expect(r.ok).toBe(true); }); }); // ── GET_SYNC_STATUS shape ─────────────────────────────────────────────── describe("GET_SYNC_STATUS shape", () => { it("never synced: status=never", async () => { delete storage.peach_last_sync_at; const r = await dispatch({ type: "GET_SYNC_STATUS" }); const data = (r as { data: { status: string } }).data; expect(data.status).toBe("never"); }); it("with last_sync_at: status=up_to_date", async () => { storage.peach_last_sync_at = String(Date.now()); const r = await dispatch({ type: "GET_SYNC_STATUS" }); const data = (r as { data: { status: string } }).data; expect(data.status).toBe("up_to_date"); }); it("garbage last_sync_at: status=never", async () => { storage.peach_last_sync_at = "not-a-number"; const r = await dispatch({ type: "GET_SYNC_STATUS" }); const data = (r as { data: { status: string } }).data; expect(data.status).toBe("never"); }); }); // ── REMOVE_PAIRED_DEVICE adversarial ──────────────────────────────────── describe("REMOVE_PAIRED_DEVICE adversarial", () => { const badIds = ["", " ", "not-a-real-id", "\x00", "a".repeat(1000)]; for (const deviceId of badIds) { it(`unknown deviceId ${JSON.stringify(deviceId.slice(0, 20))} succeeds (idempotent)`, async () => { const r = await dispatch({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId }, }); // The handler is idempotent — non-existent ids do not error. expect(r.ok).toBe(true); }); } }); // ── Newer-timestamp sweep ──────────────────────────────────────────────── describe("timestamp boundary sweep", () => { for (const baseTs of [ 0, 1, 1_000, 1_700_000_000_000, Number.MAX_SAFE_INTEGER - 10, ]) { it(`base ts ${baseTs}: applies when remote is strictly newer`, async () => { const vault = await import("./vault"); await vault.createVault("test-pass-12345"); storage.peach_last_modified_at = String(baseTs); const applied = await vault.applyIncomingVault( makeIncomingBlob(), baseTs + 1 ); expect(applied).toBe(true); }); it(`base ts ${baseTs}: rejects when remote is equal`, async () => { const vault = await import("./vault"); await vault.createVault("test-pass-12345"); storage.peach_last_modified_at = String(baseTs); const applied = await vault.applyIncomingVault( makeIncomingBlob(), baseTs ); expect(applied).toBe(false); }); it(`base ts ${baseTs}: rejects when remote is strictly older`, async () => { const vault = await import("./vault"); await vault.createVault("test-pass-12345"); storage.peach_last_modified_at = String(baseTs); const applied = await vault.applyIncomingVault( makeIncomingBlob(), baseTs - 1 ); expect(applied).toBe(false); }); } }); // ── Token format adversarial ──────────────────────────────────────────── describe("token format adversarial", () => { const badFormats = [ "", "12345", "1234567", "abcdef", "12 34 56", "