paired-devices.test.ts

18.6 KB · raw

1import { describe, expect, it, beforeEach } from "vitest";2import type { PairedDevice } from "../shared/sync-types";3import {4  clearPairedDevices,5  findPairedDeviceById,6  findPairedDeviceByPubkey,7  getLastSyncAt,8  listPairedDevices,9  markLastSyncedAt,10  isDeviceRevoked,11  listDeviceRevocations,12  mergeDeviceRevocations,13  repairPairedRevocationOverlaps,14  removePairedDeviceById,15  renamePairedDevice,16  revokeDeviceById,17  setLastSyncAt,18  setRelayUrlForAllPairedDevices,19  upsertPairedDevice,20  validatePairedDeviceIdentities,21} from "./paired-devices";2223function makeStorageStub() {24  const sync = new Map<string, unknown>();25  const local = new Map<string, unknown>();26  function area(store: Map<string, unknown>) {27    return {28      async get(key?: string | string[] | null) {29        if (key === null || key === undefined) {30          const all: Record<string, unknown> = {};31          for (const [k, v] of store) all[k] = v;32          return all;33        }34        const keys = Array.isArray(key) ? key : [key];35        const out: Record<string, unknown> = {};36        for (const k of keys) if (store.has(k)) out[k] = store.get(k);37        return out;38      },39      async set(values: Record<string, unknown>) {40        for (const [k, v] of Object.entries(values)) store.set(k, v);41      },42      async remove(key: string | string[]) {43        const keys = Array.isArray(key) ? key : [key];44        for (const k of keys) store.delete(k);45      },46    };47  }48  (globalThis as unknown as { chrome: unknown }).chrome = {49    storage: { sync: area(sync), local: area(local) },50  };51  return { sync, local };52}5354const SAMPLE: PairedDevice = {55  deviceId: "AAA-111",56  deviceName: "June's Pixel 9",57  deviceType: "android",58  longTermPublicKeyHex: "0".repeat(64),59  signingPublicKeyHex: "1".repeat(64),60  pairedAt: 1700000000000,61};6263describe("paired-devices registry", () => {64  beforeEach(() => makeStorageStub());6566  it("upsert + list", async () => {67    await upsertPairedDevice(SAMPLE);68    expect(await listPairedDevices()).toEqual([SAMPLE]);69  });7071  it("upsert is idempotent on the same deviceId — updates metadata in place, no duplicate", async () => {72    await upsertPairedDevice(SAMPLE);73    await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel", pairedAt: 1700000001000 });74    const all = await listPairedDevices();75    expect(all).toHaveLength(1);76    expect(all[0]).toMatchObject({77      deviceName: "Renamed Pixel",78      pairedAt: 1700000001000,79      longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,80      signingPublicKeyHex: SAMPLE.signingPublicKeyHex,81    });82  });8384  it("stores a local alias without changing the peer-reported name or identity", async () => {85    await upsertPairedDevice(SAMPLE);86    await renamePairedDevice(SAMPLE.deviceId, "  Travel phone  ");87    const renamed = await findPairedDeviceById(SAMPLE.deviceId);88    expect(renamed).toMatchObject({89      deviceName: SAMPLE.deviceName,90      localAlias: "Travel phone",91      longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,92    });93  });9495  it("rejects blank, oversized, and unknown-device aliases", async () => {96    await upsertPairedDevice(SAMPLE);97    await expect(renamePairedDevice(SAMPLE.deviceId, " ")).rejects.toThrow(/empty/);98    await expect(renamePairedDevice(SAMPLE.deviceId, "x".repeat(65))).rejects.toThrow(/64/);99    await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);100  });101102  it("rejects a same-deviceId long-term key replacement and leaves the stored row unchanged", async () => {103    await upsertPairedDevice(SAMPLE);104    const before = await findPairedDeviceById(SAMPLE.deviceId);105    await expect(106      upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: "f".repeat(64) }),107    ).rejects.toThrow("device_identity_immutable");108    expect(await findPairedDeviceById(SAMPLE.deviceId)).toEqual(before);109  });110111  it("rejects a same-deviceId signing key replacement and leaves the stored row unchanged", async () => {112    await upsertPairedDevice(SAMPLE);113    const before = await findPairedDeviceById(SAMPLE.deviceId);114    await expect(115      upsertPairedDevice({ ...SAMPLE, signingPublicKeyHex: "2".repeat(64) }),116    ).rejects.toThrow("device_identity_immutable");117    expect(await findPairedDeviceById(SAMPLE.deviceId)).toEqual(before);118  });119  it("preserves an existing signing key when an incoming row omits or nulls it", async () => {120    await upsertPairedDevice(SAMPLE);121    await upsertPairedDevice({ ...SAMPLE, deviceName: "Updated Pixel", signingPublicKeyHex: undefined });122    await upsertPairedDevice({123      ...SAMPLE,124      deviceName: "Updated Again",125      signingPublicKeyHex: null as unknown as string,126    });127    expect((await findPairedDeviceById(SAMPLE.deviceId))?.signingPublicKeyHex).toBe(128      SAMPLE.signingPublicKeyHex,129    );130  });131132  it("rejects duplicate manifest identities before any registry mutation", async () => {133    await expect(134      validatePairedDeviceIdentities([135        { ...SAMPLE, deviceName: "First" },136        { ...SAMPLE, deviceName: "Second", longTermPublicKeyHex: "f".repeat(64) },137      ]),138    ).rejects.toThrow("device_identity_immutable");139    expect(await listPairedDevices()).toEqual([]);140  });141  it("rejects upsert with missing fields", async () => {142    await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);143    await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);144    await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);145    await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);146  });147148  it("caps oversized peer device names at the registry seam", async () => {149    await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });150    const found = await findPairedDeviceById("LONG-1");151    expect(found?.deviceName).toBe("N".repeat(64));152  });153154  it("findPairedDeviceByPubkey returns the correct device", async () => {155    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };156    await upsertPairedDevice(SAMPLE);157    await upsertPairedDevice(other);158    expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);159    expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();160  });161162  it("removePairedDeviceById drops the device", async () => {163    await upsertPairedDevice(SAMPLE);164    await removePairedDeviceById(SAMPLE.deviceId);165    expect(await listPairedDevices()).toEqual([]);166  });167168  it("clearPairedDevices wipes the whole registry", async () => {169    await upsertPairedDevice(SAMPLE);170    await clearPairedDevices();171    expect(await listPairedDevices()).toEqual([]);172  });173174  it("markLastSyncedAt mutates only the target device", async () => {175    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };176    await upsertPairedDevice(SAMPLE);177    await upsertPairedDevice(other);178    await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);179    const found = await findPairedDeviceById(SAMPLE.deviceId);180    expect(found?.lastSyncedAt).toBe(1700001000000);181    const untouched = await findPairedDeviceById(other.deviceId);182    expect(untouched?.lastSyncedAt).toBeUndefined();183  });184185  it("serializes a concurrent pairing upsert with a sync timestamp update", async () => {186    const other: PairedDevice = {187      ...SAMPLE,188      deviceId: "BBB-222",189      longTermPublicKeyHex: "1".repeat(64),190    };191    await upsertPairedDevice(SAMPLE);192193    await Promise.all([194      upsertPairedDevice(other),195      markLastSyncedAt(SAMPLE.deviceId, 1700001000000),196    ]);197198    expect(await listPairedDevices()).toEqual([199      { ...SAMPLE, lastSyncedAt: 1700001000000 },200      other,201    ]);202  });203204  it("moves every existing pair to a new mesh relay", async () => {205    const other: PairedDevice = {206      ...SAMPLE,207      deviceId: "BBB-222",208      longTermPublicKeyHex: "1".repeat(64),209      relayUrl: "wss://old-relay.example",210    };211    await upsertPairedDevice(SAMPLE);212    await upsertPairedDevice(other);213214    await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");215216    expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([217      "wss://new-relay.example/base",218      "wss://new-relay.example/base",219    ]);220  });221222  it("last sync timestamp accepts a legacy string value", async () => {223    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({224      peach_last_sync_at: "1700002000000",225    });226    expect(await getLastSyncAt()).toBe(1700002000000);227  });228229  it("setLastSyncAt persists a numeric value", async () => {230    await setLastSyncAt(1700003000000);231    expect(await getLastSyncAt()).toBe(1700003000000);232  });233234  it("listPairedDevices ignores garbage entries silently", async () => {235    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({236      peach_paired_devices_v3: [237        { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },238        { not: "a device" },239        null,240        42,241      ],242    });243    const devices = await listPairedDevices();244    expect(devices).toHaveLength(1);245    expect(devices[0].deviceId).toBe("real");246  });247248  describe("device revocation", () => {249    it("revokeDeviceById records the device's key and drops it from the registry", async () => {250      await upsertPairedDevice(SAMPLE);251      const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {252        now: 1_700_000_000_000,253      });254255      expect(record).toMatchObject({256        deviceId: SAMPLE.deviceId,257        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,258        revokedAt: 1_700_000_000_000,259        revokedByDeviceId: "this-device",260      });261      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();262      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);263    });264265    it("revoking twice is a no-op rather than a duplicate record", async () => {266      await upsertPairedDevice(SAMPLE);267      await revokeDeviceById(SAMPLE.deviceId, "this-device");268      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();269      expect(await listDeviceRevocations()).toHaveLength(1);270    });271272    it("an existing revocation still removes a stale re-paired row", async () => {273      await upsertPairedDevice(SAMPLE);274      await revokeDeviceById(SAMPLE.deviceId, "this-device");275      // Reproduce the real corrupt overlap created by older direct-pair276      // handlers: the row returned but its terminal revocation remained.277      await upsertPairedDevice(SAMPLE);278279      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();280      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();281      expect(await listDeviceRevocations()).toHaveLength(1);282    });283284    it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {285      const first = await mergeDeviceRevocations([286        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },287      ]);288      expect(first).toEqual(["a"]);289290      const second = await mergeDeviceRevocations([291        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },292        { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },293      ]);294      expect(second).toEqual(["b"]);295      expect(await listDeviceRevocations()).toHaveLength(2);296    });297298    // PB-26-110: the ledger must stay bounded without letting an early flood299    // consume it permanently. A later revocation from a DIFFERENT issuer is300    // the record that matters — it has to be admitted even when the ledger is301    // already full of another issuer's junk.302    it("a later revocation from a different issuer is still admitted on a full ledger", async () => {303      await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({304        deviceId: `terminal-${index}`,305        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),306        revokedAt: index + 1,307        revokedByDeviceId: "issuer-a",308      })));309310      expect(await mergeDeviceRevocations([{311        deviceId: "peer-extra",312        longTermPublicKeyHex: "f".repeat(64),313        revokedAt: 999,314        revokedByDeviceId: "issuer-b",315      }])).toEqual(["peer-extra"]);316317      const records = await listDeviceRevocations();318      expect(records).toHaveLength(500);319      // The genuine record is admitted and still blocks re-vouching.320      expect(await isDeviceRevoked("peer-extra")).toBe(true);321      // Room was made by evicting the over-represented issuer's own record,322      // not another issuer's.323      expect(records.filter((r) => r.revokedByDeviceId === "issuer-b")).toHaveLength(1);324      expect(await isDeviceRevoked("terminal-0")).toBe(false);325    });326327    it("a same-issuer local flood retains the bounded ledger", async () => {328      for (let index = 0; index < 501; index += 1) {329        await upsertPairedDevice({330          ...SAMPLE,331          deviceId: `local-${index}`,332          longTermPublicKeyHex: index.toString(16).padStart(64, "0"),333        });334        await revokeDeviceById(`local-${index}`, "issuer-a", { now: index + 1 });335      }336      const records = await listDeviceRevocations();337      expect(records).toHaveLength(500);338      expect(records.some((record) => record.deviceId === "local-0")).toBe(false);339      expect(records.at(-1)?.deviceId).toBe("local-500");340    });341342    it("a different issuer local revoke preserves one of its genuine records", async () => {343      await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({344        deviceId: `terminal-${index}`,345        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),346        revokedAt: index + 1,347        revokedByDeviceId: index === 0 ? "issuer-a" : "issuer-b",348      })));349      await upsertPairedDevice({ ...SAMPLE, deviceId: "local-new", longTermPublicKeyHex: "f".repeat(64) });350      await revokeDeviceById("local-new", "issuer-a", { now: 501 });351      const records = await listDeviceRevocations();352      expect(records).toHaveLength(500);353      expect(records.some((record) => record.deviceId === "terminal-0")).toBe(true);354      expect(records.some((record) => record.deviceId === "local-new")).toBe(true);355    });356357    it("a peer flood cannot evict an older revocation from another issuer", async () => {358      await mergeDeviceRevocations([{359        deviceId: "genuine",360        longTermPublicKeyHex: "a".repeat(64),361        revokedAt: 1,362        revokedByDeviceId: "honest-issuer",363      }]);364      const flood = Array.from({ length: 600 }, (_, index) => ({365        deviceId: `junk-${index}`,366        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),367        revokedAt: index + 2,368        revokedByDeviceId: "flooding-issuer",369      }));370      const admitted = await mergeDeviceRevocations(flood);371      expect(admitted).toHaveLength(600);372      const records = await listDeviceRevocations();373      expect(records).toHaveLength(500);374      expect(await isDeviceRevoked("genuine")).toBe(true);375      // The flooder is the over-represented issuer, so its own oldest records376      // are what get dropped — the newest junk survives, the oldest does not.377      expect(await isDeviceRevoked("junk-599")).toBe(true);378      expect(await isDeviceRevoked("junk-0")).toBe(false);379    });380381    it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {382      await mergeDeviceRevocations([383        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },384      ]);385      await mergeDeviceRevocations([386        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },387      ]);388      const records = await listDeviceRevocations();389      expect(records).toHaveLength(1);390      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });391    });392393    it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {394      const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;395      await mergeDeviceRevocations([396        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },397      ]);398      const [record] = await listDeviceRevocations();399      expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);400    });401402    it("startup repair keeps a newer direct pair and clears its older revocation", async () => {403      const now = Date.now();404      await mergeDeviceRevocations([{405        deviceId: SAMPLE.deviceId,406        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,407        revokedAt: now - 1_000,408        revokedByDeviceId: "this-device",409      }]);410      await upsertPairedDevice({411        ...SAMPLE,412        pairedAt: now,413        trustSource: "direct",414      });415416      expect(await repairPairedRevocationOverlaps()).toEqual({417        revocationsCleared: 1,418        pairedRowsRemoved: 0,419      });420      expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();421      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);422    });423424    it("startup repair removes a direct pair older than its revocation", async () => {425      const now = Date.now();426      await upsertPairedDevice({427        ...SAMPLE,428        pairedAt: now - 2_000,429        trustSource: "direct",430      });431      await mergeDeviceRevocations([{432        deviceId: SAMPLE.deviceId,433        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,434        revokedAt: now - 1_000,435        revokedByDeviceId: "this-device",436      }]);437438      expect(await repairPairedRevocationOverlaps()).toEqual({439        revocationsCleared: 0,440        pairedRowsRemoved: 1,441      });442      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();443      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);444    });445446    it("startup repair never lets a newer vouched pair lift a revocation", async () => {447      const now = Date.now();448      await mergeDeviceRevocations([{449        deviceId: SAMPLE.deviceId,450        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,451        revokedAt: now - 1_000,452        revokedByDeviceId: "this-device",453      }]);454      await upsertPairedDevice({455        ...SAMPLE,456        pairedAt: now,457        trustSource: "vouched",458      });459460      expect(await repairPairedRevocationOverlaps()).toEqual({461        revocationsCleared: 0,462        pairedRowsRemoved: 1,463      });464      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();465      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);466    });467  });468});