vault-adopt-snapshot.test.ts

15.7 KB · raw

1import { describe, expect, it } from "vitest";2import {3  MAX_ADOPT_SNAPSHOT_BYTES,4  MAX_ADOPT_SNAPSHOT_ENTRIES,5  MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES,6  MAX_ADOPT_SNAPSHOT_ID_CHARS,7  MAX_ADOPT_SNAPSHOT_TOMBSTONES,8  MAX_SNAPSHOT_CHUNKS,9  SNAPSHOT_FRAME_WIRE_CEILING,10  applyAdoptSnapshotFrame,11  createAdoptSnapshotBudget,12  isAdoptSnapshot,13  receiveFrame,14  receiveVaultSnapshot,15  requestAdoption,16  sendFrame,17  sendVaultSnapshot,18  type AdoptionReceiverDeps,19} from "./vault-adopt";20import type { PairingTransport } from "./pairing-handshake";2122function memoryTransportPair(): [PairingTransport, PairingTransport] {23  const a: Uint8Array[] = [];24  const b: Uint8Array[] = [];25  const aReaders: Array<(message: Uint8Array) => void> = [];26  const bReaders: Array<(message: Uint8Array) => void> = [];2728  const transport = (29    inbox: Uint8Array[],30    outbox: Uint8Array[],31    inboxReaders: Array<(message: Uint8Array) => void>,32    outboxReaders: Array<(message: Uint8Array) => void>,33  ): PairingTransport => ({34    async send(message) {35      const reader = outboxReaders.shift();36      if (reader) reader(message);37      else outbox.push(message);38    },39    receive() {40      const message = inbox.shift();41      return message42        ? Promise.resolve(message)43        : new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));44    },45  });4647  return [transport(a, b, aReaders, bReaders), transport(b, a, bReaders, aReaders)];48}4950describe("inline vault snapshot", () => {51  it("round-trips an empty vault (0 entries)", async () => {52    const [source, receiver] = memoryTransportPair();53    const [result] = await Promise.all([54      receiveVaultSnapshot(receiver),55      sendVaultSnapshot(source, {}, {}),56    ]);57    expect(result.entries).toEqual({});58    expect(result.tombstones).toEqual({});59  });6061  it("round-trips 50 entries + 5 tombstones in a single chunk", async () => {62    const [source, receiver] = memoryTransportPair();63    const entries: Record<string, string> = {};64    for (let i = 0; i < 50; i++) entries[`id-${i}`] = `cipher-${i}`;65    const tombstones: Record<string, number> = {};66    for (let i = 0; i < 5; i++) tombstones[`dead-${i}`] = 1000 + i;67    const [result] = await Promise.all([68      receiveVaultSnapshot(receiver),69      sendVaultSnapshot(source, entries, tombstones),70    ]);71    expect(result.entries).toEqual(entries);72    expect(result.tombstones).toEqual(tombstones);73  });7475  it("splits a large vault into 3+ chunks and reassembles it", async () => {76    const [source, receiver] = memoryTransportPair();77    let frames = 0;78    const countingSource: PairingTransport = {79      send: (msg) => {80        frames++;81        return source.send(msg);82      },83      receive: () => source.receive(),84    };85    const big = "x".repeat(200 * 1024); // 200 KiB per entry forces chunking86    const entries: Record<string, string> = {};87    for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;88    const tombstones: Record<string, number> = { "dead-0": 42 };89    const [result] = await Promise.all([90      receiveVaultSnapshot(receiver),91      sendVaultSnapshot(countingSource, entries, tombstones),92    ]);93    expect(frames).toBeGreaterThanOrEqual(3);94    expect(result.entries).toEqual(entries);95    expect(result.tombstones).toEqual(tombstones);96  });9798  it("rejects oversized duress before sending any snapshot frame", async () => {99    const [source] = memoryTransportPair();100    let frames = 0;101    const countingSource: PairingTransport = {102      send: (msg) => {103        frames++;104        return source.send(msg);105      },106      receive: () => source.receive(),107    };108109    await expect(sendVaultSnapshot(110      countingSource,111      { e1: "cipher1" },112      {},113      "x".repeat(768 * 1024),114    )).rejects.toThrow("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");115    expect(frames).toBe(0);116  });117118  it("rejects out-of-order chunks", async () => {119    const [source, receiver] = memoryTransportPair();120    const send = sendFrame(source, {121      type: "ADOPT_SNAPSHOT",122      chunk_index: 1,123      chunk_count: 2,124      entries: { a: "x" },125      tombstones: {},126    });127    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/OUT_OF_ORDER/);128    await send;129  });130131  it("rejects a chunk_count exceeding MAX_SNAPSHOT_CHUNKS", async () => {132    const [source, receiver] = memoryTransportPair();133    const send = sendFrame(source, {134      type: "ADOPT_SNAPSHOT",135      chunk_index: 0,136      chunk_count: MAX_SNAPSHOT_CHUNKS + 1,137      entries: {},138      tombstones: {},139    });140    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/TOO_MANY_CHUNKS/);141    await send;142  });143144  it("validates ADOPT_SNAPSHOT frames", () => {145    expect(146      isAdoptSnapshot({147        type: "ADOPT_SNAPSHOT",148        chunk_index: 0,149        chunk_count: 1,150        entries: { a: "x" },151        tombstones: {},152      }),153    ).toBe(true);154    // Missing tombstones / chunk_index.155    expect(156      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {} }),157    ).toBe(false);158    expect(159      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_count: 1, entries: {}, tombstones: {} }),160    ).toBe(false);161    // Wrong type tag.162    expect(163      isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),164    ).toBe(false);165    // Invalid indices and value types.166    expect(167      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: -1, chunk_count: 1, entries: {}, tombstones: {} }),168    ).toBe(false);169    expect(170      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 0, entries: {}, tombstones: {} }),171    ).toBe(false);172    expect(173      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: { a: 5 }, tombstones: {} }),174    ).toBe(false);175    expect(isAdoptSnapshot(null)).toBe(false);176  });177178  it("advertises snapshot_capable in the ADOPT_REQUEST frame", async () => {179    const [receiverChannel, sourceChannel] = memoryTransportPair();180    const deps: AdoptionReceiverDeps = {181      deviceName: "recv",182      generateDeviceId: () => "recv-id",183      async confirmSas() {184        return true;185      },186      async createVaultFromRootKey() {187        return { vaultId: "v" };188      },189      async deriveLocalIdentity() {190        return {191          deviceId: "recv-id",192          deviceName: "recv",193          deviceType: "extension",194          longTermPublicKeyHex: "ab".repeat(32),195        };196      },197      async upsertPairedDevice() {},198      onError() {},199    };200    void requestAdoption(receiverChannel, deps);201    const first = await receiveFrame(sourceChannel);202    expect(first).toMatchObject({ type: "ADOPT_REQUEST", snapshot_capable: true });203  });204205  it("round-trips a duress slot on the final chunk (single-chunk vault)", async () => {206    const [source, receiver] = memoryTransportPair();207    const duressJson = JSON.stringify({208      version: 1,209      salt: "aa".repeat(16),210      nonce: "bb".repeat(12),211      ciphertext: "cc".repeat(32),212      tag: "dd".repeat(16),213    });214    const [result] = await Promise.all([215      receiveVaultSnapshot(receiver),216      sendVaultSnapshot(source, { e1: "cipher1" }, { t1: 100 }, duressJson),217    ]);218    expect(result.entries).toEqual({ e1: "cipher1" });219    expect(result.tombstones).toEqual({ t1: 100 });220    expect(result.duressSlot).toBe(duressJson);221  });222223  it("round-trips a duress slot across multiple chunks (final chunk only)", async () => {224    const [source, receiver] = memoryTransportPair();225    const big = "x".repeat(200 * 1024);226    const entries: Record<string, string> = {};227    for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;228    const duressJson = JSON.stringify({229      version: 1,230      salt: "11".repeat(16),231      nonce: "22".repeat(12),232      ciphertext: "33".repeat(32),233      tag: "44".repeat(16),234    });235    const [result] = await Promise.all([236      receiveVaultSnapshot(receiver),237      sendVaultSnapshot(source, entries, { dead: 1 }, duressJson),238    ]);239    expect(result.entries).toEqual(entries);240    expect(result.duressSlot).toBe(duressJson);241  });242243  it("omits duress_slot when no duress is configured (null)", async () => {244    const [source, receiver] = memoryTransportPair();245    const [result] = await Promise.all([246      receiveVaultSnapshot(receiver),247      sendVaultSnapshot(source, { e1: "v1" }, {}),248    ]);249    expect(result.duressSlot).toBeNull();250  });251252  it("isAdoptSnapshot accepts frames with valid duress_slot string", () => {253    expect(254      isAdoptSnapshot({255        type: "ADOPT_SNAPSHOT",256        chunk_index: 0,257        chunk_count: 1,258        entries: {},259        tombstones: {},260        duress_slot: '{"version":1}',261      }),262    ).toBe(true);263  });264265  it("isAdoptSnapshot accepts frames without duress_slot", () => {266    expect(267      isAdoptSnapshot({268        type: "ADOPT_SNAPSHOT",269        chunk_index: 0,270        chunk_count: 1,271        entries: {},272        tombstones: {},273      }),274    ).toBe(true);275  });276277  it("isAdoptSnapshot rejects frames with non-string duress_slot", () => {278    expect(279      isAdoptSnapshot({280        type: "ADOPT_SNAPSHOT",281        chunk_index: 0,282        chunk_count: 1,283        entries: {},284        tombstones: {},285        duress_slot: 42,286      }),287    ).toBe(false);288    expect(289      isAdoptSnapshot({290        type: "ADOPT_SNAPSHOT",291        chunk_index: 0,292        chunk_count: 1,293        entries: {},294        tombstones: {},295        duress_slot: { version: 1 },296      }),297    ).toBe(false);298  });299});300301describe("adoption snapshot aggregate budget (PB-26-206)", () => {302  // Cross-platform boundary vectors V1..V11. The same wire shapes and verdicts303  // are asserted in Android's VaultAdoptSnapshotTest — both clients must agree304  // on accept/reject for every vector. Vectors use the shared helper directly305  // where a full 10 MiB wire transfer would only prove the same comparison.306307  it("V1: frame bytes at the aggregate limit pass, limit+1 rejects before merge", () => {308    const atLimit = createAdoptSnapshotBudget();309    atLimit.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100;310    applyAdoptSnapshotFrame(atLimit, { a: "x" }, {}, null, 100);311    expect(atLimit.bytes).toBe(MAX_ADOPT_SNAPSHOT_BYTES);312313    const over = createAdoptSnapshotBudget();314    over.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100;315    expect(() => applyAdoptSnapshotFrame(over, { a: "x" }, {}, null, 101))316      .toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");317    // Rejected before merge: no partial adopted state escapes.318    expect(over.entries).toEqual({});319  });320321  it("V2: 25,000 entries pass, the 25,001st rejects", () => {322    const entries: Record<string, string> = {};323    for (let i = 0; i < MAX_ADOPT_SNAPSHOT_ENTRIES; i++) entries[`e${i}`] = "v";324    const acc = createAdoptSnapshotBudget();325    applyAdoptSnapshotFrame(acc, entries, {}, null, 100);326    expect(acc.entryCount).toBe(MAX_ADOPT_SNAPSHOT_ENTRIES);327    expect(() => applyAdoptSnapshotFrame(acc, { oneMore: "v" }, {}, null, 1))328      .toThrow("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES");329    expect(acc.entries.oneMore).toBeUndefined();330  });331332  it("V3: 25,000 tombstones pass, the 25,001st rejects", () => {333    const tombstones: Record<string, number> = {};334    for (let i = 0; i < MAX_ADOPT_SNAPSHOT_TOMBSTONES; i++) tombstones[`dead-${i}`] = i + 1;335    const acc = createAdoptSnapshotBudget();336    applyAdoptSnapshotFrame(acc, {}, tombstones, null, 100);337    expect(acc.tombstoneCount).toBe(MAX_ADOPT_SNAPSHOT_TOMBSTONES);338    expect(() => applyAdoptSnapshotFrame(acc, {}, { oneMore: 1 }, null, 1))339      .toThrow("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES");340    expect(acc.tombstones.oneMore).toBeUndefined();341  });342343  it("V4: entry/tombstone IDs of 512 chars pass, 513 reject", () => {344    const ok = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS);345    const acc = createAdoptSnapshotBudget();346    applyAdoptSnapshotFrame(acc, { [ok]: "v" }, { [ok]: 1 }, null, 10);347    expect(acc.entries[ok]).toBe("v");348349    const tooLong = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS + 1);350    expect(() => applyAdoptSnapshotFrame(351      createAdoptSnapshotBudget(), { [tooLong]: "v" }, {}, null, 10,352    )).toThrow("ADOPT_SNAPSHOT_ID_TOO_LONG");353    expect(() => applyAdoptSnapshotFrame(354      createAdoptSnapshotBudget(), {}, { [tooLong]: 1 }, null, 10,355    )).toThrow("ADOPT_SNAPSHOT_ID_TOO_LONG");356  });357358  it("V5: envelope at the base64-overhead ceiling passes, +1 rejects", () => {359    const atLimit = "c".repeat(Math.floor(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES));360    const acc = createAdoptSnapshotBudget();361    applyAdoptSnapshotFrame(acc, { e1: atLimit }, {}, null, 10);362    expect(acc.entries.e1).toBe(atLimit);363364    const over = "c".repeat(Math.floor(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) + 1);365    expect(() => applyAdoptSnapshotFrame(366      createAdoptSnapshotBudget(), { e1: over }, {}, null, 10,367    )).toThrow("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE");368  });369370  it("V6: duress slot at the wire-frame ceiling passes, +1 rejects", () => {371    const acc = createAdoptSnapshotBudget();372    applyAdoptSnapshotFrame(acc, {}, {}, "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING), 10);373    expect(acc.duressSlot).toHaveLength(SNAPSHOT_FRAME_WIRE_CEILING);374375    expect(() => applyAdoptSnapshotFrame(376      createAdoptSnapshotBudget(), {}, {}, "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING + 1), 10,377    )).toThrow("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");378  });379380  it("V7: non-finite tombstone values reject", () => {381    expect(() => applyAdoptSnapshotFrame(382      createAdoptSnapshotBudget(), {}, { dead: Number.NaN }, null, 10,383    )).toThrow("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");384    expect(() => applyAdoptSnapshotFrame(385      createAdoptSnapshotBudget(), {}, { dead: Number.POSITIVE_INFINITY }, null, 10,386    )).toThrow("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");387  });388389  it("V8: high-count/low-byte snapshot round-trips (25,000 tiny entries)", async () => {390    const [source, receiver] = memoryTransportPair();391    const entries: Record<string, string> = {};392    for (let i = 0; i < MAX_ADOPT_SNAPSHOT_ENTRIES; i++) entries[`e${i}`] = "v";393    const [result] = await Promise.all([394      receiveVaultSnapshot(receiver),395      sendVaultSnapshot(source, entries, {}),396    ]);397    expect(Object.keys(result.entries)).toHaveLength(MAX_ADOPT_SNAPSHOT_ENTRIES);398  });399400  it("V9: low-count/high-byte stream over the aggregate rejects with no partial result", async () => {401    // 16 envelopes of ~674 KiB each: every frame fits the 768 KiB wire402    // ceiling, but the ~10.8 MiB total exceeds the 10 MiB aggregate.403    const [source, receiver] = memoryTransportPair();404    const envelope = "c".repeat(690_000);405    const chunks = 16;406    const send = (async () => {407      for (let i = 0; i < chunks; i++) {408        await sendFrame(source, {409          type: "ADOPT_SNAPSHOT",410          chunk_index: i,411          chunk_count: chunks,412          entries: { [`big-${i}`]: envelope },413          tombstones: {},414        });415      }416    })();417    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");418    await send;419  });420421  it("V10: over-budget sender preflights with zero frames sent (sync fallback, never truncation)", async () => {422    const [source] = memoryTransportPair();423    let frames = 0;424    const countingSource: PairingTransport = {425      send: (msg) => {426        frames++;427        return source.send(msg);428      },429      receive: () => source.receive(),430    };431    const envelope = "c".repeat(690_000);432    const entries: Record<string, string> = {};433    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;434    await expect(sendVaultSnapshot(countingSource, entries, {}))435      .rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");436    expect(frames).toBe(0);437  });438439  it("V11: duress bytes count against the aggregate budget", () => {440    const acc = createAdoptSnapshotBudget();441    acc.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100;442    // A 200-byte duress slot does not fit the remaining 100-byte budget.443    expect(() => applyAdoptSnapshotFrame(acc, {}, {}, "d".repeat(200), 200))444      .toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");445  });446});