323 lines
18 KiB
Markdown
323 lines
18 KiB
Markdown
# 协议 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](https://github.com/tiagozip/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` 时由创建者指定),看不到内容。
|
||
- 清单内容(字段顺序固定,便于测试向量字节级比对):
|
||
|
||
```json
|
||
{"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>"}`,需要所有者令牌。服务端校验:
|
||
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)、多快照槽。
|