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";212223function 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); 91 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 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 167 expect(168 isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),169 ).toBe(false);170 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 308 309 310 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 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 407 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 448 expect(() => applyAdoptSnapshotFrame(acc, {}, {}, "d".repeat(200), 200))449 .toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");450 });451});452453454455456457458459describe("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 479 for (const size of [...withoutDuress, ...withDuress]) {480 expect(size % 256).toBe(0);481 }482 483 484 expect(withDuress.at(-1)).toBe(withoutDuress.at(-1));485 });486});