239 lines
9.7 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
});
|