Remove sealLabel/openLabel and their label constants; devices are no longer named per channel. Rename the create setting seats to max_members and point the e2e join helper at POST /join.
438 lines
19 KiB
TypeScript
438 lines
19 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { fromBase64Url, toBase64Url } from '../src/bytes.js';
|
|
import {
|
|
CapabilityError,
|
|
ChannelSession,
|
|
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: { max_members: 3, delete_policy: 'anyone' } });
|
|
expect(request.body.settings).toEqual({ max_members: 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('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))).toContain('明文内容');
|
|
|
|
const item = itemOf(await member.createItemReader().pushFrame(frame!.bytes));
|
|
expect(item.kind).toBe('text');
|
|
expect(item.meta?.kind).toBe('text');
|
|
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: 80 });
|
|
const frames = await collectFrames(plan);
|
|
|
|
expect(plan.gated).toBe(true);
|
|
expect(frames.length).toBeGreaterThan(1);
|
|
expect(frames.every((f) => f.bytes.length - 26 <= 80)).toBe(true);
|
|
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(last!.complete).toBe(true);
|
|
expect(toHex(last!.content)).toBe(toHex(content));
|
|
});
|
|
|
|
it('名称使用与加密条目一致的元数据', async () => {
|
|
const { session } = await ChannelSession.create({ mode: 'raw' });
|
|
const plan = session.encryptBytes(new Uint8Array(3), { name: 'note.txt' });
|
|
const [frame] = await collectFrames(plan);
|
|
expect(itemOf(await session.createItemReader().pushFrame(frame!.bytes)).meta?.name).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');
|
|
});
|
|
});
|