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

470 lines
20 KiB
TypeScript

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');
});
});