387 lines
30 KiB
Markdown
387 lines
30 KiB
Markdown
# 协议 v1
|
||
|
||
### 频道数据域边界
|
||
|
||
新工作区的空间凭据只用于空间访问与管理,不作为频道内容密钥。每个频道有独立 chat/files 数据域与 epoch;客户端为每个条目生成随机 32 字节对象密钥,以对应数据域的 epoch key 用 AES-256-GCM 封装。封装上下文绑定空间、频道、数据域、epoch 和条目 ID。服务端保存对象归属与密钥封装,不能获得对象明文密钥。客户端解密必须先取得对应数据域授权,禁止回退到空间密钥。
|
||
|
||
域密钥由创建者随机生成,再通过现有账号 ECDH 公钥信封逐账号封装;域信封上下文包括空间 ID、频道 ID、域名和 epoch。邀请前服务端按成员权限和历史设置生成 epoch/收件人计划;浏览器只为计划中的收件人封装密钥,服务端在单事务中重算并校验邀请、权限、envelopes 和 epoch。服务端根据登录身份只返回该账号可读域的信封和匹配对象索引,并在日志读取、文件下载及写入时执行成员权限检查。默认频道的新成员获得 chat read/write、files read/upload;新增频道的访问不会由空间成员身份自动推导。Owner 可分别决定新成员能否读取聊天和文件历史。关闭某域历史时,邀请流程生成新 epoch,并将新密钥给现有授权成员与新成员;新成员不获旧 epoch 信封。未启用 E2EE 时,后端用服务端维护的 crypto revision 作为访问起点,过滤历史日志和目录位置。该策略不撤回已经交付的密钥;已移除成员的撤销轮换目前尚未实现。
|
||
|
||
该模型的服务端是授权执行方,而不是可被客户端验证的授权来源:目前没有 Owner 签名 Capability、账号签名身份、设备证书或事件签名。首次邀请公钥也来自服务端目录,公钥替换风险仍存在。不能把 AEAD 校验或服务端权限过滤描述成对恶意服务端的端到端授权验证。
|
||
|
||
新版本的频道、分组、目录和文件归属通过后端空间结构接口管理。资源内容继续采用浏览器端加密。`file.published` 的 item 使用独立协议结构,不序列化 UI 的 state、preview、remaining 或版本列表。接收端只重建资源渲染状态,不从资源正文构建账户或空间结构。
|
||
|
||
本文是协议的权威来源。所有实现(服务端、`@webdrop/core`、将来的 CLI)必须通过 `test-vectors/` 中的向量。
|
||
|
||
## 1. 编码约定
|
||
|
||
- 多字节整数一律**大端**。
|
||
- 二进制数据在 URL、头部、JSON 中一律 **base64url(无填充)**。
|
||
- 随机数一律来自 CSPRNG(`crypto.getRandomValues`)。
|
||
|
||
| 名称 | 长度 | 说明 |
|
||
|---|---|---|
|
||
| `channel_id` | 16 B | 随机,客户端生成 |
|
||
| `item_id` | 16 B | 随机,客户端生成 |
|
||
| `K` | 32 B | 空间/临时频道访问凭据;新工作区内容加密不使用它,可选能力链接使用 fragment |
|
||
| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 |
|
||
| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 |
|
||
| `S` | 32 B | 便捷分享的终端密钥(创建者与读者),仅存本地;频道与空间不使用,只凭账号会话鉴权 |
|
||
|
||
## 2. 模式与链接
|
||
|
||
只有一个频道模型。创建时可选择是否启用端到端加密,默认 enc;raw 表示不加密内容。账号成员、所有者、加入规则、线程、目录、日志和权限完全共用,差别限于客户端的负载加解密。创建后不能改变加密方式。
|
||
|
||
| | 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 | CONTROL | 加密资源元数据记录 |
|
||
| 5-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` | 已弃用(v7 起客户端不再写入,读取时忽略);发送者以服务端记录的账号为准 |
|
||
| `thread_id` | 可选,频道 thread ID |
|
||
| `folder_id` | 可选,文件的虚拟文件夹 ID |
|
||
|
||
第 1 帧起,`data` 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。
|
||
|
||
**频道 thread 与文件夹**:客户端把 thread/folder 元数据放在上述共享元数据中(enc 时加密,raw 时服务端可见)。频道、分组、文件夹和条目归属由后端结构接口管理,不写入内容日志。新建空间与初始“常规”频道由后端原子创建。GATED 的 announce 不含元数据,file.published 加密记录提供名称和原始归属;目录记录未到达时显示等待同步占位符。
|
||
|
||
**raw 模式(API 契约 2)**:负载使用与 enc 相同的 `u16 meta_len ‖ meta_json ‖ content` 条目结构,只是不加密。名称、thread/folder ID 与其他元数据对服务端可见。旧版“纯字节、无元数据”的 raw 负载不属于新应用契约,旧客户端会被 API 主版本检查拒绝。
|
||
|
||
**未完成的条目**:没有 FINAL 的条目是「进行中」,接收方可以渐进消费(流式播放、边收边写),但必须标记为不完整,直到收到已认证的 FINAL。
|
||
|
||
## 8. 日志、序号与条目生命周期
|
||
|
||
`CONTROL=0x10` 标记加密资源元数据记录;保留位为 `0xE0`。控制条目必须是非 GATED、非 SNAPSHOT 的 FINAL 单帧,payload 最多 16,400 B;其生命周期为频道生命周期,不受普通内容 TTL 或 max_items 淘汰。每频道最多 4096 条控制记录,仍计入总字节配额。加密和非加密频道使用相同控制正文与日志处理。
|
||
|
||
频道维护一条服务端排序的**日志**,每条记录的序号 `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. 用户、设备与成员人数
|
||
|
||
- **成员是用户(账号)**。设备只是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的请求凭有效的账号 session 鉴权,不再需要终端密钥,也不登记终端。
|
||
- **频道强制登录**(v8):没有账号会话的请求一律返回 `account_required`,不再有未登录访客(没有 `allow_guests`、`/guests`、访客终端席位)。持链接的人先登录,再通过显式的加入请求(`POST .../join`)成为成员;未加入的账号访问频道内容返回 `not_joined`;所有者通过撤销邀请移除成员。便捷分享不受影响。
|
||
- 频道设置字段 `max_members`(v7 前叫 `seats`)是**最大成员人数**。owner 和每个唯一账号各计一人,同一账号的额外设备不增加人数。成员人数达到上限时,新的成员加入返回 `members_full`。
|
||
- 受限下载(GATED)的计数单位是「下载方」:按用户;同一下载方对同一条目重复发起 `from=0` 才算续传。
|
||
- 在线状态由服务端按账号统计(`members` 接口的 `online`、`devices`、`online_devices`),只来自内存里的订阅登记,不写库。
|
||
- **已移除**:每终端的加密设备名(`K_label`、AAD `WD1L`、`PUT .../seats/me` 与 `.../seats/{hash}/label`)和设备席位清单。设备名只在账号设备管理里存在(`/accounts/devices`)。`K_label` 的派生仍保留在 §3,为已发布的测试向量保持稳定,但不再被使用。
|
||
|
||
## 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. 设置
|
||
|
||
频道设置对服务端可见,服务端据此执行。`name` 是共享频道名,所有加入者都从频道信息中读取:
|
||
|
||
| 设置 | 默认 | 范围 |
|
||
|---|---|---|
|
||
| `name` | 无 | 可选,最多 48 个字符;所有频道成员和服务端可见 |
|
||
| `max_members`(v7 前叫 `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` |
|
||
|
||
工作区对象帧使用独立容量限制:单个工作区默认最多 1 TiB,单个 `WorkspaceChannel` 的 Files 域默认最多 10 GiB;Files 域用量以非 CONTROL 帧字节计,删除条目后释放用量。其他未绑定工作区对象的旧式频道仍遵循上表 1 GiB 限制。实际配置值由 `/info` 返回,客户端不得自行推导或执行配额。
|
||
|
||
## 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)、多快照槽。
|
||
|
||
## 17. 账号凭据与频道域密钥
|
||
|
||
空间访问凭据用于服务端资源/终端鉴权,不是频道内容根。创建者用自己的账号 P-256 公钥封装 Owner 凭据,随空间、默认频道、Owner 成员记录原子保存。账号接受空间邀请后,可凭账号 session 从 `/accounts/channels` 取回自己的空间凭据;账号成员不登记终端,实际打开空间时只确认成员关系(`POST .../join` 幂等)。
|
||
|
||
内容加密密钥由浏览器生成。每个频道各有独立 `chat` 与 `files` 域,每域从 epoch 1 开始使用随机 32 B key。域信封沿用账号 ECDH、HKDF、AES-GCM,AAD 绑定账号、空间 ID、频道 ID、域名与 epoch。服务端保存 opaque 密文,只向通过频道/域读取授权的账号返回信封。每条消息、文件和加密发布元数据使用独立随机 32 B 对象密钥;对象密钥由域 key 的 AES-GCM 封装,AAD 绑定空间、频道、域、epoch 和 item ID。
|
||
|
||
默认频道成员权限是 chat read/write 与 files read/upload;其它频道不能从空间成员身份推导访问权。Owner 邀请时可分别允许新成员读取已有聊天和文件。关闭历史会对对应域创建新 epoch,把新 key 封装给现有授权账号和新成员,不向新成员分发旧 epoch。此策略不撤回此前已交付的密钥,也不等于成员移除时的完整密码学撤销;已加入成员撤销后的轮换、离线和并发协议仍待实现。
|
||
|
||
服务端会按账号权限过滤频道结构、域信封、对象索引和日志/下载,并拒绝无写入授权或旧 epoch 的新帧。这是服务端执行的访问策略,不是客户端可独立验证的签名 Capability。域密钥封装目前按账号而非设备,目录公钥替换风险、恶意服务端伪造结构/作者和事件签名缺失仍然存在。
|
||
|
||
|
||
## 18. 频道加密文件目录
|
||
|
||
文件密文帧之后,在同一发送队列追加 CONTROL file.published,正文包含 item.id、kind=file、threadId、folderId(可选)、name、mime、size、ts、deviceId、gated。资源记录使用 webdrop.workspace v1 正文。名称/类型/大小/原始 thread/folder 必须与文件第 0 帧元数据相符。目录允许先显示名称,但不得因此自动下载 GATED 内容。
|
||
|
||
客户端只有在当前日志存在对应文件帧或 announce 时展示目录;文件墓碑清除目录可见状态,全量重放时不恢复不存在/过期文件。目录记录属于 CONTROL,仍受 4096 条控制记录和频道字节配额限制。文件移动由服务端更新归属,不重新加密原始文件,不改变最初认证的内容元数据。
|
||
|
||
## 19. 空间内的频道分组
|
||
|
||
界面顶层“空间”对应 API space/底层同步资源,界面“频道”是空间中的 WorkspaceChannel,帧元数据 threadId 映射到频道 ID。频道和分组由后端结构接口创建、重命名和管理归属,名称限制与关联验证由后端实施。成员与同步日志归属于空间,频道各自拥有 chat/files 密钥域;分组仅组织频道,不建立新授权或密钥。客户端不重放 group.create、thread.create 等结构记录。
|
||
|
||
## 20. 发送者展示快照
|
||
|
||
聊天发送者由服务端已存条目的 authorAccount 与后端个人资料决定。file.published 不写入作者声明。头像在 Worker 内缩放为最多 64×64 后用于渲染,不影响加密帧或鉴权。
|
||
|
||
服务端提供的作者归属与个人资料不代表端对端发送者签名;资源密文仍由浏览器验证。未关联账号的访客资源回退设备来源,不推测当前用户。
|
||
|
||
## 21. 签名扩展基础
|
||
|
||
本节定义后续签名授权和签名事件共用的签名原语;在对应 API 与事件模型完成前,它**不改变现有无签名请求的行为,也不表示频道操作已启用验签**。
|
||
|
||
- 算法为 ECDSA P-256 / SHA-256。签名线格式为 64 字节 IEEE P1363 `r ‖ s`;公钥按 P-256 JWK 的 `x`、`y` 表示,各为 32 字节 base64url。
|
||
- 被签名字节为 `"WDS\x01" ‖ kind_len(u16) ‖ kind(ASCII) ‖ payload_len(u32) ‖ payload`,整数大端。`kind` 为 1–64 字节、匹配 `[a-z][a-z0-9._-]*`;payload 最大 1 MiB。
|
||
- `kind` 必须区分对象用途,不能跨类型复用签名。payload 后续按设备证书、Capability、ChatEvent 各自定义唯一字段顺序和二进制编码;禁止直接签未经规范化的 JSON。
|
||
- 浏览器和服务端都必须先校验 payload 结构、身份绑定、授权范围和上下文,再验签;任一步失败均拒绝操作。服务端接受请求不代替浏览器验签。
|
||
- 此原语只证明某密钥持有者签署了这些字节,不证明公钥属于声称的账号,也不防止服务端隐藏、回滚或向不同客户端展示分叉状态。
|
||
|
||
`device.certificate` 的 payload 为 `username_len(u8) ‖ lowercase_username(ASCII) ‖ device_id_len(u8) ‖ lowercase_device_id(ASCII UUID D 格式) ‖ device_public_x(32 B) ‖ device_public_y(32 B)`。账号根密钥签发此证书;设备密钥预留用于签署频道结构命令和聊天/文件事件。新设备通过密码/Passkey 解锁保险库恢复账号根密钥,不要求旧设备在线批准。其他账号第一次看到某账号根公钥时采用 TOFU:首次固定并展示“未核验”状态,后续变化不得静默接受;这不能防止首次接触时服务端替换密钥。成员可通过独立渠道比较指纹。透明日志、跨客户端一致性证明和历史检查点属于后续阶段。
|
||
|
||
`content.event` 的 payload 顺序为 `space_id(16 B) ‖ channel_id_len(u8) ‖ channel_id(ASCII UUID) ‖ username_len(u8) ‖ username(lowercase ASCII) ‖ device_id_len(u8) ‖ device_id(lowercase ASCII UUID) ‖ event_id(16 B) ‖ event_kind_len(u8) ‖ event_kind(ASCII) ‖ domain(u8:chat=1,files=2) ‖ epoch(u32) ‖ item_id(16 B) ‖ frame_count(u32) ‖ manifest_hash(32 B) ‖ target_present(u8) ‖ target_item_id(16 B 或全零)`。`manifest_hash = SHA-256("WD1M" ‖ frame_count(u32) ‖ (frame_index(u32) ‖ SHA-256(完整 frame envelope))...)`,帧必须从 0 连续排列。事件类型包括 `chat.create/edit/snapshot/delete`、`file.publish/catalog/delete`。FINAL 上传头带事件字段;后端确认证书、签名、对象作者/domain/epoch/权限及数据库帧清单一致后,在对象元数据保存签名。`CryptoState` 按域授权过滤后把签名材料返回客户端。浏览器在投影完整帧前验设备证书、TOFU 根 key、事件签名和本地 manifest。
|
||
|
||
当前已接通新账号根公钥、设备证书校验,以及有账号设备签名密钥的对象最终帧验签/作者 manifest 校验;已登记签名设备的删除也要求签名并把删除证明保存到对象元数据;未注册签名设备的旧账号仍走兼容路径。**频道结构命令和成员/权限变更尚未签名**。TOFU 首次接触风险、服务器分叉/回滚、撤销设备密钥轮换仍未解决,不能宣称已完整抵抗主动恶意服务端。
|