18 KiB
协议 v1
本文是协议的权威来源。所有实现(服务端、@webdrop/core、将来的 CLI)必须通过 test-vectors/ 中的向量。
1. 编码约定
- 多字节整数一律大端。
- 二进制数据在 URL、头部、JSON 中一律 base64url(无填充)。
- 随机数一律来自 CSPRNG(
crypto.getRandomValues)。
| 名称 | 长度 | 说明 |
|---|---|---|
channel_id |
16 B | 随机,客户端生成 |
item_id |
16 B | 随机,客户端生成 |
K |
32 B | 频道密钥,仅 enc 模式,只存在于 URL fragment |
A |
32 B | 管理密钥,独立随机,只存在于所有者链接 |
W |
32 B | 写密钥,仅 raw 模式且成员可上传时 |
S |
32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 |
2. 模式与链接
频道创建时二选一,之后不可更改。
| enc(默认) | raw | |
|---|---|---|
| 负载 | AES-256-GCM 密文 | 原样字节,服务端可见 |
| 所有者链接 | /c/{id}#k=<K>&a=<A> |
/c/{id}#a=<A>[&w=<W>] |
| 成员链接 | /c/{id}#k=<K> |
/c/{id}[#w=<W>] |
| 读权限 | 持 T_read |
知道 id 即可(能力链接) |
| 写权限 | 持 T_write(成员可上传时)或 T_owner |
持 W 或 A |
降级防御:客户端解析链接时,fragment 含 k 就只接受 mode=enc 的频道且只接受认证通过的帧;不含 k 的链接对应 raw。服务端报告的模式与链接不符时,客户端必须拒绝。
3. 密钥派生(enc)
HKDF-SHA256,salt = channel_id(16 B),输出 32 B。info 为 UTF-8 字符串。
| 输出 | IKM | info |
|---|---|---|
K_enc |
K |
webdrop/v1/enc |
T_read |
K |
webdrop/v1/token/read |
T_write |
K |
webdrop/v1/token/write |
T_owner |
A |
webdrop/v1/token/owner |
K_item |
K_enc,salt = item_id |
webdrop/v1/item |
K_label |
K_enc,salt = channel_id |
webdrop/v1/label |
raw 模式下 T_owner 同样由 A 派生;T_write = W。
服务端只保存令牌的 SHA-256 哈希(read_hash、write_hash、owner_hash),验证时对请求中的令牌取哈希后做常量时间比较。
4. 帧封装(Envelope)
服务端可见的明文头部,固定 26 字节,后接负载。
偏移 长度 字段
0 1 ver = 0x01
1 1 flags
2 16 item_id
18 4 frame_index 条目内的序号,从 0 开始
22 4 payload_len 负载字节数
26 ... payload
flags:
| 位 | 名称 | 含义 |
|---|---|---|
| 0 | FINAL | 条目的最后一帧 |
| 1 | SNAPSHOT | 该条目是频道的「最新快照」,取代之前的快照条目 |
| 2 | GATED | 不通过实时推送内联分发,只能按条目拉取,并计下载次数 |
| 3 | ENC | 负载为 AES-GCM 密文,必须与频道模式一致 |
| 4-7 | 保留 | 必须为 0 |
服务端拒绝不符合上述规则的封装,但从不解析负载。
5. 加密(enc)
- 算法:AES-256-GCM,密钥为
K_item,标签 16 B。 - IV(12 B)=
00 00 00 00 00 00 00 00 ‖ frame_index(u32)。在每个条目独立的K_item下,frame_index唯一即保证 IV 唯一。 - AAD =
"WD1"(3 B ASCII)‖channel_id‖envelope[0..22)(ver、flags、item_id、frame_index)。 - 负载 =
密文 ‖ tag。
重要规则:同一 (item_id, frame_index) 只能加密一份固定明文。重试必须重发相同字节;断点续传时,明文要从文件偏移重新确定性生成,或在本地队列里持久化已加密的帧。违反规则会造成 IV 重用。
6. 明文布局与 padding
每帧的明文:
data_len (u32) ‖ data ‖ 0x00 填充
明文总长取不小于 4 + data_len 的最小档位:
1 KiB, 4 KiB, 16 KiB, 64 KiB, 256 KiB, 1 MiB
因此单帧明文最大 1 MiB,data 最大 1,048,572 B。负载长度 = 档位 + 16。接收方解密后检查 data_len ≤ 明文长度 − 4,并检查填充全为零。
大文件的中间帧都是满档(1 MiB),只有最后一帧会泄露所处档位。
7. 条目
条目是共享同一 item_id 的一组帧,frame_index 从 0 连续递增,最后一帧带 FINAL。
第 0 帧的 data:
meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
meta_json(enc 模式下在密文里):
| 字段 | 说明 |
|---|---|
v |
1 |
kind |
text / json / file / other |
name |
可选,文件名 |
mime |
可选 |
size |
可选,内容总字节数(流式时可省略) |
ts |
毫秒时间戳,仅供参考,排序以 seq 为准 |
dev |
可选,设备名 |
第 1 帧起,data 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。
raw 模式:负载就是内容字节,没有 meta。条目名称等信息由用户在上传时放进 X-WD-Label(明文,≤255 B)。
未完成的条目:没有 FINAL 的条目是「进行中」,接收方可以渐进消费(流式播放、边收边写),但必须标记为不完整,直到收到已认证的 FINAL。
8. 日志、序号与条目生命周期
频道维护一条服务端排序的日志,每条记录的序号 seq 是 u64,频道内单调递增。
| kind | 值 | 内容 |
|---|---|---|
| frame | 1 | 一帧的完整封装(仅非 GATED 条目,它们都是单帧) |
| tombstone | 2 | item_id(16 B) ‖ reason(u8:0 删除,1 过期,2 超限淘汰,3 下载达上限,4 被新快照取代) |
| announce | 3 | item_id(16 B) ‖ flags(u8) ‖ created_ms(u64),GATED 条目只在第 0 帧到达时产生 |
记录的线格式:seq (u64) ‖ kind (u8) ‖ body,body 长度:frame 为 26 + payload_len(封装自带长度,自分隔),tombstone 固定 17 字节,announce 固定 25 字节。
seq 的分配:服务端为每一个被接受的帧分配 seq,包括 GATED 条目中不进入日志的后续帧,以及每条墓碑,所以日志里的 seq 可以不连续。客户端只用 seq 做排序和续传,不要假定连续。
推送规则:
- 非 GATED 条目以 frame 记录推送给订阅者。服务端强制:非 GATED 条目必须是单帧且带 FINAL(
frame_index = 0)。 - GATED 条目只推送一条 announce,字节通过条目拉取接口取得(见 api.md),
from=0的拉取计入下载次数。 from>0只有在同一终端此前已对同一条目成功发起from=0后才是有效续传;服务端按资源、条目和终端绑定这份续传许可,任意跳到中间帧不能绕过下载次数。- 因此客户端上传多帧条目(文件)或带下载上限的条目时,必须设置 GATED。
- 同一条目的所有帧,SNAPSHOT 与 GATED 标志必须一致;FINAL 不能早于已存在的更大序号;已有 FINAL 之后不能再追加更大序号的帧。GATED 条目的帧允许乱序到达(并行上传),条目拉取只返回从起点开始连续的帧。
幂等:服务端以 (channel_id, item_id, frame_index) 为键。重复提交内容相同则返回原 seq,内容不同返回 409。因此客户端只需「重发直到收到确认」,断线、超时、重复提交都不会产生重复帧。
续传:客户端记住处理过的最大 seq,重连时用 from = last_seq + 1。
墓碑与全量同步:墓碑保留 7 天(服务端可配置)后被清理,清理水位记为 compacted_through,频道信息里的 oldest_seq = compacted_through + 1。如果客户端的 from 满足 0 < from < oldest_seq,它可能漏掉了已被清理的删除事件,服务端返回 410 并带 oldest_seq。此时客户端必须从 from=0 全量同步:重新读取日志,并丢弃本地存在、但全量日志里已不存在的条目。不要从 oldest_seq 开始读,否则会漏掉 seq 小于它的、仍然存活的条目。from=0 永远允许。
9. 快照
带 SNAPSHOT 的条目取代频道中之前的快照条目(最后写入者胜)。快照必须是非 GATED 的单帧(整份状态不超过一帧,约 1 MiB),所以新快照一被接受,服务端就在同一个事务里删除旧快照并产生一条 reason = 4 的 tombstone。一个频道在 v1 里只有一个快照槽,多文档请使用多个频道。
10. 终端与席位
- 终端在本地为每个频道生成随机
S,请求中带S;服务端保存SHA-256(S)作为终端标识。 - 终端通过显式的加入请求(
POST .../seats)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(seats_full),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回not_joined。 - 不同频道使用不同的
S,避免服务端把同一设备关联起来。 - 终端可以加密一个设备名上传,所有者能看到成员列表和设备名,并可以释放席位。
- 设备名格式(enc):
iv(12 B 随机) ‖ AES-256-GCM(K_label, utf8 名称) ‖ tag(16 B),AAD ="WD1L"(4 B ASCII)‖channel_id‖endpoint_hash(32 B,即SHA-256(S))。总长不超过 256 B,所以名称最多 228 字节 UTF-8。使用派生子密钥K_label而不是直接用K_enc,避免同一密钥同时用于 HKDF 和 AES-GCM;AAD 绑定终端,服务端无法把一个终端的设备名挪到另一个席位上。raw 模式下设备名是明文 UTF-8。
11. 创建与验证码
创建频道是匿名接口,需要防刷。验证码完全交给 Cap(基于 SHA-256 工作量证明的验证码),本协议不定义、也不实现任何 PoW。
流程:
- 前端从
GET /api/v1/info读到captcha.enabled和captcha.api_endpoint。 - 如果启用,前端用 Cap 的 widget(可隐藏界面,后台求解)连接
api_endpoint,求解挑战并换得一个 token。 - 创建频道时把 token 放进请求头
X-WD-Cap。 - 服务端把 token 连同自己持有的 secret 交给 Cap 的
POST /<site_key>/siteverify校验,通过才创建。token 一次有效。
频道 id 与 token 不绑定(Cap 的 token 是通用的),重放由 Cap 的一次性保证。创建之后的所有操作都不需要验证码。验证码不影响加密:它既不接触密钥,也不接触内容。
12. 设置
频道设置对服务端可见,服务端据此执行:
| 设置 | 默认 | 范围 |
|---|---|---|
seats |
2 | 1..64(服务端可配置上限) |
members_can_upload |
true | |
delete_policy |
own |
owner_only / own / anyone |
ttl_seconds |
86400 | 最长 604800,每个条目从创建起算 |
max_items |
1000 | 超出后淘汰最旧的条目 |
file_max_downloads |
10 | 0 为不限,仅对 GATED 条目生效,可被条目单独覆盖 |
ui_restrict_copy_download |
false | 仅供前端展示,服务端不强制 |
频道在没有存活条目且空闲超过 ttl_seconds 后删除。
13. 限制(服务端默认,可配置)
| 项 | 值 |
|---|---|
| 负载上限 | 1 MiB + 16 B |
| 每条目帧数 | 65,536(约 64 GiB) |
| 每频道字节数 | 1 GiB |
| 每频道条目数 | max_items |
14. 接收方校验算法
对 enc 频道的每个条目:
- 对每个收到的帧,用
K_item、规定的 IV 和 AAD 解密,失败则丢弃整条目并报告篡改。 - 要求
frame_index从 0 连续,出现缺口则条目为「不完整」。 - 只有收到已认证的 FINAL 帧且前面连续无缺口,才标记为「完整」。
- 同一
(item_id, frame_index)重复出现,内容必须逐字节相同。 - 帧的 flags 中 ENC 必须为 1;raw 频道中必须为 0。
15. 便捷分享(Share)
便捷分享是和频道并列的另一种顶层资源:把一批文件和文本做成一份只读清单发给别人,完成后封存,之后没人能改,过期自动清除。它不是频道的变体:没有成员、没有上传、没有实时推送、没有日志和 seq。
| 频道 | 便捷分享 | |
|---|---|---|
| 路径 | /c/{id}#k=… |
/s/{id}#k=… |
| 接口 | /api/v1/channels/* |
/api/v1/shares/* |
| 模式 | enc 或 raw | 只有 enc |
| 谁能写 | 所有者和成员 | 只有创建者,且只在草稿阶段 |
| 寿命 | 长期(TTL 滚动续期) | 短,封存后到期即清 |
| 访客 | 成员,占席位 | 只读访客,按终端计数,不展示人数 |
分享与频道共用同一个 16 字节 id 空间,服务端保证全局唯一,所以同一个 id 不可能既是频道又是分享。分享 id 在频道接口上返回 not_found,反之亦然。
15.1 密钥
沿用 §3 的派生:K → K_enc / T_read,A → T_owner。分享没有成员写入,所以不派生也不存储 T_write。帧的 AAD 里的 channel_id 在分享中就是分享 id。
15.2 状态机
草稿 (draft) ──[ finish ]──► 已封存 (sealed) ──[ 到期 / 下载次数用完 ]──► 清除
│ 创建者可加、删、改设置 │ 任何人(含创建者)都不能写
└─[ 24 小时未完成 / 创建者删除 ]──► 清除
| 阶段 | 谁能做什么 |
|---|---|
| 草稿 | 只有持所有者令牌的创建者:追加帧、删除条目、改设置、删除草稿、完成分享。读令牌看不到草稿(返回 not_found),此时也没有分享链接 |
| 已封存 | 持读令牌的任何人:读清单、读分享信息、下载。没有任何写入口,持所有者令牌的请求也一律拒绝 |
| 清除 | 到有效期,整个分享被清除;文件的下载次数用完,该文件被清除 |
15.3 清单(manifest)
访客要看到文件名和大小,但这些在每个条目第 0 帧的加密元数据里,而读取 GATED 条目的第 0 帧会计一次下载,所以不能让访客逐个文件去读来拼清单。解决办法:创建者在完成分享时把清单本身加密成一个条目。
- 清单是一个非 GATED 的单帧条目,元数据
kind = "manifest",内容是 UTF-8 JSON。 - 服务端只知道「哪个条目是清单」(
finish时由创建者指定),看不到内容。 - 清单内容(字段顺序固定,便于测试向量字节级比对):
{"v":1,"items":[
{"t":"file","id":"<条目 id,base64url>","name":"a.pdf","mime":"application/pdf","size":123456,"ts":1700000000000},
{"t":"text","name":"留言","text":"短文本直接内联在清单里","ts":1700000000000}
]}
| 字段 | 说明 |
|---|---|
t |
file 或 text |
id |
仅 file:文件条目的 id,访客据此下载 |
name |
文件名或文本标题,可选 |
mime |
仅 file,可选 |
size |
仅 file:内容总字节数。下载完成后必须与实际字节数一致,否则判定被截断 |
text |
仅 text:内容。文本内联在清单里,所以不占下载次数,也没有「已用完」 |
ts |
毫秒时间戳,仅供参考 |
清单单帧最大约 1 MiB。文本太多放不下时,把大文本当文件添加。
15.4 草稿阶段
- 创建者在浏览器里为每个文件或文本生成
item_id,加密成帧,逐帧POST /shares/{id}/frames。帧格式、IV、AAD、padding、幂等重发与 §4–§7 完全相同,所以断点续传和重试同样安全。 - 文件条目必须是 GATED(多帧);不允许 SNAPSHOT 标志;文本不单独成条目,内联进清单。
- 文件的下载次数上限通过第 0 帧的
X-WD-Max-Downloads设置,未设置时用分享设置里的file_max_downloads。 from>0续传必须来自同一X-WD-Endpoint对同一文件此前启动的from=0下载;否则服务端返回410 gone且不登记访客。- 草稿在服务端有 24 小时的存活期(可配置),防止遗弃的草稿一直占着空间。草稿阶段不消耗有效期。
15.5 完成分享(封存)
POST /shares/{id}/finish,请求体 {"manifest_item_id": "<b64url>"},需要所有者令牌。服务端校验:
- 分享处于草稿状态。
- 清单条目存在,是单帧、非 GATED、已完整(带 FINAL)。
- 草稿里所有条目都已完整(没有传了一半的)。
校验通过后,在一个事务里:
- 状态改为已封存,记下封存时间和清单条目 id。
- 清除所有者令牌的哈希。此后即使有人保留了旧的所有者令牌,服务端也无法验证它,所有写操作一律拒绝。
- 有效期从此刻起算:
expires_at = now + ttl_seconds,并把所有条目的过期时间对齐到它。
finish 是一次性、不可撤销的。创建者的浏览器在收到成功响应后,还要自己丢弃所有者密钥 A(@webdrop/core 的 ShareSession.markSealed() 会做)。服务端强制和浏览器丢弃是两层保证:前者防止别人(包括恶意的、保留了令牌的人)再写,后者防止创建者自己误操作。
15.6 访客
- 访客打开
/s/{id}#k=…,没有「加入」步骤。GET /shares/{id}/manifest或下载接口的首次读取会登记这个终端(终端密钥的哈希)。 - 最多访客
max_visitors(0 为不限):登记满了拒绝新终端(visitors_full),已登记的终端可以重复访问。创建者的终端(创建时的X-WD-Endpoint)不占名额。服务端永远不向访客展示访客人数。 - 访客只能预览、下载、复制。没有任何编辑或删除入口。
GET /shares/{id}返回每个文件的剩余下载次数(downloads_left,不限为null),创建者封存后靠它看进度。
15.7 保证与不保证
保证:
- 封存后服务端不再接受任何写入,包括持有旧所有者令牌的人。
- 内容机密性、完整性与频道相同(AES-GCM、AAD 绑定分享 id、条目 id、序号、标志)。服务端没有
K,无法伪造清单或文件。 - 访客用清单里的
size核对下载结果;用请求的条目 id 核对帧头里的条目 id,防止服务端换成同一分享里的别的条目。
不保证(要如实告诉用户):
- 发错了撤不回,只能等过期。所以「完成分享」前必须有明确确认。
- 持有链接就能下载,谁拿到链接谁就是访客。
- 服务端仍然看得到访问时间、IP 和每个文件被拉取的次数(元数据),看不到内容。
- 服务端不受信任:它可以拒绝服务或提前删除内容(可用性),但不能让访客看到被篡改的内容而不被发现。
- 「封存后不再接受写入」是服务端按本协议实现的行为。对一个恶意服务端,它保证不了这条;但恶意服务端没有
K,无法产出能通过访客校验的新帧,所以最坏情况是内容消失,而不是内容被替换。
16. 版本与测试向量
- 协议版本在封装的
ver字段和 HKDF 的info(webdrop/v1/...)中同时体现。 test-vectors/提供固定输入、固定输出:密钥派生、令牌、加解密、padding、封装编解码,以及篡改、重排、截断、换频道的负例,必须全部通过。- 本版预留但不实现:口令叠加(Argon2id)、短配对码(PAKE)、多快照槽。