feat(api): add account workspaces and signed content events
This commit is contained in:
1 parent
937b774a93
commit
7b8d61d1ba
63 files changed
+5407
-370
No files matched your search
+49
-19
@@ -1,5 +1,15 @@
|
||||
# 协议 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. 编码约定
|
||||
@@ -12,7 +22,7 @@
|
||||
|---|---|---|
|
||||
| `channel_id` | 16 B | 随机,客户端生成 |
|
||||
| `item_id` | 16 B | 随机,客户端生成 |
|
||||
| `K` | 32 B | 频道密钥,仅 enc 模式,按账号加密封装;可选能力链接使用 fragment |
|
||||
| `K` | 32 B | 空间/临时频道访问凭据;新工作区内容加密不使用它,可选能力链接使用 fragment |
|
||||
| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 |
|
||||
| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 |
|
||||
| `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 |
|
||||
@@ -70,7 +80,7 @@ raw 模式下 `T_owner` 同样由 `A` 派生;`T_write = W`。
|
||||
| 1 | SNAPSHOT | 该条目是频道的「最新快照」,取代之前的快照条目 |
|
||||
| 2 | GATED | 不通过实时推送内联分发,只能按条目拉取,并计下载次数 |
|
||||
| 3 | ENC | 负载为 AES-GCM 密文,必须与频道模式一致 |
|
||||
| 4 | CONTROL | 频道结构控制条目 |
|
||||
| 4 | CONTROL | 加密资源元数据记录 |
|
||||
| 5-7 | 保留 | 必须为 0 |
|
||||
|
||||
服务端拒绝不符合上述规则的封装,但从不解析负载。
|
||||
@@ -126,7 +136,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
|
||||
第 1 帧起,`data` 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。
|
||||
|
||||
**频道 thread 与文件夹**:客户端把 thread/folder 元数据放在上述共享元数据中(enc 时加密,raw 时服务端可见)。thread 的创建、重命名,文件夹的创建/重命名,以及条目移动等操作以 `kind=other` 的 CONTROL 条目追加到同一频道日志;控制正文是版本化 JSON,仅客户端识别。它们不增加服务端表或第二套同步协议。新内容必须归属已创建的 thread;前端新建频道后自动发布“常规”thread 创建记录。GATED 的 announce 不含元数据,但 file.published 加密目录记录可在不下载内容时提供名称和归属;没有目录记录时显示通用占位符。
|
||||
**频道 thread 与文件夹**:客户端把 thread/folder 元数据放在上述共享元数据中(enc 时加密,raw 时服务端可见)。频道、分组、文件夹和条目归属由后端结构接口管理,不写入内容日志。新建空间与初始“常规”频道由后端原子创建。GATED 的 announce 不含元数据,file.published 加密记录提供名称和原始归属;目录记录未到达时显示等待同步占位符。
|
||||
|
||||
**raw 模式(API 契约 2)**:负载使用与 enc 相同的 `u16 meta_len ‖ meta_json ‖ content` 条目结构,只是不加密。名称、thread/folder ID 与其他元数据对服务端可见。旧版“纯字节、无元数据”的 raw 负载不属于新应用契约,旧客户端会被 API 主版本检查拒绝。
|
||||
|
||||
@@ -134,7 +144,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
|
||||
## 8. 日志、序号与条目生命周期
|
||||
|
||||
`CONTROL=0x10` 标记频道结构控制条目;保留位为 `0xE0`。控制条目必须是非 GATED、非 SNAPSHOT 的 FINAL 单帧,payload 最多 16,400 B;其生命周期为频道生命周期,不受普通内容 TTL 或 max_items 淘汰。每频道最多 4096 条控制记录,仍计入总字节配额。加密和非加密频道使用相同控制正文与日志处理。
|
||||
`CONTROL=0x10` 标记加密资源元数据记录;保留位为 `0xE0`。控制条目必须是非 GATED、非 SNAPSHOT 的 FINAL 单帧,payload 最多 16,400 B;其生命周期为频道生命周期,不受普通内容 TTL 或 max_items 淘汰。每频道最多 4096 条控制记录,仍计入总字节配额。加密和非加密频道使用相同控制正文与日志处理。
|
||||
|
||||
频道维护一条服务端排序的**日志**,每条记录的序号 `seq` 是 u64,频道内单调递增。
|
||||
|
||||
@@ -165,13 +175,15 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
|
||||
带 SNAPSHOT 的条目取代频道中之前的快照条目(最后写入者胜)。快照必须是**非 GATED 的单帧**(整份状态不超过一帧,约 1 MiB),所以新快照一被接受,服务端就在同一个事务里删除旧快照并产生一条 `reason = 4` 的 tombstone。一个频道在 v1 里只有一个快照槽,多文档请使用多个频道。
|
||||
|
||||
## 10. 终端与席位
|
||||
## 10. 终端与成员人数
|
||||
|
||||
- 终端在本地为每个频道生成随机 `S`,请求中带 `S`;服务端保存 `SHA-256(S)` 作为终端标识。
|
||||
- 终端通过显式的加入请求(`POST .../seats`)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(`seats_full`),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回 `not_joined`。
|
||||
- 终端通过显式的加入请求(`POST .../seats`)建立设备席位;创建者的当前终端在创建时自动入席。席位用于设备级认证、命名和撤销,不限制同一账号可加入的设备数量。未入席的终端访问频道返回 `not_joined`。
|
||||
- 频道设置字段 `seats` 保留原线格式名称,但 v5 起表示**最大成员人数**。owner 和每个唯一账号各计一人,同一账号的额外设备不增加人数。成员人数达到上限时,新的账号成员加入返回 `seats_full`。
|
||||
- 未登录访客没有可跨设备识别的账号身份,每个访客终端按一个访客成员计入上限;因此访客在多台设备加入会占多个名额。若希望严格按自然人去重,应要求加入者登录账号。
|
||||
- 不同频道使用不同的 `S`,避免服务端把同一设备关联起来。
|
||||
- 终端可以加密一个设备名上传,所有者能看到成员列表和设备名,并可以释放席位。
|
||||
- 终端可更新自己的名称;所有者也可重命名任意席位。enc 模式重命名仍使用同一份加密设备标签,并将 AAD 绑定目标 endpoint hash;raw 模式标签仍为明文。所有者移除席位后,该终端不能继续读取频道,但持有成员链接的人仍可重新加入(若有空席位)。
|
||||
- 终端可以加密一个设备名上传,所有者能看到设备列表和设备名,并可以释放单个设备席位。
|
||||
- 终端可更新自己的名称;所有者也可重命名任意设备席位。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. 创建与验证码
|
||||
@@ -193,7 +205,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
| 设置 | 默认 | 范围 |
|
||||
|---|---|---|
|
||||
| `name` | 无 | 可选,最多 48 个字符;所有频道成员和服务端可见 |
|
||||
| `seats` | 2 | 1..64(服务端可配置上限) |
|
||||
| `seats`(v5 起为成员人数上限) | 2 | 1..64(服务端可配置上限);每个账号计一人,其多设备不增加人数 |
|
||||
| `members_can_upload` | true | |
|
||||
| `delete_policy` | `own` | `owner_only` / `own` / `anyone` |
|
||||
| `ttl_seconds` | 86400 | 最长 604800,每个条目从创建起算 |
|
||||
@@ -232,7 +244,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
| 模式 | enc 或 raw | **只有 enc** |
|
||||
| 谁能写 | 所有者和成员 | 只有创建者,且只在草稿阶段 |
|
||||
| 寿命 | 长期(TTL 滚动续期) | 短,封存后到期即清 |
|
||||
| 访客 | 成员,占席位 | 只读访客,按终端计数,不展示人数 |
|
||||
| 访客 | 作为访客成员计入人数上限 | 只读访客,按终端计数,不展示人数 |
|
||||
|
||||
分享与频道**共用同一个 16 字节 id 空间**,服务端保证全局唯一,所以同一个 id 不可能既是频道又是分享。分享 id 在频道接口上返回 `not_found`,反之亦然。
|
||||
|
||||
@@ -330,27 +342,45 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
- `test-vectors/` 提供固定输入、固定输出:密钥派生、令牌、加解密、padding、封装编解码,以及篡改、重排、截断、换频道的负例,必须全部通过。
|
||||
- 本版**预留**但不实现:口令叠加(Argon2id)、短配对码(PAKE)、多快照槽。
|
||||
|
||||
## 17. 按账号分发频道密钥
|
||||
## 17. 账号凭据与频道域密钥
|
||||
|
||||
频道内容仍使用共享 K 加密。创建者用自己的账号 P-256 公钥封装 owner 凭据,随创建请求的 key_package 与频道、owner 成员记录在同一事务内保存。邀请时用收件账号公钥封装 member 凭据,服务端原子保存邀请和密钥包;账号接受邀请并成为成员后,可只凭账号 session 从 /accounts/channels 取回自己的密钥包,不需要含密钥的 URL,也不要求邀请者在线。成员包不得包含管理密钥 A。
|
||||
空间访问凭据用于服务端资源/终端鉴权,不是频道内容根。创建者用自己的账号 P-256 公钥封装 Owner 凭据,随空间、默认频道、Owner 成员记录原子保存。账号接受空间邀请后,可凭账号 session 从 `/accounts/channels` 取回自己的空间凭据;设备席位仍在实际打开空间时建立。
|
||||
|
||||
密钥包沿用邀请的 ECDH、HKDF、AES-GCM,AAD 绑定收件账号与频道 ID。服务端只保存 opaque 密文;成员私钥保存在口令加密的账号保险库。成员登录后解开保险库、取回密钥包,在 Worker 内恢复频道会话;新设备生成独立 S,打开频道时才占设备席位。待接受的邀请不会自动占席位。
|
||||
内容加密密钥由浏览器生成。每个频道各有独立 `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。
|
||||
|
||||
撤销成员时同时删除密钥包和席位。此举不能收回已交付的 K 或历史明文;当前没有轮换密钥或前向保密。仅知道频道 ID 或登录账号并不足以获得密钥;新账号必须得到现有持钥 owner 的授权封装。邀请公钥仍来自服务端目录,尚无外部指纹核验。
|
||||
默认频道成员权限是 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。记录与 thread/folder 控制记录共用 webdrop.workspace v1 正文。名称/类型/大小/原始 thread/folder 必须与文件第 0 帧元数据相符。目录允许先显示名称,但不得因此自动下载 GATED 内容。
|
||||
文件密文帧之后,在同一发送队列追加 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 条控制记录和频道字节配额限制。文件移动覆盖位置索引,不重新加密原始文件,不改变最初认证的内容元数据。
|
||||
客户端只有在当前日志存在对应文件帧或 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,作为未分组频道重放。
|
||||
界面顶层“空间”对应 API space/底层同步资源,界面“频道”是空间中的 WorkspaceChannel,帧元数据 threadId 映射到频道 ID。频道和分组由后端结构接口创建、重命名和管理归属,名称限制与关联验证由后端实施。成员与同步日志归属于空间,频道各自拥有 chat/files 密钥域;分组仅组织频道,不建立新授权或密钥。客户端不重放 group.create、thread.create 等结构记录。
|
||||
|
||||
## 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 后写入加密正文,不改变内容帧、密钥派生或鉴权头。
|
||||
聊天发送者由服务端已存条目的 authorAccount 与后端个人资料决定。file.published 不写入作者声明。头像在 Worker 内缩放为最多 64×64 后用于渲染,不影响加密帧或鉴权。
|
||||
|
||||
快照是持钥成员提供的展示资料,不是发送者签名。无 author 的旧记录回退设备名;客户端不得将此类内容标记为当前账号发送。结构记录随既有 CONTROL 生命周期处理,实际消息可见性仍由条目和墓碑决定。
|
||||
服务端提供的作者归属与个人资料不代表端对端发送者签名;资源密文仍由浏览器验证。未关联账号的访客资源回退设备来源,不推测当前用户。
|
||||
|
||||
## 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 首次接触风险、服务器分叉/回滚、撤销设备密钥轮换仍未解决,不能宣称已完整抵抗主动恶意服务端。
|
||||
Reference in new issue
Block a user