vault-adopt-snapshot.test.ts

9.1 KB · raw

1import { describe, expect, it } from "vitest";2import {3  MAX_SNAPSHOT_CHUNKS,4  isAdoptSnapshot,5  receiveFrame,6  receiveVaultSnapshot,7  requestAdoption,8  sendFrame,9  sendVaultSnapshot,10  type AdoptionReceiverDeps,11} from "./vault-adopt";12import type { PairingTransport } from "./pairing-handshake";1314function memoryTransportPair(): [PairingTransport, PairingTransport] {15  const a: Uint8Array[] = [];16  const b: Uint8Array[] = [];17  const aReaders: Array<(message: Uint8Array) => void> = [];18  const bReaders: Array<(message: Uint8Array) => void> = [];1920  const transport = (21    inbox: Uint8Array[],22    outbox: Uint8Array[],23    inboxReaders: Array<(message: Uint8Array) => void>,24    outboxReaders: Array<(message: Uint8Array) => void>,25  ): PairingTransport => ({26    async send(message) {27      const reader = outboxReaders.shift();28      if (reader) reader(message);29      else outbox.push(message);30    },31    receive() {32      const message = inbox.shift();33      return message34        ? Promise.resolve(message)35        : new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));36    },37  });3839  return [transport(a, b, aReaders, bReaders), transport(b, a, bReaders, aReaders)];40}4142describe("inline vault snapshot", () => {43  it("round-trips an empty vault (0 entries)", async () => {44    const [source, receiver] = memoryTransportPair();45    const [result] = await Promise.all([46      receiveVaultSnapshot(receiver),47      sendVaultSnapshot(source, {}, {}),48    ]);49    expect(result.entries).toEqual({});50    expect(result.tombstones).toEqual({});51  });5253  it("round-trips 50 entries + 5 tombstones in a single chunk", async () => {54    const [source, receiver] = memoryTransportPair();55    const entries: Record<string, string> = {};56    for (let i = 0; i < 50; i++) entries[`id-${i}`] = `cipher-${i}`;57    const tombstones: Record<string, number> = {};58    for (let i = 0; i < 5; i++) tombstones[`dead-${i}`] = 1000 + i;59    const [result] = await Promise.all([60      receiveVaultSnapshot(receiver),61      sendVaultSnapshot(source, entries, tombstones),62    ]);63    expect(result.entries).toEqual(entries);64    expect(result.tombstones).toEqual(tombstones);65  });6667  it("splits a large vault into 3+ chunks and reassembles it", async () => {68    const [source, receiver] = memoryTransportPair();69    let frames = 0;70    const countingSource: PairingTransport = {71      send: (msg) => {72        frames++;73        return source.send(msg);74      },75      receive: () => source.receive(),76    };77    const big = "x".repeat(200 * 1024); // 200 KiB per entry forces chunking78    const entries: Record<string, string> = {};79    for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;80    const tombstones: Record<string, number> = { "dead-0": 42 };81    const [result] = await Promise.all([82      receiveVaultSnapshot(receiver),83      sendVaultSnapshot(countingSource, entries, tombstones),84    ]);85    expect(frames).toBeGreaterThanOrEqual(3);86    expect(result.entries).toEqual(entries);87    expect(result.tombstones).toEqual(tombstones);88  });8990  it("rejects oversized duress before sending any snapshot frame", async () => {91    const [source] = memoryTransportPair();92    let frames = 0;93    const countingSource: PairingTransport = {94      send: (msg) => {95        frames++;96        return source.send(msg);97      },98      receive: () => source.receive(),99    };100101    await expect(sendVaultSnapshot(102      countingSource,103      { e1: "cipher1" },104      {},105      "x".repeat(768 * 1024),106    )).rejects.toThrow("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");107    expect(frames).toBe(0);108  });109110  it("rejects out-of-order chunks", async () => {111    const [source, receiver] = memoryTransportPair();112    const send = sendFrame(source, {113      type: "ADOPT_SNAPSHOT",114      chunk_index: 1,115      chunk_count: 2,116      entries: { a: "x" },117      tombstones: {},118    });119    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/OUT_OF_ORDER/);120    await send;121  });122123  it("rejects a chunk_count exceeding MAX_SNAPSHOT_CHUNKS", async () => {124    const [source, receiver] = memoryTransportPair();125    const send = sendFrame(source, {126      type: "ADOPT_SNAPSHOT",127      chunk_index: 0,128      chunk_count: MAX_SNAPSHOT_CHUNKS + 1,129      entries: {},130      tombstones: {},131    });132    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/TOO_MANY_CHUNKS/);133    await send;134  });135136  it("validates ADOPT_SNAPSHOT frames", () => {137    expect(138      isAdoptSnapshot({139        type: "ADOPT_SNAPSHOT",140        chunk_index: 0,141        chunk_count: 1,142        entries: { a: "x" },143        tombstones: {},144      }),145    ).toBe(true);146    // Missing tombstones / chunk_index.147    expect(148      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {} }),149    ).toBe(false);150    expect(151      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_count: 1, entries: {}, tombstones: {} }),152    ).toBe(false);153    // Wrong type tag.154    expect(155      isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),156    ).toBe(false);157    // Invalid indices and value types.158    expect(159      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: -1, chunk_count: 1, entries: {}, tombstones: {} }),160    ).toBe(false);161    expect(162      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 0, entries: {}, tombstones: {} }),163    ).toBe(false);164    expect(165      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: { a: 5 }, tombstones: {} }),166    ).toBe(false);167    expect(isAdoptSnapshot(null)).toBe(false);168  });169170  it("advertises snapshot_capable in the ADOPT_REQUEST frame", async () => {171    const [receiverChannel, sourceChannel] = memoryTransportPair();172    const deps: AdoptionReceiverDeps = {173      deviceName: "recv",174      generateDeviceId: () => "recv-id",175      async confirmSas() {176        return true;177      },178      async createVaultFromRootKey() {179        return { vaultId: "v" };180      },181      async deriveLocalIdentity() {182        return {183          deviceId: "recv-id",184          deviceName: "recv",185          deviceType: "extension",186          longTermPublicKeyHex: "ab".repeat(32),187        };188      },189      async upsertPairedDevice() {},190      onError() {},191    };192    void requestAdoption(receiverChannel, deps);193    const first = await receiveFrame(sourceChannel);194    expect(first).toMatchObject({ type: "ADOPT_REQUEST", snapshot_capable: true });195  });196197  it("round-trips a duress slot on the final chunk (single-chunk vault)", async () => {198    const [source, receiver] = memoryTransportPair();199    const duressJson = JSON.stringify({200      version: 1,201      salt: "aa".repeat(16),202      nonce: "bb".repeat(12),203      ciphertext: "cc".repeat(32),204      tag: "dd".repeat(16),205    });206    const [result] = await Promise.all([207      receiveVaultSnapshot(receiver),208      sendVaultSnapshot(source, { e1: "cipher1" }, { t1: 100 }, duressJson),209    ]);210    expect(result.entries).toEqual({ e1: "cipher1" });211    expect(result.tombstones).toEqual({ t1: 100 });212    expect(result.duressSlot).toBe(duressJson);213  });214215  it("round-trips a duress slot across multiple chunks (final chunk only)", async () => {216    const [source, receiver] = memoryTransportPair();217    const big = "x".repeat(200 * 1024);218    const entries: Record<string, string> = {};219    for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;220    const duressJson = JSON.stringify({221      version: 1,222      salt: "11".repeat(16),223      nonce: "22".repeat(12),224      ciphertext: "33".repeat(32),225      tag: "44".repeat(16),226    });227    const [result] = await Promise.all([228      receiveVaultSnapshot(receiver),229      sendVaultSnapshot(source, entries, { dead: 1 }, duressJson),230    ]);231    expect(result.entries).toEqual(entries);232    expect(result.duressSlot).toBe(duressJson);233  });234235  it("omits duress_slot when no duress is configured (null)", async () => {236    const [source, receiver] = memoryTransportPair();237    const [result] = await Promise.all([238      receiveVaultSnapshot(receiver),239      sendVaultSnapshot(source, { e1: "v1" }, {}),240    ]);241    expect(result.duressSlot).toBeNull();242  });243244  it("isAdoptSnapshot accepts frames with valid duress_slot string", () => {245    expect(246      isAdoptSnapshot({247        type: "ADOPT_SNAPSHOT",248        chunk_index: 0,249        chunk_count: 1,250        entries: {},251        tombstones: {},252        duress_slot: '{"version":1}',253      }),254    ).toBe(true);255  });256257  it("isAdoptSnapshot accepts frames without duress_slot", () => {258    expect(259      isAdoptSnapshot({260        type: "ADOPT_SNAPSHOT",261        chunk_index: 0,262        chunk_count: 1,263        entries: {},264        tombstones: {},265      }),266    ).toBe(true);267  });268269  it("isAdoptSnapshot rejects frames with non-string duress_slot", () => {270    expect(271      isAdoptSnapshot({272        type: "ADOPT_SNAPSHOT",273        chunk_index: 0,274        chunk_count: 1,275        entries: {},276        tombstones: {},277        duress_slot: 42,278      }),279    ).toBe(false);280    expect(281      isAdoptSnapshot({282        type: "ADOPT_SNAPSHOT",283        chunk_index: 0,284        chunk_count: 1,285        entries: {},286        tombstones: {},287        duress_slot: { version: 1 },288      }),289    ).toBe(false);290  });291});