Files
WebDrop/docs/protocol.md
T
justme 12980ba6fe
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
docs: describe login-only spaces and the 8.0.0 contract [CI]
2026-10-16 06:46:16 +08:00

30 KiB
Raw Blame History

协议 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(基于 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 时由创建者指定),看不到内容。
  • 清单内容(字段顺序固定,便于测试向量字节级比对):
{"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 首次接触风险、服务器分叉/回滚、撤销设备密钥轮换仍未解决,不能宣称已完整抵抗主动恶意服务端。