import { describe, expect, it } from 'vitest'; import { ChannelSession, collectFrames, decryptItemFrames, type ReceivedItem } from '../src/index.js'; import { parseEnvelope } from '../src/internal.js'; import { openFrame } from '../src/frame.js'; import { asyncChunks, concatAll, ORIGIN, pseudoRandom, splitAt, toHex } from './helpers.js'; async function pair() { const { session: owner } = await ChannelSession.create(); const member = await ChannelSession.open(owner.link(ORIGIN)); return { owner, member }; } const TIERS_PLUS_TAG = [1040, 4112, 16400, 65552, 262160, 1048592]; describe('篡改全覆盖', () => { it('翻转一帧里任意一个字节的任意一位,接收方都不会产出内容', async () => { const { owner, member } = await pair(); const [frame] = await collectFrames(owner.encryptText('篡改检测:每个字节都被认证', { ts: 1 })); const original = frame!.bytes; expect(original.length).toBe(26 + 1040); const accepted: string[] = []; for (let i = 0; i < original.length; i++) { for (const bit of [0, 7]) { const mutated = original.slice(); mutated[i]! ^= 1 << bit; const events = await member.createItemReader().pushFrame(mutated); if (events.some((e) => e.type === 'item')) accepted.push(`字节 ${i} 位 ${bit}`); } } expect(accepted).toEqual([]); }); it('openFrame 对头部每个字节的改动要么在解析阶段、要么在认证阶段失败', async () => { const { owner } = await pair(); const [frame] = await collectFrames(owner.encryptText('x')); const env = parseEnvelope(frame!.bytes); const key = await owner.itemKey(env.itemId); for (let i = 0; i < 22; i++) { // 前 22 字节(版本、标志、条目 id、帧序号)都在 AAD 里 const mutated = frame!.bytes.slice(); mutated[i]! ^= 1; let failed = false; try { await openFrame(key, owner.idBytes, parseEnvelope(mutated)); } catch { failed = true; } expect(failed, `头部字节 ${i}`).toBe(true); } }); it('截断任意长度的帧都会被拒绝', async () => { const { owner, member } = await pair(); const [frame] = await collectFrames(owner.encryptText('truncate')); for (const cut of [1, 2, 15, 16, 17, 100, 1039, 1040]) { const events = await member.createItemReader().pushFrame(frame!.bytes.slice(0, frame!.bytes.length - cut)); expect(events[0], `截掉 ${cut} 字节`).toMatchObject({ type: 'rejected' }); } }); it('交换两个条目的密文负载(头部不动)会被拒绝', async () => { const { owner, member } = await pair(); const [a] = await collectFrames(owner.encryptText('A')); const [b] = await collectFrames(owner.encryptText('B')); const swapped = a!.bytes.slice(); swapped.set(b!.bytes.slice(26), 26); expect((await member.createItemReader().pushFrame(swapped))[0]).toMatchObject({ type: 'rejected', reason: 'auth_failed' }); }); }); describe('padding 与帧长度', () => { it('每种内容长度产生的帧负载都落在档位上(服务端据此强制 padding)', async () => { const { owner } = await pair(); const sizes = [0, 1, 50, 800, 900, 1000, 1010, 1018, 1019, 1020, 3000, 4000, 4080, 4096, 16000, 16380, 60000, 65000, 250000, 262000, 1048000]; const seen = new Set(); for (const size of sizes) { const frames = await collectFrames(owner.encryptBytes(pseudoRandom(size, size + 1), { kind: 'other' }, { ts: 1 })); for (const f of frames) { const payload = f.bytes.length - 26; expect(TIERS_PLUS_TAG, `size=${size}`).toContain(payload); seen.add(payload); } } expect(seen.size).toBeGreaterThanOrEqual(5); // 确实覆盖了多个档位 }); it('长度相近的内容落在同一档位,看不出具体长度', async () => { const { owner } = await pair(); const lens: number[] = []; for (const size of [10, 200, 500, 700]) { const [f] = await collectFrames(owner.encryptBytes(pseudoRandom(size), { kind: 'other' }, { ts: 1 })); lens.push(f!.bytes.length); } expect(new Set(lens).size).toBe(1); }); }); describe('随机往返', () => { it('随机长度、随机帧大小、随机网络切分:内容始终一致', async () => { const { owner, member } = await pair(); // 用固定种子的伪随机,失败时可复现 let x = 12345; const rnd = (n: number) => { x = (Math.imul(x, 1664525) + 1013904223) >>> 0; return (x >>> 8) % n; }; for (let round = 0; round < 40; round++) { const size = rnd(6000); const maxFrameData = 150 + rnd(900); const content = pseudoRandom(size, round + 100); const plan = owner.encryptFile(new Blob([content]), { name: `r${round}` }, { ts: round, maxFrameData }); const frames = await collectFrames(plan); const wire = concatAll(frames.map((f) => f.bytes)); // 经 ItemReader(逐帧) const reader = member.createItemReader(); let last: ReceivedItem | undefined; for (const f of frames) { for (const ev of await reader.pushFrame(f.bytes)) if (ev.type === 'item') last = ev.item; } expect(last?.complete, `round ${round} size ${size} frame ${maxFrameData}`).toBe(true); expect(toHex(last!.content)).toBe(toHex(content)); // 经流式解密(随机切分) const gen = decryptItemFrames( { channelId: member.idBytes, mode: 'enc', itemKey: (id) => member.itemKey(id) }, asyncChunks(splitAt(wire, round + 1)), ); const parts: Uint8Array[] = []; for (;;) { const r = await gen.next(); if (r.done) { expect(r.value.complete).toBe(true); break; } parts.push(r.value); } expect(toHex(concatAll(parts))).toBe(toHex(content)); // 帧数符合预期:第 0 帧装 maxFrameData - 元数据 的内容,其余每帧 maxFrameData expect(frames.at(-1)!.final).toBe(true); expect(frames.slice(0, -1).every((f) => !f.final)).toBe(true); } }); it('不同条目的密钥互相独立:用条目 A 的密钥解不开条目 B', async () => { const { owner } = await pair(); const [a] = await collectFrames(owner.encryptText('A')); const [b] = await collectFrames(owner.encryptText('B')); const keyA = await owner.itemKey(parseEnvelope(a!.bytes).itemId); await expect(openFrame(keyA, owner.idBytes, parseEnvelope(b!.bytes))).rejects.toMatchObject({ reason: 'auth_failed' }); }); }); describe('性能底线', () => { it('8 MiB 文件加密 + 流式解密在几秒内完成', async () => { const { owner, member } = await pair(); const content = pseudoRandom(8 * 1024 * 1024, 77); const t0 = performance.now(); const frames = await collectFrames(owner.encryptFile(new Blob([content]), { name: 'big' })); const wire = concatAll(frames.map((f) => f.bytes)); const t1 = performance.now(); const { content: stream } = member.decryptItemStream(asyncChunks([wire])); const reader = stream.getReader(); let total = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; total += value.length; } const t2 = performance.now(); expect(total).toBe(content.length); console.log(`8 MiB:加密 ${(t1 - t0).toFixed(0)} ms,解密 ${(t2 - t1).toFixed(0)} ms,${frames.length} 帧`); expect(t2 - t0).toBeLessThan(10_000); }); }); describe('来源块边界恰好落在帧边界上', () => { // 来源是流时,我们只有「多读一个字节」才能知道当前帧是不是最后一帧。 // 如果块边界恰好等于帧容量而后面还有数据,误判为最后一帧会悄悄丢掉后面的内容。 const META = { kind: 'file' as const, name: 'b' }; const metaJsonLength = JSON.stringify({ v: 1, kind: 'file', name: 'b', ts: 1 }).length; const MAX = 1000; const CAP0 = MAX - 2 - metaJsonLength; const streamOf = (sizes: number[], content: Uint8Array) => new ReadableStream({ start(c) { let o = 0; for (const n of sizes) { c.enqueue(content.slice(o, o + n)); o += n; } c.close(); }, }); const cases: Array<[string, number[]]> = [ ['第一块恰好填满第 0 帧,后面还有', [CAP0, 700]], ['第一块恰好填满第 0 帧,后面还有多帧', [CAP0, MAX, MAX, 5]], ['每一块都恰好是一帧', [CAP0, MAX, MAX, MAX]], ['块边界落在第 1 帧边界', [CAP0 + MAX, 300]], ['块跨过多个帧边界', [CAP0 + MAX * 2, 17]], ['逐字节来源', Array.from({ length: CAP0 + MAX + 40 }, () => 1)], ['最后一块恰好填满最后一帧(不能多出空帧)', [CAP0, MAX]], ]; for (const [name, sizes] of cases) { it(name, async () => { const { owner, member } = await pair(); const total = sizes.reduce((a, b) => a + b, 0); const content = pseudoRandom(total, total); const frames = await collectFrames(owner.encryptFile(streamOf(sizes, content), META, { ts: 1, maxFrameData: MAX })); const reader = member.createItemReader(); let last: ReceivedItem | undefined; for (const f of frames) for (const ev of await reader.pushFrame(f.bytes)) if (ev.type === 'item') last = ev.item; expect(last?.complete).toBe(true); expect(last!.content.length).toBe(total); // 一个字节都不能丢 expect(toHex(last!.content)).toBe(toHex(content)); expect(frames.at(-1)!.final).toBe(true); expect(frames.slice(0, -1).some((f) => f.final)).toBe(false); // 帧数必须与「长度已知」的来源完全一致,也就是不能多出空帧、不能少帧 const known = await collectFrames(owner.encryptBytes(content, META, { ts: 1, maxFrameData: MAX })); expect(frames).toHaveLength(known.length); }); } });