chore(repo): initialize WebDrop monorepo
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
commit
53768a195d
39 files changed
+9063
No files matched your search
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,177 @@
|
||||
# @webdrop/core
|
||||
|
||||
WebDrop 的端到端加密核心。**前端只调用它,不碰密码学,也不用理解帧格式。**
|
||||
|
||||
- 只依赖 WebCrypto(`crypto.subtle`),浏览器、Worker、Node 22+ 都能跑。
|
||||
- 纯逻辑:不含 UI,不发网络请求,不处理重连和队列(这些是前端的事)。
|
||||
- 协议权威文档是 [`docs/protocol.md`](../../docs/protocol.md),本库必须通过 [`test-vectors/`](../../test-vectors) 里的全部向量。
|
||||
|
||||
## 快速上手
|
||||
|
||||
```ts
|
||||
import { ChannelSession, collectFrames, itemText } from '@webdrop/core';
|
||||
|
||||
// 1. 创建频道 —— 生成 id 和密钥,返回 POST /channels 要发的内容
|
||||
const { session, request } = await ChannelSession.create({ mode: 'enc' });
|
||||
const res = await fetch('/api/v1/channels', {
|
||||
method: 'POST',
|
||||
headers: { ...request.headers, 'X-WD-Cap': capToken /* 见下文「验证码」 */, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request.body),
|
||||
});
|
||||
session.verifyChannelInfo(await res.json()); // 核对服务端返回的模式,防降级
|
||||
|
||||
const memberLink = session.link(location.origin); // 分享给对方:/c/{id}#k=…
|
||||
const ownerLink = session.link(location.origin, 'owner');
|
||||
|
||||
// 2. 发送 —— 得到可以直接 POST 的帧
|
||||
const plan = session.encryptText('hello');
|
||||
for await (const frame of plan.frames) {
|
||||
await fetch(`/api/v1/channels/${session.id}/frames`, {
|
||||
method: 'POST',
|
||||
headers: { ...session.authHeaders('owner'), ...(frame.index === 0 ? plan.frame0Headers : {}) },
|
||||
body: frame.bytes,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 对方打开链接,接收
|
||||
const member = await ChannelSession.open(location.href, { endpointSecret /* 见下文「终端密钥」 */ });
|
||||
const reader = member.createItemReader();
|
||||
ws.onmessage = async (m) => {
|
||||
for (const ev of await reader.push(new Uint8Array(m.data))) {
|
||||
if (ev.type === 'item' && ev.item.complete) show(itemText(ev.item));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 前端要做的事 / 库做的事
|
||||
|
||||
| 库负责 | 前端负责 |
|
||||
|---|---|
|
||||
| 生成 id、密钥、令牌,派生一切 | HTTP 调用、WebSocket / SSE 重连与降级 |
|
||||
| 加密成帧、padding、IV、AAD | 发送队列(IndexedDB)、失败重发 |
|
||||
| 解密、完整性校验、乱序/重复/篡改处理 | 把事件渲染成界面 |
|
||||
| 链接生成与解析、降级防御 | 持久化链接和**终端密钥** |
|
||||
| 设备名加解密 | 复制、下载(把流写到文件)、预览 |
|
||||
| 日志记录解析 | **Cap 验证码**(见下文) |
|
||||
|
||||
## API
|
||||
|
||||
### 会话
|
||||
|
||||
| 调用 | 说明 |
|
||||
|---|---|
|
||||
| `ChannelSession.create({ mode, settings, endpointSecret })` | 创建。返回 `{ session, request: { body, headers } }` |
|
||||
| `ChannelSession.open(link, { endpointSecret })` | 打开链接(也导出为 `openLink`) |
|
||||
| `session.authHeaders('read' \| 'write' \| 'owner')` | 请求头 `X-WD-Role` / `Authorization` / `X-WD-Endpoint`。没有对应权限时抛 `CapabilityError` |
|
||||
| `session.can(role)` | 当前链接是否具备该角色 |
|
||||
| `session.link(origin, 'member' \| 'owner')` | 生成分享链接 |
|
||||
| `session.verifyChannelInfo({ id, mode })` | 核对服务端返回的频道信息,不一致抛 `ModeMismatchError` / `LinkError` |
|
||||
| `session.id` / `.mode` / `.role` / `.endpointSecret` / `.endpointHash` | 只读属性 |
|
||||
| `session.deviceName` | 设置后,加密的条目元数据里会带上设备名 |
|
||||
| `session.dispose()` | 尽力清零密钥材料(JS 无法保证擦除) |
|
||||
|
||||
### 发送
|
||||
|
||||
全部返回 `ItemPlan`:`{ itemId, gated, frame0Headers, frames }`。`frames` 是异步迭代器,逐帧产出 `{ index, final, bytes }`,`bytes` 是完整封装,直接作为 `POST /frames` 的请求体。
|
||||
|
||||
| 调用 | 说明 |
|
||||
|---|---|
|
||||
| `encryptText(text, opts?)` | 文本 |
|
||||
| `encryptJson(value, opts?)` | JSON。`{ snapshot: true }` 作为频道的最新快照 |
|
||||
| `encryptFile(blobOrStream, meta?, opts?)` | `File`/`Blob` 自动带文件名、类型、大小;`ReadableStream` 大小未知 |
|
||||
| `encryptBytes(bytes, meta?, opts?)` | 任意字节 |
|
||||
| `collectFrames(plan)` | 把整个计划收集成数组(小内容用) |
|
||||
|
||||
常用 `opts`:
|
||||
|
||||
| 选项 | 说明 |
|
||||
|---|---|
|
||||
| `maxDownloads` | 下载次数上限(0 不限)。会强制 `gated`,并在 `plan.frame0Headers` 里给出 `X-WD-Max-Downloads`,**必须随第 0 帧发送** |
|
||||
| `gated` | `'auto'`(默认):放不进单帧或设置了 `maxDownloads` 时为 GATED。GATED 条目不实时推送内容,只推送 announce,字节要另外拉取 |
|
||||
| `snapshot` | 快照必须是单帧(约 1 MiB 以内),不能 GATED |
|
||||
| `itemId` | 重试和续传必须复用同一个 id |
|
||||
| `fromFrame` | 上传断点续传:从第 k 帧开始产出(字节与完整加密完全相同)。仅支持 `Blob` / `Uint8Array` |
|
||||
| `ts`, `dev` | 覆盖时间戳和设备名 |
|
||||
| `label` | 仅 raw 模式:`X-WD-Label` |
|
||||
|
||||
**GATED 的判断**:来源长度已知时创建计划时就确定;来源是流时,要产出第 0 帧之后 `plan.gated` 和 `frame0Headers` 才确定,请在拿到第 0 帧后再读取。
|
||||
|
||||
**重试安全**:同一个 `(itemId, index)` 只能加密一份固定明文,否则会造成 AES-GCM 的 IV 重用。库保证:同样的输入、同样的 `itemId` 得到**完全相同**的字节,所以「重发直到收到确认」是安全的。不要用同一个 `itemId` 加密不同内容。
|
||||
|
||||
### 接收
|
||||
|
||||
```ts
|
||||
const reader = session.createItemReader();
|
||||
const events = await reader.push(recordOrBytes); // 日志记录(parseLogRecords 的一项,或 WebSocket 的一条二进制消息)
|
||||
const events2 = await reader.pushFrame(envelope); // 单个封装(拉取 GATED 条目时按帧切出来的)
|
||||
```
|
||||
|
||||
事件:
|
||||
|
||||
| `type` | 含义 |
|
||||
|---|---|
|
||||
| `item` | 条目有新内容或已完成。`item.complete`、`item.kind`、`item.meta`、`item.content`、`item.snapshot`、`item.gated`。小内容(单帧)只触发一次 |
|
||||
| `announce` | GATED 条目的通知,字节要用 `GET /items/{id}/frames` 拉取(计下载次数) |
|
||||
| `removed` | 条目被删除/过期/淘汰/取代/下载达上限,`reason` 说明原因 |
|
||||
| `rejected` | 一帧被拒绝(篡改、乱序、模式不符……),`reason` 说明原因。**不会抛异常**,该条目的缓冲已丢弃 |
|
||||
|
||||
辅助:`itemText(item)`、`itemJson(item)`。**大文件不要用 `reader` 缓冲**,用流式解密:
|
||||
|
||||
```ts
|
||||
const res = await fetch(`/api/v1/channels/${id}/items/${itemId}/frames`, { headers: session.authHeaders('read') });
|
||||
const { content, result } = session.decryptItemStream(res.body!);
|
||||
await content.pipeTo(fileWritableStream); // 边收边解边写盘,内存只有一帧
|
||||
const { complete, nextIndex, meta } = await result;
|
||||
// 连接断了:complete 为 false,用 nextIndex 续传(from=nextIndex,服务端不重复计下载次数)
|
||||
```
|
||||
|
||||
校验失败时流会出错(`IntegrityError`,带 `reason`)。流正常结束但没有 FINAL **不是错误**(连接可能断了),此时 `complete` 为 `false`。
|
||||
|
||||
### 其他
|
||||
|
||||
| 调用 | 说明 |
|
||||
|---|---|
|
||||
| `parseLogRecords(bytes)` / `parseLogRecord(bytes)` | 解析 `GET /log` 的响应或一条 WebSocket 消息 |
|
||||
| `session.sealLabel(name)` / `session.openLabel(blob, endpointHash)` | 设备名(`PUT /seats/me` 的请求体 / 席位列表里的 `label`) |
|
||||
| `parseLink(url)` / `buildLink(origin, parts)` | 链接 |
|
||||
| `splitEnvelopes(stream)` | 把「封装首尾相接」的字节流切成一个个封装 |
|
||||
|
||||
## 必须注意的几点
|
||||
|
||||
**1. 终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这台设备在这个频道里」,服务端按它占用席位。不持久化的话,每次打开链接都会占用一个新席位。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。
|
||||
|
||||
**2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。
|
||||
|
||||
**3. 验证码(Cap)。** 工作量证明由 [Cap](https://github.com/tiagozip/cap) 提供,本库不实现。创建频道前读 `GET /api/v1/info` 的 `captcha`:`enabled` 为 true 时,用 Cap widget 连接 `captcha.api_endpoint` 求解得到 token,放进创建请求的 `X-WD-Cap` 头。
|
||||
|
||||
**4. `rejected` 不能忽略。** 它意味着服务端(或网络)给了你不符合协议的数据。至少要在界面上提示「内容校验失败」,不要静默丢弃。
|
||||
|
||||
**5. 链接就是密钥。** 密钥只放在 `#` 后面,浏览器不会把它发给服务器。拿到链接后立刻用 `history.replaceState` 清掉地址栏里的 fragment。链接里的密钥放在 `?` 后面会被 `parseLink` 直接拒绝。
|
||||
|
||||
**6. raw 模式没有保密性。** raw 频道的内容服务端完全可见,没有完整性保护,界面必须明确提示。`label`(条目标签)目前只能写入、不能从服务端读回。
|
||||
|
||||
**7. 密钥材料。** AES 密钥是不可导出的 `CryptoKey`,`K_enc` 导入后原始字节即清零。但链接里的 K/A/W 必须留在内存里(用来重新生成分享链接),令牌必须以字节形式放进请求头。**JS 无法保证擦除内存**,`dispose()` 只是尽力而为。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm test # 全部测试(含对着真实服务端的端到端)
|
||||
npm run typecheck
|
||||
npm run build # 输出到 dist/
|
||||
npm run vectors:check # 校验 test-vectors/vectors.json 是最新的
|
||||
```
|
||||
|
||||
端到端测试需要服务端可执行文件,按顺序查找:环境变量 `WEBDROP_SERVER`、`server/out/win-x64/webdrop.exe`、`server/out/linux-x64/webdrop`、`server/src/WebDrop.Api/bin/Debug/net10.0/webdrop.dll`(用 `dotnet` 运行)。都找不到时跳过这一组。
|
||||
|
||||
### 测试构成
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `vectors.test.ts` | 逐字节复现 `test-vectors/` 里 node:crypto 独立生成的全部向量,含 17 个负例 |
|
||||
| `session.test.ts` | 创建、链接、角色、降级防御、收发、设备名、raw 模式、条目计划 |
|
||||
| `reader.test.ts` | 日志记录、重放、乱序、缺帧、FINAL 冲突、标志变化、模式隔离 |
|
||||
| `stream.test.ts` | 任意切分的字节流、流式解密、篡改/拼接/丢帧/截断、断点续传 |
|
||||
| `properties.test.ts` | 每个字节每一位的篡改、padding 档位、随机往返、来源块边界对齐、性能底线 |
|
||||
| `e2e.test.ts` | 真实服务端:HTTP、WebSocket、大文件、下载上限、上传与下载续传、设备名 |
|
||||
|
||||
另外对测试集做过**变异检验**:故意在实现里注入 10 类典型错误(AAD 少一字节、跳过零填充检查、去掉模式/条目 id 校验、base64 不查规范性、分块边界差一、IV 不含帧序号、重复帧不比较、FINAL 后不拒绝、链接不拒绝查询串密钥),测试都能变红。其中「分块边界差一」最初漏网,说明测试有缺口,已补上专门的边界对齐用例。
|
||||
Generated
+1518
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@webdrop/core",
|
||||
"version": "0.1.0",
|
||||
"description": "WebDrop 端到端加密核心:密钥派生、帧加解密、条目装配、链接。只依赖 WebCrypto,无 UI、无网络。",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./internal": {
|
||||
"types": "./dist/internal.d.ts",
|
||||
"import": "./dist/internal.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.build.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"vectors:check": "node ../../test-vectors/generate.mjs --check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.6.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 字节工具。只用标准 JS,不依赖 Node 或 DOM 之外的任何东西。
|
||||
*
|
||||
* WebCrypto 的类型(TS 5.7+)要求 BufferSource 由 ArrayBuffer 支撑,
|
||||
* 所以库内部统一使用 Bytes = Uint8Array<ArrayBuffer>。
|
||||
*/
|
||||
|
||||
export type Bytes = Uint8Array<ArrayBuffer>;
|
||||
|
||||
/** 确保由 ArrayBuffer 支撑(SharedArrayBuffer 或其他情况下复制一份)。 */
|
||||
export function toBytes(u: Uint8Array): Bytes {
|
||||
return u.buffer instanceof ArrayBuffer ? (u as Bytes) : new Uint8Array(u);
|
||||
}
|
||||
|
||||
export function concat(...parts: Uint8Array[]): Bytes {
|
||||
let total = 0;
|
||||
for (const p of parts) total += p.length;
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, offset);
|
||||
offset += p.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= (a[i] as number) ^ (b[i] as number);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export function readU16(b: Uint8Array, offset: number): number {
|
||||
return ((b[offset] as number) << 8) | (b[offset + 1] as number);
|
||||
}
|
||||
|
||||
export function readU32(b: Uint8Array, offset: number): number {
|
||||
return (
|
||||
(((b[offset] as number) << 24) |
|
||||
((b[offset + 1] as number) << 16) |
|
||||
((b[offset + 2] as number) << 8) |
|
||||
(b[offset + 3] as number)) >>>
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/** 读 u64,超过 2^53 - 1 时抛错(seq 和时间戳实际远小于这个值)。 */
|
||||
export function readU64(b: Uint8Array, offset: number): number {
|
||||
const hi = readU32(b, offset);
|
||||
const lo = readU32(b, offset + 4);
|
||||
if (hi > 0x1fffff) throw new RangeError('u64 超出 JS 安全整数范围');
|
||||
return hi * 0x1_0000_0000 + lo;
|
||||
}
|
||||
|
||||
export function writeU16(value: number): Bytes {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 0xffff) throw new RangeError('u16 超出范围');
|
||||
return new Uint8Array([value >>> 8, value & 0xff]);
|
||||
}
|
||||
|
||||
export function writeU32(value: number): Bytes {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) throw new RangeError('u32 超出范围');
|
||||
return new Uint8Array([value >>> 24, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]);
|
||||
}
|
||||
|
||||
export function writeU64(value: number): Bytes {
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new RangeError('u64 超出范围');
|
||||
const out = new Uint8Array(8);
|
||||
const hi = Math.floor(value / 0x1_0000_0000);
|
||||
const lo = value >>> 0;
|
||||
out.set(writeU32(hi), 0);
|
||||
out.set(writeU32(lo), 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ───────── hex ─────────
|
||||
|
||||
export function toHex(b: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const x of b) s += x.toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
export function fromHex(s: string): Bytes {
|
||||
if (s.length % 2 !== 0 || /[^0-9a-fA-F]/.test(s)) throw new RangeError('非法的十六进制字符串');
|
||||
const out = new Uint8Array(s.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ───────── base64url(无填充,严格规范) ─────────
|
||||
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
const LOOKUP = (() => {
|
||||
const t = new Int8Array(128).fill(-1);
|
||||
for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET.charCodeAt(i)] = i;
|
||||
return t;
|
||||
})();
|
||||
|
||||
export function toBase64Url(b: Uint8Array): string {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
for (; i + 2 < b.length; i += 3) {
|
||||
const n = ((b[i] as number) << 16) | ((b[i + 1] as number) << 8) | (b[i + 2] as number);
|
||||
out += ALPHABET[(n >>> 18) & 63]! + ALPHABET[(n >>> 12) & 63]! + ALPHABET[(n >>> 6) & 63]! + ALPHABET[n & 63]!;
|
||||
}
|
||||
const rest = b.length - i;
|
||||
if (rest === 1) {
|
||||
const n = (b[i] as number) << 16;
|
||||
out += ALPHABET[(n >>> 18) & 63]! + ALPHABET[(n >>> 12) & 63]!;
|
||||
} else if (rest === 2) {
|
||||
const n = ((b[i] as number) << 16) | ((b[i + 1] as number) << 8);
|
||||
out += ALPHABET[(n >>> 18) & 63]! + ALPHABET[(n >>> 12) & 63]! + ALPHABET[(n >>> 6) & 63]!;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格解码:只接受 url 字母表,拒绝填充和非规范编码(末尾多余的比特必须为 0),
|
||||
* 与服务端 Codec.TryDecodeExact 的行为一致。非法输入返回 null,绝不抛异常。
|
||||
*/
|
||||
export function fromBase64Url(s: string, expectedLength?: number): Bytes | null {
|
||||
const len = s.length;
|
||||
if (len % 4 === 1) return null;
|
||||
const outLen = Math.floor((len * 3) / 4);
|
||||
if (expectedLength !== undefined && outLen !== expectedLength) return null;
|
||||
|
||||
const out = new Uint8Array(outLen);
|
||||
let o = 0;
|
||||
let acc = 0;
|
||||
let bits = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const code = s.charCodeAt(i);
|
||||
const v = code < 128 ? (LOOKUP[code] as number) : -1;
|
||||
if (v < 0) return null;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out[o++] = (acc >>> bits) & 0xff;
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
if (acc !== 0) return null; // 末尾多余比特必须为 0
|
||||
return out;
|
||||
}
|
||||
|
||||
// ───────── 文本 ─────────
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const strictDecoder = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
export function utf8(s: string): Bytes {
|
||||
return encoder.encode(s) as Bytes;
|
||||
}
|
||||
|
||||
/** 严格的 UTF-8 解码,非法序列返回 null。 */
|
||||
export function tryDecodeUtf8(b: Uint8Array): string | null {
|
||||
try {
|
||||
return strictDecoder.decode(b);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function randomBytes(n: number): Bytes {
|
||||
const out = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 尽力清零。JS 无法保证内存被真正擦除(GC 可能已复制过),这只是缩小暴露窗口。 */
|
||||
export function wipe(b: Uint8Array): void {
|
||||
b.fill(0);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/** 协议常量,与 docs/protocol.md 一一对应。 */
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
export const ID_SIZE = 16;
|
||||
export const KEY_SIZE = 32;
|
||||
export const TAG_SIZE = 16;
|
||||
export const HEADER_SIZE = 26;
|
||||
|
||||
/** 帧标志(protocol §4)。 */
|
||||
export const Flags = {
|
||||
Final: 1,
|
||||
Snapshot: 2,
|
||||
Gated: 4,
|
||||
Enc: 8,
|
||||
} as const;
|
||||
|
||||
/** 档位 padding 的明文长度(protocol §6)。 */
|
||||
export const TIERS = [1024, 4096, 16384, 65536, 262144, 1048576] as const;
|
||||
|
||||
/** 单帧明文上限 1 MiB,含 4 字节长度前缀,所以 data 最多 1,048,572 字节。 */
|
||||
export const MAX_FRAME_PLAINTEXT = 1048576;
|
||||
export const MAX_FRAME_DATA = MAX_FRAME_PLAINTEXT - 4;
|
||||
|
||||
/** raw 模式的负载没有加密开销,单帧上限取 1 MiB。 */
|
||||
export const RAW_MAX_PAYLOAD = 1048576;
|
||||
|
||||
/** HKDF info 标签(protocol §3)。 */
|
||||
export const Labels = {
|
||||
enc: 'webdrop/v1/enc',
|
||||
read: 'webdrop/v1/token/read',
|
||||
write: 'webdrop/v1/token/write',
|
||||
owner: 'webdrop/v1/token/owner',
|
||||
item: 'webdrop/v1/item',
|
||||
label: 'webdrop/v1/label',
|
||||
} as const;
|
||||
|
||||
/** AAD 前缀。 */
|
||||
export const AAD_FRAME = 'WD1';
|
||||
export const AAD_LABEL = 'WD1L';
|
||||
|
||||
/** 设备名总长上限(iv + 密文 + tag),与服务端 MaxSeatLabelBytes 一致。 */
|
||||
export const MAX_LABEL_BYTES = 256;
|
||||
export const LABEL_IV_SIZE = 12;
|
||||
|
||||
export const POW_NONCE_SIZE = 8;
|
||||
|
||||
export type ChannelMode = 'enc' | 'raw';
|
||||
export type ItemKind = 'text' | 'json' | 'file' | 'other';
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 错误类型。消息里绝不包含密钥、令牌或明文。
|
||||
*/
|
||||
|
||||
export class WebDropError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** 接收方校验失败的原因,与 test-vectors 的 expect 字段对应。 */
|
||||
export type IntegrityReason =
|
||||
| 'auth_failed' // GCM 认证失败:内容被篡改、头部被改、换了频道或条目
|
||||
| 'bad_envelope' // 封装格式不合法
|
||||
| 'wrong_mode' // enc 会话收到没有 ENC 标志的帧,或 raw 会话收到 ENC 帧
|
||||
| 'bad_padding' // 明文长度字段或填充不合法
|
||||
| 'bad_meta' // 条目元数据不合法
|
||||
| 'gap' // 帧序号不连续
|
||||
| 'item_mismatch' // 同一个流里混入了别的条目的帧
|
||||
| 'flags_changed' // 同一条目内 SNAPSHOT / GATED 标志不一致
|
||||
| 'duplicate_mismatch' // 同一 (条目, 序号) 出现了不同内容
|
||||
| 'frame_after_final' // FINAL 之后又出现了帧
|
||||
| 'final_conflict' // FINAL 的位置前后矛盾
|
||||
| 'incomplete'; // 流结束时条目还没有已认证的 FINAL
|
||||
|
||||
/** 数据被篡改或不符合协议。接收方应丢弃整个条目。 */
|
||||
export class IntegrityError extends WebDropError {
|
||||
readonly reason: IntegrityReason;
|
||||
|
||||
constructor(reason: IntegrityReason) {
|
||||
super('integrity', `完整性校验失败: ${reason}`);
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkError extends WebDropError {
|
||||
constructor(message: string) {
|
||||
super('link', message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 服务端报告的频道模式与链接不符(降级攻击或链接损坏)。 */
|
||||
export class ModeMismatchError extends WebDropError {
|
||||
constructor() {
|
||||
super('mode_mismatch', '频道模式与链接不一致,已拒绝');
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前会话没有这个能力(例如成员链接没有所有者令牌)。 */
|
||||
export class CapabilityError extends WebDropError {
|
||||
constructor(what: string) {
|
||||
super('capability', `当前链接不具备 ${what} 权限`);
|
||||
}
|
||||
}
|
||||
|
||||
export class LimitError extends WebDropError {
|
||||
constructor(message: string) {
|
||||
super('limit', message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { AAD_FRAME, Flags, HEADER_SIZE, ID_SIZE, MAX_FRAME_DATA, MAX_FRAME_PLAINTEXT, TAG_SIZE, TIERS } from './constants.js';
|
||||
import { concat, readU32, utf8, writeU32, type Bytes } from './bytes.js';
|
||||
import { IntegrityError } from './errors.js';
|
||||
|
||||
// ───────────────────────── padding(protocol §6) ─────────────────────────
|
||||
|
||||
/** 不小于 total 的最小档位。 */
|
||||
export function tierFor(total: number): number {
|
||||
for (const t of TIERS) if (t >= total) return t;
|
||||
throw new RangeError('超出单帧上限');
|
||||
}
|
||||
|
||||
/** 明文 = u32 data_len ‖ data ‖ 零填充,总长为档位。 */
|
||||
export function padPlaintext(data: Uint8Array): Bytes {
|
||||
if (data.length > MAX_FRAME_DATA) throw new RangeError('单帧 data 最多 1,048,572 字节');
|
||||
const tier = tierFor(4 + data.length);
|
||||
const out = new Uint8Array(tier);
|
||||
out.set(writeU32(data.length), 0);
|
||||
out.set(data, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 接收方校验:长度字段不越界,总长是档位,填充全为零。 */
|
||||
export function unpadPlaintext(plain: Uint8Array): Bytes {
|
||||
if (plain.length < 4 || !(TIERS as readonly number[]).includes(plain.length)) throw new IntegrityError('bad_padding');
|
||||
const len = readU32(plain, 0);
|
||||
if (len > plain.length - 4) throw new IntegrityError('bad_padding');
|
||||
for (let i = 4 + len; i < plain.length; i++) {
|
||||
if (plain[i] !== 0) throw new IntegrityError('bad_padding');
|
||||
}
|
||||
return plain.slice(4, 4 + len);
|
||||
}
|
||||
|
||||
// ───────────────────────── 封装(protocol §4) ─────────────────────────
|
||||
|
||||
export interface Envelope {
|
||||
flags: number;
|
||||
itemId: Bytes;
|
||||
frameIndex: number;
|
||||
payloadLength: number;
|
||||
/** 前 26 字节。 */
|
||||
header: Bytes;
|
||||
payload: Bytes;
|
||||
/** 完整封装。 */
|
||||
bytes: Bytes;
|
||||
}
|
||||
|
||||
/** 严格解析:版本、保留位、长度必须全部合法。与服务端 Envelope.TryParse 一致。 */
|
||||
export function parseEnvelope(data: Uint8Array): Envelope {
|
||||
if (data.length < HEADER_SIZE || data[0] !== 1) throw new IntegrityError('bad_envelope');
|
||||
const flags = data[1] as number;
|
||||
if ((flags & 0xf0) !== 0) throw new IntegrityError('bad_envelope');
|
||||
const payloadLength = readU32(data, 22);
|
||||
if (data.length !== HEADER_SIZE + payloadLength) throw new IntegrityError('bad_envelope');
|
||||
|
||||
const bytes = data.slice();
|
||||
return {
|
||||
flags,
|
||||
itemId: bytes.slice(2, 2 + ID_SIZE),
|
||||
frameIndex: readU32(bytes, 18),
|
||||
payloadLength,
|
||||
header: bytes.slice(0, HEADER_SIZE),
|
||||
payload: bytes.slice(HEADER_SIZE),
|
||||
bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeEnvelope(flags: number, itemId: Uint8Array, frameIndex: number, payload: Uint8Array): Bytes {
|
||||
if (itemId.length !== ID_SIZE) throw new RangeError('item_id 必须是 16 字节');
|
||||
const header = concat(new Uint8Array([1, flags]), itemId, writeU32(frameIndex), writeU32(payload.length));
|
||||
return concat(header, payload);
|
||||
}
|
||||
|
||||
/** 流式解析时用:从缓冲区起点读出下一个封装的总长度,数据不够返回 null。 */
|
||||
export function peekEnvelopeLength(buf: Uint8Array, maxPayload: number): number | null {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
if (buf[0] !== 1) throw new IntegrityError('bad_envelope');
|
||||
const payloadLength = readU32(buf, 22);
|
||||
if (payloadLength > maxPayload) throw new IntegrityError('bad_envelope');
|
||||
return HEADER_SIZE + payloadLength;
|
||||
}
|
||||
|
||||
export const MAX_ENC_PAYLOAD = MAX_FRAME_PLAINTEXT + TAG_SIZE;
|
||||
|
||||
// ───────────────────────── 加解密(protocol §5) ─────────────────────────
|
||||
|
||||
/** IV = 8 字节零 ‖ frame_index(u32)。每个条目有独立的密钥,所以 frame_index 唯一即保证 IV 唯一。 */
|
||||
export function frameIv(frameIndex: number): Bytes {
|
||||
const iv = new Uint8Array(12);
|
||||
iv.set(writeU32(frameIndex), 8);
|
||||
return iv;
|
||||
}
|
||||
|
||||
/** AAD = "WD1" ‖ channel_id ‖ envelope[0..22),绑定频道、条目、序号和全部标志位。 */
|
||||
export function frameAad(channelId: Uint8Array, header: Uint8Array): Bytes {
|
||||
return concat(utf8(AAD_FRAME), channelId, header.subarray(0, 22));
|
||||
}
|
||||
|
||||
export interface SealParams {
|
||||
flags: number;
|
||||
itemId: Uint8Array;
|
||||
index: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密一帧并返回完整封装。
|
||||
* 注意:同一 (itemId, index) 只能加密一份固定明文,否则会造成 IV 重用。重试必须重发相同字节。
|
||||
*/
|
||||
export async function sealFrame(itemKey: CryptoKey, channelId: Uint8Array, p: SealParams): Promise<Bytes> {
|
||||
if ((p.flags & Flags.Enc) === 0) throw new RangeError('enc 帧必须带 ENC 标志');
|
||||
if ((p.flags & 0xf0) !== 0) throw new RangeError('保留标志位必须为 0');
|
||||
|
||||
const plaintext = padPlaintext(p.data);
|
||||
const header = concat(new Uint8Array([1, p.flags]), p.itemId, writeU32(p.index), writeU32(plaintext.length + TAG_SIZE));
|
||||
|
||||
const cipher = await globalThis.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: frameIv(p.index), additionalData: frameAad(channelId, header), tagLength: 128 },
|
||||
itemKey,
|
||||
plaintext,
|
||||
);
|
||||
return concat(header, new Uint8Array(cipher));
|
||||
}
|
||||
|
||||
/** 认证并解密一帧,返回去掉 padding 的 data。任何失败都抛 IntegrityError。 */
|
||||
export async function openFrame(itemKey: CryptoKey, channelId: Uint8Array, env: Envelope): Promise<Bytes> {
|
||||
if ((env.flags & Flags.Enc) === 0) throw new IntegrityError('wrong_mode');
|
||||
if (env.payloadLength < TAG_SIZE) throw new IntegrityError('bad_envelope');
|
||||
|
||||
let plain: Bytes;
|
||||
try {
|
||||
plain = new Uint8Array(
|
||||
await globalThis.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: frameIv(env.frameIndex), additionalData: frameAad(channelId, env.header), tagLength: 128 },
|
||||
itemKey,
|
||||
env.payload,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
throw new IntegrityError('auth_failed');
|
||||
}
|
||||
return unpadPlaintext(plain);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @webdrop/core:WebDrop 的端到端加密核心。
|
||||
*
|
||||
* 前端只需要这几件事:
|
||||
* ChannelSession.create(...) 创建频道,得到会话、POST /channels 的请求体和请求头、分享链接
|
||||
* ChannelSession.open(link) 打开链接,得到会话
|
||||
* session.authHeaders(role) 所有请求要带的头
|
||||
* session.encryptText/Json/File 把内容加密成可直接 POST 的帧
|
||||
* session.createItemReader() 喂入 WebSocket / log 的记录,得到解密后的条目
|
||||
* session.decryptItemStream() 流式解密大文件
|
||||
*
|
||||
* 本包只依赖 WebCrypto,不含 UI,不发网络请求,不处理重连和队列。
|
||||
*/
|
||||
|
||||
export { ChannelSession, openLink } from './session.js';
|
||||
export type {
|
||||
AuthRole,
|
||||
ChannelSettings,
|
||||
CreateChannelBody,
|
||||
CreatedChannel,
|
||||
CreateOptions,
|
||||
FileMetaOverrides,
|
||||
OpenOptions,
|
||||
PlanItemOptions,
|
||||
} from './session.js';
|
||||
|
||||
export { collectFrames } from './item.js';
|
||||
export type { ContentSource, EncodedFrame, ItemMeta, ItemPlan, PlanOptions } from './item.js';
|
||||
|
||||
export { ItemReader, itemJson, itemText } from './reader.js';
|
||||
export type { ItemEvent, ReaderContext, ReceivedItem, ReceivedKind } from './reader.js';
|
||||
|
||||
export { decryptItemFrames, decryptItemStream, splitEnvelopes } from './stream.js';
|
||||
export type { DecryptStreamOptions, ItemDecodeResult } from './stream.js';
|
||||
|
||||
export { parseLogRecord, parseLogRecords } from './log.js';
|
||||
export type { LogRecord, RemovedReason } from './log.js';
|
||||
|
||||
export { buildLink, parseLink } from './link.js';
|
||||
export type { LinkParts, ParsedLink } from './link.js';
|
||||
|
||||
export { fromBase64Url, toBase64Url } from './bytes.js';
|
||||
|
||||
export { Flags, MAX_FRAME_DATA, PROTOCOL_VERSION } from './constants.js';
|
||||
export type { ChannelMode, ItemKind } from './constants.js';
|
||||
|
||||
export {
|
||||
CapabilityError,
|
||||
IntegrityError,
|
||||
LimitError,
|
||||
LinkError,
|
||||
ModeMismatchError,
|
||||
WebDropError,
|
||||
} from './errors.js';
|
||||
export type { IntegrityReason } from './errors.js';
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 仅供测试和调试:把通常不可导出的中间密钥以字节形式导出,用来和测试向量逐字节比对。
|
||||
* 正常使用请只依赖主入口。这里的函数会让密钥材料暴露在 JS 变量里,不要在产品代码中使用。
|
||||
*/
|
||||
|
||||
import { Labels } from './constants.js';
|
||||
import { utf8, type Bytes } from './bytes.js';
|
||||
|
||||
export { encodeEnvelope, frameAad, frameIv, padPlaintext, parseEnvelope, sealFrame, tierFor, unpadPlaintext } from './frame.js';
|
||||
export { encodeMeta, decodeFirstFrame, planEncItem, planRawItem } from './item.js';
|
||||
export { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256 } from './kdf.js';
|
||||
|
||||
async function hkdfBytes(ikm: Uint8Array, salt: Uint8Array, label: string): Promise<Bytes> {
|
||||
const key = await globalThis.crypto.subtle.importKey('raw', ikm as Bytes, 'HKDF', false, ['deriveBits']);
|
||||
const bits = await globalThis.crypto.subtle.deriveBits(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt: salt as Bytes, info: utf8(label) },
|
||||
key,
|
||||
256,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
export interface RawChannelSecrets {
|
||||
kEnc: Bytes;
|
||||
tRead: Bytes;
|
||||
tWrite: Bytes;
|
||||
kLabel: Bytes;
|
||||
}
|
||||
|
||||
export async function deriveRawChannelSecrets(channelId: Uint8Array, k: Uint8Array): Promise<RawChannelSecrets> {
|
||||
const kEnc = await hkdfBytes(k, channelId, Labels.enc);
|
||||
return {
|
||||
kEnc,
|
||||
tRead: await hkdfBytes(k, channelId, Labels.read),
|
||||
tWrite: await hkdfBytes(k, channelId, Labels.write),
|
||||
kLabel: await hkdfBytes(kEnc, channelId, Labels.label),
|
||||
};
|
||||
}
|
||||
|
||||
export const deriveRawOwnerToken = (channelId: Uint8Array, a: Uint8Array): Promise<Bytes> => hkdfBytes(a, channelId, Labels.owner);
|
||||
|
||||
export const deriveRawItemKey = (kEnc: Uint8Array, itemId: Uint8Array): Promise<Bytes> => hkdfBytes(kEnc, itemId, Labels.item);
|
||||
@@ -0,0 +1,340 @@
|
||||
import { Flags, MAX_FRAME_DATA, RAW_MAX_PAYLOAD, type ItemKind } from './constants.js';
|
||||
import { concat, readU16, toBytes, tryDecodeUtf8, utf8, writeU16, toBase64Url, type Bytes } from './bytes.js';
|
||||
import { IntegrityError, LimitError } from './errors.js';
|
||||
|
||||
// ───────────────────────── 条目元数据(protocol §7) ─────────────────────────
|
||||
|
||||
export interface ItemMeta {
|
||||
kind: ItemKind;
|
||||
name?: string;
|
||||
mime?: string;
|
||||
/** 内容总字节数,流式上传时可能未知。 */
|
||||
size?: number;
|
||||
/** 毫秒时间戳,仅供参考,排序以 seq 为准。 */
|
||||
ts: number;
|
||||
/** 设备名。 */
|
||||
dev?: string;
|
||||
}
|
||||
|
||||
const KINDS: readonly ItemKind[] = ['text', 'json', 'file', 'other'];
|
||||
|
||||
/** u16 meta_len ‖ meta_json。字段顺序固定,undefined 的字段省略,保证字节级确定。 */
|
||||
export function encodeMeta(meta: ItemMeta): Bytes {
|
||||
const o: Record<string, unknown> = { v: 1, kind: meta.kind };
|
||||
if (meta.name !== undefined) o.name = meta.name;
|
||||
if (meta.mime !== undefined) o.mime = meta.mime;
|
||||
if (meta.size !== undefined) o.size = meta.size;
|
||||
o.ts = meta.ts;
|
||||
if (meta.dev !== undefined) o.dev = meta.dev;
|
||||
|
||||
const json = utf8(JSON.stringify(o));
|
||||
if (json.length > 0xffff) throw new LimitError('条目元数据过大');
|
||||
return concat(writeU16(json.length), json);
|
||||
}
|
||||
|
||||
/** 解析第 0 帧的 data:u16 meta_len ‖ meta_json ‖ 内容。 */
|
||||
export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: Bytes } {
|
||||
if (data.length < 2) throw new IntegrityError('bad_meta');
|
||||
const metaLen = readU16(data, 0);
|
||||
if (data.length < 2 + metaLen) throw new IntegrityError('bad_meta');
|
||||
|
||||
const text = tryDecodeUtf8(data.subarray(2, 2 + metaLen));
|
||||
if (text === null) throw new IntegrityError('bad_meta');
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
throw new IntegrityError('bad_meta');
|
||||
}
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new IntegrityError('bad_meta');
|
||||
const o = raw as Record<string, unknown>;
|
||||
|
||||
if (o.v !== 1 || typeof o.kind !== 'string' || typeof o.ts !== 'number' || !Number.isFinite(o.ts)) {
|
||||
throw new IntegrityError('bad_meta');
|
||||
}
|
||||
const meta: ItemMeta = {
|
||||
// 未知的 kind 按 other 处理,向前兼容
|
||||
kind: (KINDS as readonly string[]).includes(o.kind) ? (o.kind as ItemKind) : 'other',
|
||||
ts: o.ts,
|
||||
};
|
||||
for (const key of ['name', 'mime', 'dev'] as const) {
|
||||
const v = o[key];
|
||||
if (v === undefined) continue;
|
||||
if (typeof v !== 'string') throw new IntegrityError('bad_meta');
|
||||
meta[key] = v;
|
||||
}
|
||||
if (o.size !== undefined) {
|
||||
if (typeof o.size !== 'number' || !Number.isSafeInteger(o.size) || o.size < 0) throw new IntegrityError('bad_meta');
|
||||
meta.size = o.size;
|
||||
}
|
||||
return { meta, content: data.slice(2 + metaLen) };
|
||||
}
|
||||
|
||||
// ───────────────────────── 条目编码(加密) ─────────────────────────
|
||||
|
||||
export type ContentSource = Uint8Array | Blob | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
|
||||
|
||||
export interface PlanOptions {
|
||||
/** 指定条目 id(重试、续传时必须复用同一个);默认随机生成。 */
|
||||
itemId?: Uint8Array;
|
||||
/** 作为频道的「最新快照」。快照必须是单帧,所以内容不能超过一帧。 */
|
||||
snapshot?: boolean;
|
||||
/**
|
||||
* 是否 GATED(不实时内联推送,只能按条目拉取并计下载次数)。
|
||||
* 'auto'(默认):内容放不进一帧,或设置了 maxDownloads 时为 GATED。
|
||||
*/
|
||||
gated?: boolean | 'auto';
|
||||
/** 下载次数上限,0 为不限。设置后条目必须是 GATED;需要作为 X-WD-Max-Downloads 随第 0 帧发送。 */
|
||||
maxDownloads?: number;
|
||||
/** 单帧 data 的字节上限,默认 1,048,572。调小只用于测试和极弱网络。 */
|
||||
maxFrameData?: number;
|
||||
/** 从第几帧开始产出(断点续传)。只支持长度已知的来源(Uint8Array、Blob)。 */
|
||||
fromFrame?: number;
|
||||
}
|
||||
|
||||
export interface EncodedFrame {
|
||||
index: number;
|
||||
final: boolean;
|
||||
/** 完整封装,可直接作为 POST /frames 的请求体。 */
|
||||
bytes: Bytes;
|
||||
}
|
||||
|
||||
export interface ItemPlan {
|
||||
/** 条目 id 的 base64url 文本。 */
|
||||
itemId: string;
|
||||
idBytes: Bytes;
|
||||
/**
|
||||
* 是否 GATED。来源长度已知时在创建计划时就确定;来源是流(长度未知)时,
|
||||
* 要等第 0 帧产出之后才确定,所以请在拿到第 0 帧之后再读取它和 frame0Headers。
|
||||
*/
|
||||
readonly gated: boolean;
|
||||
/** 第 0 帧要带的附加请求头(X-WD-Max-Downloads / X-WD-Label)。 */
|
||||
frame0Headers: Record<string, string>;
|
||||
/** 按序产出加密后的帧。同一个计划只能迭代一次。 */
|
||||
frames: AsyncGenerator<EncodedFrame, void, void>;
|
||||
}
|
||||
|
||||
export async function collectFrames(plan: ItemPlan): Promise<EncodedFrame[]> {
|
||||
const out: EncodedFrame[] = [];
|
||||
for await (const f of plan.frames) out.push(f);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function newItemId(): Bytes {
|
||||
const id = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 把各种来源统一成字节块的异步序列。 */
|
||||
async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void, void> {
|
||||
if (source instanceof Uint8Array) {
|
||||
yield source;
|
||||
return;
|
||||
}
|
||||
if (typeof Blob !== 'undefined' && source instanceof Blob) {
|
||||
yield* chunksOf(source.stream());
|
||||
return;
|
||||
}
|
||||
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
|
||||
// 不依赖 ReadableStream 的异步迭代器(旧版 Safari 没有)
|
||||
const reader = source.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
yield* source as AsyncIterable<Uint8Array>;
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
data: Bytes;
|
||||
last: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把任意切分的字节流重新切成 [firstCap, cap, cap, ...] 大小的块,并通过向前看一个块知道哪一块是最后一块。
|
||||
* 内容恰好填满某一块时不会多出一个空块;内容为空时产出一个空的最后一块。
|
||||
*/
|
||||
async function* rechunk(source: ContentSource, firstCap: number, cap: number): AsyncGenerator<Chunk, void, void> {
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let target = firstCap;
|
||||
let ended = false;
|
||||
const iter = chunksOf(source)[Symbol.asyncIterator]();
|
||||
|
||||
const take = (n: number): Bytes => {
|
||||
const out = new Uint8Array(n);
|
||||
let filled = 0;
|
||||
while (filled < n) {
|
||||
const head = parts[0] as Uint8Array;
|
||||
const need = n - filled;
|
||||
if (head.length <= need) {
|
||||
out.set(head, filled);
|
||||
filled += head.length;
|
||||
parts.shift();
|
||||
} else {
|
||||
out.set(head.subarray(0, need), filled);
|
||||
parts[0] = head.subarray(need);
|
||||
filled += need;
|
||||
}
|
||||
}
|
||||
total -= n;
|
||||
return out;
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
// 需要缓冲到 target+1 字节,才能确定当前块不是最后一块
|
||||
while (!ended && total <= target) {
|
||||
const r = await iter.next();
|
||||
if (r.done) ended = true;
|
||||
else if (r.value.length > 0) {
|
||||
parts.push(r.value);
|
||||
total += r.value.length;
|
||||
}
|
||||
}
|
||||
if (total > target) {
|
||||
yield { data: take(target), last: false };
|
||||
target = cap;
|
||||
continue;
|
||||
}
|
||||
yield { data: take(total), last: true };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function isSized(source: ContentSource): source is Uint8Array | Blob {
|
||||
return source instanceof Uint8Array || (typeof Blob !== 'undefined' && source instanceof Blob);
|
||||
}
|
||||
|
||||
/** 构造条目计划的公共部分:标志位、GATED 的取舍、校验。 */
|
||||
function resolveGating(opts: PlanOptions, fitsInOneFrame: boolean | undefined): boolean {
|
||||
const wantsLimit = opts.maxDownloads !== undefined;
|
||||
if (opts.snapshot && (opts.gated === true || wantsLimit)) throw new RangeError('快照不能是 GATED,也不能设置下载上限');
|
||||
if (opts.gated === true || wantsLimit) return true;
|
||||
if (opts.gated === false) {
|
||||
if (fitsInOneFrame === false) throw new LimitError('内容放不进单帧,不能关闭 GATED');
|
||||
return false;
|
||||
}
|
||||
return fitsInOneFrame === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* enc 模式:把内容加密成一个条目(protocol §7)。
|
||||
* 小内容单帧、非 GATED;放不下就拆成多帧并标记 GATED。第 0 帧携带元数据。
|
||||
*/
|
||||
export function planEncItem(
|
||||
seal: (p: { flags: number; itemId: Uint8Array; index: number; data: Uint8Array }) => Promise<Bytes>,
|
||||
source: ContentSource,
|
||||
meta: ItemMeta,
|
||||
opts: PlanOptions = {},
|
||||
): ItemPlan {
|
||||
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
|
||||
const maxData = opts.maxFrameData ?? MAX_FRAME_DATA;
|
||||
if (maxData < 1 || maxData > MAX_FRAME_DATA) throw new RangeError('maxFrameData 超出范围');
|
||||
|
||||
const head = encodeMeta(meta);
|
||||
const cap0 = maxData - head.length;
|
||||
if (cap0 < 0) throw new LimitError('元数据超过单帧上限');
|
||||
|
||||
const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined;
|
||||
const fits = size === undefined ? undefined : size <= cap0;
|
||||
let gated = resolveGating(opts, fits);
|
||||
|
||||
const fromFrame = opts.fromFrame ?? 0;
|
||||
if (fromFrame > 0 && !isSized(source)) throw new RangeError('只有长度已知的来源才能从中间帧续传');
|
||||
|
||||
const frame0Headers: Record<string, string> = {};
|
||||
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
|
||||
|
||||
const snapshot = opts.snapshot === true;
|
||||
|
||||
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
|
||||
const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | Flags.Enc;
|
||||
const emit = async (index: number, last: boolean, data: Uint8Array, gatedNow: boolean): Promise<EncodedFrame> => ({
|
||||
index,
|
||||
final: last,
|
||||
bytes: await seal({ flags: base(gatedNow) | (last ? Flags.Final : 0), itemId: idBytes, index, data }),
|
||||
});
|
||||
|
||||
if (fromFrame > 0) {
|
||||
// 断点续传:跳过已经上传的帧。第 k(>=1)帧的内容起点 = cap0 + (k-1) * maxData
|
||||
const blob = source as Uint8Array | Blob;
|
||||
const total = blob instanceof Uint8Array ? blob.length : blob.size;
|
||||
if (total <= cap0) throw new RangeError('内容只有一帧,没有可续传的后续帧');
|
||||
const offset = cap0 + (fromFrame - 1) * maxData;
|
||||
if (offset >= total) throw new RangeError('fromFrame 超出条目的帧数');
|
||||
const rest = blob instanceof Uint8Array ? blob.subarray(offset) : blob.slice(offset);
|
||||
let index = fromFrame;
|
||||
for await (const chunk of rechunk(rest, maxData, maxData)) {
|
||||
yield await emit(index++, chunk.last, chunk.data, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
for await (const chunk of rechunk(source, cap0, maxData)) {
|
||||
if (index === 0) {
|
||||
// 第 0 帧:来源长度未知时,到这里才知道放不放得进单帧
|
||||
if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false;
|
||||
else if (!chunk.last) {
|
||||
if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧');
|
||||
if (opts.gated === false) throw new LimitError('内容放不进单帧,不能关闭 GATED');
|
||||
gated = true;
|
||||
}
|
||||
yield await emit(0, chunk.last, concat(head, chunk.data), gated);
|
||||
} else {
|
||||
yield await emit(index, chunk.last, chunk.data, true);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
|
||||
}
|
||||
|
||||
/**
|
||||
* raw 模式:负载原样,没有加密、没有元数据。单帧放不下时拆成 GATED 多帧。
|
||||
* 注意 raw 内容对服务端完全可见。
|
||||
*/
|
||||
export function planRawItem(
|
||||
build: (p: { flags: number; itemId: Uint8Array; index: number; payload: Uint8Array }) => Bytes,
|
||||
source: ContentSource,
|
||||
opts: PlanOptions & { label?: string } = {},
|
||||
): ItemPlan {
|
||||
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
|
||||
const cap = Math.min(opts.maxFrameData ?? RAW_MAX_PAYLOAD, RAW_MAX_PAYLOAD);
|
||||
const snapshot = opts.snapshot === true;
|
||||
|
||||
const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined;
|
||||
let gated = resolveGating(opts, size === undefined ? undefined : size <= cap);
|
||||
|
||||
const frame0Headers: Record<string, string> = {};
|
||||
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
|
||||
if (opts.label !== undefined) frame0Headers['X-WD-Label'] = opts.label;
|
||||
|
||||
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
|
||||
let index = 0;
|
||||
for await (const chunk of rechunk(source, cap, cap)) {
|
||||
if (index === 0) {
|
||||
if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false;
|
||||
else if (!chunk.last) {
|
||||
if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧');
|
||||
gated = true;
|
||||
}
|
||||
}
|
||||
const flags = (gated ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (chunk.last ? Flags.Final : 0);
|
||||
yield { index, final: chunk.last, bytes: build({ flags, itemId: idBytes, index, payload: chunk.data }) };
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Labels } from './constants.js';
|
||||
import { utf8, wipe, type Bytes } from './bytes.js';
|
||||
|
||||
/**
|
||||
* 密钥派生(protocol §3)。全部是 HKDF-SHA256,salt 为 channel_id(或 item_id),info 为固定标签。
|
||||
*
|
||||
* 尽量让原始密钥字节不落在 JS 变量里:
|
||||
* - K_enc 导入为不可导出的 HKDF CryptoKey 后立即清零;
|
||||
* - K_item / K_label 直接用 deriveKey 得到不可导出的 AES-GCM CryptoKey。
|
||||
* 令牌(T_read 等)必须以字节形式放进请求头,所以是 Uint8Array。
|
||||
*/
|
||||
|
||||
const subtle = (): SubtleCrypto => globalThis.crypto.subtle;
|
||||
|
||||
function importHkdf(ikm: Uint8Array): Promise<CryptoKey> {
|
||||
return subtle().importKey('raw', ikm as Bytes, 'HKDF', false, ['deriveBits', 'deriveKey']);
|
||||
}
|
||||
|
||||
async function hkdfBits(key: CryptoKey, salt: Uint8Array, label: string): Promise<Bytes> {
|
||||
const bits = await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: salt as Bytes, info: utf8(label) }, key, 256);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
function hkdfAesKey(key: CryptoKey, salt: Uint8Array, label: string): Promise<CryptoKey> {
|
||||
return subtle().deriveKey(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt: salt as Bytes, info: utf8(label) },
|
||||
key,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChannelKeys {
|
||||
/** 用于派生每个条目的密钥。不可导出,原始字节已清零。 */
|
||||
encRoot: CryptoKey;
|
||||
/** 设备名加密密钥。不可导出。 */
|
||||
labelKey: CryptoKey;
|
||||
readToken: Bytes;
|
||||
writeToken: Bytes;
|
||||
}
|
||||
|
||||
/** 由频道密钥 K 派生。channelId 作为 HKDF 的 salt。 */
|
||||
export async function deriveChannelKeys(channelId: Uint8Array, k: Uint8Array): Promise<ChannelKeys> {
|
||||
const root = await importHkdf(k);
|
||||
|
||||
const kEnc = await hkdfBits(root, channelId, Labels.enc);
|
||||
const encRoot = await importHkdf(kEnc);
|
||||
wipe(kEnc);
|
||||
|
||||
return {
|
||||
encRoot,
|
||||
labelKey: await hkdfAesKey(encRoot, channelId, Labels.label),
|
||||
readToken: await hkdfBits(root, channelId, Labels.read),
|
||||
writeToken: await hkdfBits(root, channelId, Labels.write),
|
||||
};
|
||||
}
|
||||
|
||||
/** 所有者令牌由管理密钥 A 派生。raw 模式同样如此。 */
|
||||
export async function deriveOwnerToken(channelId: Uint8Array, a: Uint8Array): Promise<Bytes> {
|
||||
return hkdfBits(await importHkdf(a), channelId, Labels.owner);
|
||||
}
|
||||
|
||||
/** 每个条目独立的 AES-GCM 密钥,所以同一条目内 frame_index 唯一即保证 IV 唯一。 */
|
||||
export function deriveItemKey(encRoot: CryptoKey, itemId: Uint8Array): Promise<CryptoKey> {
|
||||
return hkdfAesKey(encRoot, itemId, Labels.item);
|
||||
}
|
||||
|
||||
export async function sha256(data: Uint8Array): Promise<Bytes> {
|
||||
return new Uint8Array(await subtle().digest('SHA-256', data as Bytes));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ID_SIZE, KEY_SIZE, type ChannelMode } from './constants.js';
|
||||
import { fromBase64Url, toBase64Url, type Bytes } from './bytes.js';
|
||||
import { LinkError } from './errors.js';
|
||||
|
||||
/**
|
||||
* 频道链接(protocol §2):
|
||||
* enc 所有者 /c/{id}#k=<K>&a=<A> 成员 /c/{id}#k=<K>
|
||||
* raw 所有者 /c/{id}#a=<A>[&w=<W>] 成员 /c/{id}[#w=<W>]
|
||||
*
|
||||
* 密钥只放在 fragment 里,浏览器不会把它发给服务器。降级防御:带 k 就是 enc,否则是 raw。
|
||||
*/
|
||||
|
||||
export interface ParsedLink {
|
||||
/** 频道 id 的 base64url 文本。 */
|
||||
id: string;
|
||||
idBytes: Bytes;
|
||||
/** 带 k 即 enc,否则 raw。客户端必须据此核对服务端报告的模式。 */
|
||||
mode: ChannelMode;
|
||||
role: 'owner' | 'member';
|
||||
/** 频道密钥,仅 enc。 */
|
||||
k?: Bytes;
|
||||
/** 管理密钥,仅所有者链接。 */
|
||||
a?: Bytes;
|
||||
/** raw 模式成员的写密钥。 */
|
||||
w?: Bytes;
|
||||
}
|
||||
|
||||
const PATH = /(?:^|\/)c\/([A-Za-z0-9_-]{22})\/?$/;
|
||||
|
||||
export function parseLink(input: string | URL): ParsedLink {
|
||||
let url: URL;
|
||||
try {
|
||||
url = typeof input === 'string' ? new URL(input) : input;
|
||||
} catch {
|
||||
throw new LinkError('不是合法的链接');
|
||||
}
|
||||
|
||||
const match = PATH.exec(url.pathname);
|
||||
if (!match) throw new LinkError('链接里没有频道 id');
|
||||
const id = match[1] as string;
|
||||
const idBytes = fromBase64Url(id, ID_SIZE);
|
||||
if (!idBytes) throw new LinkError('频道 id 不合法');
|
||||
|
||||
// 密钥放在查询串里会随请求发给服务器,等于已经泄露,必须拒绝而不是悄悄当成 raw 链接
|
||||
for (const name of ['k', 'a', 'w']) {
|
||||
if (url.searchParams.has(name)) throw new LinkError('密钥必须放在 # 后面,放在 ? 后面会被发送给服务器');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash);
|
||||
const key = (name: string): Bytes | undefined => {
|
||||
const v = params.get(name);
|
||||
if (v === null) return undefined;
|
||||
const bytes = fromBase64Url(v, KEY_SIZE);
|
||||
if (!bytes) throw new LinkError(`链接里的 ${name} 不合法`);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const k = key('k');
|
||||
const a = key('a');
|
||||
const w = key('w');
|
||||
|
||||
// enc 频道的写令牌由 K 派生,出现 w 说明链接被拼错或被篡改
|
||||
if (k && w) throw new LinkError('enc 链接不应包含 w');
|
||||
|
||||
const link: ParsedLink = {
|
||||
id,
|
||||
idBytes,
|
||||
mode: k ? 'enc' : 'raw',
|
||||
role: a ? 'owner' : 'member',
|
||||
};
|
||||
if (k) link.k = k;
|
||||
if (a) link.a = a;
|
||||
if (w) link.w = w;
|
||||
return link;
|
||||
}
|
||||
|
||||
export interface LinkParts {
|
||||
id: string;
|
||||
k?: Uint8Array;
|
||||
a?: Uint8Array;
|
||||
w?: Uint8Array;
|
||||
}
|
||||
|
||||
/** 生成链接。origin 如 "https://app.example.com",路径固定为 /c/{id}。 */
|
||||
export function buildLink(origin: string, parts: LinkParts): string {
|
||||
const params: string[] = [];
|
||||
if (parts.k) params.push(`k=${toBase64Url(parts.k)}`);
|
||||
if (parts.a) params.push(`a=${toBase64Url(parts.a)}`);
|
||||
if (parts.w) params.push(`w=${toBase64Url(parts.w)}`);
|
||||
const base = `${origin.replace(/\/+$/, '')}/c/${parts.id}`;
|
||||
return params.length > 0 ? `${base}#${params.join('&')}` : base;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { HEADER_SIZE, ID_SIZE } from './constants.js';
|
||||
import { readU32, readU64, toBase64Url, type Bytes } from './bytes.js';
|
||||
import { IntegrityError } from './errors.js';
|
||||
|
||||
/** 墓碑原因(protocol §8)。 */
|
||||
export type RemovedReason = 'deleted' | 'expired' | 'evicted' | 'download_limit' | 'superseded';
|
||||
|
||||
const REASONS: readonly RemovedReason[] = ['deleted', 'expired', 'evicted', 'download_limit', 'superseded'];
|
||||
|
||||
export type LogRecord =
|
||||
| { kind: 'frame'; seq: number; envelope: Bytes }
|
||||
| { kind: 'tombstone'; seq: number; itemId: string; reason: RemovedReason }
|
||||
| { kind: 'announce'; seq: number; itemId: string; flags: number; createdAt: number };
|
||||
|
||||
const KIND_FRAME = 1;
|
||||
const KIND_TOMBSTONE = 2;
|
||||
const KIND_ANNOUNCE = 3;
|
||||
|
||||
/** body 的字节数,不够返回 null。 */
|
||||
function bodyLength(kind: number, buf: Uint8Array, bodyStart: number): number | null {
|
||||
switch (kind) {
|
||||
case KIND_FRAME: {
|
||||
// 封装自带 payload_len:body 长度 = 26 + payload_len
|
||||
if (buf.length < bodyStart + HEADER_SIZE) return null;
|
||||
return HEADER_SIZE + readU32(buf, bodyStart + 22);
|
||||
}
|
||||
case KIND_TOMBSTONE:
|
||||
return ID_SIZE + 1;
|
||||
case KIND_ANNOUNCE:
|
||||
return ID_SIZE + 1 + 8;
|
||||
default:
|
||||
throw new IntegrityError('bad_envelope');
|
||||
}
|
||||
}
|
||||
|
||||
function parseOne(buf: Uint8Array, offset: number): { record: LogRecord; next: number } {
|
||||
if (buf.length < offset + 9) throw new IntegrityError('bad_envelope');
|
||||
const seq = readU64(buf, offset);
|
||||
const kind = buf[offset + 8] as number;
|
||||
const bodyStart = offset + 9;
|
||||
|
||||
const len = bodyLength(kind, buf, bodyStart);
|
||||
if (len === null || buf.length < bodyStart + len) throw new IntegrityError('bad_envelope');
|
||||
const body = buf.subarray(bodyStart, bodyStart + len);
|
||||
const next = bodyStart + len;
|
||||
|
||||
switch (kind) {
|
||||
case KIND_FRAME:
|
||||
return { record: { kind: 'frame', seq, envelope: body.slice() }, next };
|
||||
case KIND_TOMBSTONE: {
|
||||
const reason = REASONS[body[ID_SIZE] as number];
|
||||
if (!reason) throw new IntegrityError('bad_envelope');
|
||||
return { record: { kind: 'tombstone', seq, itemId: toBase64Url(body.subarray(0, ID_SIZE)), reason }, next };
|
||||
}
|
||||
default:
|
||||
return {
|
||||
record: {
|
||||
kind: 'announce',
|
||||
seq,
|
||||
itemId: toBase64Url(body.subarray(0, ID_SIZE)),
|
||||
flags: body[ID_SIZE] as number,
|
||||
createdAt: readU64(body, ID_SIZE + 1),
|
||||
},
|
||||
next,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 log 接口返回的记录拼接(长轮询的响应体)。 */
|
||||
export function parseLogRecords(data: Uint8Array): LogRecord[] {
|
||||
const out: LogRecord[] = [];
|
||||
let offset = 0;
|
||||
while (offset < data.length) {
|
||||
const { record, next } = parseOne(data, offset);
|
||||
out.push(record);
|
||||
offset = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 解析恰好一条记录(WebSocket 的一条二进制消息)。多余或缺少字节都视为非法。 */
|
||||
export function parseLogRecord(data: Uint8Array): LogRecord {
|
||||
const { record, next } = parseOne(data, 0);
|
||||
if (next !== data.length) throw new IntegrityError('bad_envelope');
|
||||
return record;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Flags, type ChannelMode, type ItemKind } from './constants.js';
|
||||
import { concat, equalBytes, tryDecodeUtf8, toBase64Url, type Bytes } from './bytes.js';
|
||||
import { IntegrityError, type IntegrityReason } from './errors.js';
|
||||
import { openFrame, parseEnvelope, type Envelope } from './frame.js';
|
||||
import { sha256 } from './kdf.js';
|
||||
import { decodeFirstFrame, type ItemMeta } from './item.js';
|
||||
import { parseLogRecord, type LogRecord, type RemovedReason } from './log.js';
|
||||
|
||||
/** 接收端需要的会话信息。由 ChannelSession 提供。 */
|
||||
export interface ReaderContext {
|
||||
channelId: Uint8Array;
|
||||
mode: ChannelMode;
|
||||
/** enc 模式:取某个条目的 AES-GCM 密钥。 */
|
||||
itemKey(itemId: Uint8Array): Promise<CryptoKey>;
|
||||
}
|
||||
|
||||
export type ReceivedKind = ItemKind | 'raw';
|
||||
|
||||
export interface ReceivedItem {
|
||||
itemId: string;
|
||||
/** 帧所在日志记录的 seq(经 pushFrame 单独喂入且未提供 seq 时为空)。 */
|
||||
seq: number | undefined;
|
||||
kind: ReceivedKind;
|
||||
/** 仅 enc 模式。 */
|
||||
meta?: ItemMeta;
|
||||
snapshot: boolean;
|
||||
gated: boolean;
|
||||
/** 已收到已认证的 FINAL,且 0..FINAL 的帧全部到齐。 */
|
||||
complete: boolean;
|
||||
/** 到第一个缺口为止已收到的内容。大文件请改用 decryptItemStream,不要在这里缓冲。 */
|
||||
content: Bytes;
|
||||
}
|
||||
|
||||
export type ItemEvent =
|
||||
/** GATED 条目的通知:字节需要另外拉取(GET /items/{id}/frames),拉取计入下载次数。 */
|
||||
| { type: 'announce'; itemId: string; seq: number; flags: number; createdAt: number }
|
||||
/** 一个条目有新内容或已完成。小内容(单帧)只会触发一次,complete 为 true。 */
|
||||
| { type: 'item'; item: ReceivedItem }
|
||||
/** 条目被删除、过期、淘汰、取代或下载达上限。 */
|
||||
| { type: 'removed'; itemId: string; seq: number; reason: RemovedReason }
|
||||
/** 一帧被拒绝(篡改、乱序、模式不符等)。该条目的缓冲状态已丢弃。 */
|
||||
| { type: 'rejected'; itemId: string | undefined; seq: number | undefined; reason: IntegrityReason };
|
||||
|
||||
interface FrameState {
|
||||
content: Bytes;
|
||||
digest: Bytes;
|
||||
}
|
||||
|
||||
interface ItemState {
|
||||
itemFlags: number;
|
||||
frames: Map<number, FrameState>;
|
||||
finalIndex: number | undefined;
|
||||
maxIndex: number;
|
||||
meta: ItemMeta | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目装配器:喂入日志记录或条目的帧,吐出解密、校验后的事件。
|
||||
*
|
||||
* 校验失败**不抛异常**,而是产生 `rejected` 事件并丢弃该条目的缓冲:
|
||||
* 一个坏帧(或恶意服务端)不应让整个订阅挂掉。直接调用 openFrame 才会抛 IntegrityError。
|
||||
*/
|
||||
export class ItemReader {
|
||||
readonly #ctx: ReaderContext;
|
||||
readonly #open = new Map<string, ItemState>();
|
||||
/** 已完成的条目。回放时重复到达的帧直接忽略,不会让条目「复活」。 */
|
||||
readonly #done = new Set<string>();
|
||||
|
||||
constructor(ctx: ReaderContext) {
|
||||
this.#ctx = ctx;
|
||||
}
|
||||
|
||||
/** 喂入一条日志记录(WebSocket 的一条二进制消息,或 parseLogRecords 的一项)。 */
|
||||
async push(input: Uint8Array | LogRecord): Promise<ItemEvent[]> {
|
||||
let record: LogRecord;
|
||||
try {
|
||||
record = input instanceof Uint8Array ? parseLogRecord(input) : input;
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) return [{ type: 'rejected', itemId: undefined, seq: undefined, reason: e.reason }];
|
||||
throw e;
|
||||
}
|
||||
|
||||
switch (record.kind) {
|
||||
case 'announce':
|
||||
return [{ type: 'announce', itemId: record.itemId, seq: record.seq, flags: record.flags, createdAt: record.createdAt }];
|
||||
case 'tombstone':
|
||||
this.#open.delete(record.itemId);
|
||||
this.#done.delete(record.itemId);
|
||||
return [{ type: 'removed', itemId: record.itemId, seq: record.seq, reason: record.reason }];
|
||||
case 'frame':
|
||||
return this.pushFrame(record.envelope, record.seq);
|
||||
}
|
||||
}
|
||||
|
||||
/** 喂入一个完整的封装(例如拉取 GATED 条目时按帧切出来的)。 */
|
||||
async pushFrame(envelope: Uint8Array, seq?: number): Promise<ItemEvent[]> {
|
||||
let env: Envelope;
|
||||
try {
|
||||
env = parseEnvelope(envelope);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) return [{ type: 'rejected', itemId: undefined, seq, reason: e.reason }];
|
||||
throw e;
|
||||
}
|
||||
|
||||
const itemId = toBase64Url(env.itemId);
|
||||
try {
|
||||
return await this.#accept(env, itemId, seq);
|
||||
} catch (e) {
|
||||
if (!(e instanceof IntegrityError)) throw e;
|
||||
this.#open.delete(itemId);
|
||||
return [{ type: 'rejected', itemId, seq, reason: e.reason }];
|
||||
}
|
||||
}
|
||||
|
||||
/** 丢弃某个条目的缓冲(例如用户取消了下载)。 */
|
||||
forget(itemId: string): void {
|
||||
this.#open.delete(itemId);
|
||||
this.#done.delete(itemId);
|
||||
}
|
||||
|
||||
async #accept(env: Envelope, itemId: string, seq: number | undefined): Promise<ItemEvent[]> {
|
||||
const isEncFrame = (env.flags & Flags.Enc) !== 0;
|
||||
// 降级防御:enc 会话只接受认证过的 enc 帧,服务端不能注入明文帧;raw 会话反之
|
||||
if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode');
|
||||
if (this.#done.has(itemId)) return [];
|
||||
|
||||
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot);
|
||||
const gated = (env.flags & Flags.Gated) !== 0;
|
||||
const isFinal = (env.flags & Flags.Final) !== 0;
|
||||
// 非 GATED 条目必须是单帧且 FINAL(协议规则,服务端也强制)
|
||||
if (!gated && (env.frameIndex !== 0 || !isFinal)) throw new IntegrityError('bad_envelope');
|
||||
|
||||
let state = this.#open.get(itemId);
|
||||
if (state && state.itemFlags !== itemFlags) throw new IntegrityError('flags_changed');
|
||||
if (state?.finalIndex !== undefined && env.frameIndex > state.finalIndex) throw new IntegrityError('frame_after_final');
|
||||
if (isFinal && state) {
|
||||
if (state.maxIndex > env.frameIndex) throw new IntegrityError('final_conflict');
|
||||
if (state.finalIndex !== undefined && state.finalIndex !== env.frameIndex) throw new IntegrityError('final_conflict');
|
||||
}
|
||||
|
||||
// 重复帧:内容必须逐字节相同,相同则视为重放,不产生事件
|
||||
const digest = await sha256(env.payload);
|
||||
const dup = state?.frames.get(env.frameIndex);
|
||||
if (dup) {
|
||||
if (!equalBytes(dup.digest, digest)) throw new IntegrityError('duplicate_mismatch');
|
||||
return [];
|
||||
}
|
||||
|
||||
// 解密 / 取出内容
|
||||
let data: Bytes;
|
||||
if (this.#ctx.mode === 'enc') {
|
||||
data = await openFrame(await this.#ctx.itemKey(env.itemId), this.#ctx.channelId, env);
|
||||
} else {
|
||||
data = env.payload;
|
||||
}
|
||||
|
||||
let meta = state?.meta;
|
||||
let content = data;
|
||||
if (this.#ctx.mode === 'enc' && env.frameIndex === 0) {
|
||||
const first = decodeFirstFrame(data);
|
||||
meta = first.meta;
|
||||
content = first.content;
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
state = { itemFlags, frames: new Map(), finalIndex: undefined, maxIndex: -1, meta: undefined };
|
||||
this.#open.set(itemId, state);
|
||||
}
|
||||
state.frames.set(env.frameIndex, { content, digest });
|
||||
state.maxIndex = Math.max(state.maxIndex, env.frameIndex);
|
||||
if (isFinal) state.finalIndex = env.frameIndex;
|
||||
if (meta) state.meta = meta;
|
||||
|
||||
// 拼接到第一个缺口为止
|
||||
const parts: Uint8Array[] = [];
|
||||
for (let i = 0; state.frames.has(i); i++) parts.push((state.frames.get(i) as FrameState).content);
|
||||
const complete = state.finalIndex !== undefined && parts.length === state.finalIndex + 1;
|
||||
|
||||
const item: ReceivedItem = {
|
||||
itemId,
|
||||
seq,
|
||||
kind: this.#ctx.mode === 'raw' ? 'raw' : (state.meta?.kind ?? 'other'),
|
||||
snapshot: (itemFlags & Flags.Snapshot) !== 0,
|
||||
gated,
|
||||
complete,
|
||||
content: concat(...parts),
|
||||
};
|
||||
if (state.meta) item.meta = state.meta;
|
||||
|
||||
if (complete) {
|
||||
this.#open.delete(itemId);
|
||||
this.#done.add(itemId);
|
||||
}
|
||||
return [{ type: 'item', item }];
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 便利函数 ─────────────────────────
|
||||
|
||||
/** 内容按 UTF-8 解码,非法序列抛 TypeError。 */
|
||||
export function itemText(item: Pick<ReceivedItem, 'content'>): string {
|
||||
const text = tryDecodeUtf8(item.content);
|
||||
if (text === null) throw new TypeError('条目内容不是合法的 UTF-8');
|
||||
return text;
|
||||
}
|
||||
|
||||
export function itemJson(item: Pick<ReceivedItem, 'content'>): unknown {
|
||||
return JSON.parse(itemText(item));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import { AAD_LABEL, ID_SIZE, KEY_SIZE, LABEL_IV_SIZE, MAX_LABEL_BYTES, TAG_SIZE, type ChannelMode } from './constants.js';
|
||||
import { concat, fromBase64Url, randomBytes, toBase64Url, tryDecodeUtf8, utf8, wipe, type Bytes } from './bytes.js';
|
||||
import { CapabilityError, IntegrityError, LimitError, LinkError, ModeMismatchError } from './errors.js';
|
||||
import { encodeEnvelope, sealFrame } from './frame.js';
|
||||
import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.js';
|
||||
import { buildLink, parseLink, type ParsedLink } from './link.js';
|
||||
import { planEncItem, planRawItem, type ContentSource, type ItemMeta, type ItemPlan, type PlanOptions } from './item.js';
|
||||
import { ItemReader, type ReaderContext } from './reader.js';
|
||||
import { decryptItemStream, type DecryptStreamOptions, type ItemDecodeResult } from './stream.js';
|
||||
|
||||
export type AuthRole = 'read' | 'write' | 'owner';
|
||||
|
||||
/** 频道设置,字段名与 API 一致(snake_case)。 */
|
||||
export interface ChannelSettings {
|
||||
seats?: number;
|
||||
members_can_upload?: boolean;
|
||||
delete_policy?: 'owner_only' | 'own' | 'anyone';
|
||||
ttl_seconds?: number;
|
||||
max_items?: number;
|
||||
file_max_downloads?: number;
|
||||
ui_restrict_copy_download?: boolean;
|
||||
}
|
||||
|
||||
/** POST /api/v1/channels 的请求体。 */
|
||||
export interface CreateChannelBody {
|
||||
id: string;
|
||||
mode: ChannelMode;
|
||||
settings?: ChannelSettings;
|
||||
read_hash?: string;
|
||||
write_hash?: string;
|
||||
owner_hash: string;
|
||||
}
|
||||
|
||||
export interface CreateOptions {
|
||||
mode?: ChannelMode;
|
||||
settings?: ChannelSettings;
|
||||
/** 本设备在这个频道里的终端密钥。由前端持久化(IndexedDB),下次打开链接时传回来;不传则新生成。 */
|
||||
endpointSecret?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface OpenOptions {
|
||||
endpointSecret?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface CreatedChannel {
|
||||
session: ChannelSession;
|
||||
/**
|
||||
* 创建请求。服务端启用验证码时,还要在 headers 之外加上 `X-WD-Cap: <Cap 的 token>`
|
||||
* (token 由前端用 Cap widget 求解得到,见 GET /info 的 captcha.api_endpoint)。
|
||||
*/
|
||||
request: { body: CreateChannelBody; headers: Record<string, string> };
|
||||
}
|
||||
|
||||
export interface PlanItemOptions extends PlanOptions {
|
||||
/** 覆盖默认的时间戳(毫秒),主要用于测试。 */
|
||||
ts?: number;
|
||||
/** 覆盖设备名。默认使用 session.deviceName。 */
|
||||
dev?: string;
|
||||
/** 仅 raw 模式:条目标签,作为 X-WD-Label 随第 0 帧发送(明文,服务端可见)。enc 模式下忽略,名字放在加密的元数据里。 */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface FileMetaOverrides {
|
||||
name?: string;
|
||||
mime?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const ITEM_KEY_CACHE = 256;
|
||||
|
||||
interface SessionInit {
|
||||
link: Pick<ParsedLink, 'id' | 'idBytes' | 'mode' | 'role'>;
|
||||
k?: Bytes;
|
||||
a?: Bytes;
|
||||
w?: Bytes;
|
||||
keys?: ChannelKeys;
|
||||
ownerToken?: Bytes;
|
||||
endpointSecret: Bytes;
|
||||
endpointHash: Bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个频道的会话:由链接中的密钥派生出一切,并提供加密、解密和请求头。
|
||||
*
|
||||
* 密钥材料说明:AES 密钥(每条目、设备名)是不可导出的 CryptoKey,K_enc 导入后原始字节即清零。
|
||||
* 但链接里的 K / A / W 必须保留在内存里(用来重新生成分享链接),令牌必须以字节形式放进请求头,
|
||||
* JS 无法保证这些内存被真正擦除。调用 dispose() 只能尽力清零。
|
||||
*/
|
||||
export class ChannelSession {
|
||||
readonly id: string;
|
||||
readonly idBytes: Bytes;
|
||||
readonly mode: ChannelMode;
|
||||
/** 来自链接:带管理密钥的是所有者,否则是成员。 */
|
||||
readonly role: 'owner' | 'member';
|
||||
/** 本终端的密钥 S(32 字节)。前端必须持久化它,否则每次打开都会占用新席位。 */
|
||||
readonly endpointSecret: Bytes;
|
||||
/** SHA-256(S),即服务端看到的终端标识。 */
|
||||
readonly endpointHash: Bytes;
|
||||
/** 加密条目元数据时默认带上的设备名。 */
|
||||
deviceName: string | undefined;
|
||||
|
||||
readonly #k: Bytes | undefined;
|
||||
readonly #a: Bytes | undefined;
|
||||
readonly #w: Bytes | undefined;
|
||||
readonly #keys: ChannelKeys | undefined;
|
||||
readonly #ownerToken: Bytes | undefined;
|
||||
readonly #itemKeys = new Map<string, Promise<CryptoKey>>();
|
||||
|
||||
private constructor(init: SessionInit) {
|
||||
this.id = init.link.id;
|
||||
this.idBytes = init.link.idBytes;
|
||||
this.mode = init.link.mode;
|
||||
this.role = init.link.role;
|
||||
this.#k = init.k;
|
||||
this.#a = init.a;
|
||||
this.#w = init.w;
|
||||
this.#keys = init.keys;
|
||||
this.#ownerToken = init.ownerToken;
|
||||
this.endpointSecret = init.endpointSecret;
|
||||
this.endpointHash = init.endpointHash;
|
||||
}
|
||||
|
||||
// ───────────────────────── 创建与打开 ─────────────────────────
|
||||
|
||||
/** 打开一个频道链接(含降级防御:链接里有 k 就是 enc,否则 raw)。 */
|
||||
static async open(link: string | URL | ParsedLink, opts: OpenOptions = {}): Promise<ChannelSession> {
|
||||
const parsed = typeof link === 'string' || link instanceof URL ? parseLink(link) : link;
|
||||
const endpointSecret = opts.endpointSecret ? Uint8Array.from(opts.endpointSecret) : randomBytes(KEY_SIZE);
|
||||
if (endpointSecret.length !== KEY_SIZE) throw new RangeError('终端密钥必须是 32 字节');
|
||||
|
||||
const init: SessionInit = {
|
||||
link: parsed,
|
||||
endpointSecret,
|
||||
endpointHash: await sha256(endpointSecret),
|
||||
};
|
||||
if (parsed.k) {
|
||||
init.k = parsed.k;
|
||||
init.keys = await deriveChannelKeys(parsed.idBytes, parsed.k);
|
||||
}
|
||||
if (parsed.a) {
|
||||
init.a = parsed.a;
|
||||
init.ownerToken = await deriveOwnerToken(parsed.idBytes, parsed.a);
|
||||
}
|
||||
if (parsed.w) init.w = parsed.w;
|
||||
return new ChannelSession(init);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新频道:生成 id 和密钥,返回会话以及 POST /channels 的请求体和请求头。
|
||||
* 随机数全部来自 crypto.getRandomValues。
|
||||
*/
|
||||
static async create(opts: CreateOptions = {}): Promise<CreatedChannel> {
|
||||
const mode = opts.mode ?? 'enc';
|
||||
const idBytes = randomBytes(ID_SIZE);
|
||||
const id = toBase64Url(idBytes);
|
||||
const a = randomBytes(KEY_SIZE);
|
||||
const endpointSecret = opts.endpointSecret ? Uint8Array.from(opts.endpointSecret) : randomBytes(KEY_SIZE);
|
||||
if (endpointSecret.length !== KEY_SIZE) throw new RangeError('终端密钥必须是 32 字节');
|
||||
|
||||
const ownerToken = await deriveOwnerToken(idBytes, a);
|
||||
const body: CreateChannelBody = { id, mode, owner_hash: toBase64Url(await sha256(ownerToken)) };
|
||||
if (opts.settings) body.settings = opts.settings;
|
||||
|
||||
const init: SessionInit = {
|
||||
link: { id, idBytes, mode, role: 'owner' as const },
|
||||
a,
|
||||
ownerToken,
|
||||
endpointSecret,
|
||||
endpointHash: await sha256(endpointSecret),
|
||||
};
|
||||
|
||||
if (mode === 'enc') {
|
||||
const k = randomBytes(KEY_SIZE);
|
||||
const keys = await deriveChannelKeys(idBytes, k);
|
||||
init.k = k;
|
||||
init.keys = keys;
|
||||
body.read_hash = toBase64Url(await sha256(keys.readToken));
|
||||
// enc 频道的写令牌总是由 K 派生;成员能不能写由频道设置 members_can_upload 决定
|
||||
body.write_hash = toBase64Url(await sha256(keys.writeToken));
|
||||
} else if (opts.settings?.members_can_upload !== false) {
|
||||
// raw 频道没有 K:成员的写令牌是随机的 W,放进成员链接
|
||||
const w = randomBytes(KEY_SIZE);
|
||||
init.w = w;
|
||||
body.write_hash = toBase64Url(await sha256(w));
|
||||
}
|
||||
|
||||
const session = new ChannelSession(init);
|
||||
return { session, request: { body, headers: session.authHeaders('owner') } };
|
||||
}
|
||||
|
||||
// ───────────────────────── 凭据与链接 ─────────────────────────
|
||||
|
||||
/** 当前会话能以哪些角色发请求。 */
|
||||
can(role: AuthRole): boolean {
|
||||
switch (role) {
|
||||
case 'read':
|
||||
return true; // raw 无需令牌;enc 链接一定带 K
|
||||
case 'write':
|
||||
return this.mode === 'enc' ? this.#keys !== undefined : this.#w !== undefined;
|
||||
case 'owner':
|
||||
return this.#ownerToken !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** X-WD-Role / Authorization / X-WD-Endpoint。缺少对应密钥时抛 CapabilityError。 */
|
||||
authHeaders(role: AuthRole): Record<string, string> {
|
||||
if (!this.can(role)) throw new CapabilityError(role);
|
||||
const headers: Record<string, string> = {
|
||||
'X-WD-Role': role,
|
||||
'X-WD-Endpoint': toBase64Url(this.endpointSecret),
|
||||
};
|
||||
const token = this.#token(role);
|
||||
if (token) headers.Authorization = `Bearer ${toBase64Url(token)}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
#token(role: AuthRole): Bytes | undefined {
|
||||
if (role === 'owner') return this.#ownerToken;
|
||||
if (this.mode === 'enc') return role === 'read' ? this.#keys?.readToken : this.#keys?.writeToken;
|
||||
return role === 'write' ? this.#w : undefined; // raw 的读权限就是 id 本身
|
||||
}
|
||||
|
||||
/**
|
||||
* 核对服务端返回的频道信息(GET /channels/{id} 或创建响应)与链接是否一致。
|
||||
* 链接带 k 却被告知是 raw,说明服务端想让客户端降级成明文,必须拒绝。
|
||||
*/
|
||||
verifyChannelInfo(info: { id: string; mode: string }): void {
|
||||
if (info.id !== this.id) throw new LinkError('服务端返回了另一个频道');
|
||||
if (info.mode !== this.mode) throw new ModeMismatchError();
|
||||
}
|
||||
|
||||
/** 生成分享链接。origin 如 "https://app.example.com"。 */
|
||||
link(origin: string, kind: 'member' | 'owner' = 'member'): string {
|
||||
if (kind === 'owner') {
|
||||
if (!this.#a) throw new CapabilityError('owner');
|
||||
return buildLink(origin, { id: this.id, ...(this.#k && { k: this.#k }), a: this.#a, ...(this.#w && { w: this.#w }) });
|
||||
}
|
||||
return buildLink(origin, { id: this.id, ...(this.#k && { k: this.#k }), ...(this.mode === 'raw' && this.#w && { w: this.#w }) });
|
||||
}
|
||||
|
||||
// ───────────────────────── 发送:加密成帧 ─────────────────────────
|
||||
|
||||
/** 一个条目的 AES-GCM 密钥,带缓存。 */
|
||||
itemKey(itemId: Uint8Array): Promise<CryptoKey> {
|
||||
if (!this.#keys) throw new CapabilityError('enc');
|
||||
const cacheKey = toBase64Url(itemId);
|
||||
let key = this.#itemKeys.get(cacheKey);
|
||||
if (!key) {
|
||||
key = deriveItemKey(this.#keys.encRoot, itemId);
|
||||
if (this.#itemKeys.size >= ITEM_KEY_CACHE) this.#itemKeys.delete(this.#itemKeys.keys().next().value as string);
|
||||
this.#itemKeys.set(cacheKey, key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
#meta(kind: ItemMeta['kind'], opts: PlanItemOptions, extra: Partial<ItemMeta> = {}): ItemMeta {
|
||||
const meta: ItemMeta = { kind, ts: opts.ts ?? Date.now(), ...extra };
|
||||
const dev = opts.dev ?? this.deviceName;
|
||||
if (dev !== undefined) meta.dev = dev;
|
||||
return meta;
|
||||
}
|
||||
|
||||
#plan(source: ContentSource, meta: ItemMeta, opts: PlanItemOptions): ItemPlan {
|
||||
if (this.mode === 'enc') {
|
||||
return planEncItem(
|
||||
async (p) => sealFrame(await this.itemKey(p.itemId), this.idBytes, p),
|
||||
source,
|
||||
meta,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
// raw:没有元数据,内容原样放进负载
|
||||
return planRawItem((p) => encodeEnvelope(p.flags, p.itemId, p.index, p.payload), source, opts);
|
||||
}
|
||||
|
||||
encryptBytes(content: Uint8Array, meta: Partial<ItemMeta> & { kind?: ItemMeta['kind'] } = {}, opts: PlanItemOptions = {}): ItemPlan {
|
||||
const { kind, ...rest } = meta;
|
||||
return this.#plan(content, this.#meta(kind ?? 'other', opts, rest), opts);
|
||||
}
|
||||
|
||||
encryptText(text: string, opts: PlanItemOptions = {}): ItemPlan {
|
||||
return this.#plan(utf8(text), this.#meta('text', opts), opts);
|
||||
}
|
||||
|
||||
/** 把一个 JSON 值作为条目发送。`snapshot: true` 时作为频道的最新快照。 */
|
||||
encryptJson(value: unknown, opts: PlanItemOptions = {}): ItemPlan {
|
||||
const text = JSON.stringify(value);
|
||||
if (text === undefined) throw new TypeError('值无法序列化为 JSON');
|
||||
return this.#plan(utf8(text), this.#meta('json', opts), opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文件或流。File/Blob 会自动带上文件名、类型和大小;ReadableStream 的大小未知,
|
||||
* 可通过 meta 覆盖。放不进单帧的内容自动拆成多帧并标记 GATED。
|
||||
*/
|
||||
encryptFile(file: Blob | ReadableStream<Uint8Array>, meta: FileMetaOverrides = {}, opts: PlanItemOptions = {}): ItemPlan {
|
||||
const extra: Partial<ItemMeta> = {};
|
||||
const isBlob = typeof Blob !== 'undefined' && file instanceof Blob;
|
||||
const name = meta.name ?? (isBlob && 'name' in file ? (file as File).name : undefined);
|
||||
const mime = meta.mime ?? (isBlob && file.type ? file.type : undefined);
|
||||
const size = meta.size ?? (isBlob ? file.size : undefined);
|
||||
if (name !== undefined) extra.name = name;
|
||||
if (mime !== undefined) extra.mime = mime;
|
||||
if (size !== undefined) extra.size = size;
|
||||
return this.#plan(file, this.#meta('file', opts, extra), opts);
|
||||
}
|
||||
|
||||
// ───────────────────────── 接收:解密与装配 ─────────────────────────
|
||||
|
||||
get #readerContext(): ReaderContext {
|
||||
return {
|
||||
channelId: this.idBytes,
|
||||
mode: this.mode,
|
||||
itemKey: (itemId) => this.itemKey(itemId),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建一个条目装配器:喂入 WebSocket / log 接口的记录,得到解密后的条目事件。 */
|
||||
createItemReader(): ItemReader {
|
||||
return new ItemReader(this.#readerContext);
|
||||
}
|
||||
|
||||
/** 流式解密一个条目(GET /items/{id}/frames 的响应体),用于大文件。 */
|
||||
decryptItemStream(
|
||||
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
||||
opts: DecryptStreamOptions = {},
|
||||
): { content: ReadableStream<Uint8Array>; result: Promise<ItemDecodeResult> } {
|
||||
return decryptItemStream(this.#readerContext, source, opts);
|
||||
}
|
||||
|
||||
// ───────────────────────── 设备名(protocol §10) ─────────────────────────
|
||||
|
||||
/**
|
||||
* 加密设备名,作为 PUT /seats/me 的请求体。格式:iv(12) ‖ AES-GCM(K_label, 名称) ‖ tag(16),
|
||||
* AAD = "WD1L" ‖ channel_id ‖ endpoint_hash。raw 模式下设备名是明文 UTF-8。
|
||||
* @param opts.iv 仅用于测试向量,真实使用必须让库随机生成。
|
||||
*/
|
||||
async sealLabel(name: string, opts: { iv?: Uint8Array } = {}): Promise<Bytes> {
|
||||
const plain = utf8(name);
|
||||
if (this.mode === 'raw') {
|
||||
if (plain.length > MAX_LABEL_BYTES) throw new LimitError('设备名过长');
|
||||
return plain;
|
||||
}
|
||||
if (plain.length === 0) throw new RangeError('设备名不能为空');
|
||||
if (plain.length + LABEL_IV_SIZE + TAG_SIZE > MAX_LABEL_BYTES) {
|
||||
throw new LimitError(`设备名过长(最多 ${MAX_LABEL_BYTES - LABEL_IV_SIZE - TAG_SIZE} 字节 UTF-8)`);
|
||||
}
|
||||
const iv = opts.iv ?? randomBytes(LABEL_IV_SIZE);
|
||||
const sealed = await globalThis.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv as Bytes, additionalData: this.#labelAad(this.endpointHash), tagLength: 128 },
|
||||
(this.#keys as ChannelKeys).labelKey,
|
||||
plain,
|
||||
);
|
||||
return concat(iv, new Uint8Array(sealed));
|
||||
}
|
||||
|
||||
/** 解密席位列表里某个终端的设备名。endpointHash 可以是 base64url 文本或字节。 */
|
||||
async openLabel(blob: Uint8Array | string, endpointHash: Uint8Array | string): Promise<string> {
|
||||
const raw = typeof blob === 'string' ? fromBase64Url(blob) : blob;
|
||||
const hash = typeof endpointHash === 'string' ? fromBase64Url(endpointHash, 32) : endpointHash;
|
||||
if (!raw || !hash) throw new IntegrityError('bad_envelope');
|
||||
|
||||
if (this.mode === 'raw') {
|
||||
const text = tryDecodeUtf8(raw);
|
||||
if (text === null) throw new IntegrityError('bad_meta');
|
||||
return text;
|
||||
}
|
||||
if (raw.length < LABEL_IV_SIZE + TAG_SIZE + 1) throw new IntegrityError('bad_envelope');
|
||||
try {
|
||||
const plain = await globalThis.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: raw.slice(0, LABEL_IV_SIZE), additionalData: this.#labelAad(hash), tagLength: 128 },
|
||||
(this.#keys as ChannelKeys).labelKey,
|
||||
raw.slice(LABEL_IV_SIZE),
|
||||
);
|
||||
const text = tryDecodeUtf8(new Uint8Array(plain));
|
||||
if (text === null) throw new IntegrityError('bad_meta');
|
||||
return text;
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) throw e;
|
||||
throw new IntegrityError('auth_failed');
|
||||
}
|
||||
}
|
||||
|
||||
#labelAad(endpointHash: Uint8Array): Bytes {
|
||||
return concat(utf8(AAD_LABEL), this.idBytes, endpointHash);
|
||||
}
|
||||
|
||||
// ───────────────────────── 清理 ─────────────────────────
|
||||
|
||||
/** 尽力清零可以清零的字节并清空缓存。JS 无法保证擦除,这只是缩小暴露窗口。 */
|
||||
dispose(): void {
|
||||
for (const b of [this.#k, this.#a, this.#w, this.#ownerToken, this.#keys?.readToken, this.#keys?.writeToken]) {
|
||||
if (b) wipe(b);
|
||||
}
|
||||
wipe(this.endpointSecret);
|
||||
this.#itemKeys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开频道链接的便捷函数。 */
|
||||
export const openLink = ChannelSession.open;
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Flags } from './constants.js';
|
||||
import { equalBytes, type Bytes } from './bytes.js';
|
||||
import { IntegrityError } from './errors.js';
|
||||
import { MAX_ENC_PAYLOAD, openFrame, parseEnvelope, peekEnvelopeLength } from './frame.js';
|
||||
import { decodeFirstFrame, type ItemMeta } from './item.js';
|
||||
import type { ReaderContext } from './reader.js';
|
||||
|
||||
/** 字节队列:接收任意切分的块,按需取出前 n 个字节。 */
|
||||
class ByteQueue {
|
||||
readonly #parts: Uint8Array[] = [];
|
||||
#length = 0;
|
||||
|
||||
get length(): number {
|
||||
return this.#length;
|
||||
}
|
||||
|
||||
push(chunk: Uint8Array): void {
|
||||
if (chunk.length === 0) return;
|
||||
this.#parts.push(chunk);
|
||||
this.#length += chunk.length;
|
||||
}
|
||||
|
||||
/** 复制前 n 个字节,不消耗。 */
|
||||
peek(n: number): Bytes {
|
||||
const out = new Uint8Array(n);
|
||||
let filled = 0;
|
||||
for (const part of this.#parts) {
|
||||
if (filled >= n) break;
|
||||
const take = Math.min(part.length, n - filled);
|
||||
out.set(part.subarray(0, take), filled);
|
||||
filled += take;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
take(n: number): Bytes {
|
||||
const out = new Uint8Array(n);
|
||||
let filled = 0;
|
||||
while (filled < n) {
|
||||
const head = this.#parts[0] as Uint8Array;
|
||||
const need = n - filled;
|
||||
if (head.length <= need) {
|
||||
out.set(head, filled);
|
||||
filled += head.length;
|
||||
this.#parts.shift();
|
||||
} else {
|
||||
out.set(head.subarray(0, need), filled);
|
||||
this.#parts[0] = head.subarray(need);
|
||||
filled += need;
|
||||
}
|
||||
}
|
||||
this.#length -= n;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
async function* bytesOf(source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array, void, void> {
|
||||
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
|
||||
const reader = source.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
yield* source as AsyncIterable<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「封装首尾相接」的字节流(GET /items/{id}/frames 的响应体)切成一个个完整封装。
|
||||
* 连接中途断开会在末尾留下半个封装:这是正常的网络情况,不是篡改,所以直接丢弃,
|
||||
* 调用方根据 decryptItemStream 报告的 nextIndex 续传即可。
|
||||
*/
|
||||
export async function* splitEnvelopes(
|
||||
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
||||
): AsyncGenerator<Bytes, void, void> {
|
||||
const queue = new ByteQueue();
|
||||
for await (const chunk of bytesOf(source)) {
|
||||
queue.push(chunk);
|
||||
for (;;) {
|
||||
if (queue.length < 26) break;
|
||||
const total = peekEnvelopeLength(queue.peek(26), MAX_ENC_PAYLOAD);
|
||||
if (total === null || queue.length < total) break;
|
||||
yield queue.take(total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DecryptStreamOptions {
|
||||
/** 这次响应从哪一帧开始(拉取时的 from 参数),默认 0。 */
|
||||
fromIndex?: number;
|
||||
/** 期望的条目 id(base64url 解码后的字节)。提供后,混入别的条目的帧会被拒绝。 */
|
||||
itemId?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface ItemDecodeResult {
|
||||
/** 已收到已认证的 FINAL。 */
|
||||
complete: boolean;
|
||||
/** 下一个期望的帧序号。没收完时用它作为 from 续传(不会重复计下载次数)。 */
|
||||
nextIndex: number;
|
||||
/** 第 0 帧里的元数据;从中间帧续传时没有。 */
|
||||
meta: ItemMeta | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式解密一个条目:边收边解边产出内容,内存占用只有一帧。可以放进 Worker 里运行。
|
||||
*
|
||||
* 逐帧校验:认证、条目 id、帧序号连续、标志一致、FINAL 之后不再有帧。
|
||||
* 任何一项失败都会抛 IntegrityError(流会出错),已产出的内容不可信的部分由调用方丢弃。
|
||||
* 流正常结束但没有 FINAL 不是错误(连接可能被断开),此时 complete 为 false。
|
||||
*/
|
||||
export async function* decryptItemFrames(
|
||||
ctx: ReaderContext,
|
||||
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
||||
opts: DecryptStreamOptions = {},
|
||||
): AsyncGenerator<Bytes, ItemDecodeResult, void> {
|
||||
let expected = opts.fromIndex ?? 0;
|
||||
let itemId = opts.itemId;
|
||||
let flagsMask: number | undefined;
|
||||
let finalSeen = false;
|
||||
let meta: ItemMeta | undefined;
|
||||
|
||||
for await (const bytes of splitEnvelopes(source)) {
|
||||
const env = parseEnvelope(bytes);
|
||||
|
||||
const isEnc = (env.flags & Flags.Enc) !== 0;
|
||||
if (isEnc !== (ctx.mode === 'enc')) throw new IntegrityError('wrong_mode');
|
||||
|
||||
if (!itemId) itemId = env.itemId;
|
||||
else if (!equalBytes(itemId, env.itemId)) throw new IntegrityError('item_mismatch');
|
||||
|
||||
if (finalSeen) throw new IntegrityError('frame_after_final');
|
||||
if (env.frameIndex !== expected) throw new IntegrityError('gap');
|
||||
|
||||
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot);
|
||||
if (flagsMask === undefined) flagsMask = itemFlags;
|
||||
else if (flagsMask !== itemFlags) throw new IntegrityError('flags_changed');
|
||||
|
||||
let data: Bytes;
|
||||
if (ctx.mode === 'enc') {
|
||||
data = await openFrame(await ctx.itemKey(env.itemId), ctx.channelId, env);
|
||||
if (env.frameIndex === 0) {
|
||||
const first = decodeFirstFrame(data);
|
||||
meta = first.meta;
|
||||
data = first.content;
|
||||
}
|
||||
} else {
|
||||
data = env.payload;
|
||||
}
|
||||
|
||||
expected++;
|
||||
if ((env.flags & Flags.Final) !== 0) finalSeen = true;
|
||||
if (data.length > 0) yield data;
|
||||
}
|
||||
|
||||
return { complete: finalSeen, nextIndex: expected, meta };
|
||||
}
|
||||
|
||||
/** 把 decryptItemFrames 包成 ReadableStream,便于直接 pipe 到文件。 */
|
||||
export function decryptItemStream(
|
||||
ctx: ReaderContext,
|
||||
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
|
||||
opts: DecryptStreamOptions = {},
|
||||
): { content: ReadableStream<Uint8Array>; result: Promise<ItemDecodeResult> } {
|
||||
const gen = decryptItemFrames(ctx, source, opts);
|
||||
let resolve!: (r: ItemDecodeResult) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const result = new Promise<ItemDecodeResult>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
result.catch(() => {}); // 错误通过流本身传播,避免未处理的 rejection
|
||||
|
||||
const content = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const r = await gen.next();
|
||||
if (r.done) {
|
||||
resolve(r.value);
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(r.value);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
controller.error(e);
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
await gen.return({ complete: false, nextIndex: opts.fromIndex ?? 0, meta: undefined });
|
||||
},
|
||||
});
|
||||
return { content, result };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [],
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "test", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
testTimeout: 30_000,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user