feat(api): add account workspaces and signed content events

This commit is contained in:
justme committed 2026-10-14 04:05:30 +08:00
1 parent 937b774a93
commit 7b8d61d1ba
63 files changed
+5407 -370

No files matched your search

+31 -20
View File
@@ -1,6 +1,12 @@
# API 契约 2.1
# API 契约 6.0
URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `2.1.0`。旧主版本客户端会被契约预检拒绝。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。
账户资料使用 `GET/PATCH /accounts/profile`;头像为 PNG/JPEG/WebP data URI,禁止远程 URL 和 SVG。不提供旧资料导入。
空间结构使用 `GET /spaces/{id}/structure`,包含 revision、channels、groups、folders、locations。频道与分组分别通过 `/spaces/{id}/channels` 和 `/spaces/{id}/groups` 创建;响应同时包含完整结构快照和服务端新建实体及 ID。其他字段通过 PATCH 重命名;频道归属通过 `PUT /spaces/{id}/channels/{channelId}/group` 修改。文件夹通过 `/spaces/{id}/folders` 创建及 PATCH 重命名,文件归属通过 `PUT /spaces/{id}/locations` 修改。
频道、分组和目录写操作需要账号会话和空间写权限;允许上传的访客可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。
URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `6.0.0`。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。
`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.seats` 与 `limits.max_seats` 表示成员人数范围,不代表设备数。频道设置 JSON 字段 `seats` 为保持已有频道配置可读而保留其名称;v5 起它限制的是成员数。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。
基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。
@@ -14,6 +20,7 @@ URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `2.1.0`。旧主
| `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) |
| `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 |
| `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;创建、管理和账号成员请求必填,访客省略 |
| `X-WD-Event-*` | 已登记签名设备提交频道对象 FINAL 帧时必填,绑定对象 ID、域/epoch、事件类型与完整密文帧 manifest,见 protocol §21 |
raw 模式的读请求不需要令牌,但仍需要 `X-WD-Endpoint`。
@@ -34,22 +41,26 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
用户名为 3–32 位 ASCII 字母、数字、`_` 或 `-`,大小写不敏感。客户端 PBKDF2-SHA256(600,000 次)派生口令根,再分别派生登录凭据和 AES-GCM 保险库密钥。登录凭据仅经 HTTPS 发送;服务端再用随机 salt 和 PBKDF2-SHA256(210,000 次)存储慢哈希,数据库里的哈希不能直接用于登录。服务端不接收口令根或保险库密钥。二进制字段使用标准 Base64,保险库密文最大 1.3 MB。
- `POST /api/v1/accounts` 注册 `{username,salt,verifier,vault,public_key}`。public_key 为 P-256 ECDH 公钥 JWK 的 JSON 字符串;私钥只在客户端加密保险库中保存。
- `POST /api/v1/accounts` 注册 `{username,salt,verifier,vault,public_key,signing_public_key}`。`public_key` 是 P-256 ECDH 公钥,`signing_public_key` 是 P-256 ECDSA 账号根公钥;对应私钥只保存在客户端加密保险库中。API v6 不兼容旧账号保险库协议,不自动迁移旧签名身份。
- `POST /api/v1/accounts/salt` 请求 `{username}`,返回 `{salt}`,供客户端派生登录凭据。
- `POST /api/v1/accounts/session` 请求 `{username,verifier,device_id?,device_name?}`。成功返回 `{username,token}`;session token 随机生成,只保存 SHA-256,期限 30 天。
- `GET /api/v1/accounts/vault` 使用 `Authorization: Bearer <token>` 读取 `{revision,vault}`。
- `PUT /api/v1/accounts/vault` 使用同一鉴权,发送 `{revision,vault}`。revision 乐观并发控制;冲突返回 `409 vault_conflict`,客户端必须先读取并合并,不能静默覆盖。
- `DELETE /api/v1/accounts/session` 撤销当前 bearer session;退出登录时调用。
- `GET /api/v1/accounts/users/{username}/key` 已登录用户查询收件账号公开邀请密钥。
- `GET /api/v1/accounts/users/{username}/key` 已登录用户查询 ECDH 邀请公钥和可选的账号签名根公钥。
- `PUT /api/v1/accounts/devices/signing-key` 当前设备提交 `{device_id,public_key,signature}`。服务端核对账号 session 绑定的 device_id,并验证账号根密钥对 P-256 设备公钥证书的签名后保存。
- `GET /api/v1/accounts/users/{username}/key` 返回账号 ECDH 公钥与签名根公钥;签名对象携带设备公钥和根签设备证书,客户端首次 TOFU 固定根公钥后离线验设备证书。
- `GET /api/v1/accounts/invitations` 返回当前账号的邀请,payload 为绑定收件用户名和频道 ID 的 ECDH + HKDF + AES-GCM 密文。
没有邮箱找回流程。忘记密码后保险库无法恢复。保险库仅同步资源凭据和草稿清单,不同步设备 S、设备名、缓存、原始文件或发送队列。浏览器存储按账号与访客分库;切换账号会中止订阅并清空内存会话。资源清单移除使用带时间戳的墓碑同步,避免旧设备将条目恢复。
签名根密钥保存在加密保险库;设备签名私钥以不可导出的 CryptoKey 保存在本机账户数据库,不随保险库同步。已登记签名设备写入 Workspace 对象时,FINAL 帧和删除请求必须带签名事件;尚未把签名要求扩展到频道结构和成员/权限变更。
### `GET /api/v1/info`
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力以及创建默认值/选项。`defaults.channel_mode`、`defaults.channel` 和 `choices` 是前端创建表单的初始值与可选项;服务端独立校验提交的设置。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
### `POST /api/v1/channels`
创建者必须登录。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入该频道的 owner 成员记录并占一个设备席位。服务端启用验证码时还需要 `X-WD-Cap`。
创建者必须登录。创建请求必须提供 `settings.name`,服务端拒绝缺失、空白或纯空格名称并返回 `400 space_name_required`,存储时去除首尾空格;名称长度最多 48 个字符。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入 owner 成员记录并占一个成员名额,同时登记当前设备终端;该账号的其他设备不增加名额。服务端启用验证码时还需要 `X-WD-Cap`。
```json
{
@@ -66,32 +77,32 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者的终端(`X-WD-Endpoint`)自动入席。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。
### `GET /api/v1/channels/{id}`
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`seats_used`)、`oldest_seq`、`last_seq`、时间。`seats_used` 统计唯一账号成员和匿名访客终端,不统计账号的设备数。**不占成员名额**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
### `POST /api/v1/channels/{id}/seats`
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。
为一个终端加入频道。每个设备终端各自建立席位以便认证、重命名和撤销,但席位不再受人数上限约束。账号第一次加入时占一个成员名额;同一账号的更多设备不增加名额。同一设备重复调用幂等。成员名额已满时,新的账号成员或访客终端返回 `403 seats_full`。匿名访客无法跨设备识别,每个访客终端各计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。
可发送 JSON `{password}`。默认 `join_policy=open,allow_guests=true`,允许持链接的陌生人设备加入。`allow_guests=false` 要求账号登录;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。成员账号和设备身份独立:同一账号可占多个独立设备席位;账号成员的读写请求必须出示对应账号 session 和该设备 S。
可发送 JSON `{password}`。默认 `join_policy=open,allow_guests=true`,允许持链接的陌生人设备加入。`allow_guests=false` 要求账号登录;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。成员账号和设备终端身份独立:同一账号可拥有不限数量的设备席位,但只占一个成员名额;账号成员的读写请求必须出示对应账号 session 和该设备 S。
`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色。`GET/POST /channels/{id}/invitations` 由频道主查询/发送 `{username,payload}` 邀请;payload 只含客户端加密的成员链接。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权与该账号的设备席位,并阻止重新加入,直到再次邀请。不能移除频道主。设备面板踢出访客也会阻止该 S 重新加入。
`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色。邀请前,频道主调用 `GET /channels/{id}/invitations/{username}/crypto-plan` 获取服务端根据成员权限和历史设置计算的域/epoch/收件人计划。浏览器只为计划中的收件人生成并封装域密钥;随后 `POST /channels/{id}/invitations` 原子提交账号链接包与 `domain_envelopes`。服务端会重新计算计划并校验每个 epoch 和收件人,不接受前端自行增加授权或选择轮换范围。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权、域密钥包与该账号的设备席位,并阻止重新加入,直到再次邀请。不能移除频道主。设备面板踢出访客也会阻止该 S 重新加入。
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号或访客设备、频道成员/席位与写入权限,不能靠客户端隐藏按钮绕过。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号或访客设备、频道成员、设备席位与写入权限,不能靠客户端隐藏按钮绕过。移除单台设备只撤销该终端,不释放账号成员名额;移除账号成员才会释放名额。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
### `PATCH /api/v1/channels/{id}/settings`
所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`ttl_seconds` 的修改从现在起算。
所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`seats` 调整成员人数上限,不能改变已有成员身份;`ttl_seconds` 的修改从现在起算。
### `DELETE /api/v1/channels/{id}`
所有者删除整个频道和全部密文。
### `POST /api/v1/channels/{id}/frames`
追加一帧。请求体是完整封装(`application/octet-stream`)。
追加一帧。请求体是完整封装(`application/octet-stream`)。当账号设备已登记签名证书且对象在 `/spaces/{id}/crypto/objects` 声明时,最终帧必须携带 `X-WD-Event-Id/Kind/Channel/Domain/Epoch/Frames/Manifest/Signature`(编辑可带 `X-WD-Event-Target`)。服务端验签并核对此前帧摘要与当前 FINAL 帧的清单;缺少或无效签名拒绝提交。签名随对象元数据进入 `CryptoState`,成员读取时仍按数据域权限过滤。
| 响应 | 含义 |
|---|---|
| `201 {"seq"}` | 新追加 |
| `200 {"seq"}` | 幂等重发,返回原 seq |
| `409` | 同一 `(item_id, frame_index)` 内容不同 |
| `403` | 无权、席位已满、模式或 ENC 标志不符 |
| `403` | 无权、成员人数已满、模式或 ENC 标志不符 |
| `413` | 超出限制 |
| `429` | 限流 |
@@ -115,13 +126,13 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一终端此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
### `DELETE /api/v1/channels/{id}/items/{item_id}`
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。已登记签名设备还必须提交对应 `chat.delete`/`file.delete` 事件头;服务端验证其绑定的设备证书、权限和原对象帧 manifest 后保存删除签名,浏览器验签后才应用 tombstone。
### 席位
- `GET /api/v1/channels/{id}/seats`:所有者查看(终端哈希、加密设备名、加入时间)。
### 设备终端席位
- `GET /api/v1/channels/{id}/seats`:所有者查看已加入设备(终端哈希、加密设备名、加入时间)。设备清单不等于成员人数;一个账号可有多个设备席位。
- `PUT /api/v1/channels/{id}/seats/me`:终端上传设备名(≤256 B);enc 模式加密,raw 模式明文。
- `PUT /api/v1/channels/{id}/seats/{endpoint_hash}/label`:所有者为指定设备更新名称;enc 模式的密文绑定目标 endpoint hash,raw 模式明文。设备也可用 `/seats/me` 更新自己的名称。
- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者移除该设备的席位,其活动订阅随后会被拒绝。
- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者移除该设备的席位,其活动订阅随后会被拒绝;账号成员身份和人数名额保留。撤销账号邀请/成员会移除该账号全部设备并释放成员名额。
## 4. 便捷分享接口
@@ -204,7 +215,7 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
| `not_joined` | 403 | 终端未入席,或席位已被所有者释放 |
| `visitors_full` | 403 | 便捷分享的访客名额已满,且这个终端未登记 |
| `sealed` | 409 | 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误 |
| `seats_full` | 403 | 席位已满 |
| `seats_full` | 403 | 成员人数已满 |
| `mode_mismatch` | 403 | 帧的 ENC 标志与频道模式不符 |
| `not_found` | 404 | 频道或条目不存在,或已过期 |
| `conflict` | 409 | 幂等冲突 |
@@ -219,7 +230,7 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
- `GET /api/v1/accounts/channels`:只需 `X-WD-Account`,返回当前账号已加入频道的 `{channel_id,role,payload,created_at}` 密钥包;不返回其他账号或未接受邀请的密钥包,不占席位。
- `PUT /api/v1/channels/{id}/key-package`:需要该频道 owner 能力和 owner 账号,仅更新当前 owner 自己的 `{payload}`,用于补齐已创建频道的账号密钥包。
- 创建频道可携带 `key_package`(1–16384 字符 opaque 密文),与频道和 owner 成员记录原子保存。前端必须提供;底层 API 允许省略供纯协议客户端使用。
- 发送邀请时原子保存收件账号的密钥包;接受后仍可跨设备领取。撤销邀请/成员同时删除密钥包,删除频道清理全部密钥包。
- 发送邀请时原子保存收件账号的空间密钥包、频道数据域 envelopes、默认权限和 epoch 更新;接受后仍可跨设备领取。撤销邀请/成员会删除服务端密钥包与访问记录,但无法收回对方此前保存的密钥,成员移除后的 epoch 轮换仍未实现。删除空间时清理全部密钥包。
### 账号设备与资料
+455 -234
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: WebDrop API
version: 2.1.0
version: 6.0.0
servers:
- url: /api/v1
@@ -15,6 +15,388 @@ tags:
- name: account
paths:
/accounts/profile:
get:
tags: [account]
parameters:
- name: X-WD-Account
in: header
required: true
schema: { type: string }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/AccountProfile'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
patch:
tags: [account]
parameters:
- name: X-WD-Account
in: header
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/AccountProfileRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/AccountProfile'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/accounts/spaces:
get:
tags: [account]
parameters:
- name: X-WD-Account
in: header
required: true
schema: { type: string }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
type: array
items: { $ref: '../contracts/api.schemas.json#/$defs/ChannelInfo' }
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/structure:
get:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: X-WD-Account
in: header
required: false
schema: { type: string }
- $ref: '#/components/parameters/Role'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/channels:
post:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceChannelRequest' }
responses:
'201':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceChannelCreatedBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/channels/{channelId}:
patch:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: channelId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/channels/{channelId}/group:
put:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: channelId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceGroupRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/groups:
post:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' }
responses:
'201':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceGroupCreatedBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/groups/{groupId}:
patch:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: groupId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/folders:
post:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceFolderRequest' }
responses:
'201':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/folders/{folderId}:
patch:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: folderId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/locations:
put:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceMoveRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/accounts/channels:
get:
tags: [account]
@@ -244,7 +626,7 @@ paths:
post:
tags: [channel]
security: [{ bearer: [], accountBearer: [] }]
summary: 邀请已注册用户
summary: 原子提交账号邀请及服务端授权计划要求的域密钥包
requestBody:
required: true
content:
@@ -253,6 +635,26 @@ paths:
responses:
'204': { description: 已邀请 }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/channels/{id}/invitations/{username}/crypto-plan:
get:
tags: [channel]
security: [{ bearer: [], accountBearer: [] }]
summary: 获取新成员域密钥封装计划
description: 返回由服务端历史策略和当前成员权限计算的 epoch 与收件人集合;提交时服务端会重新计算并校验。
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/RoleOwner' }
- { name: username, in: path, required: true, schema: { type: string } }
responses:
'200':
description: 服务端计算的加密授权计划
content:
application/json:
schema: { $ref: '#/components/schemas/InvitationCryptoPlan' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/channels/{id}/invitations/{username}:
delete:
tags: [channel]
@@ -299,8 +701,8 @@ paths:
- { $ref: '#/components/parameters/ChannelId' }
get:
tags: [channel]
summary: 读取频道信息(不占席位)
description: 任意角色可调用。用于加入前预览席位占用和核对模式。
summary: 读取频道信息(不占成员名额)
description: 任意角色可调用。`seats_used` 表示唯一账号成员数加匿名访客终端数,不是设备数。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/Role' }
@@ -355,11 +757,11 @@ paths:
- { $ref: '#/components/parameters/ChannelId' }
post:
tags: [seat]
summary: 加入频道(占用席位)
summary: 登记当前设备并加入频道
description: |
幂等:同一终端重复调用返回同一席位,不重复占用。
席位已满返回 403 `seats_full`。
读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则 403 `not_joined`。
幂等:同一终端重复调用返回同一设备席位,不重复登记。
同一账号的设备不限量,账号首次加入时才占一个成员名额;成员名额已满返回 403 `seats_full`。
未登录访客每个终端单独计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、设备席位/设备名等操作要求终端已入席,否则 403 `not_joined`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/Role' }
@@ -375,7 +777,7 @@ paths:
'404': { $ref: '#/components/responses/Error' }
get:
tags: [seat]
summary: 查看席位列表(所有者)
summary: 查看已加入设备列表(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。
security: [{ bearer: [] }]
parameters:
@@ -437,8 +839,8 @@ paths:
/channels/{id}/seats/{endpointHash}:
delete:
tags: [seat]
summary: 释放席位(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。被释放终端后续请求返回 `not_joined`;向订阅者推送 `{"t":"seats"}`。
summary: 撤销单台设备(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用设备席位。被释放终端后续请求返回 `not_joined`;账号成员身份及人数名额保留;向订阅者推送 `{"t":"seats"}`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
@@ -917,87 +1319,43 @@ components:
schemas:
AccountChannel:
type: object
required: [channel_id, role, payload, created_at]
properties:
channel_id: { $ref: '#/components/schemas/Base64Url' }
role: { type: string, enum: [owner, member] }
payload: { type: string, minLength: 1, maxLength: 16384 }
created_at: { type: integer, format: int64 }
$ref: '../contracts/api.schemas.json#/$defs/AccountChannelBody'
AccountDevice:
type: object
required: [id, name, created_at, last_seen, current]
properties:
id: { type: string, format: uuid }
name: { type: string, minLength: 1, maxLength: 80 }
created_at: { type: integer, format: int64 }
last_seen: { type: integer, format: int64 }
current: { type: boolean }
$ref: '../contracts/api.schemas.json#/$defs/AccountDeviceBody'
PublicKey:
type: object
required: [public_key]
properties:
public_key: { type: string, maxLength: 1024 }
$ref: '../contracts/api.schemas.json#/$defs/PublicKeyBody'
ChannelInvitation:
type: object
required: [channel_id, username, payload, created_at]
properties:
channel_id: { $ref: '#/components/schemas/Base64Url' }
username: { type: string }
payload: { type: string, maxLength: 16384, description: 客户端加密的成员链接 }
created_at: { type: integer, format: int64 }
$ref: '../contracts/api.schemas.json#/$defs/InvitationBody'
InvitationRequest:
type: object
required: [username, payload]
properties:
username: { type: string }
payload: { type: string, maxLength: 16384 }
$ref: '../contracts/api.schemas.json#/$defs/InvitationRequest'
InvitationCryptoPlan:
$ref: '../contracts/api.schemas.json#/$defs/InvitationCryptoPlan'
ChannelMember:
type: object
required: [username, role, joined_at]
properties:
username: { type: string }
role: { type: string, enum: [owner, member] }
joined_at: { type: integer, format: int64 }
$ref: '../contracts/api.schemas.json#/$defs/MemberBody'
AccountRegister:
type: object
required: [username, salt, verifier, vault, public_key]
properties:
username: { type: string, minLength: 3, maxLength: 32, pattern: '^[A-Za-z0-9_-]+$' }
salt: { type: string, contentEncoding: base64, minLength: 24, maxLength: 24 }
verifier: { type: string, contentEncoding: base64, minLength: 44, maxLength: 44 }
vault: { type: string, contentEncoding: base64, maxLength: 1733336 }
public_key: { type: string, maxLength: 1024, description: P-256 ECDH 公钥 JWK 的 JSON 字符串 }
$ref: '../contracts/api.schemas.json#/$defs/AccountRegisterRequest'
AccountSaltRequest:
type: object
required: [username]
properties:
username: { type: string, minLength: 3, maxLength: 32 }
$ref: '../contracts/api.schemas.json#/$defs/AccountSaltRequest'
AccountSalt:
type: object
required: [salt]
properties:
salt: { type: string, contentEncoding: base64 }
$ref: '../contracts/api.schemas.json#/$defs/AccountSaltBody'
AccountSessionRequest:
type: object
required: [username, verifier]
properties:
device_id: { type: string, format: uuid }
device_name: { type: string, minLength: 1, maxLength: 80 }
username: { type: string }
verifier: { type: string, contentEncoding: base64 }
$ref: '../contracts/api.schemas.json#/$defs/AccountSessionRequest'
AccountSession:
type: object
required: [username, token]
properties:
username: { type: string }
token: { type: string, contentEncoding: base64 }
$ref: '../contracts/api.schemas.json#/$defs/AccountSessionBody'
AccountVault:
type: object
required: [revision, vault]
properties:
revision: { type: integer, format: int64, minimum: 0 }
vault: { type: string, contentEncoding: base64, maxLength: 1733336 }
$ref: '../contracts/api.schemas.json#/$defs/AccountVaultBody'
Base64Url:
type: string
pattern: '^[A-Za-z0-9_-]+$'
@@ -1043,186 +1401,49 @@ components:
oldest_seq: { type: integer, format: int64, description: "清理水位 + 1;0 < from < oldest_seq 的续传需要从 0 全量同步" }
ServerInfo:
type: object
required: [protocol, contract_version, limits, captcha, transports]
properties:
protocol: { type: integer, const: 1 }
contract_version: { type: string, example: 2.0.0, description: API 契约的 SemVer。破坏性变更递增主版本。 }
captcha:
type: object
required: [provider, enabled]
properties:
provider: { type: string, enum: [none, cap] }
enabled: { type: boolean }
api_endpoint:
type: string
description: Cap widget 要连接的地址(data-cap-api-endpoint),仅在启用时出现
transports:
type: object
properties:
websocket: { type: boolean }
sse: { type: boolean }
http3: { type: boolean }
limits:
type: object
properties:
max_payload_bytes: { type: integer }
max_frames_per_item: { type: integer }
max_channel_bytes: { type: integer, format: int64 }
max_seats: { type: integer }
max_ttl_seconds: { type: integer }
$ref: '../contracts/api.schemas.json#/$defs/ServerInfoBody'
ChannelMode:
type: string
enum: [enc, raw]
$ref: '../contracts/api.schemas.json#/$defs/ChannelMode'
DeletePolicy:
type: string
enum: [owner_only, own, anyone]
$ref: '../contracts/api.schemas.json#/$defs/DeletePolicy'
ChannelSettings:
type: object
required: [seats, members_can_upload, delete_policy, ttl_seconds, max_items, file_max_downloads, ui_restrict_copy_download]
properties:
join_policy: { type: string, enum: [open, invite], default: open }
allow_guests: { type: boolean, default: true }
name: { type: string, maxLength: 48, description: "共享频道名,所有成员和服务端可见" }
seats: { type: integer, minimum: 1, default: 2 }
members_can_upload: { type: boolean, default: true }
delete_policy: { $ref: '#/components/schemas/DeletePolicy' }
ttl_seconds: { type: integer, minimum: 60, maximum: 604800, default: 86400 }
max_items: { type: integer, minimum: 1, default: 1000 }
file_max_downloads: { type: integer, minimum: 0, default: 10, description: "0 为不限,仅对 GATED 条目生效" }
ui_restrict_copy_download: { type: boolean, default: false, description: "仅供前端展示,服务端不强制" }
$ref: '../contracts/api.schemas.json#/$defs/ChannelSettings'
ChannelSettingsPatch:
type: object
description: ChannelSettings 的全部字段均可选
properties:
join_policy: { type: string, enum: [open, invite] }
allow_guests: { type: boolean }
join_password: { type: string, maxLength: 256, description: 至少 8 字符;空字符串清除;省略保持原值 }
name: { type: string, maxLength: 48, description: "更新共享频道名;空字符串清除名称" }
seats: { type: integer, minimum: 1 }
members_can_upload: { type: boolean }
delete_policy: { $ref: '#/components/schemas/DeletePolicy' }
ttl_seconds: { type: integer, minimum: 60, maximum: 604800 }
max_items: { type: integer, minimum: 1 }
file_max_downloads: { type: integer, minimum: 0 }
ui_restrict_copy_download: { type: boolean }
$ref: '../contracts/api.schemas.json#/$defs/ChannelSettingsPatch'
CreateChannelRequest:
type: object
required: [id, mode, settings, owner_hash]
properties:
key_package: { type: string, minLength: 1, maxLength: 16384, description: 用创建者账号公钥封装的 owner 凭据 }
join_password: { type: string, minLength: 8, maxLength: 256 }
id: { $ref: '#/components/schemas/Base64Url' }
mode: { $ref: '#/components/schemas/ChannelMode' }
settings: { $ref: '#/components/schemas/ChannelSettings' }
read_hash:
$ref: '#/components/schemas/Base64Url'
description: SHA-256(T_read),enc 模式必填,raw 模式省略
write_hash:
$ref: '#/components/schemas/Base64Url'
description: SHA-256(T_write 或 W),仅在成员可上传时提供
owner_hash:
$ref: '#/components/schemas/Base64Url'
description: SHA-256(T_owner)
$ref: '../contracts/api.schemas.json#/$defs/CreateChannelRequest'
ShareSettings:
type: object
required: [ttl_seconds, max_visitors, file_max_downloads]
properties:
ttl_seconds: { type: integer, minimum: 60, maximum: 604800, default: 86400 }
max_visitors: { type: integer, minimum: 0, maximum: 10000, default: 0, description: 0 为不限 }
file_max_downloads: { type: integer, minimum: 0, default: 10, description: 0 为不限 }
$ref: '../contracts/api.schemas.json#/$defs/ShareSettings'
ShareSettingsPatch:
type: object
description: ShareSettings 的字段均可选。
properties:
ttl_seconds: { type: integer, minimum: 60, maximum: 604800 }
max_visitors: { type: integer, minimum: 0, maximum: 10000 }
file_max_downloads: { type: integer, minimum: 0 }
$ref: '../contracts/api.schemas.json#/$defs/ShareSettingsPatch'
CreateShareRequest:
type: object
required: [id, read_hash, owner_hash]
properties:
id: { $ref: '#/components/schemas/Base64Url' }
read_hash: { $ref: '#/components/schemas/Base64Url', description: SHA-256(T_read) }
owner_hash: { $ref: '#/components/schemas/Base64Url', description: SHA-256(T_owner) }
settings: { $ref: '#/components/schemas/ShareSettings' }
$ref: '../contracts/api.schemas.json#/$defs/CreateShareRequest'
FinishShareRequest:
type: object
required: [manifest_item_id]
properties:
manifest_item_id: { $ref: '#/components/schemas/Base64Url' }
$ref: '../contracts/api.schemas.json#/$defs/FinishShareRequest'
ShareItemInfo:
type: object
required: [item_id, bytes, frames, max_downloads, downloads_left]
properties:
complete: { type: boolean }
item_id: { $ref: '#/components/schemas/Base64Url' }
bytes: { type: integer, format: int64 }
frames: { type: integer }
max_downloads: { type: integer, format: int64, description: 0 为不限 }
downloads_left: { type: [integer, 'null'], format: int64, description: "null 表示不限" }
$ref: '../contracts/api.schemas.json#/$defs/ShareItemInfo'
ShareInfo:
type: object
required: [id, state, expires_at, created_at, settings, items]
properties:
id: { $ref: '#/components/schemas/Base64Url' }
state: { type: string, enum: [draft, sealed] }
expires_at: { type: integer, format: int64 }
created_at: { type: integer, format: int64 }
sealed_at: { type: [integer, 'null'], format: int64 }
manifest_item_id: { type: [string, 'null'], description: 清单条目 id }
settings: { $ref: '#/components/schemas/ShareSettings' }
items:
type: array
items: { $ref: '#/components/schemas/ShareItemInfo' }
$ref: '../contracts/api.schemas.json#/$defs/ShareInfo'
ChannelInfo:
type: object
required: [id, mode, settings, seats_used, oldest_seq, last_seq, created_at, expires_at]
properties:
password_required: { type: boolean }
creator_account: { type: string }
id: { $ref: '#/components/schemas/Base64Url' }
mode: { $ref: '#/components/schemas/ChannelMode' }
settings: { $ref: '#/components/schemas/ChannelSettings' }
seats_used: { type: integer }
oldest_seq: { type: integer, format: int64 }
last_seq: { type: integer, format: int64 }
created_at: { type: integer, format: int64 }
expires_at: { type: integer, format: int64 }
$ref: '../contracts/api.schemas.json#/$defs/ChannelInfo'
SeatJoined:
type: object
required: [endpoint_hash, joined_at, seats_used, seats_max]
properties:
endpoint_hash: { $ref: '#/components/schemas/Base64Url' }
joined_at: { type: integer, format: int64 }
seats_used: { type: integer }
seats_max: { type: integer }
$ref: '../contracts/api.schemas.json#/$defs/SeatJoined'
Seat:
type: object
required: [endpoint_hash, joined_at]
properties:
endpoint_hash: { $ref: '#/components/schemas/Base64Url' }
joined_at: { type: integer, format: int64 }
label:
type: [string, 'null']
description: 加密的设备名(base64url),用 K_enc 解密;未设置为 null
$ref: '../contracts/api.schemas.json#/$defs/SeatInfo'
AppendResult:
type: object
required: [seq]
properties:
seq: { type: integer, format: int64 }
$ref: '../contracts/api.schemas.json#/$defs/AppendResult'
+49 -19
View File
@@ -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 首次接触风险、服务器分叉/回滚、撤销设备密钥轮换仍未解决,不能宣称已完整抵抗主动恶意服务端。