Files
WebDrop/packages/core/test/properties.test.ts
T
2026-10-02 00:59:22 +08:00

239 lines
9.7 KiB
TypeScript

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<number>();
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<Uint8Array>({
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);
});
}
});