Opening a space link while signed out shows the sign-in page first and keeps the link in memory. The channel settings no longer offer the stranger switch, and the client supports API contract major 8.
174 lines
10 KiB
Markdown
174 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.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` | 覆盖时间戳 |
|
|
|
|
**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 消息 |
|
|
| `parseLink(url)` / `buildLink(origin, parts)` | 链接 |
|
|
| `splitEnvelopes(stream)` | 把「封装首尾相接」的字节流切成一个个封装 |
|
|
|
|
## 必须注意的几点
|
|
|
|
**1. 终端密钥只用于便捷分享。** `session.endpointSecret`(32 字节)标识「这个终端」,便捷分享的创建者与读者靠它登记和续传;频道与空间只凭账号会话鉴权,不使用它。分享读者不持久化的话,每次打开链接都会登记一个新访客终端,用 IndexedDB 保存。
|
|
|
|
**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 后不拒绝、链接不拒绝查询串密钥),测试都能变红。其中「分块边界差一」最初漏网,说明测试有缺口,已补上专门的边界对齐用例。
|