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): 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))).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({ 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({ 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'); }); });