import { describe, expect, it } from "vitest"; import { MAX_SNAPSHOT_CHUNKS, isAdoptSnapshot, receiveFrame, receiveVaultSnapshot, requestAdoption, sendFrame, sendVaultSnapshot, type AdoptionReceiverDeps, } from "./vault-adopt"; import type { PairingTransport } from "./pairing-handshake"; function memoryTransportPair(): [PairingTransport, PairingTransport] { const a: Uint8Array[] = []; const b: Uint8Array[] = []; const aReaders: Array<(message: Uint8Array) => void> = []; const bReaders: Array<(message: Uint8Array) => void> = []; const transport = ( inbox: Uint8Array[], outbox: Uint8Array[], inboxReaders: Array<(message: Uint8Array) => void>, outboxReaders: Array<(message: Uint8Array) => void>, ): PairingTransport => ({ async send(message) { const reader = outboxReaders.shift(); if (reader) reader(message); else outbox.push(message); }, receive() { const message = inbox.shift(); return message ? Promise.resolve(message) : new Promise((resolve) => inboxReaders.push(resolve)); }, }); return [transport(a, b, aReaders, bReaders), transport(b, a, bReaders, aReaders)]; } describe("inline vault snapshot", () => { it("round-trips an empty vault (0 entries)", async () => { const [source, receiver] = memoryTransportPair(); const [result] = await Promise.all([ receiveVaultSnapshot(receiver), sendVaultSnapshot(source, {}, {}), ]); expect(result.entries).toEqual({}); expect(result.tombstones).toEqual({}); }); it("round-trips 50 entries + 5 tombstones in a single chunk", async () => { const [source, receiver] = memoryTransportPair(); const entries: Record = {}; for (let i = 0; i < 50; i++) entries[`id-${i}`] = `cipher-${i}`; const tombstones: Record = {}; for (let i = 0; i < 5; i++) tombstones[`dead-${i}`] = 1000 + i; const [result] = await Promise.all([ receiveVaultSnapshot(receiver), sendVaultSnapshot(source, entries, tombstones), ]); expect(result.entries).toEqual(entries); expect(result.tombstones).toEqual(tombstones); }); it("splits a large vault into 3+ chunks and reassembles it", async () => { const [source, receiver] = memoryTransportPair(); let frames = 0; const countingSource: PairingTransport = { send: (msg) => { frames++; return source.send(msg); }, receive: () => source.receive(), }; const big = "x".repeat(200 * 1024); // 200 KiB per entry forces chunking const entries: Record = {}; for (let i = 0; i < 5; i++) entries[`id-${i}`] = big; const tombstones: Record = { "dead-0": 42 }; const [result] = await Promise.all([ receiveVaultSnapshot(receiver), sendVaultSnapshot(countingSource, entries, tombstones), ]); expect(frames).toBeGreaterThanOrEqual(3); expect(result.entries).toEqual(entries); expect(result.tombstones).toEqual(tombstones); }); it("rejects oversized duress before sending any snapshot frame", async () => { const [source] = memoryTransportPair(); let frames = 0; const countingSource: PairingTransport = { send: (msg) => { frames++; return source.send(msg); }, receive: () => source.receive(), }; await expect(sendVaultSnapshot( countingSource, { e1: "cipher1" }, {}, "x".repeat(768 * 1024), )).rejects.toThrow("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"); expect(frames).toBe(0); }); it("rejects out-of-order chunks", async () => { const [source, receiver] = memoryTransportPair(); const send = sendFrame(source, { type: "ADOPT_SNAPSHOT", chunk_index: 1, chunk_count: 2, entries: { a: "x" }, tombstones: {}, }); await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/OUT_OF_ORDER/); await send; }); it("rejects a chunk_count exceeding MAX_SNAPSHOT_CHUNKS", async () => { const [source, receiver] = memoryTransportPair(); const send = sendFrame(source, { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: MAX_SNAPSHOT_CHUNKS + 1, entries: {}, tombstones: {}, }); await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/TOO_MANY_CHUNKS/); await send; }); it("validates ADOPT_SNAPSHOT frames", () => { expect( isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: { a: "x" }, tombstones: {}, }), ).toBe(true); // Missing tombstones / chunk_index. expect( isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {} }), ).toBe(false); expect( isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_count: 1, entries: {}, tombstones: {} }), ).toBe(false); // Wrong type tag. expect( isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }), ).toBe(false); // Invalid indices and value types. expect( isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: -1, chunk_count: 1, entries: {}, tombstones: {} }), ).toBe(false); expect( isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 0, entries: {}, tombstones: {} }), ).toBe(false); expect( isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: { a: 5 }, tombstones: {} }), ).toBe(false); expect(isAdoptSnapshot(null)).toBe(false); }); it("advertises snapshot_capable in the ADOPT_REQUEST frame", async () => { const [receiverChannel, sourceChannel] = memoryTransportPair(); const deps: AdoptionReceiverDeps = { deviceName: "recv", generateDeviceId: () => "recv-id", async confirmSas() { return true; }, async createVaultFromRootKey() { return { vaultId: "v" }; }, async deriveLocalIdentity() { return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension", longTermPublicKeyHex: "ab".repeat(32), }; }, async upsertPairedDevice() {}, onError() {}, }; void requestAdoption(receiverChannel, deps); const first = await receiveFrame(sourceChannel); expect(first).toMatchObject({ type: "ADOPT_REQUEST", snapshot_capable: true }); }); it("round-trips a duress slot on the final chunk (single-chunk vault)", async () => { const [source, receiver] = memoryTransportPair(); const duressJson = JSON.stringify({ version: 1, salt: "aa".repeat(16), nonce: "bb".repeat(12), ciphertext: "cc".repeat(32), tag: "dd".repeat(16), }); const [result] = await Promise.all([ receiveVaultSnapshot(receiver), sendVaultSnapshot(source, { e1: "cipher1" }, { t1: 100 }, duressJson), ]); expect(result.entries).toEqual({ e1: "cipher1" }); expect(result.tombstones).toEqual({ t1: 100 }); expect(result.duressSlot).toBe(duressJson); }); it("round-trips a duress slot across multiple chunks (final chunk only)", async () => { const [source, receiver] = memoryTransportPair(); const big = "x".repeat(200 * 1024); const entries: Record = {}; for (let i = 0; i < 5; i++) entries[`id-${i}`] = big; const duressJson = JSON.stringify({ version: 1, salt: "11".repeat(16), nonce: "22".repeat(12), ciphertext: "33".repeat(32), tag: "44".repeat(16), }); const [result] = await Promise.all([ receiveVaultSnapshot(receiver), sendVaultSnapshot(source, entries, { dead: 1 }, duressJson), ]); expect(result.entries).toEqual(entries); expect(result.duressSlot).toBe(duressJson); }); it("omits duress_slot when no duress is configured (null)", async () => { const [source, receiver] = memoryTransportPair(); const [result] = await Promise.all([ receiveVaultSnapshot(receiver), sendVaultSnapshot(source, { e1: "v1" }, {}), ]); expect(result.duressSlot).toBeNull(); }); it("isAdoptSnapshot accepts frames with valid duress_slot string", () => { expect( isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {}, duress_slot: '{"version":1}', }), ).toBe(true); }); it("isAdoptSnapshot accepts frames without duress_slot", () => { expect( isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {}, }), ).toBe(true); }); it("isAdoptSnapshot rejects frames with non-string duress_slot", () => { expect( isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {}, duress_slot: 42, }), ).toBe(false); expect( isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {}, duress_slot: { version: 1 }, }), ).toBe(false); }); });