pairing-completed-gate.test.ts

4.9 KB · raw

1/**2 * pairing-completed-gate.test.ts — M13/SEC-0053 *4 * PAIRING_COMPLETED must verify the publicKeyHex came from a real Noise NK5 * handshake before calling addPairedDevice. Forged messages with an6 * unverified key must be rejected.7 */89import { beforeEach, describe, expect, it, vi } from "vitest";1011import type { Message, MessageResponse } from "../shared/types";1213type StorageShape = Record<string, unknown>;14type MessageListener = (15  msg: unknown,16  sender: chrome.runtime.MessageSender,17  sendResponse: (response: MessageResponse) => void18) => boolean;1920describe("PAIRING_COMPLETED gate (M13/SEC-005)", () => {21  let storage: StorageShape;22  let listener: MessageListener | undefined;2324  beforeEach(async () => {25    vi.resetModules();26    vi.useRealTimers();27    storage = { peach_settings: { breachMonitoring: false } };28    listener = undefined;2930    vi.stubGlobal(31      "fetch",32      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" })),33    );34    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);3536    const chromeMock = {37      runtime: {38        id: "peach-extension",39        onMessage: {40          addListener: vi.fn((fn: MessageListener) => {41            listener = fn;42          }),43        },44        sendMessage: vi.fn(async () => ({ ok: true })),45      },46      storage: {47        local: {48          get: vi.fn(async (key?: string | string[]) => {49            if (!key) return storage;50            if (typeof key === "string") return { [key]: storage[key] };51            return key.reduce<Record<string, unknown>>((acc, k) => {52              acc[k] = storage[k];53              return acc;54            }, {});55          }),56          set: vi.fn(async (next: Record<string, unknown>) => {57            Object.assign(storage, next);58          }),59          remove: vi.fn(async (keys: string | string[]) => {60            const list = Array.isArray(keys) ? keys : [keys];61            for (const k of list) delete storage[k];62          }),63        },64        session: {65          get: vi.fn(async () => ({})),66          set: vi.fn(async () => {}),67          remove: vi.fn(async () => {}),68        },69      },70      alarms: {71        clear: vi.fn(),72        create: vi.fn(),73        onAlarm: { addListener: vi.fn() },74      },75      idle: {76        setDetectionInterval: vi.fn(),77        onStateChanged: { addListener: vi.fn() },78      },79      scripting: { executeScript: vi.fn(async () => []) },80    } as unknown as typeof chrome;81    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8283    const messages = await import("./messages");84    messages.setupMessageHandler();85  });8687  async function dispatch(message: Message): Promise<MessageResponse> {88    if (!listener) throw new Error("listener not registered");89    return new Promise<MessageResponse>((resolve) => {90      listener!(message, { id: "peach-extension" } as chrome.runtime.MessageSender, resolve);91    });92  }9394  it("rejects PAIRING_COMPLETED for an unverified, unpaired publicKeyHex", async () => {95    const fakeKey = "deadbeef".repeat(8);96    const r = await dispatch({97      type: "PAIRING_COMPLETED",98      payload: { deviceName: "Attacker", publicKeyHex: fakeKey },99    });100    expect(r.ok).toBe(false);101    expect(r.error).toBe("pairing_not_verified");102103    // Did not write to paired devices.104    const paired = (storage.peach_paired_devices as unknown[] | undefined) ?? [];105    expect(paired.length).toBe(0);106  });107108  it("accepts PAIRING_COMPLETED for a freshly verified handshake", async () => {109    const messages = await import("./messages");110    const goodKey = "abcdef01".repeat(8);111    messages.markPairingHandshakeVerified(goodKey);112113    const r = await dispatch({114      type: "PAIRING_COMPLETED",115      payload: { deviceName: "RealDevice", publicKeyHex: goodKey },116    });117    expect(r.ok).toBe(true);118119    const paired = (storage.peach_paired_devices as Array<Record<string, unknown>> | undefined) ?? [];120    expect(paired.some(d => d.x25519PublicKey === goodKey)).toBe(true);121  });122123  it("verified marker is single-use — second PAIRING_COMPLETED after the first device is removed is rejected", async () => {124    const messages = await import("./messages");125    const goodKey = "12345678".repeat(8);126    messages.markPairingHandshakeVerified(goodKey);127128    const first = await dispatch({129      type: "PAIRING_COMPLETED",130      payload: { deviceName: "Dev", publicKeyHex: goodKey },131    });132    expect(first.ok).toBe(true);133134    // Wipe paired state so alreadyPaired is false.135    delete storage.peach_paired_devices;136137    const second = await dispatch({138      type: "PAIRING_COMPLETED",139      payload: { deviceName: "Dev", publicKeyHex: goodKey },140    });141    expect(second.ok).toBe(false);142    expect(second.error).toBe("pairing_not_verified");143  });144145  it("missing publicKeyHex is rejected", async () => {146    const r = await dispatch({147      type: "PAIRING_COMPLETED",148      payload: { deviceName: "X" },149    });150    expect(r.ok).toBe(false);151    expect(r.error).toBe("publicKeyHex required");152  });153});