Files
justme 8233369cd4 feat(web)!: sign in before joining a space and drop the stranger option
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.
2026-10-16 06:46:15 +08:00
..

@webdrop/core

WebDrop 的端到端加密核心。前端只调用它,不碰密码学,也不用理解帧格式。

  • 只依赖 WebCrypto(crypto.subtle),浏览器、Worker、Node 22+ 都能跑。
  • 纯逻辑:不含 UI,不发网络请求,不处理重连和队列(这些是前端的事)。
  • 协议权威文档是 docs/protocol.md,本库必须通过 test-vectors/ 里的全部向量。

快速上手

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 加密不同内容。

接收

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 缓冲,用流式解密:

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 提供,本库不实现。创建频道前读 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() 只是尽力而为。

开发

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