vault-adopt-snapshot.test.ts

17.3 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  sendVaultSnapshot,17  type AdoptionReceiverDeps,18} from "./vault-adopt";19import type { PairingTransport } from "./pairing-handshake";20import { padSyncFrame } from "./sync-padding";2122/** Snapshot frames are bucket-padded on the wire (PB-26-604). */23function sendSnapshotFrame(channel: PairingTransport, frame: unknown): Promise<void> {24  return channel.send(padSyncFrame(new TextEncoder().encode(JSON.stringify(frame))));25}2627function memoryTransportPair(): [PairingTransport, PairingTransport] {28  const a: Uint8Array[] = [];29  const b: Uint8Array[] = [];30  const aReaders: Array<(message: Uint8Array) => void> = [];31  const bReaders: Array<(message: Uint8Array) => void> = [];3233  const transport = (34    inbox: Uint8Array[],35    outbox: Uint8Array[],36    inboxReaders: Array<(message: Uint8Array) => void>,37    outboxReaders: Array<(message: Uint8Array) => void>,38  ): PairingTransport => ({39    async send(message) {40      const reader = outboxReaders.shift();41      if (reader) reader(message);42      else outbox.push(message);43    },44    receive() {45      const message = inbox.shift();46      return message47        ? Promise.resolve(message)48        : new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));49    },50  });5152  return [transport(a, b, aReaders, bReaders), transport(b, a, bReaders, aReaders)];53}5455describe("inline vault snapshot", () => {56  it("round-trips an empty vault (0 entries)", async () => {57    const [source, receiver] = memoryTransportPair();58    const [result] = await Promise.all([59      receiveVaultSnapshot(receiver),60      sendVaultSnapshot(source, {}, {}),61    ]);62    expect(result.entries).toEqual({});63    expect(result.tombstones).toEqual({});64  });6566  it("round-trips 50 entries + 5 tombstones in a single chunk", async () => {67    const [source, receiver] = memoryTransportPair();68    const entries: Record<string, string> = {};69    for (let i = 0; i < 50; i++) entries[`id-${i}`] = `cipher-${i}`;70    const tombstones: Record<string, number> = {};71    for (let i = 0; i < 5; i++) tombstones[`dead-${i}`] = 1000 + i;72    const [result] = await Promise.all([73      receiveVaultSnapshot(receiver),74      sendVaultSnapshot(source, entries, tombstones),75    ]);76    expect(result.entries).toEqual(entries);77    expect(result.tombstones).toEqual(tombstones);78  });7980  it("splits a large vault into 3+ chunks and reassembles it", async () => {81    const [source, receiver] = memoryTransportPair();82    let frames = 0;83    const countingSource: PairingTransport = {84      send: (msg) => {85        frames++;86        return source.send(msg);87      },88      receive: () => source.receive(),89    };90    const big = "x".repeat(200 * 1024); // 200 KiB per entry forces chunking91    const entries: Record<string, string> = {};92    for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;93    const tombstones: Record<string, number> = { "dead-0": 42 };94    const [result] = await Promise.all([95      receiveVaultSnapshot(receiver),96      sendVaultSnapshot(countingSource, entries, tombstones),97    ]);98    expect(frames).toBeGreaterThanOrEqual(3);99    expect(result.entries).toEqual(entries);100    expect(result.tombstones).toEqual(tombstones);101  });102103  it("rejects oversized duress before sending any snapshot frame", async () => {104    const [source] = memoryTransportPair();105    let frames = 0;106    const countingSource: PairingTransport = {107      send: (msg) => {108        frames++;109        return source.send(msg);110      },111      receive: () => source.receive(),112    };113114    await expect(sendVaultSnapshot(115      countingSource,116      { e1: "cipher1" },117      {},118      "x".repeat(768 * 1024),119    )).rejects.toThrow("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");120    expect(frames).toBe(0);121  });122123  it("rejects out-of-order chunks", async () => {124    const [source, receiver] = memoryTransportPair();125    const send = sendSnapshotFrame(source, {126      type: "ADOPT_SNAPSHOT",127      chunk_index: 1,128      chunk_count: 2,129      entries: { a: "x" },130      tombstones: {},131    });132    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/OUT_OF_ORDER/);133    await send;134  });135136  it("rejects a chunk_count exceeding MAX_SNAPSHOT_CHUNKS", async () => {137    const [source, receiver] = memoryTransportPair();138    const send = sendSnapshotFrame(source, {139      type: "ADOPT_SNAPSHOT",140      chunk_index: 0,141      chunk_count: MAX_SNAPSHOT_CHUNKS + 1,142      entries: {},143      tombstones: {},144    });145    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/TOO_MANY_CHUNKS/);146    await send;147  });148149  it("validates ADOPT_SNAPSHOT frames", () => {150    expect(151      isAdoptSnapshot({152        type: "ADOPT_SNAPSHOT",153        chunk_index: 0,154        chunk_count: 1,155        entries: { a: "x" },156        tombstones: {},157      }),158    ).toBe(true);159    // Missing tombstones / chunk_index.160    expect(161      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {} }),162    ).toBe(false);163    expect(164      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_count: 1, entries: {}, tombstones: {} }),165    ).toBe(false);166    // Wrong type tag.167    expect(168      isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),169    ).toBe(false);170    // Invalid indices and value types.171    expect(172      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: -1, chunk_count: 1, entries: {}, tombstones: {} }),173    ).toBe(false);174    expect(175      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 0, entries: {}, tombstones: {} }),176    ).toBe(false);177    expect(178      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: { a: 5 }, tombstones: {} }),179    ).toBe(false);180    expect(isAdoptSnapshot(null)).toBe(false);181  });182183  it("advertises snapshot_capable in the ADOPT_REQUEST frame", async () => {184    const [receiverChannel, sourceChannel] = memoryTransportPair();185    const deps: AdoptionReceiverDeps = {186      deviceName: "recv",187      generateDeviceId: () => "recv-id",188      async confirmSas() {189        return true;190      },191      async createVaultFromRootKey() {192        return { vaultId: "v" };193      },194      async deriveLocalIdentity() {195        return {196          deviceId: "recv-id",197          deviceName: "recv",198          deviceType: "extension",199          longTermPublicKeyHex: "ab".repeat(32),200        };201      },202      async upsertPairedDevice() {},203      onError() {},204    };205    void requestAdoption(receiverChannel, deps);206    const first = await receiveFrame(sourceChannel);207    expect(first).toMatchObject({ type: "ADOPT_REQUEST", snapshot_capable: true });208  });209210  it("round-trips a duress slot on the final chunk (single-chunk vault)", async () => {211    const [source, receiver] = memoryTransportPair();212    const duressJson = JSON.stringify({213      version: 1,214      salt: "aa".repeat(16),215      nonce: "bb".repeat(12),216      ciphertext: "cc".repeat(32),217      tag: "dd".repeat(16),218    });219    const [result] = await Promise.all([220      receiveVaultSnapshot(receiver),221      sendVaultSnapshot(source, { e1: "cipher1" }, { t1: 100 }, duressJson),222    ]);223    expect(result.entries).toEqual({ e1: "cipher1" });224    expect(result.tombstones).toEqual({ t1: 100 });225    expect(result.duressSlot).toBe(duressJson);226  });227228  it("round-trips a duress slot across multiple chunks (final chunk only)", async () => {229    const [source, receiver] = memoryTransportPair();230    const big = "x".repeat(200 * 1024);231    const entries: Record<string, string> = {};232    for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;233    const duressJson = JSON.stringify({234      version: 1,235      salt: "11".repeat(16),236      nonce: "22".repeat(12),237      ciphertext: "33".repeat(32),238      tag: "44".repeat(16),239    });240    const [result] = await Promise.all([241      receiveVaultSnapshot(receiver),242      sendVaultSnapshot(source, entries, { dead: 1 }, duressJson),243    ]);244    expect(result.entries).toEqual(entries);245    expect(result.duressSlot).toBe(duressJson);246  });247248  it("omits duress_slot when no duress is configured (null)", async () => {249    const [source, receiver] = memoryTransportPair();250    const [result] = await Promise.all([251      receiveVaultSnapshot(receiver),252      sendVaultSnapshot(source, { e1: "v1" }, {}),253    ]);254    expect(result.duressSlot).toBeNull();255  });256257  it("isAdoptSnapshot accepts frames with valid duress_slot string", () => {258    expect(259      isAdoptSnapshot({260        type: "ADOPT_SNAPSHOT",261        chunk_index: 0,262        chunk_count: 1,263        entries: {},264        tombstones: {},265        duress_slot: '{"version":1}',266      }),267    ).toBe(true);268  });269270  it("isAdoptSnapshot accepts frames without duress_slot", () => {271    expect(272      isAdoptSnapshot({273        type: "ADOPT_SNAPSHOT",274        chunk_index: 0,275        chunk_count: 1,276        entries: {},277        tombstones: {},278      }),279    ).toBe(true);280  });281282  it("isAdoptSnapshot rejects frames with non-string duress_slot", () => {283    expect(284      isAdoptSnapshot({285        type: "ADOPT_SNAPSHOT",286        chunk_index: 0,287        chunk_count: 1,288        entries: {},289        tombstones: {},290        duress_slot: 42,291      }),292    ).toBe(false);293    expect(294      isAdoptSnapshot({295        type: "ADOPT_SNAPSHOT",296        chunk_index: 0,297        chunk_count: 1,298        entries: {},299        tombstones: {},300        duress_slot: { version: 1 },301      }),302    ).toBe(false);303  });304});305306describe("adoption snapshot aggregate budget (PB-26-206)", () => {307  // Cross-platform boundary vectors V1..V11. The same wire shapes and verdicts308  // are asserted in Android's VaultAdoptSnapshotTest — both clients must agree309  // on accept/reject for every vector. Vectors use the shared helper directly310  // where a full 10 MiB wire transfer would only prove the same comparison.311312  it("V1: frame bytes at the aggregate limit pass, limit+1 rejects before merge", () => {313    const atLimit = createAdoptSnapshotBudget();314    atLimit.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100;315    applyAdoptSnapshotFrame(atLimit, { a: "x" }, {}, null, 100);316    expect(atLimit.bytes).toBe(MAX_ADOPT_SNAPSHOT_BYTES);317318    const over = createAdoptSnapshotBudget();319    over.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100;320    expect(() => applyAdoptSnapshotFrame(over, { a: "x" }, {}, null, 101))321      .toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");322    // Rejected before merge: no partial adopted state escapes.323    expect(over.entries).toEqual({});324  });325326  it("V2: 25,000 entries pass, the 25,001st rejects", () => {327    const entries: Record<string, string> = {};328    for (let i = 0; i < MAX_ADOPT_SNAPSHOT_ENTRIES; i++) entries[`e${i}`] = "v";329    const acc = createAdoptSnapshotBudget();330    applyAdoptSnapshotFrame(acc, entries, {}, null, 100);331    expect(acc.entryCount).toBe(MAX_ADOPT_SNAPSHOT_ENTRIES);332    expect(() => applyAdoptSnapshotFrame(acc, { oneMore: "v" }, {}, null, 1))333      .toThrow("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES");334    expect(acc.entries.oneMore).toBeUndefined();335  });336337  it("V3: 25,000 tombstones pass, the 25,001st rejects", () => {338    const tombstones: Record<string, number> = {};339    for (let i = 0; i < MAX_ADOPT_SNAPSHOT_TOMBSTONES; i++) tombstones[`dead-${i}`] = i + 1;340    const acc = createAdoptSnapshotBudget();341    applyAdoptSnapshotFrame(acc, {}, tombstones, null, 100);342    expect(acc.tombstoneCount).toBe(MAX_ADOPT_SNAPSHOT_TOMBSTONES);343    expect(() => applyAdoptSnapshotFrame(acc, {}, { oneMore: 1 }, null, 1))344      .toThrow("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES");345    expect(acc.tombstones.oneMore).toBeUndefined();346  });347348  it("V4: entry/tombstone IDs of 512 chars pass, 513 reject", () => {349    const ok = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS);350    const acc = createAdoptSnapshotBudget();351    applyAdoptSnapshotFrame(acc, { [ok]: "v" }, { [ok]: 1 }, null, 10);352    expect(acc.entries[ok]).toBe("v");353354    const tooLong = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS + 1);355    expect(() => applyAdoptSnapshotFrame(356      createAdoptSnapshotBudget(), { [tooLong]: "v" }, {}, null, 10,357    )).toThrow("ADOPT_SNAPSHOT_ID_TOO_LONG");358    expect(() => applyAdoptSnapshotFrame(359      createAdoptSnapshotBudget(), {}, { [tooLong]: 1 }, null, 10,360    )).toThrow("ADOPT_SNAPSHOT_ID_TOO_LONG");361  });362363  it("V5: envelope at the base64-overhead ceiling passes, +1 rejects", () => {364    const atLimit = "c".repeat(Math.floor(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES));365    const acc = createAdoptSnapshotBudget();366    applyAdoptSnapshotFrame(acc, { e1: atLimit }, {}, null, 10);367    expect(acc.entries.e1).toBe(atLimit);368369    const over = "c".repeat(Math.floor(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) + 1);370    expect(() => applyAdoptSnapshotFrame(371      createAdoptSnapshotBudget(), { e1: over }, {}, null, 10,372    )).toThrow("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE");373  });374375  it("V6: duress slot at the wire-frame ceiling passes, +1 rejects", () => {376    const acc = createAdoptSnapshotBudget();377    applyAdoptSnapshotFrame(acc, {}, {}, "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING), 10);378    expect(acc.duressSlot).toHaveLength(SNAPSHOT_FRAME_WIRE_CEILING);379380    expect(() => applyAdoptSnapshotFrame(381      createAdoptSnapshotBudget(), {}, {}, "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING + 1), 10,382    )).toThrow("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");383  });384385  it("V7: non-finite tombstone values reject", () => {386    expect(() => applyAdoptSnapshotFrame(387      createAdoptSnapshotBudget(), {}, { dead: Number.NaN }, null, 10,388    )).toThrow("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");389    expect(() => applyAdoptSnapshotFrame(390      createAdoptSnapshotBudget(), {}, { dead: Number.POSITIVE_INFINITY }, null, 10,391    )).toThrow("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");392  });393394  it("V8: high-count/low-byte snapshot round-trips (25,000 tiny entries)", async () => {395    const [source, receiver] = memoryTransportPair();396    const entries: Record<string, string> = {};397    for (let i = 0; i < MAX_ADOPT_SNAPSHOT_ENTRIES; i++) entries[`e${i}`] = "v";398    const [result] = await Promise.all([399      receiveVaultSnapshot(receiver),400      sendVaultSnapshot(source, entries, {}),401    ]);402    expect(Object.keys(result.entries)).toHaveLength(MAX_ADOPT_SNAPSHOT_ENTRIES);403  });404405  it("V9: low-count/high-byte stream over the aggregate rejects with no partial result", async () => {406    // 16 envelopes of ~674 KiB each: every frame fits the 768 KiB wire407    // ceiling, but the ~10.8 MiB total exceeds the 10 MiB aggregate.408    const [source, receiver] = memoryTransportPair();409    const envelope = "c".repeat(690_000);410    const chunks = 16;411    const send = (async () => {412      for (let i = 0; i < chunks; i++) {413        await sendSnapshotFrame(source, {414          type: "ADOPT_SNAPSHOT",415          chunk_index: i,416          chunk_count: chunks,417          entries: { [`big-${i}`]: envelope },418          tombstones: {},419        });420      }421    })();422    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");423    await send;424  });425426  it("V10: over-budget sender preflights with zero frames sent (sync fallback, never truncation)", async () => {427    const [source] = memoryTransportPair();428    let frames = 0;429    const countingSource: PairingTransport = {430      send: (msg) => {431        frames++;432        return source.send(msg);433      },434      receive: () => source.receive(),435    };436    const envelope = "c".repeat(690_000);437    const entries: Record<string, string> = {};438    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;439    await expect(sendVaultSnapshot(countingSource, entries, {}))440      .rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");441    expect(frames).toBe(0);442  });443444  it("V11: duress bytes count against the aggregate budget", () => {445    const acc = createAdoptSnapshotBudget();446    acc.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100;447    // A 200-byte duress slot does not fit the remaining 100-byte budget.448    expect(() => applyAdoptSnapshotFrame(acc, {}, {}, "d".repeat(200), 200))449      .toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");450  });451});452453/**454 * PB-26-604 — adoption snapshot frames were sent as raw JSON, so a relay455 * observing Noise ciphertext saw plaintext_size + 16 per message and could456 * single out the distinctive duress frame. They now ride the sync transport's457 * bucket ladder.458 */459describe("PB-26-604 adoption snapshot padding", () => {460  it("pads every frame to a bucket so similar logical frames look alike", async () => {461    const [source] = memoryTransportPair();462    let wireSizes: number[] = [];463    const probe: PairingTransport = {464      send: (msg) => {465        wireSizes.push(msg.length);466        return source.send(msg);467      },468      receive: () => source.receive(),469    };470471    await sendVaultSnapshot(probe, { "id-0": "x".repeat(1000) }, {}, null);472    const withoutDuress = wireSizes;473474    wireSizes = [];475    await sendVaultSnapshot(probe, { "id-0": "x".repeat(1000) }, {}, "d".repeat(1000));476    const withDuress = wireSizes;477478    // Every frame is bucket-padded: no wire size is the raw JSON length.479    for (const size of [...withoutDuress, ...withDuress]) {480      expect(size % 256).toBe(0);481    }482    // The duress frame lands in the same bucket as an entries frame of similar483    // logical size, so the relay cannot tell which transfer carried duress.484    expect(withDuress.at(-1)).toBe(withoutDuress.at(-1));485  });486});