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); 86 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 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 162 expect(163 isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),164 ).toBe(false);165 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 303 304 305 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 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 402 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 443 expect(() => applyAdoptSnapshotFrame(acc, {}, {}, "d".repeat(200), 200))444 .toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");445 });446});