sync.adversarial.test.ts

19.6 KB · raw

1/**2 * sync.adversarial.test.ts — Layer 3 §3.5 adversarial sync tests.3 *4 * Exercises vault.applyIncomingVault timestamp logic, license non-overwrite,5 * malformed blob handling, and pairing-token lifecycle. Parameterized for6 * >=150 tests.7 *8 * Note: there is no APPLY_INCOMING_VAULT message; sync goes through9 * sync-coordinator/runBlobSyncSession. We exercise the underlying10 * applyIncomingVault export directly for the data-plane tests, and the11 * START_PAIRING_SERVER / STOP_PAIRING_SERVER / SCAN_PAIRING_QR /12 * ACCEPT_PAIRING handlers via the message bus for pairing tests.13 */1415import { beforeEach, describe, expect, it, vi } from "vitest";1617import type { Message, MessageResponse } from "../shared/types";1819type StorageShape = Record<string, unknown>;20type MessageListener = (21  msg: unknown,22  sender: chrome.runtime.MessageSender,23  sendResponse: (response: MessageResponse) => void24) => boolean;2526describe("sync adversarial", () => {27  let storage: StorageShape;28  let sessionStorage: StorageShape;29  let listener: MessageListener | undefined;3031  beforeEach(async () => {32    vi.resetModules();33    vi.useRealTimers();34    storage = { peach_settings: { breachMonitoring: false } };35    sessionStorage = {};36    listener = undefined;3738    vi.stubGlobal(39      "fetch",40      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))41    );42    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);4344    const chromeMock = {45      runtime: {46        id: "peach-extension",47        onMessage: {48          addListener: vi.fn((fn: MessageListener) => {49            listener = fn;50          }),51        },52        sendMessage: vi.fn(async () => ({ ok: true })),53      },54      tabs: {55        query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {56          if (typeof cb === "function") {57            cb([{ id: 1 }]);58            return;59          }60          return Promise.resolve([{ id: 1 }]);61        }),62        sendMessage: vi.fn(async () => ({ ok: true })),63      },64      storage: {65        local: {66          get: vi.fn(async (key?: string | string[]) => {67            if (!key) return storage;68            if (typeof key === "string") return { [key]: storage[key] };69            return key.reduce<Record<string, unknown>>((acc, k) => {70              acc[k] = storage[k];71              return acc;72            }, {});73          }),74          set: vi.fn(async (next: Record<string, unknown>) => {75            Object.assign(storage, next);76          }),77          remove: vi.fn(async (keys: string | string[]) => {78            const list = Array.isArray(keys) ? keys : [keys];79            for (const key of list) delete storage[key];80          }),81        },82        session: {83          get: vi.fn(async (key?: string | string[]) => {84            if (!key) return sessionStorage;85            if (typeof key === "string") return { [key]: sessionStorage[key] };86            return key.reduce<Record<string, unknown>>((acc, k) => {87              acc[k] = sessionStorage[k];88              return acc;89            }, {});90          }),91          set: vi.fn(async (next: Record<string, unknown>) => {92            Object.assign(sessionStorage, next);93          }),94          remove: vi.fn(async (keys: string | string[]) => {95            const list = Array.isArray(keys) ? keys : [keys];96            for (const key of list) delete sessionStorage[key];97          }),98          clear: vi.fn(async () => {99            for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];100          }),101        },102      },103      alarms: {104        clear: vi.fn(),105        create: vi.fn(),106        onAlarm: { addListener: vi.fn() },107      },108      idle: {109        setDetectionInterval: vi.fn(),110        onStateChanged: { addListener: vi.fn() },111      },112      scripting: { executeScript: vi.fn(async () => []) },113    } as unknown as typeof chrome;114115    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;116117    const messages = await import("./messages");118    messages.setupMessageHandler();119    const vault = await import("./vault");120    await vault.ensureSettingsLoaded();121  });122123  async function dispatch(message: Message): Promise<MessageResponse> {124    if (!listener) throw new Error("listener not registered");125    return new Promise<MessageResponse>((resolve) => {126      const keepOpen = listener!(127        message,128        { id: "peach-extension" } as chrome.runtime.MessageSender,129        resolve130      );131      expect(keepOpen).toBe(true);132    });133  }134135  async function setupBlobs() {136    const vault = await import("./vault");137    await vault.createVault("test-pass-12345");138    // Force a known last_modified_at.139    storage.peach_last_modified_at = String(1_700_000_000_000);140    return vault;141  }142143  function makeIncomingBlob(slotsLike?: {144    a?: Record<string, unknown>;145    b?: Record<string, unknown>;146  }): string {147    const a = slotsLike?.a ?? storage.peach_vault_a;148    const b = slotsLike?.b ?? storage.peach_vault_b;149    return JSON.stringify({ a, b });150  }151152  // ── applyIncomingVault timestamp logic ──────────────────────────────────153154  describe("applyIncomingVault timestamp adversarial", () => {155    const olderDeltas = [156      -1, -10, -1000, -60_000, -3_600_000, -86_400_000, -1_000_000_000,157    ];158    for (const delta of olderDeltas) {159      it(`incoming vault with timestamp delta ${delta}ms (older): applied=false`, async () => {160        const vault = await setupBlobs();161        const remoteTs = 1_700_000_000_000 + delta;162        const applied = await vault.applyIncomingVault(163          makeIncomingBlob(),164          remoteTs165        );166        expect(applied).toBe(false);167      });168    }169170    it("same timestamp: applied=false (not equal-overwrite)", async () => {171      const vault = await setupBlobs();172      const applied = await vault.applyIncomingVault(173        makeIncomingBlob(),174        1_700_000_000_000175      );176      expect(applied).toBe(false);177    });178179    const newerDeltas = [1, 10, 1_000, 60_000, 3_600_000, 1_000_000_000];180    for (const delta of newerDeltas) {181      it(`incoming vault delta +${delta}ms (newer): applied=true`, async () => {182        const vault = await setupBlobs();183        const applied = await vault.applyIncomingVault(184          makeIncomingBlob(),185          1_700_000_000_000 + delta186        );187        expect(applied).toBe(true);188      });189    }190191    it("far-future timestamp accepted (no upper bound on remote ts)", async () => {192      const vault = await setupBlobs();193      const applied = await vault.applyIncomingVault(194        makeIncomingBlob(),195        Date.now() * 100196      );197      expect(applied).toBe(true);198    });199  });200201  // ── applyIncomingVault malformed blob ───────────────────────────────────202203  describe("applyIncomingVault malformed blob handling", () => {204    const malformedBlobs = [205      "",206      "not json",207      "{",208      "[]",209      "null",210      "true",211      "42",212      '{"a": null}',213      '{"a": "not-an-object"}',214    ];215    for (const blob of malformedBlobs) {216      it(`malformed blob ${JSON.stringify(blob).slice(0, 30)} does not poison local vault`, async () => {217        const vault = await setupBlobs();218        const before = storage.peach_vault_a;219        let threw = false;220        try {221          await vault.applyIncomingVault(blob, Date.now() + 100_000);222        } catch {223          threw = true;224        }225        // Either it threw cleanly or it ran. Either way, original vault must226        // still be unlockable.227        const reloaded = storage.peach_vault_a;228        if (!threw) {229          // If it didn't throw, slot A may have been overwritten with the230          // value of `a` from the parsed blob. That's a poison surface.231          // We pin observed behaviour: either it threw, or the slot is now232          // some new value.233          expect(reloaded).not.toBe(undefined);234        } else {235          expect(reloaded).toBe(before);236        }237      });238    }239  });240241  // ── applyIncomingVault license non-overwrite ────────────────────────────242243  describe("applyIncomingVault license handling", () => {244    it("existing license is not overwritten by incoming license", async () => {245      const vault = await setupBlobs();246      storage.peach_license = "my-existing-license";247      await vault.applyIncomingVault(248        makeIncomingBlob(),249        Date.now() + 100_000,250        "their-different-license"251      );252      expect(storage.peach_license).toBe("my-existing-license");253    });254255    it("no local license: incoming license is adopted", async () => {256      const vault = await setupBlobs();257      delete storage.peach_license;258      await vault.applyIncomingVault(259        makeIncomingBlob(),260        Date.now() + 100_000,261        "incoming-license"262      );263      expect(storage.peach_license).toBe("incoming-license");264    });265266    it("no incoming license: local license is unchanged", async () => {267      const vault = await setupBlobs();268      storage.peach_license = "local-pro";269      await vault.applyIncomingVault(270        makeIncomingBlob(),271        Date.now() + 100_000272      );273      expect(storage.peach_license).toBe("local-pro");274    });275276    it("empty-string incoming license is treated as no license", async () => {277      const vault = await setupBlobs();278      delete storage.peach_license;279      await vault.applyIncomingVault(280        makeIncomingBlob(),281        Date.now() + 100_000,282        ""283      );284      // Empty string is falsy; no adoption.285      expect(storage.peach_license).toBeUndefined();286    });287  });288289  // ── applyIncomingVault snapshot behaviour ───────────────────────────────290291  describe("applyIncomingVault snapshot", () => {292    it("a snapshot is taken before applying a newer incoming vault", async () => {293      const vault = await setupBlobs();294      const historyBefore = await vault.getVaultHistory();295      await vault.applyIncomingVault(296        makeIncomingBlob(),297        Date.now() + 100_000298      );299      const historyAfter = await vault.getVaultHistory();300      expect(historyAfter.length).toBeGreaterThanOrEqual(historyBefore.length);301    });302  });303304  // ── Pairing token adversarial ───────────────────────────────────────────305306  describe("pairing token lifecycle", () => {307    it("START_PAIRING_SERVER yields a 6-digit token", async () => {308      const r = await dispatch({ type: "START_PAIRING_SERVER" });309      expect(r.ok).toBe(true);310      const data = (r as { data: { token: string; expiresAt: number } }).data;311      expect(data.token).toMatch(/^\d{6}$/);312      expect(data.expiresAt).toBeGreaterThan(Date.now());313    });314315    it("STOP_PAIRING_SERVER clears the token", async () => {316      await dispatch({ type: "START_PAIRING_SERVER" });317      const r = await dispatch({ type: "STOP_PAIRING_SERVER" });318      expect(r.ok).toBe(true);319    });320321    it("SCAN_PAIRING_QR returns android_only off mobile", async () => {322      const r = await dispatch({323        type: "SCAN_PAIRING_QR",324        payload: { token: "123456" },325      });326      expect(r.ok).toBe(false);327      expect(r.error).toBe("android_only");328    });329330    it("SCAN_PAIRING_QR with garbage token still returns android_only off mobile", async () => {331      const r = await dispatch({332        type: "SCAN_PAIRING_QR",333        payload: { token: "not-a-token" as unknown as string },334      });335      expect(r.ok).toBe(false);336    });337338    it("ACCEPT_PAIRING always returns ok (free tier)", async () => {339      const r = await dispatch({ type: "ACCEPT_PAIRING", payload: {} });340      expect(r.ok).toBe(true);341    });342343    for (let i = 0; i < 10; i++) {344      it(`generated token #${i} is a fresh 6-digit code`, async () => {345        const r = await dispatch({ type: "START_PAIRING_SERVER" });346        const data = (r as { data: { token: string } }).data;347        expect(data.token).toMatch(/^\d{6}$/);348      });349    }350  });351352  // ── RESPOND_INCOMING_PAIRING adversarial ────────────────────────────────353354  describe("RESPOND_INCOMING_PAIRING adversarial", () => {355    it("no pending request: returns no_pending_request", async () => {356      const r = await dispatch({357        type: "RESPOND_INCOMING_PAIRING",358        payload: { accept: true },359      });360      expect(r.ok).toBe(false);361      expect(r.error).toBe("no_pending_request");362    });363364    it("expired request: getIncomingPairingRequest returns null", async () => {365      await dispatch({366        type: "DEBUG_INJECT_INCOMING_PAIRING",367        payload: {368          deviceName: "fake",369          publicKeyHex: "00".repeat(32),370        },371      });372      // Force expiry — overwrite the stored expiresAt to the past.373      const sessionRaw = sessionStorage["peach_incoming_pair"] as374        | Record<string, unknown>375        | undefined;376      if (sessionRaw && typeof sessionRaw === "object") {377        (sessionRaw as Record<string, unknown>).expiresAt = Date.now() - 1000;378      }379      const r = await dispatch({380        type: "RESPOND_INCOMING_PAIRING",381        payload: { accept: true },382      });383      // Expired request causes either explicit "expired" or "no_pending_request"384      // (because getIncomingPairingRequest auto-clears on expiry).385      expect(r.ok).toBe(false);386    });387388    it("missing payload returns no_pending or error", async () => {389      const r = await dispatch({390        type: "RESPOND_INCOMING_PAIRING",391        payload: {} as { accept: boolean },392      });393      expect(r).toBeDefined();394    });395  });396397  // ── DEBUG_INJECT_INCOMING_PAIRING adversarial ───────────────────────────398399  describe("DEBUG_INJECT_INCOMING_PAIRING adversarial", () => {400    it("missing fields returns missing_payload", async () => {401      const r = await dispatch({402        type: "DEBUG_INJECT_INCOMING_PAIRING",403        payload: {},404      });405      expect(r.ok).toBe(false);406      expect(r.error).toBe("missing_payload");407    });408409    const partials = [410      { deviceName: "x" },411      { publicKeyHex: "y" },412      { deviceName: "", publicKeyHex: "" },413    ];414    for (const [i, p] of partials.entries()) {415      it(`partial payload #${i} returns missing_payload`, async () => {416        const r = await dispatch({417          type: "DEBUG_INJECT_INCOMING_PAIRING",418          payload: p,419        });420        expect(r.ok).toBe(false);421        expect(r.error).toBe("missing_payload");422      });423    }424425    it("valid payload returns ok", async () => {426      const r = await dispatch({427        type: "DEBUG_INJECT_INCOMING_PAIRING",428        payload: {429          deviceName: "test-device",430          publicKeyHex: "ab".repeat(32),431        },432      });433      expect(r.ok).toBe(true);434    });435  });436437  // ── GET_SYNC_STATUS shape ───────────────────────────────────────────────438439  describe("GET_SYNC_STATUS shape", () => {440    it("never synced: status=never", async () => {441      delete storage.peach_last_sync_at;442      const r = await dispatch({ type: "GET_SYNC_STATUS" });443      const data = (r as { data: { status: string } }).data;444      expect(data.status).toBe("never");445    });446447    it("with last_sync_at: status=up_to_date", async () => {448      storage.peach_last_sync_at = String(Date.now());449      const r = await dispatch({ type: "GET_SYNC_STATUS" });450      const data = (r as { data: { status: string } }).data;451      expect(data.status).toBe("up_to_date");452    });453454    it("garbage last_sync_at: status=never", async () => {455      storage.peach_last_sync_at = "not-a-number";456      const r = await dispatch({ type: "GET_SYNC_STATUS" });457      const data = (r as { data: { status: string } }).data;458      expect(data.status).toBe("never");459    });460  });461462  // ── REMOVE_PAIRED_DEVICE adversarial ────────────────────────────────────463464  describe("REMOVE_PAIRED_DEVICE adversarial", () => {465    const badIds = ["", "   ", "not-a-real-id", "\x00", "a".repeat(1000)];466    for (const deviceId of badIds) {467      it(`unknown deviceId ${JSON.stringify(deviceId.slice(0, 20))} succeeds (idempotent)`, async () => {468        const r = await dispatch({469          type: "REMOVE_PAIRED_DEVICE",470          payload: { deviceId },471        });472        // The handler is idempotent — non-existent ids do not error.473        expect(r.ok).toBe(true);474      });475    }476  });477478  // ── Newer-timestamp sweep ────────────────────────────────────────────────479480  describe("timestamp boundary sweep", () => {481    for (const baseTs of [482      0,483      1,484      1_000,485      1_700_000_000_000,486      Number.MAX_SAFE_INTEGER - 10,487    ]) {488      it(`base ts ${baseTs}: applies when remote is strictly newer`, async () => {489        const vault = await import("./vault");490        await vault.createVault("test-pass-12345");491        storage.peach_last_modified_at = String(baseTs);492        const applied = await vault.applyIncomingVault(493          makeIncomingBlob(),494          baseTs + 1495        );496        expect(applied).toBe(true);497      });498499      it(`base ts ${baseTs}: rejects when remote is equal`, async () => {500        const vault = await import("./vault");501        await vault.createVault("test-pass-12345");502        storage.peach_last_modified_at = String(baseTs);503        const applied = await vault.applyIncomingVault(504          makeIncomingBlob(),505          baseTs506        );507        expect(applied).toBe(false);508      });509510      it(`base ts ${baseTs}: rejects when remote is strictly older`, async () => {511        const vault = await import("./vault");512        await vault.createVault("test-pass-12345");513        storage.peach_last_modified_at = String(baseTs);514        const applied = await vault.applyIncomingVault(515          makeIncomingBlob(),516          baseTs - 1517        );518        expect(applied).toBe(false);519      });520    }521  });522523  // ── Token format adversarial ────────────────────────────────────────────524525  describe("token format adversarial", () => {526    const badFormats = [527      "",528      "12345",529      "1234567",530      "abcdef",531      "12 34 56",532      "<script>",533      "null",534      "undefined",535      "-12345",536      "1.23456",537      "🔢🔢🔢🔢🔢🔢",538    ];539    for (const token of badFormats) {540      it(`SCAN_PAIRING_QR with token ${JSON.stringify(token)} returns android_only`, async () => {541        const r = await dispatch({542          type: "SCAN_PAIRING_QR",543          payload: { token },544        });545        expect(r.ok).toBe(false);546      });547    }548  });549550  // ── runBlobSyncSession via deps (no transport) ──────────────────────────551552  describe("runBlobSyncSession safety", () => {553    it("locked vault returns skipped status", async () => {554      const { runBlobSyncSession } = await import("./sync-coordinator");555      // Don't unlock. Build a stub transport that never resolves.556      const result = await runBlobSyncSession(557        "initiator",558        {559          send: vi.fn(async () => {}),560          receive: vi.fn(async () => new Uint8Array()),561          close: vi.fn(async () => {}),562        } as unknown as Parameters<typeof runBlobSyncSession>[1],563        {564          getVaultBlobForSync: async () => null,565          applyIncomingVault: async () => false,566          isVaultUnlocked: () => false,567        }568      );569      expect(result.status).toBe("skipped");570      expect(result.applied).toBe(false);571    });572  });573});