chore(repo): initialize WebDrop monorepo

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:22 +08:00
commit 53768a195d
39 files changed
+9063

No files matched your search

+435
View File
@@ -0,0 +1,435 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { toBase64Url } from '../src/bytes.js';
import {
ChannelSession,
collectFrames,
itemJson,
itemText,
parseLogRecords,
type AuthRole,
type ItemEvent,
type ItemPlan,
type ReceivedItem,
} from '../src/index.js';
import { concatAll, ORIGIN, pseudoRandom, readAll, toHex } from './helpers.js';
/**
* 端到端:用 @webdrop/core 生成的真实密文,对着真实的服务端(NativeAOT 可执行文件,
* 没有就退回到 JIT 的 dll)走一遍完整流程。证明库产出的帧能通过服务端的全部校验,
* 并且经由真实的 HTTP / WebSocket 往返后能被正确解密。
*
* 用环境变量 WEBDROP_SERVER 指定可执行文件;WEBDROP_SERVER_DLL 指定 dll。
*/
const root = join(import.meta.dirname, '..', '..', '..', 'server');
const exe = process.env.WEBDROP_SERVER ?? join(root, 'out', 'win-x64', 'webdrop.exe');
const linuxExe = join(root, 'out', 'linux-x64', 'webdrop');
const dll = process.env.WEBDROP_SERVER_DLL ?? join(root, 'src', 'WebDrop.Api', 'bin', 'Debug', 'net10.0', 'webdrop.dll');
const command: [string, string[]] | undefined = existsSync(exe)
? [exe, []]
: existsSync(linuxExe)
? [linuxExe, []]
: existsSync(dll)
? ['dotnet', [dll]]
: undefined;
let proc: ChildProcess | undefined;
let dataDir = '';
let base = '';
beforeAll(async () => {
if (!command) return;
dataDir = mkdtempSync(join(tmpdir(), 'webdrop-e2e-'));
proc = spawn(command[0], [...command[1], '--urls', 'http://127.0.0.1:0'], {
env: {
...process.env,
WebDrop__DataDir: dataDir,
WebDrop__RequestsPerMinute: '1000000',
WebDrop__ChannelCreatesPerMinute: '1000000',
WebDrop__Captcha__Provider: 'none',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
base = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('服务端没有在 20 秒内启动')), 20_000);
let buffer = '';
proc!.stdout!.on('data', (chunk: Buffer) => {
buffer += chunk.toString();
const m = /Now listening on:\s*(http:\/\/\S+)/.exec(buffer);
if (m) {
clearTimeout(timer);
resolve(m[1]!);
}
});
proc!.on('exit', (code) => reject(new Error(`服务端提前退出,代码 ${code}`)));
});
}, 30_000);
afterAll(async () => {
if (proc && !proc.killed) proc.kill();
await new Promise((r) => setTimeout(r, 300));
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
});
// ───────────────────────── 客户端辅助 ─────────────────────────
const api = (path: string) => `${base}/api/v1${path}`;
async function createChannel(opts: Parameters<typeof ChannelSession.create>[0] = {}) {
const created = await ChannelSession.create(opts);
const res = await fetch(api('/channels'), {
method: 'POST',
headers: { ...created.request.headers, 'Content-Type': 'application/json' },
body: JSON.stringify(created.request.body),
});
expect(res.status).toBe(201);
const info = (await res.json()) as { id: string; mode: string };
created.session.verifyChannelInfo(info); // 核对服务端返回的模式与链接一致
return created.session;
}
async function joinChannel(session: ChannelSession, role: AuthRole = 'read') {
const res = await fetch(api(`/channels/${session.id}/seats`), { method: 'POST', headers: session.authHeaders(role) });
expect(res.status, await res.clone().text()).toBe(200);
}
async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) {
const seqs: number[] = [];
let first = true;
for await (const frame of plan.frames) {
const res = await fetch(api(`/channels/${session.id}/frames`), {
method: 'POST',
headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...(first ? plan.frame0Headers : {}) },
body: frame.bytes,
});
expect(res.status, await res.clone().text()).toBeLessThan(300);
seqs.push(((await res.json()) as { seq: number }).seq);
first = false;
}
return seqs;
}
async function readLog(session: ChannelSession, role: AuthRole, from = 0) {
const res = await fetch(api(`/channels/${session.id}/log?from=${from}`), { headers: session.authHeaders(role) });
expect(res.status).toBe(200);
return parseLogRecords(new Uint8Array(await res.arrayBuffer()));
}
/** 连接 WebSocket(凭据放在子协议里),收集事件直到满足 until。 */
function subscribe(session: ChannelSession, role: AuthRole, until: (events: ItemEvent[]) => boolean, from = 0) {
const h = session.authHeaders(role);
const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`];
if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`);
const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols);
ws.binaryType = 'arraybuffer';
const reader = session.createItemReader();
const events: ItemEvent[] = [];
const done = new Promise<ItemEvent[]>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`等待 WebSocket 事件超时,已收到 ${JSON.stringify(events.map((e) => e.type))}`)), 15_000);
let chain = Promise.resolve();
ws.onmessage = (m) => {
if (typeof m.data === 'string') return; // 控制消息(心跳等)
chain = chain.then(async () => {
events.push(...(await reader.push(new Uint8Array(m.data as ArrayBuffer))));
if (until(events)) {
clearTimeout(timer);
resolve(events);
}
});
};
ws.onerror = () => reject(new Error('WebSocket 出错'));
});
const opened = new Promise<void>((resolve) => (ws.onopen = () => resolve()));
return { ws, done, opened, events };
}
const items = (events: ItemEvent[]): ReceivedItem[] => events.flatMap((e) => (e.type === 'item' ? [e.item] : []));
const suite = command ? describe : describe.skip;
// 全局 WebSocket 从 Node 22 起提供(本包要求 >=22);更旧的 Node 上明确跳过依赖它的用例,而不是误报失败
const wsIt = typeof WebSocket === 'undefined' ? it.skip : it;
suite('端到端(真实服务端)', () => {
it('文本:所有者发送,成员经日志拉取并解密', async () => {
const owner = await createChannel();
const member = await ChannelSession.open(owner.link(ORIGIN));
owner.deviceName = '桌面';
await joinChannel(member, 'read');
await send(owner, owner.encryptText('hello 端到端', { ts: 1 }), 'owner');
const reader = member.createItemReader();
const events: ItemEvent[] = [];
for (const rec of await readLog(member, 'read')) events.push(...(await reader.push(rec)));
const [item] = items(events);
expect(itemText(item!)).toBe('hello 端到端');
expect(item!.meta).toMatchObject({ kind: 'text', dev: '桌面' });
});
wsIt('WebSocket 实时推送:成员在线时收到所有者新发的内容', async () => {
const owner = await createChannel();
const member = await ChannelSession.open(owner.link(ORIGIN));
await joinChannel(member, 'read');
const sub = subscribe(member, 'read', (ev) => items(ev).length >= 2);
await sub.opened;
await send(owner, owner.encryptText('第一条'), 'owner');
await send(owner, owner.encryptJson({ n: 2 }), 'owner');
const got = items(await sub.done);
expect(itemText(got[0]!)).toBe('第一条');
expect(itemJson(got[1]!)).toEqual({ n: 2 });
sub.ws.close();
});
wsIt('成员也能上传(写令牌由 K 派生),所有者实时收到', async () => {
const owner = await createChannel();
const member = await ChannelSession.open(owner.link(ORIGIN));
await joinChannel(member, 'write');
const sub = subscribe(owner, 'owner', (ev) => items(ev).length >= 1);
await sub.opened;
await send(member, member.encryptText('来自成员'), 'write');
expect(itemText(items(await sub.done)[0]!)).toBe('来自成员');
sub.ws.close();
});
wsIt('快照:新快照取代旧快照,订阅者先后收到 item 和 removed(superseded)', async () => {
const owner = await createChannel();
const sub = subscribe(owner, 'owner', (ev) => ev.some((e) => e.type === 'removed'));
await sub.opened;
await send(owner, owner.encryptJson({ v: 1 }, { snapshot: true }), 'owner');
await send(owner, owner.encryptJson({ v: 2 }, { snapshot: true }), 'owner');
const events = await sub.done;
expect(events.map((e) => e.type)).toEqual(['item', 'item', 'removed']);
expect(items(events).map((i) => itemJson(i))).toEqual([{ v: 1 }, { v: 2 }]);
expect(events.find((e) => e.type === 'removed')).toMatchObject({ reason: 'superseded' });
sub.ws.close();
});
it('幂等:重发同一帧返回 200 和同一个 seq(确定性加密)', async () => {
const owner = await createChannel();
const plan = owner.encryptText('retry', { ts: 5 });
const [first] = await send(owner, plan, 'owner');
const again = owner.encryptText('retry', { ts: 5, itemId: plan.idBytes });
const frame = (await collectFrames(again))[0]!;
const res = await fetch(api(`/channels/${owner.id}/frames`), {
method: 'POST',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
body: frame.bytes,
});
expect(res.status).toBe(200);
expect(((await res.json()) as { seq: number }).seq).toBe(first);
});
it('服务端的存储里没有明文', async () => {
const owner = await createChannel();
const marker = 'UNIQUE-PLAINTEXT-MARKER-7f3a9c';
await send(owner, owner.encryptText(marker), 'owner');
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: owner.authHeaders('owner') });
const wire = new Uint8Array(await res.arrayBuffer());
expect(new TextDecoder('latin1').decode(wire)).not.toContain(marker);
expect(new TextDecoder('latin1').decode(wire)).not.toContain('"kind"');
});
it('大文件:多帧 GATED 上传,日志里只有 announce,下载流式解密后与原文一致', async () => {
const owner = await createChannel();
const member = await ChannelSession.open(owner.link(ORIGIN));
await joinChannel(member, 'read');
const content = pseudoRandom(3_300_000, 99);
const plan = owner.encryptFile(new Blob([content]), { name: 'big.bin', mime: 'application/octet-stream' }, { maxDownloads: 5 });
await send(owner, plan, 'owner');
expect(plan.gated).toBe(true);
// 日志里只有一条 announce,没有任何文件字节
const log = await readLog(member, 'read');
expect(log.map((r) => r.kind)).toEqual(['announce']);
// 流式下载 + 流式解密
const res = await fetch(api(`/channels/${owner.id}/items/${plan.itemId}/frames`), { headers: member.authHeaders('read') });
expect(res.status).toBe(200);
const { content: stream, result } = member.decryptItemStream(res.body!, { itemId: plan.idBytes });
const out = await readAll(stream);
expect(await result).toMatchObject({ complete: true });
expect(out.length).toBe(content.length);
expect(Buffer.compare(out, content)).toBe(0);
expect((await result).meta).toMatchObject({ name: 'big.bin', size: content.length });
}, 60_000);
it('下载上限:超过后从头下载返回 410,续传(from>0)在宽限期内仍可用', async () => {
const owner = await createChannel();
const content = pseudoRandom(2_500_000, 5);
const plan = owner.encryptFile(new Blob([content]), { name: 'once.bin' }, { maxDownloads: 1 });
await send(owner, plan, 'owner');
const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`);
const first = await fetch(url, { headers: owner.authHeaders('owner') });
const decoded = owner.decryptItemStream(first.body!);
expect(Buffer.compare(await readAll(decoded.content), content)).toBe(0);
const second = await fetch(url, { headers: owner.authHeaders('owner') });
expect(second.status).toBe(410);
expect(((await second.json()) as { error: string }).error).toBe('gone');
const resumed = await fetch(`${url}?from=1`, { headers: owner.authHeaders('owner') });
expect(resumed.status).toBe(200);
await resumed.arrayBuffer();
}, 60_000);
it('上传断点续传:中途中断后用 fromFrame 重新加密剩余部分,服务端得到完整条目', async () => {
const owner = await createChannel();
const content = pseudoRandom(3_300_000, 123);
const blob = new Blob([content]);
const itemId = owner.encryptFile(blob).idBytes;
const full = await collectFrames(owner.encryptFile(blob, { name: 'resume.bin' }, { itemId, ts: 9 }));
expect(full.length).toBe(4);
// 第一轮只上传前两帧,然后「崩溃」
const first = owner.encryptFile(blob, { name: 'resume.bin' }, { itemId, ts: 9 });
let sent = 0;
let isFirst = true;
for await (const f of first.frames) {
if (sent === 2) break;
const res = await fetch(api(`/channels/${owner.id}/frames`), {
method: 'POST',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream', ...(isFirst ? first.frame0Headers : {}) },
body: f.bytes,
});
expect(res.status).toBe(201);
isFirst = false;
sent++;
}
// 第二轮:从第 2 帧开始重新加密(字节与完整加密一致),并且重发第 1 帧也是幂等的
const rest = owner.encryptFile(blob, { name: 'resume.bin' }, { itemId, ts: 9, fromFrame: 1 });
const statuses: number[] = [];
for await (const f of rest.frames) {
const res = await fetch(api(`/channels/${owner.id}/frames`), {
method: 'POST',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
body: f.bytes,
});
statuses.push(res.status);
}
expect(statuses).toEqual([200, 201, 201]); // 第 1 帧已存在(幂等),第 2、3 帧是新的
const res = await fetch(api(`/channels/${owner.id}/items/${toBase64Url(itemId)}/frames`), { headers: owner.authHeaders('owner') });
const { content: stream, result } = owner.decryptItemStream(res.body!);
expect(Buffer.compare(await readAll(stream), content)).toBe(0);
expect((await result).complete).toBe(true);
}, 60_000);
it('下载中途断线,用 nextIndex 续传,拼起来与原文一致', async () => {
const owner = await createChannel();
const content = pseudoRandom(3_300_000, 7);
const plan = owner.encryptFile(new Blob([content]), { name: 'cut.bin' });
await send(owner, plan, 'owner');
const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`);
// 第一次:只读前约 1.5 帧就断开
const first = await fetch(url, { headers: owner.authHeaders('owner') });
const reader = first.body!.getReader();
const chunks: Uint8Array[] = [];
let got = 0;
while (got < 1_600_000) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
got += value.length;
}
await reader.cancel();
const part1 = await (async () => {
const dec = owner.decryptItemStream(new ReadableStream<Uint8Array>({ start: (c) => { chunks.forEach((x) => c.enqueue(x)); c.close(); } }));
return { bytes: await readAll(dec.content), result: await dec.result };
})();
expect(part1.result.complete).toBe(false);
const second = await fetch(`${url}?from=${part1.result.nextIndex}`, { headers: owner.authHeaders('owner') });
expect(second.status).toBe(200);
const dec2 = owner.decryptItemStream(second.body!, { fromIndex: part1.result.nextIndex });
const part2 = await readAll(dec2.content);
expect(toHex(concatAll([part1.bytes, part2]).subarray(0, 32))).toBe(toHex(content.subarray(0, 32)));
expect(Buffer.compare(concatAll([part1.bytes, part2]), content)).toBe(0);
}, 60_000);
it('设备名:成员加密上传,所有者在席位列表里解开', async () => {
const owner = await createChannel();
const member = await ChannelSession.open(owner.link(ORIGIN));
await joinChannel(member, 'read');
const blob = await member.sealLabel('我的 iPhone');
const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
method: 'PUT',
headers: { ...member.authHeaders('read'), 'Content-Type': 'application/octet-stream' },
body: blob,
});
expect(put.status).toBe(204);
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: owner.authHeaders('owner') });
const seats = (await res.json()) as Array<{ endpoint_hash: string; label?: string }>;
const mine = seats.find((s) => s.endpoint_hash === toBase64Url(member.endpointHash))!;
expect(await owner.openLabel(mine.label!, mine.endpoint_hash)).toBe('我的 iPhone');
// 服务端存的是密文
expect(Buffer.from(mine.label!, 'base64url').toString('utf8')).not.toContain('iPhone');
});
it('最长的设备名(228 字节)也被服务端接受', async () => {
const owner = await createChannel();
const blob = await owner.sealLabel('x'.repeat(228));
const put = await fetch(api(`/channels/${owner.id}/seats/me`), {
method: 'PUT',
headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' },
body: blob,
});
expect(put.status).toBe(204);
});
it('raw 频道:匿名成员无需令牌即可读取,服务端看得到明文', async () => {
const owner = await createChannel({ mode: 'raw' });
const member = await ChannelSession.open(owner.link(ORIGIN));
await joinChannel(member, 'read');
await send(owner, owner.encryptText('raw 明文 visible'), 'owner');
const records = await readLog(member, 'read');
const [item] = items(await (async () => {
const reader = member.createItemReader();
const out: ItemEvent[] = [];
for (const r of records) out.push(...(await reader.push(r)));
return out;
})());
expect(item!.kind).toBe('raw');
expect(itemText(item!)).toBe('raw 明文 visible');
// 对比 enc:raw 的明文确实出现在服务端返回的字节里
const res = await fetch(api(`/channels/${owner.id}/log`), { headers: member.authHeaders('read') });
expect(new TextDecoder().decode(await res.arrayBuffer())).toContain('raw 明文 visible');
});
it('下发给成员的链接没有管理权:成员拿不到所有者能力', async () => {
const owner = await createChannel();
const member = await ChannelSession.open(owner.link(ORIGIN));
await joinChannel(member, 'read');
const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: { ...member.authHeaders('read') } });
expect(res.status).toBe(403);
expect(() => member.authHeaders('owner')).toThrow();
});
});
+90
View File
@@ -0,0 +1,90 @@
import { readFileSync } from 'node:fs';
import { fromHex, toBase64Url, toHex, type Bytes } from '../src/bytes.js';
import { ChannelSession, collectFrames, type EncodedFrame, type ItemPlan } from '../src/index.js';
/* eslint-disable @typescript-eslint/no-explicit-any */
export const vectors: any = JSON.parse(readFileSync(new URL('../../../test-vectors/vectors.json', import.meta.url), 'utf8'));
export const h = (s: string): Bytes => fromHex(s);
export { toHex };
/** 向量里的固定输入。 */
export const inputs = {
channelId: h(vectors.inputs.channel_id),
channelIdB64: vectors.inputs.channel_id_b64 as string,
k: h(vectors.inputs.k),
a: h(vectors.inputs.a),
w: h(vectors.inputs.w),
endpointSecret: h(vectors.inputs.endpoint_secret),
itemA: h(vectors.inputs.item_a),
itemB: h(vectors.inputs.item_b),
itemC: h(vectors.inputs.item_c),
};
export const ORIGIN = 'https://app.example.test';
/** 用向量里的 channel_id / K(/A) 打开一个会话,终端密钥也取向量值。 */
export function openVectorSession(opts: { owner?: boolean } = {}): Promise<ChannelSession> {
const parts = [`k=${toBase64Url(inputs.k)}`];
if (opts.owner) parts.push(`a=${toBase64Url(inputs.a)}`);
return ChannelSession.open(`${ORIGIN}/c/${inputs.channelIdB64}#${parts.join('&')}`, { endpointSecret: inputs.endpointSecret });
}
export async function framesOf(plan: ItemPlan): Promise<EncodedFrame[]> {
return collectFrames(plan);
}
/** 随机但可复现的字节(线性同余),测试里需要「看起来随机」的内容时用。 */
export function pseudoRandom(length: number, seed = 1): Bytes {
const out = new Uint8Array(length);
let x = seed >>> 0 || 1;
for (let i = 0; i < length; i++) {
x = (Math.imul(x, 1664525) + 1013904223) >>> 0;
out[i] = x >>> 24;
}
return out;
}
/** 把字节流在随机位置切成小块,模拟网络分片。 */
export function* splitAt(bytes: Uint8Array, seed = 7): Generator<Uint8Array> {
let x = seed >>> 0 || 1;
let offset = 0;
while (offset < bytes.length) {
x = (Math.imul(x, 1664525) + 1013904223) >>> 0;
const size = 1 + (x >>> 8) % 4096;
yield bytes.subarray(offset, offset + size);
offset += size;
}
}
export async function* asyncChunks(chunks: Iterable<Uint8Array>): AsyncGenerator<Uint8Array> {
for (const c of chunks) yield c;
}
export async function readAll(stream: ReadableStream<Uint8Array>): Promise<Bytes> {
const parts: Uint8Array[] = [];
const reader = stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
parts.push(value);
}
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
}
export function concatAll(parts: Uint8Array[]): Bytes {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
}
+238
View File
@@ -0,0 +1,238 @@
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);
});
}
});
+249
View File
@@ -0,0 +1,249 @@
import { describe, expect, it } from 'vitest';
import { concat, toBase64Url, writeU32, writeU64 } from '../src/bytes.js';
import { ChannelSession, collectFrames, itemText, parseLogRecords, type ItemEvent, type ItemPlan, type ReceivedItem } from '../src/index.js';
import { encodeMeta, parseEnvelope, sealFrame } from '../src/internal.js';
import { ORIGIN, pseudoRandom, toHex } from './helpers.js';
const GATED = 4;
const ENC = 8;
const FINAL = 1;
const SNAPSHOT = 2;
async function pair(mode: 'enc' | 'raw' = 'enc') {
const { session: owner } = await ChannelSession.create({ mode });
const member = await ChannelSession.open(owner.link(ORIGIN));
return { owner, member };
}
/** 一条 frame 日志记录的线格式:seq(8) ‖ kind(1) ‖ 封装。 */
const frameRecord = (seq: number, envelope: Uint8Array) => concat(writeU64(seq), new Uint8Array([1]), envelope);
const only = (events: ItemEvent[]): ItemEvent => {
expect(events).toHaveLength(1);
return events[0]!;
};
const itemOf = (events: ItemEvent[]): ReceivedItem => {
const ev = only(events);
if (ev.type !== 'item') throw new Error(`期望 item,得到 ${JSON.stringify(ev)}`);
return ev.item;
};
/** 构造一个多帧 GATED 条目的各帧(小帧,便于测试)。 */
async function gatedFrames(session: ChannelSession, size: number, frameData = 100) {
const content = pseudoRandom(size, 11);
const plan: ItemPlan = session.encryptBytes(content, { kind: 'file', name: 'f' }, { maxFrameData: frameData, ts: 1 });
return { content, plan, frames: await collectFrames(plan) };
}
describe('日志记录', () => {
it('frame 记录:带 seq,解密出条目', async () => {
const { owner, member } = await pair();
const [frame] = await collectFrames(owner.encryptText('via log'));
const events = await member.createItemReader().push(frameRecord(42, frame!.bytes));
const item = itemOf(events);
expect(item.seq).toBe(42);
expect(itemText(item)).toBe('via log');
});
it('parseLogRecords 解析的记录可直接喂给装配器', async () => {
const { owner, member } = await pair();
const frames = [
...(await collectFrames(owner.encryptText('a'))),
...(await collectFrames(owner.encryptText('b'))),
];
const blob = concat(frameRecord(1, frames[0]!.bytes), frameRecord(2, frames[1]!.bytes));
const reader = member.createItemReader();
const texts: string[] = [];
for (const rec of parseLogRecords(blob)) for (const ev of await reader.push(rec)) if (ev.type === 'item') texts.push(itemText(ev.item));
expect(texts).toEqual(['a', 'b']);
});
it('announce:GATED 条目只通知,不含内容', async () => {
const { member } = await pair();
const id = new Uint8Array(16).fill(5);
const record = concat(writeU64(9), new Uint8Array([3]), id, new Uint8Array([GATED | ENC]), writeU64(1700000000123));
expect(only(await member.createItemReader().push(record))).toEqual({
type: 'announce',
itemId: toBase64Url(id),
seq: 9,
flags: GATED | ENC,
createdAt: 1700000000123,
});
});
it('tombstone:删除条目,并清掉装配中的缓冲', async () => {
const { owner, member } = await pair();
const { frames, plan } = await gatedFrames(owner, 250);
const reader = member.createItemReader();
await reader.pushFrame(frames[0]!.bytes); // 只收到一部分
const tomb = concat(writeU64(7), new Uint8Array([2]), plan.idBytes, new Uint8Array([3]));
expect(only(await reader.push(tomb))).toEqual({ type: 'removed', itemId: plan.itemId, seq: 7, reason: 'download_limit' });
// 缓冲已清空:后面的帧从头开始装配,不会和旧状态混在一起
const ev = itemOf(await reader.pushFrame(frames[1]!.bytes));
expect(ev.complete).toBe(false);
});
it('损坏的日志记录变成 rejected,不抛异常', async () => {
const { member } = await pair();
const ev = only(await member.createItemReader().push(new Uint8Array([1, 2, 3])));
expect(ev).toMatchObject({ type: 'rejected', reason: 'bad_envelope' });
});
});
describe('重放与重复', () => {
it('同一帧重复到达:不产生新事件(日志回放不会让条目重复出现)', async () => {
const { owner, member } = await pair();
const [frame] = await collectFrames(owner.encryptText('once'));
const reader = member.createItemReader();
expect(itemOf(await reader.pushFrame(frame!.bytes)).complete).toBe(true);
expect(await reader.pushFrame(frame!.bytes)).toEqual([]);
expect(await reader.pushFrame(frame!.bytes)).toEqual([]);
});
it('装配中的条目收到内容不同的同序号帧:duplicate_mismatch,并丢弃该条目', async () => {
const { owner, member } = await pair();
const { frames } = await gatedFrames(owner, 250);
const reader = member.createItemReader();
await reader.pushFrame(frames[0]!.bytes);
const forged = frames[0]!.bytes.slice();
forged[forged.length - 1]! ^= 1;
expect(only(await reader.pushFrame(forged))).toMatchObject({ type: 'rejected', reason: 'duplicate_mismatch' });
});
it('条目被删除后,再来的旧帧会重新装配(回放到墓碑之前的数据时不会卡住)', async () => {
const { owner, member } = await pair();
const [frame] = await collectFrames(owner.encryptText('again'));
const reader = member.createItemReader();
const { itemId } = parseEnvelopeId(frame!.bytes);
await reader.pushFrame(frame!.bytes);
await reader.push(concat(writeU64(2), new Uint8Array([2]), itemId, new Uint8Array([0])));
expect(itemOf(await reader.pushFrame(frame!.bytes)).complete).toBe(true);
});
});
function parseEnvelopeId(envelope: Uint8Array) {
return { itemId: parseEnvelope(envelope).itemId };
}
describe('多帧条目的装配', () => {
it('乱序到达也能装配,到齐才 complete', async () => {
const { owner, member } = await pair();
const { content, frames } = await gatedFrames(owner, 450); // 约 5 帧
expect(frames.length).toBeGreaterThanOrEqual(4);
const reader = member.createItemReader();
const order = [2, 0, 3, 1, ...frames.slice(4).map((_, i) => i + 4)];
const states: boolean[] = [];
let last: ReceivedItem | undefined;
for (const i of order) {
last = itemOf(await reader.pushFrame(frames[i]!.bytes));
states.push(last.complete);
}
expect(states.slice(0, -1).every((c) => !c)).toBe(true);
expect(last!.complete).toBe(true);
expect(toHex(last!.content)).toBe(toHex(content));
expect(last!.meta?.name).toBe('f');
});
it('缺一帧时不会 complete,content 只到第一个缺口', async () => {
const { owner, member } = await pair();
const { frames } = await gatedFrames(owner, 250);
expect(frames).toHaveLength(3);
const reader = member.createItemReader();
await reader.pushFrame(frames[0]!.bytes);
const item = itemOf(await reader.pushFrame(frames[2]!.bytes)); // 跳过第 1 帧
expect(item.complete).toBe(false);
const filled = itemOf(await reader.pushFrame(frames[1]!.bytes));
expect(filled.complete).toBe(true);
});
it('FINAL 之后又出现更大序号的帧:frame_after_final', async () => {
const { owner, member } = await pair();
const { frames, plan } = await gatedFrames(owner, 250); // 3 帧,序号 2 是 FINAL
const reader = member.createItemReader();
await reader.pushFrame(frames[0]!.bytes);
await reader.pushFrame(frames[2]!.bytes);
const key = await owner.itemKey(plan.idBytes);
const extra = await sealFrame(key, owner.idBytes, { flags: GATED | ENC, itemId: plan.idBytes, index: 3, data: new Uint8Array(5) });
expect(only(await reader.pushFrame(extra))).toMatchObject({ type: 'rejected', reason: 'frame_after_final' });
});
it('FINAL 出现在已有更大序号之前:final_conflict', async () => {
const { owner, member } = await pair();
const { frames, plan } = await gatedFrames(owner, 250);
const reader = member.createItemReader();
await reader.pushFrame(frames[2]!.bytes); // 序号 2 先到(它带 FINAL)
// 伪造一个序号 1、也带 FINAL 的帧:FINAL 位置前后矛盾
const key = await owner.itemKey(plan.idBytes);
const clash = await sealFrame(key, owner.idBytes, { flags: GATED | ENC | FINAL, itemId: plan.idBytes, index: 1, data: new Uint8Array(5) });
expect(only(await reader.pushFrame(clash))).toMatchObject({ type: 'rejected', reason: 'final_conflict' });
});
it('同一条目内标志位变化:flags_changed', async () => {
const { owner, member } = await pair();
const { frames, plan } = await gatedFrames(owner, 250);
const reader = member.createItemReader();
await reader.pushFrame(frames[0]!.bytes);
const key = await owner.itemKey(plan.idBytes);
// 第 0 帧是 GATED,这里发一个带 SNAPSHOT 的第 1 帧(密文本身是合法认证的)
const odd = await sealFrame(key, owner.idBytes, { flags: GATED | SNAPSHOT | ENC, itemId: plan.idBytes, index: 1, data: new Uint8Array(5) });
expect(only(await reader.pushFrame(odd))).toMatchObject({ type: 'rejected', reason: 'flags_changed' });
});
it('非 GATED 条目的帧序号不是 0 或没有 FINAL:拒绝(协议规则)', async () => {
const { owner, member } = await pair();
const id = new Uint8Array(16).fill(1);
const key = await owner.itemKey(id);
const noFinal = await sealFrame(key, owner.idBytes, { flags: ENC, itemId: id, index: 0, data: concat(encodeMeta({ kind: 'text', ts: 1 }), new Uint8Array(1)) });
expect(only(await member.createItemReader().pushFrame(noFinal))).toMatchObject({ type: 'rejected', reason: 'bad_envelope' });
});
it('第 0 帧的元数据不合法:bad_meta', async () => {
const { owner, member } = await pair();
const id = new Uint8Array(16).fill(2);
const key = await owner.itemKey(id);
const garbage = new TextEncoder().encode('{"v":1,"kind":123,"ts":"x"}');
const frame = await sealFrame(key, owner.idBytes, { flags: ENC | FINAL, itemId: id, index: 0, data: concat(new Uint8Array([0, garbage.length]), garbage) });
expect(only(await member.createItemReader().pushFrame(frame))).toMatchObject({ type: 'rejected', reason: 'bad_meta' });
});
});
describe('模式隔离(防降级)', () => {
it('enc 会话拒绝没有 ENC 标志的帧(恶意服务端注入明文)', async () => {
const { member } = await pair('enc');
const raw = concat(new Uint8Array([1, FINAL]), new Uint8Array(16), writeU32(0), writeU32(4), new TextEncoder().encode('evil'));
expect(only(await member.createItemReader().pushFrame(raw))).toMatchObject({ type: 'rejected', reason: 'wrong_mode' });
});
it('raw 会话拒绝带 ENC 标志的帧', async () => {
const { owner: encOwner } = await pair('enc');
const { member: rawMember } = await pair('raw');
const [frame] = await collectFrames(encOwner.encryptText('x'));
expect(only(await rawMember.createItemReader().pushFrame(frame!.bytes))).toMatchObject({ type: 'rejected', reason: 'wrong_mode' });
});
it('一个条目被拒绝不影响其他条目', async () => {
const { owner, member } = await pair();
const reader = member.createItemReader();
const [good1] = await collectFrames(owner.encryptText('one'));
const [bad] = await collectFrames(owner.encryptText('bad'));
const [good2] = await collectFrames(owner.encryptText('two'));
bad!.bytes[bad!.bytes.length - 1]! ^= 1;
const results = [
...(await reader.pushFrame(good1!.bytes)),
...(await reader.pushFrame(bad!.bytes)),
...(await reader.pushFrame(good2!.bytes)),
];
expect(results.map((e) => e.type)).toEqual(['item', 'rejected', 'item']);
});
});
+469
View File
@@ -0,0 +1,469 @@
import { describe, expect, it } from 'vitest';
import { fromBase64Url, toBase64Url } from '../src/bytes.js';
import {
CapabilityError,
ChannelSession,
IntegrityError,
LimitError,
LinkError,
ModeMismatchError,
collectFrames,
itemJson,
itemText,
parseLink,
type ItemEvent,
type ReceivedItem,
} from '../src/index.js';
import { sha256 } from '../src/internal.js';
import { ORIGIN, pseudoRandom, toHex } from './helpers.js';
const bearer = (headers: Record<string, string>): Uint8Array => {
const token = headers.Authorization!.replace('Bearer ', '');
return fromBase64Url(token, 32)!;
};
const itemOf = (events: ItemEvent[]): ReceivedItem => {
const ev = events.find((e) => e.type === 'item');
if (!ev || ev.type !== 'item') throw new Error(`没有 item 事件: ${JSON.stringify(events)}`);
return ev.item;
};
describe('创建 enc 频道', () => {
it('请求体里的哈希与会话实际使用的令牌一致', async () => {
const { session, request } = await ChannelSession.create();
const { body, headers } = request;
expect(body.mode).toBe('enc');
expect(body.id).toBe(session.id);
expect(body.id).toHaveLength(22);
// 服务端存的是令牌的 SHA-256:客户端之后出示的令牌必须能对上
expect(body.read_hash).toBe(toBase64Url(await sha256(bearer(session.authHeaders('read')))));
expect(body.write_hash).toBe(toBase64Url(await sha256(bearer(session.authHeaders('write')))));
expect(body.owner_hash).toBe(toBase64Url(await sha256(bearer(session.authHeaders('owner')))));
expect(headers['X-WD-Role']).toBe('owner');
expect(headers['X-WD-Endpoint']).toBe(toBase64Url(session.endpointSecret));
expect(headers.Authorization).toBeDefined();
});
it('三个令牌互不相同,且都不等于密钥', async () => {
const { session } = await ChannelSession.create();
const tokens = (['read', 'write', 'owner'] as const).map((r) => toHex(bearer(session.authHeaders(r))));
expect(new Set(tokens).size).toBe(3);
});
it('每次创建的 id 和密钥都不同', async () => {
const a = await ChannelSession.create();
const b = await ChannelSession.create();
expect(a.session.id).not.toBe(b.session.id);
expect(a.session.link(ORIGIN)).not.toBe(b.session.link(ORIGIN));
});
it('设置原样放进请求体', async () => {
const { request } = await ChannelSession.create({ settings: { seats: 3, delete_policy: 'anyone' } });
expect(request.body.settings).toEqual({ seats: 3, delete_policy: 'anyone' });
});
});
describe('创建 raw 频道', () => {
it('没有 read_hash;成员可上传时有写令牌 W,放进成员链接', async () => {
const { session, request } = await ChannelSession.create({ mode: 'raw' });
expect(request.body.read_hash).toBeUndefined();
expect(request.body.write_hash).toBeDefined();
const member = parseLink(session.link(ORIGIN));
expect(member.mode).toBe('raw');
expect(member.role).toBe('member');
expect(member.w).toBeDefined();
expect(request.body.write_hash).toBe(toBase64Url(await sha256(member.w!)));
});
it('禁止成员上传时没有写令牌,成员链接也不带 w', async () => {
const { session, request } = await ChannelSession.create({ mode: 'raw', settings: { members_can_upload: false } });
expect(request.body.write_hash).toBeUndefined();
expect(parseLink(session.link(ORIGIN)).w).toBeUndefined();
});
it('读请求不带 Authorization(读权限就是 id 本身)', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' });
const headers = session.authHeaders('read');
expect(headers.Authorization).toBeUndefined();
expect(headers['X-WD-Role']).toBe('read');
});
});
describe('链接与角色', () => {
it('所有者链接和成员链接得到同一把密钥,权限不同', async () => {
const { session: owner } = await ChannelSession.create();
const member = await ChannelSession.open(owner.link(ORIGIN, 'member'));
const reopened = await ChannelSession.open(owner.link(ORIGIN, 'owner'));
expect(member.role).toBe('member');
expect(member.can('owner')).toBe(false);
expect(reopened.role).toBe('owner');
expect(reopened.can('owner')).toBe(true);
expect(toHex(bearer(member.authHeaders('read')))).toBe(toHex(bearer(owner.authHeaders('read'))));
expect(toHex(bearer(member.authHeaders('write')))).toBe(toHex(bearer(owner.authHeaders('write'))));
expect(toHex(bearer(reopened.authHeaders('owner')))).toBe(toHex(bearer(owner.authHeaders('owner'))));
});
it('成员链接里没有管理密钥', async () => {
const { session } = await ChannelSession.create();
const memberLink = session.link(ORIGIN, 'member');
expect(memberLink).not.toContain('a=');
expect(memberLink).toMatch(/#k=[A-Za-z0-9_-]{43}$/);
});
it('成员没有所有者权限:authHeaders 和 owner 链接都抛 CapabilityError', async () => {
const { session } = await ChannelSession.create();
const member = await ChannelSession.open(session.link(ORIGIN));
expect(() => member.authHeaders('owner')).toThrowError(CapabilityError);
expect(() => member.link(ORIGIN, 'owner')).toThrowError(CapabilityError);
});
it('raw 成员链接没有 w 时不能写', async () => {
const { session } = await ChannelSession.create({ mode: 'raw', settings: { members_can_upload: false } });
const member = await ChannelSession.open(session.link(ORIGIN));
expect(member.can('read')).toBe(true);
expect(member.can('write')).toBe(false);
expect(() => member.authHeaders('write')).toThrowError(CapabilityError);
});
it('终端密钥可以持久化:传回同一个密钥得到同一个终端标识', async () => {
const { session } = await ChannelSession.create();
const link = session.link(ORIGIN);
const first = await ChannelSession.open(link);
const again = await ChannelSession.open(link, { endpointSecret: first.endpointSecret });
const other = await ChannelSession.open(link);
expect(toHex(again.endpointHash)).toBe(toHex(first.endpointHash));
expect(toHex(other.endpointHash)).not.toBe(toHex(first.endpointHash));
expect(toHex(first.endpointHash)).toBe(toHex(await sha256(first.endpointSecret)));
});
it('拒绝长度不对的终端密钥', async () => {
const { session } = await ChannelSession.create();
await expect(ChannelSession.open(session.link(ORIGIN), { endpointSecret: new Uint8Array(16) })).rejects.toThrow(RangeError);
});
it('密钥放进查询串的链接被拒绝', async () => {
const { session } = await ChannelSession.create();
const k = parseLink(session.link(ORIGIN)).k!;
await expect(ChannelSession.open(`${ORIGIN}/c/${session.id}?k=${toBase64Url(k)}`)).rejects.toThrowError(LinkError);
});
});
describe('核对服务端返回的频道信息(降级防御)', () => {
it('一致时通过', async () => {
const { session } = await ChannelSession.create();
expect(() => session.verifyChannelInfo({ id: session.id, mode: 'enc' })).not.toThrow();
});
it('链接带密钥却被告知是 raw:拒绝', async () => {
const { session } = await ChannelSession.create();
expect(() => session.verifyChannelInfo({ id: session.id, mode: 'raw' })).toThrowError(ModeMismatchError);
});
it('链接没有密钥却被告知是 enc:拒绝', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' });
expect(() => session.verifyChannelInfo({ id: session.id, mode: 'enc' })).toThrowError(ModeMismatchError);
});
it('频道 id 不一致:拒绝', async () => {
const { session } = await ChannelSession.create();
expect(() => session.verifyChannelInfo({ id: 'AAAAAAAAAAAAAAAAAAAAAA', mode: 'enc' })).toThrowError(LinkError);
});
});
describe('发送和接收(enc)', () => {
async function pair() {
const { session: owner } = await ChannelSession.create();
owner.deviceName = '我的 iPhone';
const member = await ChannelSession.open(owner.link(ORIGIN));
return { owner, member };
}
it('文本:所有者发送,成员解密,带设备名和时间', async () => {
const { owner, member } = await pair();
const frames = await collectFrames(owner.encryptText('hello, 世界', { ts: 1700000000000 }));
expect(frames).toHaveLength(1);
expect(frames[0]!.final).toBe(true);
const events = await member.createItemReader().pushFrame(frames[0]!.bytes);
const item = itemOf(events);
expect(item.kind).toBe('text');
expect(item.complete).toBe(true);
expect(item.gated).toBe(false);
expect(item.snapshot).toBe(false);
expect(itemText(item)).toBe('hello, 世界');
expect(item.meta).toEqual({ kind: 'text', ts: 1700000000000, dev: '我的 iPhone' });
});
it('JSON 和快照', async () => {
const { owner, member } = await pair();
const value = { theme: 'dark', n: [1, 2, 3], nested: { ok: true } };
const frames = await collectFrames(owner.encryptJson(value, { snapshot: true }));
const item = itemOf(await member.createItemReader().pushFrame(frames[0]!.bytes));
expect(item.kind).toBe('json');
expect(item.snapshot).toBe(true);
expect(itemJson(item)).toEqual(value);
});
it('空文本也是合法条目', async () => {
const { owner, member } = await pair();
const frames = await collectFrames(owner.encryptText(''));
const item = itemOf(await member.createItemReader().pushFrame(frames[0]!.bytes));
expect(item.complete).toBe(true);
expect(itemText(item)).toBe('');
});
it('不是所有者也能用成员身份加密、对方能解开(同一把 K)', async () => {
const { owner, member } = await pair();
const frames = await collectFrames(member.encryptText('来自成员'));
expect(itemText(itemOf(await owner.createItemReader().pushFrame(frames[0]!.bytes)))).toBe('来自成员');
});
it('二进制内容原样往返,所有帧负载长度都是 档位+16', async () => {
const { owner, member } = await pair();
const content = pseudoRandom(50_000, 3);
const plan = owner.encryptBytes(content, { kind: 'other' });
const frames = await collectFrames(plan);
expect(frames).toHaveLength(1);
expect([1040, 4112, 16400, 65552, 262160, 1048592]).toContain(frames[0]!.bytes.length - 26);
const item = itemOf(await member.createItemReader().pushFrame(frames[0]!.bytes));
expect(toHex(item.content)).toBe(toHex(content));
});
it('密文不含明文', async () => {
const { owner } = await pair();
const secret = 'TOP-SECRET-MARKER-0123456789';
const [frame] = await collectFrames(owner.encryptText(secret));
const haystack = new TextDecoder('latin1').decode(frame!.bytes);
expect(haystack).not.toContain('TOP-SECRET');
expect(haystack).not.toContain('"kind"'); // 元数据也是密文
});
it('同样的内容两次加密(不同条目)得到不同密文', async () => {
const { owner } = await pair();
const [a] = await collectFrames(owner.encryptText('same'));
const [b] = await collectFrames(owner.encryptText('same'));
expect(toHex(a!.bytes)).not.toBe(toHex(b!.bytes));
});
it('同一个条目 id 重新加密得到完全相同的字节(重试安全)', async () => {
const { owner } = await pair();
const plan1 = owner.encryptText('retry me', { ts: 5 });
const [a] = await collectFrames(plan1);
const [b] = await collectFrames(owner.encryptText('retry me', { ts: 5, itemId: plan1.idBytes }));
expect(toHex(a!.bytes)).toBe(toHex(b!.bytes));
});
it('另一个频道的成员解不开(密钥不同)', async () => {
const { owner } = await pair();
const { session: stranger } = await ChannelSession.create();
const [frame] = await collectFrames(owner.encryptText('x'));
const events = await stranger.createItemReader().pushFrame(frame!.bytes);
expect(events[0]).toMatchObject({ type: 'rejected', reason: 'auth_failed' });
});
it('同一把 K 但频道 id 不同:换频道重放被 AAD 拒绝', async () => {
const { owner } = await pair();
const k = parseLink(owner.link(ORIGIN)).k!;
const otherId = toBase64Url(new Uint8Array(16).fill(9));
const sameKeyOtherChannel = await ChannelSession.open(`${ORIGIN}/c/${otherId}#k=${toBase64Url(k)}`);
const [frame] = await collectFrames(owner.encryptText('x'));
const events = await sameKeyOtherChannel.createItemReader().pushFrame(frame!.bytes);
expect(events[0]).toMatchObject({ type: 'rejected', reason: 'auth_failed' });
});
});
describe('设备名', () => {
it('所有者能读出成员的设备名,其他人换个终端哈希读不出', async () => {
const { session: owner } = await ChannelSession.create();
const member = await ChannelSession.open(owner.link(ORIGIN));
const blob = await member.sealLabel('办公室电脑');
expect(await owner.openLabel(blob, member.endpointHash)).toBe('办公室电脑');
expect(await owner.openLabel(toBase64Url(blob), toBase64Url(member.endpointHash))).toBe('办公室电脑');
await expect(owner.openLabel(blob, owner.endpointHash)).rejects.toThrowError(IntegrityError);
});
it('每次加密使用新的随机 IV', async () => {
const { session } = await ChannelSession.create();
const a = await session.sealLabel('x');
const b = await session.sealLabel('x');
expect(toHex(a.slice(0, 12))).not.toBe(toHex(b.slice(0, 12)));
});
it('篡改后解不开', async () => {
const { session } = await ChannelSession.create();
const blob = await session.sealLabel('abc');
blob[blob.length - 1]! ^= 1;
await expect(session.openLabel(blob, session.endpointHash)).rejects.toThrowError(IntegrityError);
});
it('raw 模式下设备名是明文', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' });
const blob = await session.sealLabel('plain');
expect(new TextDecoder().decode(blob)).toBe('plain');
expect(await session.openLabel(blob, session.endpointHash)).toBe('plain');
});
});
describe('raw 模式往返', () => {
it('单帧:负载就是内容,没有加密标志', async () => {
const { session: owner } = await ChannelSession.create({ mode: 'raw' });
const member = await ChannelSession.open(owner.link(ORIGIN));
const [frame] = await collectFrames(owner.encryptText('明文内容'));
expect(frame!.bytes[1]! & 8).toBe(0); // 没有 ENC 标志
expect(new TextDecoder().decode(frame!.bytes.slice(26))).toBe('明文内容');
const item = itemOf(await member.createItemReader().pushFrame(frame!.bytes));
expect(item.kind).toBe('raw');
expect(item.meta).toBeUndefined();
expect(itemText(item)).toBe('明文内容');
});
it('大内容拆成 GATED 多帧', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' });
const content = pseudoRandom(100, 5);
const plan = session.encryptBytes(content, {}, { maxFrameData: 40 });
const frames = await collectFrames(plan);
expect(plan.gated).toBe(true);
expect(frames.map((f) => f.bytes.length - 26)).toEqual([40, 40, 20]);
expect(frames.map((f) => f.final)).toEqual([false, false, true]);
const reader = session.createItemReader();
let last: ReceivedItem | undefined;
for (const f of frames) last = itemOf(await reader.pushFrame(f.bytes));
expect(last!.complete).toBe(true);
expect(toHex(last!.content)).toBe(toHex(content));
});
it('label 放进第 0 帧的附加请求头', async () => {
const { session } = await ChannelSession.create({ mode: 'raw' });
const plan = session.encryptBytes(new Uint8Array(3), {}, { label: 'note.txt' });
expect(plan.frame0Headers['X-WD-Label']).toBe('note.txt');
});
});
describe('条目计划', () => {
it('下载上限强制 GATED,并生成 X-WD-Max-Downloads 头', async () => {
const { session } = await ChannelSession.create();
const plan = session.encryptText('limited', { maxDownloads: 3 });
expect(plan.gated).toBe(true);
expect(plan.frame0Headers['X-WD-Max-Downloads']).toBe('3');
const [frame] = await collectFrames(plan);
expect((frame!.bytes[1]! & 4) !== 0).toBe(true);
});
it('快照不能 GATED,也不能超过一帧', async () => {
const { session } = await ChannelSession.create();
expect(() => session.encryptJson({}, { snapshot: true, gated: true })).toThrow(RangeError);
expect(() => session.encryptJson({}, { snapshot: true, maxDownloads: 1 })).toThrow(RangeError);
const big = session.encryptBytes(pseudoRandom(300), { kind: 'json' }, { snapshot: true, maxFrameData: 100 });
await expect(collectFrames(big)).rejects.toThrowError(LimitError);
});
it('gated:false 与放不进单帧的内容冲突', async () => {
const { session } = await ChannelSession.create();
expect(() => session.encryptBytes(pseudoRandom(500), {}, { gated: false, maxFrameData: 100 })).toThrowError(LimitError);
});
it('元数据本身超过单帧上限:拒绝', async () => {
const { session } = await ChannelSession.create();
expect(() => session.encryptBytes(new Uint8Array(1), { name: 'x'.repeat(200) }, { maxFrameData: 100 })).toThrowError(LimitError);
});
it('encryptJson 拒绝无法序列化的值', async () => {
const { session } = await ChannelSession.create();
expect(() => session.encryptJson(undefined)).toThrow(TypeError);
});
it('File 自动带上文件名、类型和大小', async () => {
const { session } = await ChannelSession.create();
const file = new File([new Uint8Array(10).fill(1)], 'a.bin', { type: 'application/octet-stream' });
const frames = await collectFrames(session.encryptFile(file, {}, { ts: 1 }));
const item = itemOf(await session.createItemReader().pushFrame(frames[0]!.bytes));
expect(item.meta).toMatchObject({ kind: 'file', name: 'a.bin', mime: 'application/octet-stream', size: 10 });
});
it('流式来源:长度未知,小内容仍然是单帧非 GATED', async () => {
const { session } = await ChannelSession.create();
const stream = new ReadableStream<Uint8Array>({
start(c) {
c.enqueue(new Uint8Array([1, 2, 3]));
c.enqueue(new Uint8Array([4, 5]));
c.close();
},
});
const plan = session.encryptFile(stream, { name: 'live.bin' });
const frames = await collectFrames(plan);
expect(frames).toHaveLength(1);
expect(plan.gated).toBe(false); // 第 0 帧产出之后才确定
const item = itemOf(await session.createItemReader().pushFrame(frames[0]!.bytes));
expect(Array.from(item.content)).toEqual([1, 2, 3, 4, 5]);
expect(item.meta?.size).toBeUndefined();
});
it('流式来源:内容多时变成 GATED 多帧', async () => {
const { session } = await ChannelSession.create();
const content = pseudoRandom(700, 9);
const stream = new ReadableStream<Uint8Array>({
start(c) {
for (let i = 0; i < content.length; i += 33) c.enqueue(content.slice(i, i + 33));
c.close();
},
});
const plan = session.encryptFile(stream, {}, { maxFrameData: 200 });
const frames = await collectFrames(plan);
expect(plan.gated).toBe(true);
expect(frames.length).toBeGreaterThan(2);
expect(frames.at(-1)!.final).toBe(true);
const reader = session.createItemReader();
let last: ReceivedItem | undefined;
for (const f of frames) last = itemOf(await reader.pushFrame(f.bytes));
expect(toHex(last!.content)).toBe(toHex(content));
});
it('内容恰好填满某一帧时不会多出空帧', async () => {
const { session } = await ChannelSession.create();
const meta = { kind: 'file' as const, name: 'b' };
// 先算出第 0 帧的内容容量:maxFrameData - (2 + meta_json 长度)
const probe = await collectFrames(session.encryptBytes(new Uint8Array(0), meta, { ts: 1, maxFrameData: 1000 }));
expect(probe).toHaveLength(1);
const metaJsonLen = JSON.stringify({ v: 1, kind: 'file', name: 'b', ts: 1 }).length;
const cap0 = 1000 - 2 - metaJsonLen;
for (const [size, expectedFrames] of [
[cap0, 1],
[cap0 + 1, 2],
[cap0 + 1000, 2], // 第 0 帧满 + 第 1 帧满,恰好两帧
[cap0 + 1001, 3],
[cap0 + 2000, 3],
] as const) {
const frames = await collectFrames(session.encryptBytes(pseudoRandom(size), meta, { ts: 1, maxFrameData: 1000 }));
expect(frames, `size=${size}`).toHaveLength(expectedFrames);
expect(frames.at(-1)!.final).toBe(true);
expect(frames.slice(0, -1).every((f) => !f.final)).toBe(true);
}
});
});
describe('openLink 别名', () => {
it('作为独立函数调用也能工作(静态方法没有依赖 this)', async () => {
const { openLink } = await import('../src/index.js');
const { session } = await ChannelSession.create();
const reopened = await openLink(session.link(ORIGIN, 'owner'));
expect(reopened.id).toBe(session.id);
expect(reopened.role).toBe('owner');
});
});
+289
View File
@@ -0,0 +1,289 @@
import { describe, expect, it } from 'vitest';
import { ChannelSession, IntegrityError, collectFrames, decryptItemFrames, splitEnvelopes } from '../src/index.js';
import { sealFrame } from '../src/internal.js';
import type { Bytes } from '../src/bytes.js';
import { concatAll, ORIGIN, pseudoRandom, readAll, splitAt, asyncChunks, toHex } from './helpers.js';
const GATED = 4;
const ENC = 8;
const FINAL = 1;
async function pair(mode: 'enc' | 'raw' = 'enc') {
const { session: owner } = await ChannelSession.create({ mode });
const member = await ChannelSession.open(owner.link(ORIGIN));
return { owner, member };
}
async function encode(session: ChannelSession, content: Bytes, maxFrameData?: number) {
const plan = session.encryptFile(new Blob([content]), { name: 'doc.bin', mime: 'application/octet-stream' }, { ts: 7, ...(maxFrameData && { maxFrameData }) });
const frames = await collectFrames(plan);
return { plan, frames, wire: concatAll(frames.map((f) => f.bytes)) };
}
/** 迭代解密生成器,收集内容并拿到返回值。 */
async function drain(gen: ReturnType<typeof decryptItemFrames>) {
const parts: Uint8Array[] = [];
for (;;) {
const r = await gen.next();
if (r.done) return { content: concatAll(parts), result: r.value };
parts.push(r.value);
}
}
const ctx = (s: ChannelSession) => ({ channelId: s.idBytes, mode: s.mode, itemKey: (id: Uint8Array) => s.itemKey(id) });
describe('splitEnvelopes', () => {
it('任意切分的字节流都能还原出原来的封装', async () => {
const { owner } = await pair();
const { frames, wire } = await encode(owner, pseudoRandom(3000, 1), 700);
expect(frames.length).toBeGreaterThan(3);
for (const seed of [1, 2, 3, 4, 5]) {
const out: Uint8Array[] = [];
for await (const env of splitEnvelopes(asyncChunks(splitAt(wire, seed)))) out.push(env);
expect(out.map(toHex)).toEqual(frames.map((f) => toHex(f.bytes)));
}
});
it('逐字节喂入也行', async () => {
const { owner } = await pair();
const { frames, wire } = await encode(owner, pseudoRandom(500, 2), 300);
const out: Uint8Array[] = [];
for await (const env of splitEnvelopes(asyncChunks(Array.from(wire, (b) => new Uint8Array([b]))))) out.push(env);
expect(out).toHaveLength(frames.length);
});
it('末尾被截断的半个封装直接丢弃(这是断线,不是篡改)', async () => {
const { owner } = await pair();
const { frames, wire } = await encode(owner, pseudoRandom(1500, 3), 500);
const cut = wire.slice(0, wire.length - 10);
const out: Uint8Array[] = [];
for await (const env of splitEnvelopes(asyncChunks([cut]))) out.push(env);
expect(out).toHaveLength(frames.length - 1);
});
it('声明了离谱的负载长度:拒绝,而不是等待永远不会到的数据', async () => {
const header = new Uint8Array(26);
header[0] = 1;
header.set([0xff, 0xff, 0xff, 0xff], 22);
const gen = splitEnvelopes(asyncChunks([header]));
await expect(gen.next()).rejects.toThrowError(IntegrityError);
});
it('接受 ReadableStream', async () => {
const { owner } = await pair();
const { frames, wire } = await encode(owner, pseudoRandom(900, 4), 400);
const stream = new ReadableStream<Uint8Array>({
start(c) {
for (const chunk of splitAt(wire, 9)) c.enqueue(chunk);
c.close();
},
});
const out: Uint8Array[] = [];
for await (const env of splitEnvelopes(stream)) out.push(env);
expect(out).toHaveLength(frames.length);
});
});
describe('流式解密', () => {
it('大文件(多个 1 MiB 帧)往返一致,元数据正确,标记 GATED', async () => {
const { owner, member } = await pair();
const content = pseudoRandom(2_600_000, 21);
const { plan, frames, wire } = await encode(owner, content);
expect(plan.gated).toBe(true);
expect(frames).toHaveLength(3); // 2.6 MB / 约 1 MiB 每帧
const { content: out, result } = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(wire, 5))));
expect(result.complete).toBe(true);
expect(result.nextIndex).toBe(frames.length);
expect(result.meta).toMatchObject({ kind: 'file', name: 'doc.bin', mime: 'application/octet-stream', size: content.length });
expect(out.length).toBe(content.length);
expect(toHex(out.slice(0, 64))).toBe(toHex(content.slice(0, 64)));
expect(toHex(out.slice(-64))).toBe(toHex(content.slice(-64)));
expect(Buffer.compare(out, content)).toBe(0);
});
it('包成 ReadableStream 使用,并给出 result', async () => {
const { owner, member } = await pair();
const content = pseudoRandom(5000, 22);
const { wire } = await encode(owner, content, 1500);
const { content: stream, result } = member.decryptItemStream(asyncChunks(splitAt(wire, 6)));
expect(toHex(await readAll(stream))).toBe(toHex(content));
expect(await result).toMatchObject({ complete: true, nextIndex: 4 });
});
it('中间帧被篡改:流报 auth_failed,result 同样失败', async () => {
const { owner, member } = await pair();
const { frames } = await encode(owner, pseudoRandom(4000, 23), 1000);
const tampered = frames.map((f) => f.bytes.slice());
tampered[2]![tampered[2]!.length - 3]! ^= 1;
const { content, result } = member.decryptItemStream(asyncChunks([concatAll(tampered)]));
await expect(readAll(content)).rejects.toMatchObject({ reason: 'auth_failed' });
await expect(result).rejects.toThrowError(IntegrityError);
});
it('混入别的条目的帧(服务端拼接):item_mismatch', async () => {
const { owner, member } = await pair();
const a = await encode(owner, pseudoRandom(2500, 24), 1000);
const b = await encode(owner, pseudoRandom(2500, 25), 1000);
// 前两帧来自 A,第三帧换成 B 的第三帧(两者都是合法认证过的帧)
const spliced = concatAll([a.frames[0]!.bytes, a.frames[1]!.bytes, b.frames[2]!.bytes]);
await expect(drain(decryptItemFrames(ctx(member), asyncChunks([spliced])))).rejects.toMatchObject({ reason: 'item_mismatch' });
});
it('指定了期望的条目 id 时,第一帧就不匹配也会被拒绝', async () => {
const { owner, member } = await pair();
const a = await encode(owner, pseudoRandom(100, 26));
const b = await encode(owner, pseudoRandom(100, 27));
await expect(
drain(decryptItemFrames(ctx(member), asyncChunks([a.wire]), { itemId: b.plan.idBytes })),
).rejects.toMatchObject({ reason: 'item_mismatch' });
});
it('跳过一帧(服务端丢帧):gap', async () => {
const { owner, member } = await pair();
const { frames } = await encode(owner, pseudoRandom(4000, 28), 1000);
const dropped = concatAll([frames[0]!.bytes, frames[2]!.bytes, frames[3]!.bytes]);
await expect(drain(decryptItemFrames(ctx(member), asyncChunks([dropped])))).rejects.toMatchObject({ reason: 'gap' });
});
it('重复的帧:同样被当作 gap 拒绝', async () => {
const { owner, member } = await pair();
const { frames } = await encode(owner, pseudoRandom(3000, 29), 1000);
const dup = concatAll([frames[0]!.bytes, frames[0]!.bytes, frames[1]!.bytes]);
await expect(drain(decryptItemFrames(ctx(member), asyncChunks([dup])))).rejects.toMatchObject({ reason: 'gap' });
});
it('FINAL 之后还有帧:frame_after_final', async () => {
const { owner, member } = await pair();
const { frames, plan } = await encode(owner, pseudoRandom(2500, 30), 1000);
const key = await owner.itemKey(plan.idBytes);
const extra = await sealFrame(key, owner.idBytes, { flags: GATED | ENC, itemId: plan.idBytes, index: frames.length, data: new Uint8Array(3) });
await expect(
drain(decryptItemFrames(ctx(member), asyncChunks([concatAll([...frames.map((f) => f.bytes), extra])]))),
).rejects.toMatchObject({ reason: 'frame_after_final' });
});
it('被截断的内容不会被当成完整:没有 FINAL 时 complete 为 false', async () => {
const { owner, member } = await pair();
const content = pseudoRandom(4000, 31);
const { frames } = await encode(owner, content, 1000);
const withoutLast = concatAll(frames.slice(0, -1).map((f) => f.bytes)); // 服务端丢掉最后一帧
const { result } = await drain(decryptItemFrames(ctx(member), asyncChunks([withoutLast])));
expect(result.complete).toBe(false);
expect(result.nextIndex).toBe(frames.length - 1);
});
it('enc 会话里出现没有 ENC 标志的帧:wrong_mode', async () => {
const { member } = await pair();
const evil = concatAll([new Uint8Array([1, FINAL]), new Uint8Array(16), new Uint8Array(4), new Uint8Array([0, 0, 0, 4]), new Uint8Array(4)]);
await expect(drain(decryptItemFrames(ctx(member), asyncChunks([evil])))).rejects.toMatchObject({ reason: 'wrong_mode' });
});
});
describe('断点续传', () => {
it('接收端:连接中途断开,用 nextIndex 续传,拼起来与原文一致', async () => {
const { owner, member } = await pair();
const content = pseudoRandom(9000, 40);
const { frames } = await encode(owner, content, 1000);
expect(frames.length).toBeGreaterThanOrEqual(9);
const wire = concatAll(frames.map((f) => f.bytes));
// 第一次:在第 5 帧中间断开
const firstLen = frames.slice(0, 4).reduce((n, f) => n + f.bytes.length, 0) + 100;
const first = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(wire.slice(0, firstLen), 3))));
expect(first.result.complete).toBe(false);
expect(first.result.nextIndex).toBe(4);
expect(first.result.meta?.name).toBe('doc.bin');
// 第二次:从 nextIndex 开始(服务端 GET ...?from=4,不重复计下载)
const rest = concatAll(frames.slice(4).map((f) => f.bytes));
const second = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(rest, 4)), { fromIndex: first.result.nextIndex }));
expect(second.result.complete).toBe(true);
expect(second.result.meta).toBeUndefined(); // 续传的响应里没有第 0 帧
expect(toHex(concatAll([first.content, second.content]))).toBe(toHex(content));
});
it('从中间开始时,帧序号必须从 fromIndex 起连续', async () => {
const { owner, member } = await pair();
const { frames } = await encode(owner, pseudoRandom(4000, 41), 1000);
const wrongStart = concatAll(frames.slice(2).map((f) => f.bytes));
await expect(drain(decryptItemFrames(ctx(member), asyncChunks([wrongStart]), { fromIndex: 1 }))).rejects.toMatchObject({ reason: 'gap' });
});
it('发送端:从第 k 帧开始重新加密,得到与完整加密完全相同的字节', async () => {
const { owner } = await pair();
const content = pseudoRandom(5500, 42);
const blob = new Blob([content]);
const full = owner.encryptFile(blob, { name: 'x' }, { ts: 3, maxFrameData: 1000 });
const fullFrames = await collectFrames(full);
expect(fullFrames.length).toBeGreaterThanOrEqual(6);
for (const from of [1, 2, fullFrames.length - 1]) {
const resumed = await collectFrames(
owner.encryptFile(blob, { name: 'x' }, { ts: 3, maxFrameData: 1000, itemId: full.idBytes, fromFrame: from }),
);
expect(resumed.map((f) => f.index)).toEqual(fullFrames.slice(from).map((f) => f.index));
expect(resumed.map((f) => toHex(f.bytes))).toEqual(fullFrames.slice(from).map((f) => toHex(f.bytes)));
}
});
it('发送端续传:Uint8Array 来源同样适用', async () => {
const { owner } = await pair();
const content = pseudoRandom(3200, 43);
const full = owner.encryptBytes(content, { kind: 'file' }, { ts: 1, maxFrameData: 800 });
const fullFrames = await collectFrames(full);
const resumed = await collectFrames(owner.encryptBytes(content, { kind: 'file' }, { ts: 1, maxFrameData: 800, itemId: full.idBytes, fromFrame: 2 }));
expect(resumed.map((f) => toHex(f.bytes))).toEqual(fullFrames.slice(2).map((f) => toHex(f.bytes)));
});
it('发送端续传的边界错误:单帧条目、越界、流式来源', async () => {
const { owner } = await pair();
const content = pseudoRandom(2500, 44);
const frames = await collectFrames(owner.encryptBytes(content, {}, { maxFrameData: 1000 }));
await expect(collectFrames(owner.encryptBytes(new Uint8Array(5), {}, { fromFrame: 1 }))).rejects.toThrow(RangeError);
await expect(collectFrames(owner.encryptBytes(content, {}, { maxFrameData: 1000, fromFrame: frames.length }))).rejects.toThrow(RangeError);
// 长度未知的来源无法定位第 k 帧的起点,在创建计划时就拒绝
expect(() => owner.encryptFile(new ReadableStream<Uint8Array>(), {}, { fromFrame: 1 })).toThrow(RangeError);
});
});
describe('raw 模式的流', () => {
it('透传内容,不做解密', async () => {
const { owner, member } = await pair('raw');
const content = pseudoRandom(700, 50);
const frames = await collectFrames(owner.encryptBytes(content, {}, { maxFrameData: 300 }));
const wire = concatAll(frames.map((f) => f.bytes));
const { content: out, result } = await drain(decryptItemFrames(ctx(member), asyncChunks(splitAt(wire, 2))));
expect(result.complete).toBe(true);
expect(toHex(out)).toBe(toHex(content));
});
});
describe('取消', () => {
it('取消流不会抛异常,也不会继续消费来源', async () => {
const { owner, member } = await pair();
const { wire } = await encode(owner, pseudoRandom(6000, 60), 1000);
let pulled = 0;
async function* source() {
for (const chunk of splitAt(wire, 8)) {
pulled++;
yield chunk;
}
}
const { content } = member.decryptItemStream(source());
const reader = content.getReader();
await reader.read();
await reader.cancel();
const after = pulled;
await new Promise((r) => setTimeout(r, 20));
expect(pulled).toBe(after);
});
});
+303
View File
@@ -0,0 +1,303 @@
import { describe, expect, it } from 'vitest';
import { fromBase64Url, toBase64Url } from '../src/bytes.js';
import {
IntegrityError,
buildLink,
parseLink,
parseLogRecord,
type ItemEvent,
} from '../src/index.js';
import {
deriveChannelKeys,
deriveItemKey,
deriveOwnerToken,
deriveRawChannelSecrets,
deriveRawItemKey,
deriveRawOwnerToken,
frameAad,
frameIv,
padPlaintext,
parseEnvelope,
sealFrame,
sha256,
tierFor,
unpadPlaintext,
encodeEnvelope,
} from '../src/internal.js';
import { openFrame } from '../src/frame.js';
import { framesOf, h, inputs, openVectorSession, toHex, vectors } from './helpers.js';
/**
* 这些向量由 test-vectors/generate.mjs 用 node:crypto 独立生成(不引用本包),
* 服务端的 C# 测试用 .NET 的密码学实现再复现一遍。
* 三套互不相干的实现一致,才说明 protocol.md 没有歧义。
*/
describe('密钥派生', () => {
it('原始字节与向量一致', async () => {
const secrets = await deriveRawChannelSecrets(inputs.channelId, inputs.k);
expect(toHex(secrets.kEnc)).toBe(vectors.kdf.k_enc);
expect(toHex(secrets.tRead)).toBe(vectors.kdf.t_read);
expect(toHex(secrets.tWrite)).toBe(vectors.kdf.t_write);
expect(toHex(secrets.kLabel)).toBe(vectors.kdf.k_label);
expect(toHex(await deriveRawOwnerToken(inputs.channelId, inputs.a))).toBe(vectors.kdf.t_owner);
for (const item of vectors.item_keys) {
expect(toHex(await deriveRawItemKey(secrets.kEnc, h(item.item_id)))).toBe(item.k_item);
}
});
it('生产路径(不可导出的 CryptoKey)得到同样的令牌', async () => {
const keys = await deriveChannelKeys(inputs.channelId, inputs.k);
expect(toHex(keys.readToken)).toBe(vectors.kdf.t_read);
expect(toHex(keys.writeToken)).toBe(vectors.kdf.t_write);
expect(toHex(await deriveOwnerToken(inputs.channelId, inputs.a))).toBe(vectors.kdf.t_owner);
});
it('生产路径里的密钥确实不可导出', async () => {
const keys = await deriveChannelKeys(inputs.channelId, inputs.k);
const itemKey = await deriveItemKey(keys.encRoot, inputs.itemA);
expect(itemKey.extractable).toBe(false);
expect(keys.encRoot.extractable).toBe(false);
expect(keys.labelKey.extractable).toBe(false);
await expect(crypto.subtle.exportKey('raw', itemKey)).rejects.toThrow();
});
it('令牌哈希与向量一致', async () => {
expect(toHex(await sha256(h(vectors.kdf.t_read)))).toBe(vectors.kdf.read_hash);
expect(toHex(await sha256(h(vectors.kdf.t_write)))).toBe(vectors.kdf.write_hash);
expect(toHex(await sha256(h(vectors.kdf.t_owner)))).toBe(vectors.kdf.owner_hash);
expect(toHex(await sha256(h(vectors.kdf.raw_write_token)))).toBe(vectors.kdf.raw_write_hash);
expect(toHex(await sha256(inputs.endpointSecret))).toBe(vectors.kdf.endpoint_hash);
});
});
describe('IV 与 AAD', () => {
it('IV = 8 字节零 ‖ frame_index', () => {
for (const v of vectors.iv) expect(toHex(frameIv(v.index))).toBe(v.iv);
});
it('AAD = "WD1" ‖ channel_id ‖ 头部前 22 字节', () => {
for (const c of vectors.aad) {
// 只含头部的封装(payload_len = 0),AAD 只取前 22 字节
const header = encodeEnvelope(c.flags, h(c.item_id), c.index, new Uint8Array(0));
expect(toHex(frameAad(inputs.channelId, header))).toBe(c.aad);
}
});
});
describe('padding', () => {
it('每个档位边界的明文长度与向量一致', () => {
for (const p of vectors.padding) {
expect(tierFor(4 + p.data_len)).toBe(p.plaintext_len);
const padded = padPlaintext(new Uint8Array(p.data_len));
expect(padded.length).toBe(p.plaintext_len);
expect(unpadPlaintext(padded).length).toBe(p.data_len);
}
});
it('拒绝非零填充、越界长度和非档位长度', () => {
const padded = padPlaintext(new Uint8Array(10));
const dirty = padded.slice();
dirty[dirty.length - 1] = 1;
expect(() => unpadPlaintext(dirty)).toThrowError(IntegrityError);
const lying = padded.slice();
lying.set([0xff, 0xff, 0xff, 0xff], 0);
expect(() => unpadPlaintext(lying)).toThrowError(IntegrityError);
expect(() => unpadPlaintext(new Uint8Array(1000))).toThrowError(IntegrityError); // 不是档位
expect(() => unpadPlaintext(new Uint8Array(2))).toThrowError(IntegrityError);
});
it('超过单帧上限时拒绝', () => {
expect(() => padPlaintext(new Uint8Array(1048573))).toThrow(RangeError);
expect(padPlaintext(new Uint8Array(1048572)).length).toBe(1048576);
});
});
describe('单帧加解密', () => {
for (const f of vectors.frames) {
it(`${f.name}:密文逐字节一致,并能解回原文`, async () => {
const keys = await deriveChannelKeys(inputs.channelId, inputs.k);
const itemKey = await deriveItemKey(keys.encRoot, h(f.item_id));
const sealed = await sealFrame(itemKey, inputs.channelId, {
flags: f.flags,
itemId: h(f.item_id),
index: f.index,
data: h(f.data),
});
expect(toHex(sealed)).toBe(f.envelope);
const opened = await openFrame(itemKey, inputs.channelId, parseEnvelope(h(f.envelope)));
expect(toHex(opened)).toBe(f.data);
});
}
});
describe('条目分帧', () => {
for (const item of vectors.items) {
it(`${item.name}:分帧与 GATED 决策和向量一致`, async () => {
const session = await openVectorSession();
const m = item.meta;
const { kind, name, mime, size } = m;
const plan = session.encryptBytes(
h(item.content),
{ kind, ...(name !== undefined && { name }), ...(mime !== undefined && { mime }), ...(size !== undefined && { size }) },
{
itemId: h(item.item_id),
ts: m.ts,
...(m.dev !== undefined && { dev: m.dev }),
snapshot: item.snapshot,
gated: item.force_gated ? true : 'auto',
maxFrameData: item.max_frame_data,
},
);
const frames = await framesOf(plan);
expect(frames.map((f) => toHex(f.bytes))).toEqual(item.frames);
expect(plan.gated).toBe(item.gated);
expect(frames.map((f) => f.final)).toEqual(frames.map((_, i) => i === frames.length - 1));
});
}
it('边界:恰好放得进第 0 帧是单帧,多一个字节就变成两帧 GATED', () => {
const fits = vectors.items.find((i: { name: string }) => i.name === 'boundary_fits_exactly');
const over = vectors.items.find((i: { name: string }) => i.name === 'boundary_one_byte_over');
expect([fits.gated, fits.frames.length]).toEqual([false, 1]);
expect([over.gated, over.frames.length]).toEqual([true, 2]);
});
});
describe('raw 帧', () => {
it('封装与向量一致', () => {
for (const f of vectors.raw_frames) {
const built = encodeEnvelope(f.flags, h(f.item_id), f.index, h(f.payload));
expect(toHex(built)).toBe(f.envelope);
}
});
});
describe('设备名', () => {
it('固定 IV 下的密文一致,并能解回', async () => {
const session = await openVectorSession();
for (const l of vectors.labels) {
const sealed = await session.sealLabel(l.name, { iv: h(l.iv) });
expect(toHex(sealed)).toBe(l.blob);
expect(sealed.length).toBeLessThanOrEqual(256);
expect(await session.openLabel(sealed, session.endpointHash)).toBe(l.name);
}
});
it('换一个终端的哈希就解不开(AAD 绑定终端)', async () => {
const session = await openVectorSession();
const sealed = await session.sealLabel('我的 iPhone');
await expect(session.openLabel(sealed, new Uint8Array(32))).rejects.toThrowError(IntegrityError);
});
it('超过 228 字节 UTF-8 时拒绝', async () => {
const session = await openVectorSession();
await expect(session.sealLabel('x'.repeat(229))).rejects.toThrow();
await expect(session.sealLabel('x'.repeat(228))).resolves.toHaveLength(256);
});
});
describe('链接', () => {
it('合法链接解析结果与向量一致', () => {
for (const v of vectors.links.valid) {
const link = parseLink(v.url);
expect(link.id).toBe(v.id);
expect(link.mode).toBe(v.mode);
expect(link.role).toBe(v.role);
expect(link.k && toHex(link.k)).toBe(v.k);
expect(link.a && toHex(link.a)).toBe(v.a);
expect(link.w && toHex(link.w)).toBe(v.w);
}
});
it('非法链接全部被拒绝', () => {
for (const v of vectors.links.invalid) {
expect(() => parseLink(v.url), v.name).toThrow();
}
});
it('生成的链接能被解析回原值', () => {
const url = buildLink('https://app.example.test/', { id: inputs.channelIdB64, k: inputs.k, a: inputs.a });
expect(url).toBe(`https://app.example.test/c/${inputs.channelIdB64}#k=${toBase64Url(inputs.k)}&a=${toBase64Url(inputs.a)}`);
const back = parseLink(url);
expect(toHex(back.k!)).toBe(toHex(inputs.k));
expect(back.role).toBe('owner');
});
});
describe('日志记录', () => {
it('解析结果与向量一致', () => {
for (const r of vectors.log_records) {
const rec = parseLogRecord(h(r.hex));
const p = r.parsed;
expect(rec.kind).toBe(p.kind);
expect(rec.seq).toBe(p.seq);
if (rec.kind === 'frame') expect(toHex(rec.envelope)).toBe(p.envelope);
if (rec.kind === 'tombstone') {
expect(toHex(fromBase64Url(rec.itemId)!)).toBe(p.item_id);
expect(['deleted', 'expired', 'evicted', 'download_limit', 'superseded'][p.reason]).toBe(rec.reason);
}
if (rec.kind === 'announce') {
expect(toHex(fromBase64Url(rec.itemId)!)).toBe(p.item_id);
expect(rec.flags).toBe(p.flags);
expect(rec.createdAt).toBe(p.created_at);
}
}
});
it('损坏的记录被拒绝', () => {
for (const r of vectors.log_invalid) {
expect(() => parseLogRecord(h(r.hex)), r.name).toThrowError(IntegrityError);
}
});
});
describe('负例:必须被接收方拒绝', () => {
for (const n of vectors.negative) {
it(`${n.name} → ${n.expect}`, async () => {
const envelope = h(n.envelope);
const channelId = n.channel_id ? h(n.channel_id) : inputs.channelId;
if (n.expect === 'bad_envelope') {
expect(() => parseEnvelope(envelope)).toThrowError(IntegrityError);
return;
}
const env = parseEnvelope(envelope);
const keys = await deriveChannelKeys(channelId === inputs.channelId ? inputs.channelId : inputs.channelId, inputs.k);
// 注意:wrong_channel 用的是「错误的 channel_id 参与 AAD」,密钥仍由真实频道派生
const itemKey = await deriveItemKey(keys.encRoot, env.itemId);
const failure = await openFrame(itemKey, channelId, env).then(
() => null,
(e: unknown) => e,
);
expect(failure).toBeInstanceOf(IntegrityError);
expect((failure as IntegrityError).reason).toBe(n.expect);
});
}
it('装配器对每个负例都产生 rejected 事件,而不是抛异常或产出内容', async () => {
const session = await openVectorSession();
const reader = session.createItemReader();
// 这两个负例在到达解密之前就违反了结构规则(非 GATED 条目必须是单帧且带 FINAL),
// 装配器先以 bad_envelope 拒绝;直接 openFrame 时则是 auth_failed(上面已断言)
const structural = new Set(['clear_final_flag', 'change_frame_index']);
for (const n of vectors.negative) {
if (n.channel_id) continue; // 这一条是换了会话的 channel_id,装配器视角下等同于正常帧
const events: ItemEvent[] = await reader.pushFrame(h(n.envelope));
expect(events, n.name).toHaveLength(1);
const ev = events[0]!;
expect(ev.type, n.name).toBe('rejected');
if (ev.type === 'rejected') expect(ev.reason, n.name).toBe(structural.has(n.name) ? 'bad_envelope' : n.expect);
}
});
});