feat(api)!: fold devices into users and report member presence

Contract 7.0.0 (breaking). Devices become an attribute of the account;
members are managed per user, and only unauthenticated guests keep a
per-terminal registration.

- rename seats -> max_members, seats_used -> members_used,
  seats_full -> members_full, SeatJoined -> JoinResult, the `seats`
  stream control message -> `members`, and POST /channels/{id}/seats ->
  POST /channels/{id}/join (stored channel settings are migrated on start)
- drop per-terminal encrypted device labels and the seat list/release
  endpoints; add GET/DELETE /channels/{id}/guests for guest terminals
- authenticate account members by account session only: the terminal key
  is optional for logged-in accounts, no seat row is written for them,
  and gated-download grants are counted per user (per terminal for guests)
- GET /channels/{id}/members now returns online, devices and
  online_devices per account; presence is tracked in memory from active
  WebSocket/SSE subscriptions and is not pushed over the stream
- owner invitation list no longer returns accounts that already joined
This commit is contained in:
justme committed 2026-10-14 22:18:59 +08:00
1 parent 0fd2a269d9
commit d946fc5e8a
35 files changed
+517 -490

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ Account 使用不可修改的 username 作为稳定身份,displayName/avatar
账号邀请私钥保存在密码加密的保险库中,多台设备登录同一账号后解锁同一账号私钥。空间创建时生成空间访问凭据,Owner 凭据与 Owner 账号密钥包由后端原子存储;它们不再加密频道内容。频道 chat/files 域密钥在浏览器生成,并通过 Owner 账号 ECDH 公钥封装;服务端保存密文,获授权成员的账号可领取并在本机解封。
已加入成员的新设备只凭账号登录即可解封账号分发给该账号的数据域密钥,不需要邀请者在线,也不需要复制带内容密钥的链接。每个浏览器有独立设备登录凭据和独立空间终端 S;读取账号空间列表不占成员名额,实际打开空间才登记设备席位。同一账号在一个空间只占一个成员名额,设备数量不受成员上限影响。设备撤销会撤销其账号 sessions;账号空间成员身份及其人数名额保留,其他设备继续访问。
已加入成员的新设备只凭账号登录即可解封账号分发给该账号的数据域密钥,不需要邀请者在线,也不需要复制带内容密钥的链接。每个浏览器有独立设备登录凭据(账号设备),成员请求凭账号 session 鉴权,不再有按空间的终端密钥或设备席位;读取账号空间列表不占成员名额。同一账号在一个空间只占一个成员名额,设备数量不受成员上限影响。设备撤销会撤销其账号 sessions;账号空间成员身份及其人数名额保留,其他设备继续访问。服务端按账号统计在线状态与设备数,成员栏只展示用户。
本机记住登录保存 session token、不可导出的保险库 CryptoKey 和加密保险库。勾选记住账号密码时,将填充资料用另一把不可导出的本机 AES-GCM 密钥加密保存;不保存 verifier。退出清除自动恢复 session,保留已选择保存的填充资料,明确清除时全部移除。登录态与本机资源数据库在同一启动屏障后恢复,避免先加载访客资源再切换账号。
+26 -27
View File
@@ -4,9 +4,9 @@
空间结构使用 `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;公开链接仍允许访客按频道加入规则访问。
URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `7.0.0`。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,访客改走 `/guests`,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。
`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.seats` 与 `limits.max_seats` 表示成员人数范围,不代表设备数。频道设置 JSON 字段 `seats` 为保持已有频道配置可读而保留其名称;v5 起它限制的是成员数。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。
`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.max_members` 与 `limits.max_members` 表示成员人数范围,不代表设备数。频道设置 JSON 字段为 `max_members`(旧存储里的 `seats` 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。
基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。
@@ -16,7 +16,7 @@ URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `6.0.0`。v6 引
|---|---|
| `X-WD-Role` | `read` / `write` / `owner`,指明 `Authorization` 里的令牌属于哪种角色 |
| `Authorization` | `Bearer <base64url(令牌)>` |
| `X-WD-Endpoint` | `base64url(S)`,终端密钥,用于席位 |
| `X-WD-Endpoint` | `base64url(S)`,终端密钥。**只有未登录访客必填**;登录账号以 `X-WD-Account` 会话鉴权,可省略 |
| `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) |
| `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 |
| `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;创建、管理和账号成员请求必填,访客省略 |
@@ -30,10 +30,10 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
| 操作 | 需要 |
|---|---|
| 读日志、拉取条目 | `read`(enc)或知道 id(raw),并占有席位 |
| 读日志、拉取条目 | `read`(enc)或知道 id(raw),并已是成员(账号)或已加入的访客终端 |
| 追加帧 | `write`(`members_can_upload` 为 true 时)或 `owner` |
| 删除条目 | `owner`;`delete_policy=own` 时终端哈希等于条目作者;`anyone` 时任意已入席终端 |
| 改设置、删频道、看和释放席位 | `owner`;这些管理操作不占席位 |
| 删除条目 | `owner`;`delete_policy=own` 时账号等于条目作者(访客为终端哈希等于条目作者);`anyone` 时任意成员 |
| 改设置、删频道、看和移除访客 | `owner`;这些管理操作不占成员名额 |
## 3. 接口
@@ -66,7 +66,7 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
{
"id": "<channel_id>",
"mode": "enc",
"settings": { "name": "工作设备", "seats": 2, "members_can_upload": true, "delete_policy": "own",
"settings": { "name": "项目组", "max_members": 2, "members_can_upload": true, "delete_policy": "own",
"ttl_seconds": 86400, "max_items": 1000, "file_max_downloads": 10 },
"read_hash": "<sha256(T_read)>",
"write_hash": "<sha256(T_write)>",
@@ -74,22 +74,22 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
}
```
raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者的终端(`X-WD-Endpoint`)自动入席。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。
raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者自动成为频道的所有者成员。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。
### `GET /api/v1/channels/{id}`
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`seats_used`)、`oldest_seq`、`last_seq`、时间。`seats_used` 统计唯一账号成员和匿名访客终端,不统计账号的设备数。**不占成员名额**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`members_used`)、`oldest_seq`、`last_seq`、时间。`members_used` 统计唯一账号成员和匿名访客终端,不统计账号的设备数。**不占成员名额**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
### `POST /api/v1/channels/{id}/seats`
为一个终端加入频道。每个设备终端各自建立席位以便认证、重命名和撤销,但席位不再受人数上限约束。账号第一次加入时占一个成员名额;同一账号的更多设备不增加名额。同一设备重复调用幂等。成员名额已满时,新的账号成员或访客终端返回 `403 seats_full`。匿名访客无法跨设备识别,每个访客终端各计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。
### `POST /api/v1/channels/{id}/join`
加入频道。登录账号只登记成员关系:账号第一次加入时占一个成员名额,同一账号的更多设备不增加名额、也不登记终端,此后仅凭账号会话鉴权(`X-WD-Endpoint` 可省略)。未登录访客登记一个终端(`X-WD-Endpoint` 必填),每个访客终端各计一个成员名额。同一账号或同一访客终端重复调用幂等。成员名额已满时返回 `403 members_full`。读取频道信息和 owner 管理接口不占成员名额;访客访问频道内容前必须已加入,否则返回 `403 not_joined`。响应 `JoinResult`:`{joined_at, members_used, members_max}`,不再返回终端标识。
可发送 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 即可,不再绑定到某个终端。
`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 重新加入。
`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色,每项附带账号级 `online`(该账号在本频道有活动 WebSocket/SSE 订阅)、`devices`(该账号的登录设备数,不小于 `online_devices`)与 `online_devices`(该账号此刻在本频道有订阅的设备数);设备是账号的属性,不单独作为成员。在线状态只来自内存里的订阅登记,不写库、不经订阅流推送,客户端按需轮询;访客没有账号身份,不参与统计。`GET /channels/{id}/invitations` 不再返回已经成为成员的账号。邀请前,频道主调用 `GET /channels/{id}/invitations/{username}/crypto-plan` 获取服务端根据成员权限和历史设置计算的域/epoch/收件人计划。浏览器只为计划中的收件人生成并封装域密钥;随后 `POST /channels/{id}/invitations` 原子提交账号链接包与 `domain_envelopes`。服务端会重新计算计划并校验每个 epoch 和收件人,不接受前端自行增加授权或选择轮换范围。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权与域密钥包,并阻止重新加入,直到再次邀请。不能移除频道主。移除访客用 `DELETE /channels/{id}/guests/{endpoint_hash}`,同样阻止该终端重新加入。
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号或访客设备、频道成员、设备席位与写入权限,不能靠客户端隐藏按钮绕过。移除单台设备只撤销该终端,不释放账号成员名额;移除账号成员才会释放名额。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号会话(或访客终端)、频道成员关系与写入权限,不能靠客户端隐藏按钮绕过。移除账号成员才会释放成员名额;撤销账号的某台登录设备只使该设备的会话失效,不影响成员身份。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
### `PATCH /api/v1/channels/{id}/settings`
所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`seats` 调整成员人数上限,不能改变已有成员身份;`ttl_seconds` 的修改从现在起算。
所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`max_members` 调整成员人数上限,不能改变已有成员身份;`ttl_seconds` 的修改从现在起算。
### `DELETE /api/v1/channels/{id}`
所有者删除整个频道和全部密文。
@@ -112,7 +112,7 @@ raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创
### `GET /api/v1/channels/{id}/ws?from=<seq>`
WebSocket 订阅(降级链的第一级)。服务端向下推送:
- 二进制消息:一条日志记录。
- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"seats"}` 席位变更。
- 文本消息(JSON 控制消息):`{"t":"hb","seq":N}` 心跳,`{"t":"settings"}` 设置变更,`{"t":"members"}` 成员/访客变更。
上传仍走 HTTP `POST`,便于幂等重试。
@@ -123,20 +123,19 @@ SSE 订阅(降级链的第二级)。记录以 base64url 放在 `data:` 中,`id:`
WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 秒,45 秒收不到任何消息即判定死链。重连使用指数退避(1 秒到 30 秒)加抖动,并携带 `from=last_seq+1`。
### `GET /api/v1/channels/{id}/items/{item_id}/frames?from=<index>`
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一终端此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一下载方(登录账号按用户,访客按终端)此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
### `DELETE /api/v1/channels/{id}/items/{item_id}`
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。已登记签名设备还必须提交对应 `chat.delete`/`file.delete` 事件头;服务端验证其绑定的设备证书、权限和原对象帧 manifest 后保存删除签名,浏览器验签后才应用 tombstone。
### 设备终端席位
- `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}`:所有者移除该设备的席位,其活动订阅随后会被拒绝;账号成员身份和人数名额保留。撤销账号邀请/成员会移除该账号全部设备并释放成员名额。
### 访客
- `GET /api/v1/channels/{id}/guests`:所有者查看未登录访客的终端(`endpoint_hash`、`joined_at`)。账号成员按用户管理(`/members`、`/invitations`),其设备不对外列出。
- `DELETE /api/v1/channels/{id}/guests/{endpoint_hash}`:所有者移除该访客终端并禁止它再加入,其活动订阅随后被拒绝(`not_joined`);对账号终端返回 `404`。
- 已移除:`PUT /channels/{id}/seats/me`、`PUT /channels/{id}/seats/{endpoint_hash}/label`、`GET/DELETE /channels/{id}/seats`。设备名只在账号设备管理里存在(`/accounts/devices`)。
## 4. 便捷分享接口
便捷分享是与频道并列的另一种资源,语义见 [protocol.md §15](protocol.md)。**没有 WebSocket、SSE、日志、席位和成员上传**。接口前缀 `/api/v1/shares`,只支持 enc 模式,鉴权头只用 `read` 和 `owner` 两种角色(`write` 会被拒绝为 `bad_request`)。
便捷分享是与频道并列的另一种资源,语义见 [protocol.md §15](protocol.md)。**没有 WebSocket、SSE、日志、成员登记和成员上传**。接口前缀 `/api/v1/shares`,只支持 enc 模式,鉴权头只用 `read` 和 `owner` 两种角色(`write` 会被拒绝为 `bad_request`)。
| 接口 | 角色 | 阶段 | 说明 |
|---|---|---|---|
@@ -212,10 +211,10 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
| `bad_request` | 400 | 封装格式或参数错误 |
| `unauthorized` | 401 | 令牌缺失或错误 |
| `forbidden` | 403 | 角色不够 |
| `not_joined` | 403 | 终端未入席,或席位已被所有者释放 |
| `not_joined` | 403 | 访客终端未加入或已被所有者移除,或账号不是该频道成员 |
| `visitors_full` | 403 | 便捷分享的访客名额已满,且这个终端未登记 |
| `sealed` | 409 | 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误 |
| `seats_full` | 403 | 成员人数已满 |
| `members_full` | 403 | 成员人数已满 |
| `mode_mismatch` | 403 | 帧的 ENC 标志与频道模式不符 |
| `not_found` | 404 | 频道或条目不存在,或已过期 |
| `conflict` | 409 | 幂等冲突 |
@@ -227,7 +226,7 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
### 按账号恢复频道密钥
- `GET /api/v1/accounts/channels`:只需 `X-WD-Account`,返回当前账号已加入频道的 `{channel_id,role,payload,created_at}` 密钥包;不返回其他账号或未接受邀请的密钥包,不占席位。
- `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 轮换仍未实现。删除空间时清理全部密钥包。
@@ -235,4 +234,4 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
### 账号设备与资料
GET /accounts/devices 返回本账号设备 {id,name,created_at,last_seen,current}。PATCH /accounts/devices/{id} 发送 {name};DELETE 撤销该设备全部登录 sessions 与频道席位,不移除账号成员。新客户端登录时携带稳定浏览器 device_id(UUID)和设备名,各设备单独记录。个人名称/头像存储在客户端加密保险库,修改后按 modifiedAt 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。
GET /accounts/devices 返回本账号设备 {id,name,created_at,last_seen,current}。PATCH /accounts/devices/{id} 发送 {name};DELETE 撤销该设备的全部登录 sessions,不移除账号成员。新客户端登录时携带稳定浏览器 device_id(UUID)和设备名,各设备单独记录。个人名称/头像存储在客户端加密保险库,修改后按 modifiedAt 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。
+4 -4
View File
@@ -77,7 +77,7 @@ SQLite 存元数据和小帧,磁盘存大帧。
| `channel_join_denials` | 被移除的账号或设备标识,再次邀请账号时清除账号禁入 |
| `accounts/account_sessions` | 登录 salt、登录凭据慢哈希及独立 salt、邀请公钥、保险库密文/revision;随机 session token 的 SHA-256 与过期时间 |
| `shares` | `id` PK(与频道共用全局 id 空间), `settings`(JSON), `read_hash`, `owner_hash`(封存时清空), `creator_endpoint_hash`, `created_at`, `expires_at`, `sealed_at`, `manifest_item_id`, `bytes` |
| `seats` | `channel_id`, `endpoint_hash`, `label` BLOB, `joined_at`;PK(`channel_id`, `endpoint_hash`) |
| `seats` | 仅登记**访客终端**:`channel_id`, `endpoint_hash`, `joined_at`;PK(`channel_id`, `endpoint_hash`)。账号成员只在 `channel_members`,不写此表(旧库里的 `label` 列不再使用) |
| `share_visitors` | `share_id`, `endpoint_hash`, `joined_at`;PK(`share_id`, `endpoint_hash`),创建者终端不写入此表 |
| `items` | `channel_id`, `item_id`, `author_hash`, `flags`, `max_downloads`, `downloads`, `frame_count`, `max_index`, `final_index`, `bytes`, `created_at`, `expires_at`, `close_deadline`, `label`;PK(`channel_id`, `item_id`)。完整与否由 `final_index` 和 `frame_count` 推出,是否进入宽限期由 `close_deadline` 表示 |
| `share_items` | 分享条目的帧状态和下载次数,独立于频道条目 |
@@ -94,7 +94,7 @@ SQLite 存元数据和小帧,磁盘存大帧。
**并发模型**:写入由单个 SQLite 连接加锁串行执行(WAL 模式),读取使用独立连接,每次 `Read` 在显式事务里,看到一致的快照。订阅枢纽 `ChannelHub` 只传递「有变化」的信号,订阅者被唤醒后自己按游标读日志,所以不存在回放与实时之间漏帧的竞态,也无需为慢客户端缓冲数据。代价是目前只支持**单实例**部署。
便捷分享与频道共用 ID 唯一性检查,但条目和帧使用独立表。分享不创建席位或日志;封存时在单个事务里清空 `owner_hash`、记录清单 id 并启动 TTL。
便捷分享与频道共用 ID 唯一性检查,但条目和帧使用独立表。分享不登记成员或日志;封存时在单个事务里清空 `owner_hash`、记录清单 id 并启动 TTL。
后台服务每 30 秒清理:过期条目、下载达上限且宽限期已过的条目、过期频道,每次产生对应的墓碑并删除密文;每 20 轮额外清理超过 10 分钟的孤立 blob。超出 `max_items` 的淘汰和新快照取代旧快照,在追加时同步完成。
@@ -114,7 +114,7 @@ SQLite 存元数据和小帧,磁盘存大帧。
- 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。
- 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。
- 下载大文件优先使用 OPFS 流式写入临时文件,认证完成后提供文件;不支持 OPFS 时回退 Blob,限制 64 MiB。
- 首页先注册/登录;公开频道/分享链接可按规则由访客访问。创建与管理在服务端绑定账号,成员人数按账号去重,设备终端席位独立管理。所有写入都检查账号或访客设备、频道角色/成员名额/设备席位和能力权限。
- 首页先注册/登录;公开频道/分享链接可按规则由访客访问。创建与管理在服务端绑定账号,成员人数按账号去重,设备只是账号的属性(账号设备与登录 session 在 `/accounts/devices` 管理);访客是未登录终端,单独登记。所有写入都检查账号会话或访客终端、频道角色、成员名额和能力权限。
- IndexedDB 按账号与访客分库;切换时中止后台订阅并清空内存。保险库同步私钥、资源凭据和移除墓碑;设备 S、设备标签、草稿内容、缓存、原始文件和 outbox 保留本机。新设备生成独立 S 后重新加入。
- 空间统一管理成员、权限与密钥,后端管理频道、分组和目录。资源日志仅保存内容及资源元数据;CONTROL 资源元数据记录随空间删除。
@@ -124,7 +124,7 @@ SQLite 存元数据和小帧,磁盘存大帧。
|---|---|---|
| 0 | 文档、`test-vectors/` | 向量由独立脚本生成并人工复核 |
| 1 | `@webdrop/core`:密钥派生、封装、加解密、padding、条目装配 | **已完成**:Vitest 通过全部向量和负例,并对着真实服务端端到端通过 |
| 2 | 服务端(AOT):频道与便捷分享、帧存储、频道日志、WebSocket/SSE/长轮询、席位、访客名额、清理 | xUnit 通过;目标平台 AOT 发布并冒烟通过 |
| 2 | 服务端(AOT):频道与便捷分享、帧存储、频道日志、WebSocket/SSE/长轮询、成员与访客、在线状态、访客名额、清理 | xUnit 通过;目标平台 AOT 发布并冒烟通过 |
| 3 | 前端:创建频道、文本和 JSON 实时同步、断线重连、发送队列、「我的频道」 | Playwright:双浏览器内容一致,断网恢复后无丢失无重复,抓包无明文和 K |
| 4 | 文件:流式加解密、GATED 拉取、下载计数、预览、粘贴和拖拽 | 100 MB 文件往返一致,下载计数正确 |
| 5 | GB 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 |
+2 -2
View File
@@ -18,7 +18,7 @@
| 密码与数据密钥 | 账户认证不直接充当数据加密根 | 密码/可用的 Passkey PRF 解锁保险库;随机账号 ECDH 私钥解开账号 Key Envelope;频道数据域密钥独立随机生成 | 密码不直接生成资源密钥;账号恢复仍与 E2EE 身份私钥恢复耦合 |
| 用户身份 | Signing Identity 与 Device Keys | 账号保险库保存 P-256 签名根密钥;本机保存不可导出的设备签名私钥,根签名设备证书由后端校验并登记 | 身份密钥基础已落地;尚未用于业务事件 |
| 新设备 | 可信恢复或已有设备审批 | 用户已决定不要求旧设备审批;新设备以账号密码/支持的 Passkey PRF 解锁同一保险库私钥 | 与参考文档的设备审批模型不同;账号被攻破会暴露该账号全部空间密钥 |
| 设备分发 | 每个获授权设备各有 Key Envelope | 按 username 分发;设备只有 UUID、session、席位 S,没有设备解包公钥 | 分发对象不同 |
| 设备分发 | 每个获授权设备各有 Key Envelope | 按 username 分发;设备只有 UUID 和 session(v7 起不再有按空间的席位或终端密钥,访客除外),没有设备解包公钥 | 分发对象不同 |
| Space | 主要是授权管理范围 | 成员、频道目录、日志与同步边界;不再作为频道内容根 | 业务边界已收束,仍由服务端定义 |
| Channel | Chat 与 FileGroup 有独立数据域和密钥 | 每频道独立 chat/files 随机 epoch key;对象另有随机 key,由域密钥封装 | 数据域已落地,密钥包仍按账号而非设备 |
| 授权链 | Owner 签名 Capability,客户端验证 | owner/member + bearer 能力令牌 + 服务端成员表,客户端没有验证签名授权链 | 服务端仍是授权判断的信任方 |
@@ -74,7 +74,7 @@
仍有几处边界值得收回或明确:
1. `SavedResource` 仍把 endpoint secret、链接等本机凭据与频道名称、席位数等服务端展示快照放在同一结构;账号保险库也同步部分快照。建议拆为本机凭据、服务端快照和传输队列,离线快照只用于只读展示。
1. `SavedResource` 仍把 endpoint secret、链接等本机凭据与频道名称、成员人数上限等服务端展示快照放在同一结构;账号保险库也同步部分快照。建议拆为本机凭据、服务端快照和传输队列,离线快照只用于只读展示。
2. Worker 从本地 `maxDownloads` 提供上传表单初始值;服务端已校验每个 GATED 文件的下载上限,缓存仅影响表单显示,不决定授权。
3. `account.ts` 仍包含用户名输入规范化和账号本地恢复编排。服务端负责账户验证、设备 session、个人资料和保险库并发版本;浏览器只生成/持有私钥,并执行解锁加密保险库所需的本地 KDF。
+45 -77
View File
@@ -1,14 +1,15 @@
openapi: 3.1.0
info:
title: WebDrop API
version: 6.0.0
version: 7.0.0
servers:
- url: /api/v1
tags:
- name: server
- name: channel
- name: seat
- name: join
- name: guest
- name: frame
- name: item
- name: share
@@ -404,7 +405,7 @@ paths:
summary: 领取当前账号已加入频道的加密密钥包
responses:
'200':
description: 仅返回已加入频道,不占设备席位
description: 仅返回已加入频道
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/AccountChannel' } }
@@ -443,7 +444,7 @@ paths:
delete:
tags: [account]
security: [{ accountBearer: [] }]
summary: 撤销该设备全部 sessions 与频道席位,保留账号成员资格
summary: 撤销该设备的全部 sessions,保留账号成员资格
responses:
'204': { description: 已撤销 }
'401': { $ref: '#/components/responses/Error' }
@@ -702,7 +703,7 @@ paths:
get:
tags: [channel]
summary: 读取频道信息(不占成员名额)
description: 任意角色可调用。`seats_used` 表示唯一账号成员数加匿名访客终端数,不是设备数。
description: 任意角色可调用。`members_used` 表示唯一账号成员数加匿名访客终端数,不是设备数。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/Role' }
@@ -717,7 +718,7 @@ paths:
delete:
tags: [channel]
summary: 删除整个频道及全部密文(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。
description: 有效的 owner 凭据即可调用,不要求 owner 终端登记。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/RoleOwner' }
@@ -733,7 +734,7 @@ paths:
summary: 修改设置(所有者)
description: |
字段均可选,只修改提供的字段。`ttl_seconds` 的变更从现在起算。
成功后向订阅者推送 `{"t":"settings"}`。有效的 owner 凭据即可调用,不要求 owner 终端占用席位。
成功后向订阅者推送 `{"t":"settings"}`。有效的 owner 凭据即可调用,不要求 owner 终端登记。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
@@ -752,35 +753,46 @@ paths:
'400': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
/channels/{id}/seats:
/channels/{id}/join:
parameters:
- { $ref: '#/components/parameters/ChannelId' }
post:
tags: [seat]
summary: 登记当前设备并加入频道
tags: [join]
summary: 加入频道
description: |
幂等:同一终端重复调用返回同一设备席位,不重复登记。
同一账号的设备不限量,账号首次加入时才占一个成员名额;成员名额已满返回 403 `seats_full`。
未登录访客每个终端单独计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、设备席位/设备名等操作要求终端已入席,否则 403 `not_joined`。
幂等。登录账号只登记成员关系:同一账号的多台设备不增加人数,也不登记终端,此后仅凭账号会话鉴权(`X-WD-Endpoint` 可省略)。
未登录访客登记一个终端(`X-WD-Endpoint` 必填),每个终端单独计一个成员名额。成员名额已满返回 403 `members_full`。
读取频道信息和 owner 管理接口不占名额;访客访问频道内容前必须已加入,否则 403 `not_joined`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/Role' }
- { $ref: '#/components/parameters/Endpoint' }
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
password: { type: string, minLength: 8, maxLength: 256 }
responses:
'200':
description: 已入席
description: 已加入
content:
application/json:
schema: { $ref: '#/components/schemas/SeatJoined' }
schema: { $ref: '#/components/schemas/JoinResult' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
/channels/{id}/guests:
get:
tags: [seat]
summary: 查看已加入设备列表(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。
tags: [guest]
summary: 查看访客(所有者)
description: 只列未登录访客的终端。账号成员按用户管理(`/members`、`/invitations`),其设备不对外列出。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/RoleOwner' }
responses:
'200':
@@ -789,69 +801,25 @@ paths:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/Seat' }
items: { $ref: '#/components/schemas/Guest' }
'403': { $ref: '#/components/responses/Error' }
/channels/{id}/seats/me:
put:
tags: [seat]
summary: 终端上传自己的加密设备名
description: 请求体是用 `K_enc` 加密后的设备名字节,≤256 B。服务端不解析。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/Role' }
- { $ref: '#/components/parameters/Endpoint' }
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, format: binary, maxLength: 256 }
responses:
'204': { description: 已保存 }
'403': { $ref: '#/components/responses/Error' }
'413': { $ref: '#/components/responses/Error' }
/channels/{id}/seats/{endpointHash}/label:
put:
tags: [seat]
summary: 所有者重命名设备
description: 请求体是设备名称(≤256 B);enc 模式以目标 endpoint hash 作为 AAD 加密,raw 模式为明文。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- name: endpointHash
in: path
required: true
description: 终端标识,即 `base64url(SHA-256(S))`
schema: { $ref: '#/components/schemas/Base64Url' }
- { $ref: '#/components/parameters/RoleOwner' }
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, format: binary, maxLength: 256 }
responses:
'204': { description: 名称已更新 }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'413': { $ref: '#/components/responses/Error' }
/channels/{id}/seats/{endpointHash}:
/channels/{id}/guests/{endpointHash}:
delete:
tags: [seat]
summary: 撤销单台设备(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用设备席位。被释放终端后续请求返回 `not_joined`;账号成员身份及人数名额保留;向订阅者推送 `{"t":"seats"}`。
tags: [guest]
summary: 移除访客(所有者)
description: 移除该访客终端并禁止它再加入;被移除终端后续请求返回 `not_joined`。账号成员改用 `DELETE /invitations/{username}`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- name: endpointHash
in: path
required: true
description: 终端标识,即 `base64url(SHA-256(S))`
description: 访客终端标识,即 `base64url(SHA-256(S))`
schema: { $ref: '#/components/schemas/Base64Url' }
- { $ref: '#/components/parameters/RoleOwner' }
responses:
'204': { description: 已释放 }
'204': { description: 已移除 }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
@@ -950,7 +918,7 @@ paths:
**服务端 → 客户端**
- 二进制消息:一条日志记录(`seq ‖ kind ‖ body`)。
- 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"seats"}` 席位变更。
- 文本消息(JSON):`{"t":"hb","seq":N}` 心跳(每 20 秒);`{"t":"settings"}` 设置变更;`{"t":"members"}` 成员/访客变更。
客户端 45 秒收不到任何消息应判定死链,以指数退避(1~30 秒)加抖动重连,并带 `from=last_seq+1`。
上传不走 WebSocket,仍用 `POST /frames`。
@@ -1294,8 +1262,8 @@ components:
Endpoint:
name: X-WD-Endpoint
in: header
required: true
description: 终端密钥 `S` 的 base64url。每个频道每个终端各生成一个,仅存本地。
required: false
description: 终端密钥 `S` 的 base64url,仅存本地。只有未登录访客必填;登录账号以 `X-WD-Account` 会话鉴权,可省略。
schema: { $ref: '#/components/schemas/Base64Url' }
ShareEndpoint:
name: X-WD-Endpoint
@@ -1368,7 +1336,7 @@ components:
- unauthorized
- forbidden
- not_joined
- seats_full
- members_full
- mode_mismatch
- not_found
- conflict
@@ -1439,11 +1407,11 @@ components:
ChannelInfo:
$ref: '../contracts/api.schemas.json#/$defs/ChannelInfo'
SeatJoined:
$ref: '../contracts/api.schemas.json#/$defs/SeatJoined'
JoinResult:
$ref: '../contracts/api.schemas.json#/$defs/JoinResult'
Seat:
$ref: '../contracts/api.schemas.json#/$defs/SeatInfo'
Guest:
$ref: '../contracts/api.schemas.json#/$defs/GuestInfo'
AppendResult:
$ref: '../contracts/api.schemas.json#/$defs/AppendResult'
+13 -14
View File
@@ -25,7 +25,7 @@
| `K` | 32 B | 空间/临时频道访问凭据;新工作区内容加密不使用它,可选能力链接使用 fragment |
| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 |
| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 |
| `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 |
| `S` | 32 B | 访客终端密钥,每个频道每个访客终端各生成一个,仅存本地;登录账号不需要 |
## 2. 模式与链接
@@ -130,7 +130,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
| `mime` | 可选 |
| `size` | 可选,内容总字节数(流式时可省略) |
| `ts` | 毫秒时间戳,仅供参考,排序以 seq 为准 |
| `dev` | 可选,设备名 |
| `dev` | 已弃用(v7 起客户端不再写入,读取时忽略);发送者以服务端记录的账号为准 |
| `thread_id` | 可选,频道 thread ID |
| `folder_id` | 可选,文件的虚拟文件夹 ID |
@@ -161,7 +161,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
**推送规则**:
- 非 GATED 条目以 frame 记录推送给订阅者。服务端强制:**非 GATED 条目必须是单帧且带 FINAL**(`frame_index = 0`)。
- GATED 条目只推送一条 announce,字节通过条目拉取接口取得(见 api.md),`from=0` 的拉取计入下载次数。
- `from>0` 只有在同一终端此前已对同一条目成功发起 `from=0` 后才是有效续传;服务端按资源、条目和终端绑定这份续传许可,任意跳到中间帧不能绕过下载次数。
- `from>0` 只有在同一下载方(登录账号按用户,访客按终端)此前已对同一条目成功发起 `from=0` 后才是有效续传;服务端按资源、条目和下载方绑定这份续传许可,任意跳到中间帧不能绕过下载次数。
- 因此客户端上传**多帧条目(文件)或带下载上限的条目时,必须设置 GATED**。
- 同一条目的所有帧,SNAPSHOT 与 GATED 标志必须一致;FINAL 不能早于已存在的更大序号;已有 FINAL 之后不能再追加更大序号的帧。GATED 条目的帧允许乱序到达(并行上传),条目拉取只返回从起点开始连续的帧。
@@ -175,16 +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`)建立设备席位;创建者的当前终端在创建时自动入席。席位用于设备级认证、命名和撤销,不限制同一账号可加入的设备数量。未入席的终端访问频道返回 `not_joined`。
- 频道设置字段 `seats` 保留原线格式名称,但 v5 起表示**最大成员人数**。owner 和每个唯一账号各计一人,同一账号的额外设备不增加人数。成员人数达到上限时,新的账号成员加入返回 `seats_full`。
- 未登录访客没有可跨设备识别的账号身份,每个访客终端按一个访客成员计入上限;因此访客在多台设备加入会占多个名额。若希望严格按自然人去重,应要求加入者登录账号。
- 不同频道使用不同的 `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。
- **成员是用户(账号)**。设备只是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的请求凭有效的账号 session 鉴权,不再需要终端密钥,也不登记终端。
- **访客**是未登录的终端:本地为每个频道生成随机 `S`,请求中带 `S`,服务端保存 `SHA-256(S)` 作为访客标识,并通过显式的加入请求(`POST .../join`)登记。未加入的访客访问频道返回 `not_joined`;所有者可以列出(`GET .../guests`)并移除(`DELETE .../guests/{endpoint_hash}`)访客。
- 频道设置字段 `max_members`(v7 前叫 `seats`)是**最大成员人数**。owner 和每个唯一账号各计一人,同一账号的额外设备不增加人数;访客没有可跨设备识别的账号身份,每个访客终端按一个访客成员计入上限,因此访客在多台设备加入会占多个名额。成员人数达到上限时,新的成员加入返回 `members_full`。若希望严格按自然人去重,应要求加入者登录账号。
- 不同频道使用不同的 `S`(仅访客),避免服务端把同一访客终端关联起来。
- 受限下载(GATED)的计数单位是「下载方」:登录账号按用户,访客按终端;同一下载方对同一条目重复发起 `from=0` 才算续传。
- 在线状态由服务端按账号统计(`members` 接口的 `online`、`devices`、`online_devices`),只来自内存里的订阅登记,不写库。
- **已移除**:每终端的加密设备名(`K_label`、AAD `WD1L`、`PUT .../seats/me` 与 `.../seats/{hash}/label`)和设备席位清单。设备名只在账号设备管理里存在(`/accounts/devices`)。`K_label` 的派生仍保留在 §3,为已发布的测试向量保持稳定,但不再被使用。
## 11. 创建与验证码
@@ -205,7 +204,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
| 设置 | 默认 | 范围 |
|---|---|---|
| `name` | 无 | 可选,最多 48 个字符;所有频道成员和服务端可见 |
| `seats`(v5 起为成员人数上限) | 2 | 1..64(服务端可配置上限);每个账号计一人,其多设备不增加人数 |
| `max_members`(v7 前叫 `seats`,最大成员人数) | 2 | 1..64(服务端可配置上限);每个账号计一人,其多设备不增加人数 |
| `members_can_upload` | true | |
| `delete_policy` | `own` | `owner_only` / `own` / `anyone` |
| `ttl_seconds` | 86400 | 最长 604800,每个条目从创建起算 |
@@ -346,7 +345,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
## 17. 账号凭据与频道域密钥
空间访问凭据用于服务端资源/终端鉴权,不是频道内容根。创建者用自己的账号 P-256 公钥封装 Owner 凭据,随空间、默认频道、Owner 成员记录原子保存。账号接受空间邀请后,可凭账号 session 从 `/accounts/channels` 取回自己的空间凭据;设备席位仍在实际打开空间时建立。
空间访问凭据用于服务端资源/终端鉴权,不是频道内容根。创建者用自己的账号 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。
+3 -3
View File
@@ -54,14 +54,14 @@
| 模式降级(enc 换成 raw) | 链接带 `k` 就只接受 enc 且认证通过的帧,不匹配直接拒绝 |
| 重放旧帧 | 同一条目内 (item_id, frame_index) 唯一,服务端幂等,客户端按条目去重 |
| 服务端隐瞒或删除条目 | **无法防止**(可用性),见 §6 |
| 链接泄露 | 链接即能力。成员人数上限限制账号成员和访客终端加入数,所有者可撤销成员、释放设备和删除频道 |
| 链接泄露 | 链接即能力。成员人数上限限制账号成员和访客终端加入数,所有者可撤销成员、移除访客和删除频道 |
| fragment 外泄 | 取出后立即 `history.replaceState`;`Referrer-Policy: no-referrer`;不把 K 放进查询串 |
| XSS | 严格 CSP;条目预览在沙箱中渲染;`raw` 内容不内联渲染,下载带 `attachment` 和 `nosniff` |
| 暴力猜频道 id | id 128 位随机 |
| 撞库/截获账号登录 | 客户端 PBKDF2 派生登录凭据,经 HTTPS 传输;服务端另存独立随机 salt 的 PBKDF2 慢哈希,并限流。TLS 或运行终端泄露仍可能暴露可登录凭据 |
| 账号数据库泄露 | 数据库只有加盐慢哈希,不能直接用于登录;弱口令仍可被离线猜测。登录凭据不能直接派生保险库密钥 |
| 账号混用/设备身份混用 | 按账号与访客隔离 IndexedDB,切换时中止后台任务;保险库不包含设备 S,每台设备单独生成 |
| 绕过频道加入规则 | 服务端检查 owner/member 角色、邀请、密码、账号 session、成员人数和设备席位;加密选项不会绕开权限 |
| 绕过频道加入规则 | 服务端检查 owner/member 角色、邀请、密码、账号 session(访客为终端登记)、成员人数;加密选项不会绕开权限 |
| 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 |
## 5. 前端信任根(残余风险)
@@ -102,6 +102,6 @@
## 8. 账号设备与本机缓存
同一账号的各设备拥有独立 sessions 和频道 S,但在频道成员上限中只计一个账号成员。撤销设备会删除该设备 sessions 与频道席位;不会移除账号成员或释放其成员名额,也不会影响其他设备。撤销不能擦除已下载数据、已持有的频道 K 或被复制的账号私钥。当前未实现密钥轮换,不声称前向保密。未登录访客无法跨设备去重,每个访客终端单独计入人数上限。
同一账号的各设备拥有独立 sessions,但在频道成员上限中只计一个账号成员,成员请求凭账号 session 鉴权。撤销设备会删除该设备 sessions;不会移除账号成员或释放其成员名额,也不会影响其他设备。撤销不能擦除已下载数据、已持有的频道 K 或被复制的账号私钥。当前未实现密钥轮换,不声称前向保密。未登录访客无法跨设备去重,每个访客终端单独计入人数上限。
本机记住登录允许本浏览器使用者访问账号,退出时删除自动恢复凭据。OPFS 存储解密后临时文件,正常退出账号时清理;浏览器崩溃可能留下本机缓存,它不属于服务端密文。