Files
WebDrop/docs/protocol.md
T

357 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 协议 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 模式,按账号加密封装;可选能力链接使用 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` | 可选,设备名 |
| `thread_id` | 可选,频道 thread ID |
| `folder_id` | 可选,文件的虚拟文件夹 ID |
第 1 帧起,`data` 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。
**频道 thread 与文件夹**:客户端把 thread/folder 元数据放在上述共享元数据中(enc 时加密,raw 时服务端可见)。thread 的创建、重命名,文件夹的创建/重命名,以及条目移动等操作以 `kind=other` 的 CONTROL 条目追加到同一频道日志;控制正文是版本化 JSON,仅客户端识别。它们不增加服务端表或第二套同步协议。新内容必须归属已创建的 thread;前端新建频道后自动发布“常规”thread 创建记录。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. 终端与席位
- 终端在本地为每个频道生成随机 `S`,请求中带 `S`;服务端保存 `SHA-256(S)` 作为终端标识。
- 终端通过显式的加入请求(`POST .../seats`)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(`seats_full`),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回 `not_joined`。
- 不同频道使用不同的 `S`,避免服务端把同一设备关联起来。
- 终端可以加密一个设备名上传,所有者能看到成员列表和设备名,并可以释放席位。
- 终端可更新自己的名称;所有者也可重命名任意席位。enc 模式重命名仍使用同一份加密设备标签,并将 AAD 绑定目标 endpoint hash;raw 模式标签仍为明文。所有者移除席位后,该终端不能继续读取频道,但持有成员链接的人仍可重新加入(若有空席位)。
- **设备名格式(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. 设置
频道设置对服务端可见,服务端据此执行。`name` 是共享频道名,所有加入者都从频道信息中读取:
| 设置 | 默认 | 范围 |
|---|---|---|
| `name` | 无 | 可选,最多 48 个字符;所有频道成员和服务端可见 |
| `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)、多快照槽。
## 17. 按账号分发频道密钥
频道内容仍使用共享 K 加密。创建者用自己的账号 P-256 公钥封装 owner 凭据,随创建请求的 key_package 与频道、owner 成员记录在同一事务内保存。邀请时用收件账号公钥封装 member 凭据,服务端原子保存邀请和密钥包;账号接受邀请并成为成员后,可只凭账号 session 从 /accounts/channels 取回自己的密钥包,不需要含密钥的 URL,也不要求邀请者在线。成员包不得包含管理密钥 A。
密钥包沿用邀请的 ECDH、HKDF、AES-GCM,AAD 绑定收件账号与频道 ID。服务端只保存 opaque 密文;成员私钥保存在口令加密的账号保险库。成员登录后解开保险库、取回密钥包,在 Worker 内恢复频道会话;新设备生成独立 S,打开频道时才占设备席位。待接受的邀请不会自动占席位。
撤销成员时同时删除密钥包和席位。此举不能收回已交付的 K 或历史明文;当前没有轮换密钥或前向保密。仅知道频道 ID 或登录账号并不足以获得密钥;新账号必须得到现有持钥 owner 的授权封装。邀请公钥仍来自服务端目录,尚无外部指纹核验。
## 18. 频道加密文件目录
文件密文帧之后,在同一发送队列追加 CONTROL file.published,正文包含 item.id、kind=file、threadId、folderId(可选)、name、mime、size、ts、deviceId、gated。记录与 thread/folder 控制记录共用 webdrop.workspace v1 正文。名称/类型/大小/原始 thread/folder 必须与文件第 0 帧元数据相符。目录允许先显示名称,但不得因此自动下载 GATED 内容。
客户端只有在当前日志存在对应文件帧或 announce 时展示目录;文件墓碑清除目录可见状态,全量重放时不恢复不存在/过期文件。目录记录属于 CONTROL,仍受 4096 条控制记录和频道字节配额限制。文件移动覆盖位置索引,不重新加密原始文件,不改变最初认证的内容元数据。
## 19. 空间内的频道分组
界面顶层“空间”对应协议 channel,界面“频道”对应 thread。沿用 webdrop.workspace v1 CONTROL 正文:group.create {id,name},group.rename {id,name},thread.group {id,groupId}(null 表示未分组);thread.create 可包含 groupId。ID 为 UUID,name 为 1–80 个非控制字符。分组仅组织频道,成员、内容密钥和日志仍归属于空间。旧创建记录不含 groupId,作为未分组频道重放。
## 20. 发送者展示快照
file.published.item 可携带 author {username,displayName,avatar?};另有 item.author {itemId,author} CONTROL,为文本关联展示身份。username 使用当前账号固定用户名,displayName 最多 48 个字符,avatar 仅允许不超过 8000 字符的 PNG/JPEG/WebP data URL。头像在客户端压缩为最多 64×64 后写入加密正文,不改变内容帧、密钥派生或鉴权头。
快照是持钥成员提供的展示资料,不是发送者签名。无 author 的旧记录回退设备名;客户端不得将此类内容标记为当前账号发送。结构记录随既有 CONTROL 生命周期处理,实际消息可见性仍由条目和墓碑决定。