chore(repo): initialize WebDrop monorepo

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

No files matched your search

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