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