178 lines
10 KiB
Markdown
178 lines
10 KiB
Markdown
# @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 后不拒绝、链接不拒绝查询串密钥),测试都能变红。其中「分块边界差一」最初漏网,说明测试有缺口,已补上专门的边界对齐用例。
|