paired-devices.test.ts

20.1 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  TrustDataCorruptedError,20  upsertPairedDevice,21  validatePairedDeviceIdentities,22} from "./paired-devices";2324function makeStorageStub() {25  const sync = new Map<string, unknown>();26  const local = new Map<string, unknown>();27  function area(store: Map<string, unknown>) {28    return {29      async get(key?: string | string[] | null) {30        if (key === null || key === undefined) {31          const all: Record<string, unknown> = {};32          for (const [k, v] of store) all[k] = v;33          return all;34        }35        const keys = Array.isArray(key) ? key : [key];36        const out: Record<string, unknown> = {};37        for (const k of keys) if (store.has(k)) out[k] = store.get(k);38        return out;39      },40      async set(values: Record<string, unknown>) {41        for (const [k, v] of Object.entries(values)) store.set(k, v);42      },43      async remove(key: string | string[]) {44        const keys = Array.isArray(key) ? key : [key];45        for (const k of keys) store.delete(k);46      },47    };48  }49  (globalThis as unknown as { chrome: unknown }).chrome = {50    storage: { sync: area(sync), local: area(local) },51  };52  return { sync, local };53}5455const SAMPLE: PairedDevice = {56  deviceId: "AAA-111",57  deviceName: "June's Pixel 9",58  deviceType: "android",59  longTermPublicKeyHex: "0".repeat(64),60  signingPublicKeyHex: "1".repeat(64),61  pairedAt: 1700000000000,62};6364describe("paired-devices registry", () => {65  beforeEach(() => makeStorageStub());6667  it("upsert + list", async () => {68    await upsertPairedDevice(SAMPLE);69    expect(await listPairedDevices()).toEqual([SAMPLE]);70  });7172  it("upsert is idempotent on the same deviceId — updates metadata in place, no duplicate", async () => {73    await upsertPairedDevice(SAMPLE);74    await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel", pairedAt: 1700000001000 });75    const all = await listPairedDevices();76    expect(all).toHaveLength(1);77    expect(all[0]).toMatchObject({78      deviceName: "Renamed Pixel",79      pairedAt: 1700000001000,80      longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,81      signingPublicKeyHex: SAMPLE.signingPublicKeyHex,82    });83  });8485  it("stores a local alias without changing the peer-reported name or identity", async () => {86    await upsertPairedDevice(SAMPLE);87    await renamePairedDevice(SAMPLE.deviceId, "  Travel phone  ");88    const renamed = await findPairedDeviceById(SAMPLE.deviceId);89    expect(renamed).toMatchObject({90      deviceName: SAMPLE.deviceName,91      localAlias: "Travel phone",92      longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,93    });94  });9596  it("rejects blank, oversized, and unknown-device aliases", async () => {97    await upsertPairedDevice(SAMPLE);98    await expect(renamePairedDevice(SAMPLE.deviceId, " ")).rejects.toThrow(/empty/);99    await expect(renamePairedDevice(SAMPLE.deviceId, "x".repeat(65))).rejects.toThrow(/64/);100    await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);101  });102103  it("rejects a same-deviceId long-term key replacement and leaves the stored row unchanged", async () => {104    await upsertPairedDevice(SAMPLE);105    const before = await findPairedDeviceById(SAMPLE.deviceId);106    await expect(107      upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: "f".repeat(64) }),108    ).rejects.toThrow("device_identity_immutable");109    expect(await findPairedDeviceById(SAMPLE.deviceId)).toEqual(before);110  });111112  it("rejects a same-deviceId signing key replacement and leaves the stored row unchanged", async () => {113    await upsertPairedDevice(SAMPLE);114    const before = await findPairedDeviceById(SAMPLE.deviceId);115    await expect(116      upsertPairedDevice({ ...SAMPLE, signingPublicKeyHex: "2".repeat(64) }),117    ).rejects.toThrow("device_identity_immutable");118    expect(await findPairedDeviceById(SAMPLE.deviceId)).toEqual(before);119  });120  it("preserves an existing signing key when an incoming row omits or nulls it", async () => {121    await upsertPairedDevice(SAMPLE);122    await upsertPairedDevice({ ...SAMPLE, deviceName: "Updated Pixel", signingPublicKeyHex: undefined });123    await upsertPairedDevice({124      ...SAMPLE,125      deviceName: "Updated Again",126      signingPublicKeyHex: null as unknown as string,127    });128    expect((await findPairedDeviceById(SAMPLE.deviceId))?.signingPublicKeyHex).toBe(129      SAMPLE.signingPublicKeyHex,130    );131  });132133  it("rejects duplicate manifest identities before any registry mutation", async () => {134    await expect(135      validatePairedDeviceIdentities([136        { ...SAMPLE, deviceName: "First" },137        { ...SAMPLE, deviceName: "Second", longTermPublicKeyHex: "f".repeat(64) },138      ]),139    ).rejects.toThrow("device_identity_immutable");140    expect(await listPairedDevices()).toEqual([]);141  });142  it("rejects upsert with missing fields", async () => {143    await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);144    await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);145    await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);146    await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);147  });148149  it("caps oversized peer device names at the registry seam", async () => {150    await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });151    const found = await findPairedDeviceById("LONG-1");152    expect(found?.deviceName).toBe("N".repeat(64));153  });154155  it("findPairedDeviceByPubkey returns the correct device", async () => {156    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };157    await upsertPairedDevice(SAMPLE);158    await upsertPairedDevice(other);159    expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);160    expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();161  });162163  it("removePairedDeviceById drops the device", async () => {164    await upsertPairedDevice(SAMPLE);165    await removePairedDeviceById(SAMPLE.deviceId);166    expect(await listPairedDevices()).toEqual([]);167  });168169  it("clearPairedDevices wipes the whole registry", async () => {170    await upsertPairedDevice(SAMPLE);171    await clearPairedDevices();172    expect(await listPairedDevices()).toEqual([]);173  });174175  it("markLastSyncedAt mutates only the target device", async () => {176    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };177    await upsertPairedDevice(SAMPLE);178    await upsertPairedDevice(other);179    await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);180    const found = await findPairedDeviceById(SAMPLE.deviceId);181    expect(found?.lastSyncedAt).toBe(1700001000000);182    const untouched = await findPairedDeviceById(other.deviceId);183    expect(untouched?.lastSyncedAt).toBeUndefined();184  });185186  it("serializes a concurrent pairing upsert with a sync timestamp update", async () => {187    const other: PairedDevice = {188      ...SAMPLE,189      deviceId: "BBB-222",190      longTermPublicKeyHex: "1".repeat(64),191    };192    await upsertPairedDevice(SAMPLE);193194    await Promise.all([195      upsertPairedDevice(other),196      markLastSyncedAt(SAMPLE.deviceId, 1700001000000),197    ]);198199    expect(await listPairedDevices()).toEqual([200      { ...SAMPLE, lastSyncedAt: 1700001000000 },201      other,202    ]);203  });204205  it("moves every existing pair to a new mesh relay", async () => {206    const other: PairedDevice = {207      ...SAMPLE,208      deviceId: "BBB-222",209      longTermPublicKeyHex: "1".repeat(64),210      relayUrl: "wss://old-relay.example",211    };212    await upsertPairedDevice(SAMPLE);213    await upsertPairedDevice(other);214215    await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");216217    expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([218      "wss://new-relay.example/base",219      "wss://new-relay.example/base",220    ]);221  });222223  it("last sync timestamp accepts a legacy string value", async () => {224    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({225      peach_last_sync_at: "1700002000000",226    });227    expect(await getLastSyncAt()).toBe(1700002000000);228  });229230  it("setLastSyncAt persists a numeric value", async () => {231    await setLastSyncAt(1700003000000);232    expect(await getLastSyncAt()).toBe(1700003000000);233  });234235  it("listPairedDevices filters invalid entries from an otherwise valid array", async () => {236    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({237      peach_paired_devices_v3: [238        { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },239        { not: "a device" },240        null,241        42,242      ],243    });244    const devices = await listPairedDevices();245    expect(devices).toHaveLength(1);246    expect(devices[0].deviceId).toBe("real");247  });248249  // §18.4: a corrupt persisted ledger must not be read as empty — that would250  // silently un-revoke every device. A non-array value is integrity damage.251  it("listPairedDevices throws TrustDataCorruptedError on a non-array registry", async () => {252    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({253      peach_paired_devices_v3: { corrupted: true },254    });255    await expect(listPairedDevices()).rejects.toThrow(TrustDataCorruptedError);256  });257258  it("listDeviceRevocations throws TrustDataCorruptedError on a non-array ledger", async () => {259    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({260      peach_device_revocations_v1: "not-an-array",261    });262    await expect(listDeviceRevocations()).rejects.toThrow(TrustDataCorruptedError);263  });264265  it("a corrupt registry is not overwritten by a mutation attempt", async () => {266    const corrupt = { corrupted: true };267    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({268      peach_paired_devices_v3: corrupt,269    });270    await expect(upsertPairedDevice(SAMPLE)).rejects.toThrow(TrustDataCorruptedError);271    // The corrupt payload is preserved — the mutation never wrote.272    const result = await chrome.storage.local.get("peach_paired_devices_v3");273    expect(result["peach_paired_devices_v3"]).toBe(corrupt);274  });275276  describe("device revocation", () => {277    it("revokeDeviceById records the device's key and drops it from the registry", async () => {278      await upsertPairedDevice(SAMPLE);279      const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {280        now: 1_700_000_000_000,281      });282283      expect(record).toMatchObject({284        deviceId: SAMPLE.deviceId,285        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,286        revokedAt: 1_700_000_000_000,287        revokedByDeviceId: "this-device",288      });289      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();290      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);291    });292293    it("revoking twice is a no-op rather than a duplicate record", async () => {294      await upsertPairedDevice(SAMPLE);295      await revokeDeviceById(SAMPLE.deviceId, "this-device");296      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();297      expect(await listDeviceRevocations()).toHaveLength(1);298    });299300    it("an existing revocation still removes a stale re-paired row", async () => {301      await upsertPairedDevice(SAMPLE);302      await revokeDeviceById(SAMPLE.deviceId, "this-device");303      // Reproduce the real corrupt overlap created by older direct-pair304      // handlers: the row returned but its terminal revocation remained.305      await upsertPairedDevice(SAMPLE);306307      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();308      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();309      expect(await listDeviceRevocations()).toHaveLength(1);310    });311312    it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {313      const first = await mergeDeviceRevocations([314        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },315      ]);316      expect(first).toEqual(["a"]);317318      const second = await mergeDeviceRevocations([319        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },320        { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },321      ]);322      expect(second).toEqual(["b"]);323      expect(await listDeviceRevocations()).toHaveLength(2);324    });325326    // PB-26-110: the ledger must stay bounded without letting an early flood327    // consume it permanently. A later revocation from a DIFFERENT issuer is328    // the record that matters — it has to be admitted even when the ledger is329    // already full of another issuer's junk.330    it("a later revocation from a different issuer is still admitted on a full ledger", async () => {331      await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({332        deviceId: `terminal-${index}`,333        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),334        revokedAt: index + 1,335        revokedByDeviceId: "issuer-a",336      })));337338      expect(await mergeDeviceRevocations([{339        deviceId: "peer-extra",340        longTermPublicKeyHex: "f".repeat(64),341        revokedAt: 999,342        revokedByDeviceId: "issuer-b",343      }])).toEqual(["peer-extra"]);344345      const records = await listDeviceRevocations();346      expect(records).toHaveLength(500);347      // The genuine record is admitted and still blocks re-vouching.348      expect(await isDeviceRevoked("peer-extra")).toBe(true);349      // Room was made by evicting the over-represented issuer's own record,350      // not another issuer's.351      expect(records.filter((r) => r.revokedByDeviceId === "issuer-b")).toHaveLength(1);352      expect(await isDeviceRevoked("terminal-0")).toBe(false);353    });354355    it("a same-issuer local flood retains the bounded ledger", async () => {356      for (let index = 0; index < 501; index += 1) {357        await upsertPairedDevice({358          ...SAMPLE,359          deviceId: `local-${index}`,360          longTermPublicKeyHex: index.toString(16).padStart(64, "0"),361        });362        await revokeDeviceById(`local-${index}`, "issuer-a", { now: index + 1 });363      }364      const records = await listDeviceRevocations();365      expect(records).toHaveLength(500);366      expect(records.some((record) => record.deviceId === "local-0")).toBe(false);367      expect(records.at(-1)?.deviceId).toBe("local-500");368    });369370    it("a different issuer local revoke preserves one of its genuine records", async () => {371      await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({372        deviceId: `terminal-${index}`,373        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),374        revokedAt: index + 1,375        revokedByDeviceId: index === 0 ? "issuer-a" : "issuer-b",376      })));377      await upsertPairedDevice({ ...SAMPLE, deviceId: "local-new", longTermPublicKeyHex: "f".repeat(64) });378      await revokeDeviceById("local-new", "issuer-a", { now: 501 });379      const records = await listDeviceRevocations();380      expect(records).toHaveLength(500);381      expect(records.some((record) => record.deviceId === "terminal-0")).toBe(true);382      expect(records.some((record) => record.deviceId === "local-new")).toBe(true);383    });384385    it("a peer flood cannot evict an older revocation from another issuer", async () => {386      await mergeDeviceRevocations([{387        deviceId: "genuine",388        longTermPublicKeyHex: "a".repeat(64),389        revokedAt: 1,390        revokedByDeviceId: "honest-issuer",391      }]);392      const flood = Array.from({ length: 600 }, (_, index) => ({393        deviceId: `junk-${index}`,394        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),395        revokedAt: index + 2,396        revokedByDeviceId: "flooding-issuer",397      }));398      const admitted = await mergeDeviceRevocations(flood);399      expect(admitted).toHaveLength(600);400      const records = await listDeviceRevocations();401      expect(records).toHaveLength(500);402      expect(await isDeviceRevoked("genuine")).toBe(true);403      // The flooder is the over-represented issuer, so its own oldest records404      // are what get dropped — the newest junk survives, the oldest does not.405      expect(await isDeviceRevoked("junk-599")).toBe(true);406      expect(await isDeviceRevoked("junk-0")).toBe(false);407    });408409    it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {410      await mergeDeviceRevocations([411        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },412      ]);413      await mergeDeviceRevocations([414        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },415      ]);416      const records = await listDeviceRevocations();417      expect(records).toHaveLength(1);418      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });419    });420421    it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {422      const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;423      await mergeDeviceRevocations([424        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },425      ]);426      const [record] = await listDeviceRevocations();427      expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);428    });429430    it("startup repair keeps a newer direct pair and clears its older revocation", async () => {431      const now = Date.now();432      await mergeDeviceRevocations([{433        deviceId: SAMPLE.deviceId,434        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,435        revokedAt: now - 1_000,436        revokedByDeviceId: "this-device",437      }]);438      await upsertPairedDevice({439        ...SAMPLE,440        pairedAt: now,441        trustSource: "direct",442      });443444      expect(await repairPairedRevocationOverlaps()).toEqual({445        revocationsCleared: 1,446        pairedRowsRemoved: 0,447      });448      expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();449      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);450    });451452    it("startup repair removes a direct pair older than its revocation", async () => {453      const now = Date.now();454      await upsertPairedDevice({455        ...SAMPLE,456        pairedAt: now - 2_000,457        trustSource: "direct",458      });459      await mergeDeviceRevocations([{460        deviceId: SAMPLE.deviceId,461        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,462        revokedAt: now - 1_000,463        revokedByDeviceId: "this-device",464      }]);465466      expect(await repairPairedRevocationOverlaps()).toEqual({467        revocationsCleared: 0,468        pairedRowsRemoved: 1,469      });470      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();471      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);472    });473474    it("startup repair never lets a newer vouched pair lift a revocation", async () => {475      const now = Date.now();476      await mergeDeviceRevocations([{477        deviceId: SAMPLE.deviceId,478        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,479        revokedAt: now - 1_000,480        revokedByDeviceId: "this-device",481      }]);482      await upsertPairedDevice({483        ...SAMPLE,484        pairedAt: now,485        trustSource: "vouched",486      });487488      expect(await repairPairedRevocationOverlaps()).toEqual({489        revocationsCleared: 0,490        pairedRowsRemoved: 1,491      });492      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();493      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);494    });495  });496});