Files
WebDrop/docs/protocol.md
T
2026-10-02 00:59:22 +08:00

18 KiB
Raw Blame History

协议 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 的拉取计入下载次数。
  • 因此客户端上传多帧条目(文件)或带下载上限的条目时,必须设置 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。

流程:

  1. 前端从 GET /api/v1/info 读到 captcha.enabled 和 captcha.api_endpoint。
  2. 如果启用,前端用 Cap 的 widget(可隐藏界面,后台求解)连接 api_endpoint,求解挑战并换得一个 token。
  3. 创建频道时把 token 放进请求头 X-WD-Cap。
  4. 服务端把 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 频道的每个条目:

  1. 对每个收到的帧,用 K_item、规定的 IV 和 AAD 解密,失败则丢弃整条目并报告篡改。
  2. 要求 frame_index 从 0 连续,出现缺口则条目为「不完整」。
  3. 只有收到已认证的 FINAL 帧且前面连续无缺口,才标记为「完整」。
  4. 同一 (item_id, frame_index) 重复出现,内容必须逐字节相同。
  5. 帧的 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。
  • 草稿在服务端有 24 小时的存活期(可配置),防止遗弃的草稿一直占着空间。草稿阶段不消耗有效期。

15.5 完成分享(封存)

POST /shares/{id}/finish,请求体 {"manifest_item_id": "<b64url>"},需要所有者令牌。服务端校验:

  1. 分享处于草稿状态。
  2. 清单条目存在,是单帧、非 GATED、已完整(带 FINAL)。
  3. 草稿里所有条目都已完整(没有传了一半的)。

校验通过后,在一个事务里:

  • 状态改为已封存,记下封存时间和清单条目 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)、多快照槽。