feat(api): bind encrypted workspaces to accounts and devices
This commit is contained in:
1 parent
019b15041a
commit
c7d8ef15bc
49 files changed
+1904
-278
No files matched your search
+44
-8
@@ -1,4 +1,6 @@
|
||||
# API v1
|
||||
# API 契约 2.1
|
||||
|
||||
URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `2.1.0`。旧主版本客户端会被契约预检拒绝。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。
|
||||
|
||||
基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。
|
||||
|
||||
@@ -10,8 +12,8 @@
|
||||
| `Authorization` | `Bearer <base64url(令牌)>` |
|
||||
| `X-WD-Endpoint` | `base64url(S)`,终端密钥,用于席位 |
|
||||
| `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) |
|
||||
| `X-WD-Label` | raw 模式条目标签,≤255 B |
|
||||
| `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 |
|
||||
| `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;创建、管理和账号成员请求必填,访客省略 |
|
||||
|
||||
raw 模式的读请求不需要令牌,但仍需要 `X-WD-Endpoint`。
|
||||
|
||||
@@ -28,17 +30,32 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
|
||||
|
||||
## 3. 接口
|
||||
|
||||
### 账号与保险库
|
||||
|
||||
用户名为 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/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/invitations` 返回当前账号的邀请,payload 为绑定收件用户名和频道 ID 的 ECDH + HKDF + AES-GCM 密文。
|
||||
|
||||
没有邮箱找回流程。忘记密码后保险库无法恢复。保险库仅同步资源凭据和草稿清单,不同步设备 S、设备名、缓存、原始文件或发送队列。浏览器存储按账号与访客分库;切换账号会中止订阅并清空内存会话。资源清单移除使用带时间戳的墓碑同步,避免旧设备将条目恢复。
|
||||
|
||||
### `GET /api/v1/info`
|
||||
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
|
||||
|
||||
### `POST /api/v1/channels`
|
||||
创建频道。服务端启用验证码时需要 `X-WD-Cap`;验证服务不可用时返回 `503 captcha_unavailable`,与「用户没通过验证」(`428 captcha_required`)区分。
|
||||
创建者必须登录。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入该频道的 owner 成员记录并占一个设备席位。服务端启用验证码时还需要 `X-WD-Cap`。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<channel_id>",
|
||||
"mode": "enc",
|
||||
"settings": { "seats": 2, "members_can_upload": true, "delete_policy": "own",
|
||||
"settings": { "name": "工作设备", "seats": 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)>",
|
||||
@@ -49,13 +66,19 @@ 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`:设置(包含所有成员共享的频道名)、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
|
||||
|
||||
### `POST /api/v1/channels/{id}/seats`
|
||||
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。
|
||||
|
||||
可发送 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 重新加入。
|
||||
|
||||
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号或访客设备、频道成员/席位与写入权限,不能靠客户端隐藏按钮绕过。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
|
||||
|
||||
### `PATCH /api/v1/channels/{id}/settings`
|
||||
所有者修改设置。`ttl_seconds` 的修改从现在起算。
|
||||
所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`ttl_seconds` 的修改从现在起算。
|
||||
|
||||
### `DELETE /api/v1/channels/{id}`
|
||||
所有者删除整个频道和全部密文。
|
||||
@@ -96,8 +119,9 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
|
||||
|
||||
### 席位
|
||||
- `GET /api/v1/channels/{id}/seats`:所有者查看(终端哈希、加密设备名、加入时间)。
|
||||
- `PUT /api/v1/channels/{id}/seats/me`:终端上传加密设备名(≤256 B)。
|
||||
- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者释放席位。
|
||||
- `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}`:所有者移除该设备的席位,其活动订阅随后会被拒绝。
|
||||
|
||||
## 4. 便捷分享接口
|
||||
|
||||
@@ -189,3 +213,15 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
|
||||
| `captcha_required` | 428 | 需要验证码:缺少 `X-WD-Cap`,或 token 无效、已用过 |
|
||||
| `captcha_unavailable` | 503 | 验证码服务暂时不可用(服务端到 Cap 的调用失败),可稍后重试 |
|
||||
| `rate_limited` | 429 | 限流 |
|
||||
|
||||
### 按账号恢复频道密钥
|
||||
|
||||
- `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 允许省略供纯协议客户端使用。
|
||||
- 发送邀请时原子保存收件账号的密钥包;接受后仍可跨设备领取。撤销邀请/成员同时删除密钥包,删除频道清理全部密钥包。
|
||||
|
||||
|
||||
### 账号设备与资料
|
||||
|
||||
GET /accounts/devices 返回本账号设备 {id,name,created_at,last_seen,current}。PATCH /accounts/devices/{id} 发送 {name};DELETE 撤销该设备全部登录 sessions 与频道席位,不移除账号成员。新客户端登录时携带稳定浏览器 device_id(UUID)和设备名,各设备单独记录。个人名称/头像存储在客户端加密保险库,修改后按 modifiedAt 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。
|
||||
+21
-8
@@ -2,6 +2,8 @@
|
||||
|
||||
## 1. 组件
|
||||
|
||||
账号、设备、频道、thread 与内容加密的统一边界见 [account-channel-architecture.md](account-channel-architecture.md)。该文档明确账号成员与设备会话分离、按账号领取频道密钥包及加密文件目录。
|
||||
|
||||
```
|
||||
浏览器 (React, 独立托管或 API 同源嵌入) 服务端 (.NET 10, NativeAOT)
|
||||
┌──────────────────────────────┐ ┌─────────────────────────────┐
|
||||
@@ -9,10 +11,10 @@
|
||||
│ @webdrop/core (加解密、帧) │ 密文 + 令牌 │ 鉴权: 只比对令牌哈希 │
|
||||
│ Worker: 流式加解密 │◄───────────►│ 日志: 分配 seq、幂等、推送 │
|
||||
│ IndexedDB: 发送队列、本地收藏 │ │ BackgroundService: 过期清理 │
|
||||
│ Service Worker: 流式下载 │ └──────┬───────────┬──────────┘
|
||||
│ OPFS: 逐块写入临时文件 │ └──────┬───────────┬──────────┘
|
||||
└──────────────────────────────┘ │ │
|
||||
SQLite IBlobStore
|
||||
(元数据、日志) (磁盘,预留 S3)
|
||||
SQLite IBlobStore
|
||||
(频道、账号密文库) (磁盘,预留 S3)
|
||||
```
|
||||
|
||||
服务端没有解密函数,也没有任何能触达明文或 K 的代码路径。可选的 `WebDrop.Frontend` 模块只提供嵌入的静态资源,不访问频道存储或加解密代码。
|
||||
@@ -42,7 +44,7 @@
|
||||
| 全球化 | `InvariantGlobalization=true` |
|
||||
| 库 | 所有项目标记 `IsAotCompatible=true`,把 IL2026、IL3050 等 AOT 和裁剪警告视为错误 |
|
||||
| HTTP/3 | `CreateSlimBuilder` 不含 HTTP/3。服务进程本身**暂不内置 QUIC**,由反向代理终结 HTTP/3,后端用 HTTP/1.1 或 HTTP/2;`WebDrop:EnableHttp3` 只是在 `/info` 里通告。需要内置时再接入 Kestrel 的 QUIC(依赖原生 msquic) |
|
||||
| 密码学 | 服务端只做 SHA-256 和常量时间比较,`System.Security.Cryptography` 在 AOT 下可用 |
|
||||
| 密码学 | 内容加解密在客户端;服务端做令牌 SHA-256、登录/加入密码的加盐 PBKDF2 与常量时间比较,全部兼容 AOT |
|
||||
|
||||
**验证结果**(win-x64,.NET 10.0.204):
|
||||
- Slim builder、Minimal API、原生 WebSocket、SSE、`BackgroundService`、`Microsoft.Data.Sqlite`(随包的 e_sqlite3 原生库,SQLite 3.53)在 NativeAOT 下都能正常运行。
|
||||
@@ -55,11 +57,17 @@
|
||||
|
||||
## 4. 存储模型
|
||||
|
||||
应用契约 2 使用 `DataDir/v2/webdrop.db` 和 `DataDir/v2/blobs`,仅包含当前表结构,不维护旧 schema/旧账号保险库/旧频道认领/旧明文负载的迁移或兼容模型。开发数据可以重新创建,历史目录不自动导入。浏览器采用新的按账号/访客隔离的数据库命名空间。
|
||||
|
||||
SQLite 存元数据和小帧,磁盘存大帧。
|
||||
|
||||
| 表 | 字段 |
|
||||
|---|---|
|
||||
| `channels` | `id` PK, `mode`, `settings`(JSON), `read_hash`, `write_hash`, `owner_hash`, `created_at`, `expires_at`, `last_seq`, `compacted_through`, `bytes` |
|
||||
| `channels` | `id` PK, `mode`(内容加密选项), `creator_account`, `settings`, 能力令牌哈希, 加入密码哈希/salt, 时间、序号、字节数 |
|
||||
| `channel_members` | 频道 ID + username,频道内 owner/member 角色、加入时间;创建者自动为 owner |
|
||||
| `channel_invitations` | 频道 ID + username、客户端加密的邀请 payload、时间 |
|
||||
| `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`) |
|
||||
| `share_visitors` | `share_id`, `endpoint_hash`, `joined_at`;PK(`share_id`, `endpoint_hash`),创建者终端不写入此表 |
|
||||
@@ -97,8 +105,10 @@ SQLite 存元数据和小帧,磁盘存大帧。
|
||||
- 加解密在 Worker 里流式处理,文件不整份进内存。
|
||||
- 发送队列落在 IndexedDB,恢复网络后自动补发,依赖协议的幂等追加。
|
||||
- 条目预览在沙箱 iframe 中渲染;raw 内容只当文本展示或下载。
|
||||
- 下载大文件使用 Service Worker 流式写入,兼容没有 File System Access API 的 iOS Safari。
|
||||
- 本地「我的频道」:链接和密钥保存在 IndexedDB,只在本机。
|
||||
- 下载大文件优先使用 OPFS 流式写入临时文件,认证完成后提供文件;不支持 OPFS 时回退 Blob,限制 64 MiB。
|
||||
- 首页先注册/登录;公开频道/分享链接可按规则由访客访问。创建与管理在服务端绑定账号,频道内成员角色与设备席位分开。所有写入都检查账号或访客设备、频道角色/席位和能力权限。
|
||||
- IndexedDB 按账号与访客分库;切换时中止后台订阅并清空内存。保险库只同步资源凭据、草稿清单和移除墓碑;设备 S、设备标签、缓存、原始文件和 outbox 保留在本机。新设备生成独立 S 后重新加入。
|
||||
- 一个 Channel/Thread 模型共用日志、权限与生命周期;内容是否端到端加密只切换客户端封装加解密。结构 CONTROL 记录不按普通内容 TTL/max_items 清理,随频道删除。
|
||||
|
||||
## 7. 阶段划分
|
||||
|
||||
@@ -111,7 +121,8 @@ SQLite 存元数据和小帧,磁盘存大帧。
|
||||
| 4 | 文件:流式加解密、GATED 拉取、下载计数、预览、粘贴和拖拽 | 100 MB 文件往返一致,下载计数正确 |
|
||||
| 5 | GB 级文件:并行上传和断点续传 | 中途断网后续传成功,内存占用平稳 |
|
||||
| 6 | PWA、Web Share Target、二维码和短配对码(PAKE) | 手机端走通 |
|
||||
| 7 | 可选账号、配额;CLI | 另行设计 |
|
||||
| 7 | 账号登录、客户端加密资源保险库 | 已实现客户端派生口令 verifier/保险库密钥、SQLite opaque vault、资源同步;口令丢失时明确告知无法恢复 |
|
||||
| 8 | thread、虚拟文件夹与 IRC 风格聊天 | 已实现沿用频道加密日志,消息按 thread 归属并展示发送设备,文件夹元数据由客户端加密 |
|
||||
|
||||
每个阶段开始前,先更新 protocol.md 和测试向量,再改代码。
|
||||
|
||||
@@ -122,3 +133,5 @@ SQLite 存元数据和小帧,磁盘存大帧。
|
||||
- 服务端日志中没有 K、令牌和频道 id 明文。
|
||||
- 抓包确认请求里没有 K 和明文。
|
||||
- AOT 发布产物能在没有 .NET 运行时的机器上运行。
|
||||
|
||||
界面术语采用「空间 → 频道分组 → 频道 → 文件夹/聊天」。内部 Channel/Thread 命名保留,分别映射空间/频道;分组沿用加密 CONTROL 日志,不引入新的成员或密钥体系。
|
||||
+379
-14
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: WebDrop API
|
||||
version: 1.0.0
|
||||
version: 2.1.0
|
||||
servers:
|
||||
- url: /api/v1
|
||||
|
||||
@@ -12,8 +12,79 @@ tags:
|
||||
- name: frame
|
||||
- name: item
|
||||
- name: share
|
||||
- name: account
|
||||
|
||||
paths:
|
||||
/accounts/channels:
|
||||
get:
|
||||
tags: [account]
|
||||
security: [{ accountBearer: [] }]
|
||||
summary: 领取当前账号已加入频道的加密密钥包
|
||||
responses:
|
||||
'200':
|
||||
description: 仅返回已加入频道,不占设备席位
|
||||
content:
|
||||
application/json:
|
||||
schema: { type: array, items: { $ref: '#/components/schemas/AccountChannel' } }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
/accounts/devices:
|
||||
get:
|
||||
tags: [account]
|
||||
security: [{ accountBearer: [] }]
|
||||
summary: 读取当前账号的设备会话
|
||||
responses:
|
||||
'200':
|
||||
description: 设备列表
|
||||
content:
|
||||
application/json:
|
||||
schema: { type: array, items: { $ref: '#/components/schemas/AccountDevice' } }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
/accounts/devices/{id}:
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
patch:
|
||||
tags: [account]
|
||||
security: [{ accountBearer: [] }]
|
||||
summary: 重命名本账号设备
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties: { name: { type: string, minLength: 1, maxLength: 80 } }
|
||||
responses:
|
||||
'204': { description: 已更新 }
|
||||
'400': { $ref: '#/components/responses/Error' }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
delete:
|
||||
tags: [account]
|
||||
security: [{ accountBearer: [] }]
|
||||
summary: 撤销该设备全部 sessions 与频道席位,保留账号成员资格
|
||||
responses:
|
||||
'204': { description: 已撤销 }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
/channels/{id}/key-package:
|
||||
put:
|
||||
tags: [channel]
|
||||
security: [{ bearer: [] }]
|
||||
summary: 频道主补齐给自己账号的加密密钥包
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [payload]
|
||||
properties: { payload: { type: string, minLength: 1, maxLength: 16384 } }
|
||||
responses:
|
||||
'204': { description: 已保存 }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
/info:
|
||||
get:
|
||||
tags: [server]
|
||||
@@ -26,14 +97,183 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ServerInfo' }
|
||||
|
||||
/accounts:
|
||||
post:
|
||||
tags: [account]
|
||||
security: []
|
||||
summary: 创建账号
|
||||
description: 仅接收口令 verifier 和客户端加密保险库,不接收密码。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AccountRegister' }
|
||||
responses:
|
||||
'204': { description: 已创建 }
|
||||
'400': { $ref: '#/components/responses/Error' }
|
||||
'409': { $ref: '#/components/responses/Error' }
|
||||
'429': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/accounts/salt:
|
||||
post:
|
||||
tags: [account]
|
||||
security: []
|
||||
summary: 查询账号口令派生 salt
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AccountSaltRequest' }
|
||||
responses:
|
||||
'200':
|
||||
description: 客户端 PBKDF2 salt
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AccountSalt' }
|
||||
'400': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/accounts/session:
|
||||
post:
|
||||
tags: [account]
|
||||
security: []
|
||||
summary: 验证客户端派生凭据并创建 session
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AccountSessionRequest' }
|
||||
responses:
|
||||
'200':
|
||||
description: 已登录
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AccountSession' }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
'400': { $ref: '#/components/responses/Error' }
|
||||
delete:
|
||||
tags: [account]
|
||||
security: [{ accountBearer: [] }]
|
||||
summary: 撤销当前账号 session
|
||||
responses:
|
||||
'204': { description: 已撤销 }
|
||||
|
||||
/accounts/vault:
|
||||
get:
|
||||
tags: [account]
|
||||
security: [{ accountBearer: [] }]
|
||||
summary: 读取客户端加密保险库
|
||||
responses:
|
||||
'200':
|
||||
description: 当前保险库和 revision
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AccountVault' }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
put:
|
||||
tags: [account]
|
||||
security: [{ accountBearer: [] }]
|
||||
summary: 以 revision 乐观并发更新保险库
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AccountVault' }
|
||||
responses:
|
||||
'200':
|
||||
description: 已更新
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AccountVault' }
|
||||
'401': { $ref: '#/components/responses/Error' }
|
||||
'409': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/accounts/users/{username}/key:
|
||||
get:
|
||||
tags: [account]
|
||||
security: [{ accountBearer: [] }]
|
||||
summary: 查询被邀请用户公钥
|
||||
parameters:
|
||||
- { name: username, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
description: 公钥
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/PublicKey' }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
/accounts/invitations:
|
||||
get:
|
||||
tags: [account]
|
||||
security: [{ accountBearer: [] }]
|
||||
summary: 当前账号收到的邀请
|
||||
responses:
|
||||
'200':
|
||||
description: 邀请列表
|
||||
content:
|
||||
application/json:
|
||||
schema: { type: array, items: { $ref: '#/components/schemas/ChannelInvitation' } }
|
||||
/channels/{id}/members:
|
||||
get:
|
||||
tags: [channel]
|
||||
security: [{ bearer: [] }]
|
||||
summary: 读取频道内账号成员及角色
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/Role' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
responses:
|
||||
'200':
|
||||
description: 成员列表
|
||||
content:
|
||||
application/json:
|
||||
schema: { type: array, items: { $ref: '#/components/schemas/ChannelMember' } }
|
||||
/channels/{id}/invitations:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
get:
|
||||
tags: [channel]
|
||||
security: [{ bearer: [], accountBearer: [] }]
|
||||
summary: 频道主查询邀请
|
||||
responses:
|
||||
'200':
|
||||
description: 邀请列表
|
||||
content:
|
||||
application/json:
|
||||
schema: { type: array, items: { $ref: '#/components/schemas/ChannelInvitation' } }
|
||||
post:
|
||||
tags: [channel]
|
||||
security: [{ bearer: [], accountBearer: [] }]
|
||||
summary: 邀请已注册用户
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/InvitationRequest' }
|
||||
responses:
|
||||
'204': { description: 已邀请 }
|
||||
'404': { $ref: '#/components/responses/Error' }
|
||||
/channels/{id}/invitations/{username}:
|
||||
delete:
|
||||
tags: [channel]
|
||||
security: [{ bearer: [], accountBearer: [] }]
|
||||
summary: 撤销邀请和成员授权并移除其设备
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
- { $ref: '#/components/parameters/RoleOwner' }
|
||||
- { name: username, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
'204': { description: 已移除 }
|
||||
'403': { $ref: '#/components/responses/Error' }
|
||||
|
||||
/channels:
|
||||
post:
|
||||
tags: [channel]
|
||||
security: []
|
||||
security: [{ accountBearer: [] }]
|
||||
summary: 创建频道
|
||||
description: |
|
||||
服务端启用验证码时需要 `X-WD-Cap`(Cap 的 token,见 protocol §11),并需要终端头 `X-WD-Endpoint`。
|
||||
创建者的终端自动入席,占一个席位。
|
||||
创建者的账号自动成为该频道 owner,其终端自动入席。
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/Captcha' }
|
||||
- { $ref: '#/components/parameters/Endpoint' }
|
||||
@@ -170,6 +410,30 @@ paths:
|
||||
'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}:
|
||||
delete:
|
||||
tags: [seat]
|
||||
@@ -196,7 +460,7 @@ paths:
|
||||
description: |
|
||||
请求体是完整的帧封装(26 字节头 + 负载),格式见 `protocol.md` §4。
|
||||
幂等键为 `(channel_id, item_id, frame_index)`。内容相同则 200 返回原 seq,内容不同则 409。
|
||||
第 0 帧可带 `X-WD-Max-Downloads` 和(raw 模式)`X-WD-Label`。
|
||||
第 0 帧可带 `X-WD-Max-Downloads`;名称与线程字段统一在条目元数据中。
|
||||
security: [{ bearer: [] }]
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/ChannelId' }
|
||||
@@ -207,11 +471,6 @@ paths:
|
||||
required: false
|
||||
schema: { type: integer, minimum: 0 }
|
||||
description: 条目的下载上限,0 为不限。只对 GATED 条目生效。
|
||||
- name: X-WD-Label
|
||||
in: header
|
||||
required: false
|
||||
schema: { type: string, maxLength: 255 }
|
||||
description: raw 模式的条目标签,明文。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -389,7 +648,7 @@ paths:
|
||||
/shares:
|
||||
post:
|
||||
tags: [share]
|
||||
security: []
|
||||
security: [{ accountBearer: [] }]
|
||||
summary: 创建便捷分享草稿
|
||||
description: 启用验证码时需携带 Cap token。创建者 endpoint 可选,创建者不计入访客数。
|
||||
parameters:
|
||||
@@ -583,6 +842,11 @@ paths:
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
accountBearer:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-WD-Account
|
||||
description: WebDrop 账号登录后颁发的随机 session token。
|
||||
bearer:
|
||||
type: http
|
||||
scheme: bearer
|
||||
@@ -652,6 +916,88 @@ components:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
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 }
|
||||
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 }
|
||||
PublicKey:
|
||||
type: object
|
||||
required: [public_key]
|
||||
properties:
|
||||
public_key: { type: string, maxLength: 1024 }
|
||||
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 }
|
||||
InvitationRequest:
|
||||
type: object
|
||||
required: [username, payload]
|
||||
properties:
|
||||
username: { type: string }
|
||||
payload: { type: string, maxLength: 16384 }
|
||||
ChannelMember:
|
||||
type: object
|
||||
required: [username, role, joined_at]
|
||||
properties:
|
||||
username: { type: string }
|
||||
role: { type: string, enum: [owner, member] }
|
||||
joined_at: { type: integer, format: int64 }
|
||||
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 字符串 }
|
||||
AccountSaltRequest:
|
||||
type: object
|
||||
required: [username]
|
||||
properties:
|
||||
username: { type: string, minLength: 3, maxLength: 32 }
|
||||
AccountSalt:
|
||||
type: object
|
||||
required: [salt]
|
||||
properties:
|
||||
salt: { type: string, contentEncoding: base64 }
|
||||
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 }
|
||||
AccountSession:
|
||||
type: object
|
||||
required: [username, token]
|
||||
properties:
|
||||
username: { type: string }
|
||||
token: { type: string, contentEncoding: base64 }
|
||||
AccountVault:
|
||||
type: object
|
||||
required: [revision, vault]
|
||||
properties:
|
||||
revision: { type: integer, format: int64, minimum: 0 }
|
||||
vault: { type: string, contentEncoding: base64, maxLength: 1733336 }
|
||||
Base64Url:
|
||||
type: string
|
||||
pattern: '^[A-Za-z0-9_-]+$'
|
||||
@@ -675,6 +1021,13 @@ components:
|
||||
- rate_limited
|
||||
- visitors_full
|
||||
- sealed
|
||||
- account_required
|
||||
- account_invalid
|
||||
- invitation_required
|
||||
- channel_password_required
|
||||
- channel_password_invalid
|
||||
- username_unavailable
|
||||
- vault_conflict
|
||||
|
||||
Error:
|
||||
type: object
|
||||
@@ -691,10 +1044,10 @@ components:
|
||||
|
||||
ServerInfo:
|
||||
type: object
|
||||
required: [protocol, contract_version, limits, captcha, transports]
|
||||
properties:
|
||||
protocol: { type: integer, const: 1 }
|
||||
contract_version: { type: string, example: 1.0.0, description: API 契约的 SemVer。破坏性变更递增主版本。 }
|
||||
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]
|
||||
@@ -731,6 +1084,9 @@ components:
|
||||
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' }
|
||||
@@ -743,6 +1099,10 @@ components:
|
||||
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' }
|
||||
@@ -755,6 +1115,8 @@ components:
|
||||
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' }
|
||||
@@ -803,6 +1165,7 @@ components:
|
||||
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 }
|
||||
@@ -828,6 +1191,8 @@ components:
|
||||
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' }
|
||||
|
||||
+41
-7
@@ -12,18 +12,18 @@
|
||||
|---|---|---|
|
||||
| `channel_id` | 16 B | 随机,客户端生成 |
|
||||
| `item_id` | 16 B | 随机,客户端生成 |
|
||||
| `K` | 32 B | 频道密钥,仅 enc 模式,只存在于 URL fragment |
|
||||
| `K` | 32 B | 频道密钥,仅 enc 模式,按账号加密封装;可选能力链接使用 fragment |
|
||||
| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 |
|
||||
| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 |
|
||||
| `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 |
|
||||
|
||||
## 2. 模式与链接
|
||||
|
||||
频道创建时二选一,之后不可更改。
|
||||
只有一个频道模型。创建时可选择是否启用端到端加密,默认 enc;raw 表示不加密内容。账号成员、所有者、加入规则、线程、目录、日志和权限完全共用,差别限于客户端的负载加解密。创建后不能改变加密方式。
|
||||
|
||||
| | enc(默认) | raw |
|
||||
|---|---|---|
|
||||
| 负载 | AES-256-GCM 密文 | 原样字节,服务端可见 |
|
||||
| 负载 | 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 即可(能力链接) |
|
||||
@@ -70,7 +70,8 @@ raw 模式下 `T_owner` 同样由 `A` 派生;`T_write = W`。
|
||||
| 1 | SNAPSHOT | 该条目是频道的「最新快照」,取代之前的快照条目 |
|
||||
| 2 | GATED | 不通过实时推送内联分发,只能按条目拉取,并计下载次数 |
|
||||
| 3 | ENC | 负载为 AES-GCM 密文,必须与频道模式一致 |
|
||||
| 4-7 | 保留 | 必须为 0 |
|
||||
| 4 | CONTROL | 频道结构控制条目 |
|
||||
| 5-7 | 保留 | 必须为 0 |
|
||||
|
||||
服务端拒绝不符合上述规则的封装,但从不解析负载。
|
||||
|
||||
@@ -120,15 +121,21 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
| `size` | 可选,内容总字节数(流式时可省略) |
|
||||
| `ts` | 毫秒时间戳,仅供参考,排序以 seq 为准 |
|
||||
| `dev` | 可选,设备名 |
|
||||
| `thread_id` | 可选,频道 thread ID |
|
||||
| `folder_id` | 可选,文件的虚拟文件夹 ID |
|
||||
|
||||
第 1 帧起,`data` 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。
|
||||
|
||||
**raw 模式**:负载就是内容字节,没有 meta。条目名称等信息由用户在上传时放进 `X-WD-Label`(明文,≤255 B)。
|
||||
**频道 thread 与文件夹**:客户端把 thread/folder 元数据放在上述共享元数据中(enc 时加密,raw 时服务端可见)。thread 的创建、重命名,文件夹的创建/重命名,以及条目移动等操作以 `kind=other` 的 CONTROL 条目追加到同一频道日志;控制正文是版本化 JSON,仅客户端识别。它们不增加服务端表或第二套同步协议。新内容必须归属已创建的 thread;前端新建频道后自动发布“常规”thread 创建记录。GATED 的 announce 不含元数据,但 file.published 加密目录记录可在不下载内容时提供名称和归属;没有目录记录时显示通用占位符。
|
||||
|
||||
**raw 模式(API 契约 2)**:负载使用与 enc 相同的 `u16 meta_len ‖ meta_json ‖ content` 条目结构,只是不加密。名称、thread/folder ID 与其他元数据对服务端可见。旧版“纯字节、无元数据”的 raw 负载不属于新应用契约,旧客户端会被 API 主版本检查拒绝。
|
||||
|
||||
**未完成的条目**:没有 FINAL 的条目是「进行中」,接收方可以渐进消费(流式播放、边收边写),但必须标记为不完整,直到收到已认证的 FINAL。
|
||||
|
||||
## 8. 日志、序号与条目生命周期
|
||||
|
||||
`CONTROL=0x10` 标记频道结构控制条目;保留位为 `0xE0`。控制条目必须是非 GATED、非 SNAPSHOT 的 FINAL 单帧,payload 最多 16,400 B;其生命周期为频道生命周期,不受普通内容 TTL 或 max_items 淘汰。每频道最多 4096 条控制记录,仍计入总字节配额。加密和非加密频道使用相同控制正文与日志处理。
|
||||
|
||||
频道维护一条服务端排序的**日志**,每条记录的序号 `seq` 是 u64,频道内单调递增。
|
||||
|
||||
| kind | 值 | 内容 |
|
||||
@@ -164,11 +171,12 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
- 终端通过显式的加入请求(`POST .../seats`)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(`seats_full`),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回 `not_joined`。
|
||||
- 不同频道使用不同的 `S`,避免服务端把同一设备关联起来。
|
||||
- 终端可以加密一个设备名上传,所有者能看到成员列表和设备名,并可以释放席位。
|
||||
- 终端可更新自己的名称;所有者也可重命名任意席位。enc 模式重命名仍使用同一份加密设备标签,并将 AAD 绑定目标 endpoint hash;raw 模式标签仍为明文。所有者移除席位后,该终端不能继续读取频道,但持有成员链接的人仍可重新加入(若有空席位)。
|
||||
- **设备名格式(enc)**:`iv(12 B 随机) ‖ AES-256-GCM(K_label, utf8 名称) ‖ tag(16 B)`,AAD = `"WD1L"`(4 B ASCII)‖ `channel_id` ‖ `endpoint_hash`(32 B,即 `SHA-256(S)`)。总长不超过 256 B,所以名称最多 228 字节 UTF-8。使用派生子密钥 `K_label` 而不是直接用 `K_enc`,避免同一密钥同时用于 HKDF 和 AES-GCM;AAD 绑定终端,服务端无法把一个终端的设备名挪到另一个席位上。raw 模式下设备名是明文 UTF-8。
|
||||
|
||||
## 11. 创建与验证码
|
||||
|
||||
创建频道是匿名接口,需要防刷。验证码**完全交给 [Cap](https://github.com/tiagozip/cap)**(基于 SHA-256 工作量证明的验证码),本协议不定义、也不实现任何 PoW。
|
||||
创建频道需要账号登录,仍需要防刷。验证码**完全交给 [Cap](https://github.com/tiagozip/cap)**(基于 SHA-256 工作量证明的验证码),本协议不定义、也不实现任何 PoW。
|
||||
|
||||
流程:
|
||||
1. 前端从 `GET /api/v1/info` 读到 `captcha.enabled` 和 `captcha.api_endpoint`。
|
||||
@@ -180,10 +188,11 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
|
||||
## 12. 设置
|
||||
|
||||
频道设置对服务端可见,服务端据此执行:
|
||||
频道设置对服务端可见,服务端据此执行。`name` 是共享频道名,所有加入者都从频道信息中读取:
|
||||
|
||||
| 设置 | 默认 | 范围 |
|
||||
|---|---|---|
|
||||
| `name` | 无 | 可选,最多 48 个字符;所有频道成员和服务端可见 |
|
||||
| `seats` | 2 | 1..64(服务端可配置上限) |
|
||||
| `members_can_upload` | true | |
|
||||
| `delete_policy` | `own` | `owner_only` / `own` / `anyone` |
|
||||
@@ -320,3 +329,28 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
- 协议版本在封装的 `ver` 字段和 HKDF 的 `info`(`webdrop/v1/...`)中同时体现。
|
||||
- `test-vectors/` 提供固定输入、固定输出:密钥派生、令牌、加解密、padding、封装编解码,以及篡改、重排、截断、换频道的负例,必须全部通过。
|
||||
- 本版**预留**但不实现:口令叠加(Argon2id)、短配对码(PAKE)、多快照槽。
|
||||
|
||||
## 17. 按账号分发频道密钥
|
||||
|
||||
频道内容仍使用共享 K 加密。创建者用自己的账号 P-256 公钥封装 owner 凭据,随创建请求的 key_package 与频道、owner 成员记录在同一事务内保存。邀请时用收件账号公钥封装 member 凭据,服务端原子保存邀请和密钥包;账号接受邀请并成为成员后,可只凭账号 session 从 /accounts/channels 取回自己的密钥包,不需要含密钥的 URL,也不要求邀请者在线。成员包不得包含管理密钥 A。
|
||||
|
||||
密钥包沿用邀请的 ECDH、HKDF、AES-GCM,AAD 绑定收件账号与频道 ID。服务端只保存 opaque 密文;成员私钥保存在口令加密的账号保险库。成员登录后解开保险库、取回密钥包,在 Worker 内恢复频道会话;新设备生成独立 S,打开频道时才占设备席位。待接受的邀请不会自动占席位。
|
||||
|
||||
撤销成员时同时删除密钥包和席位。此举不能收回已交付的 K 或历史明文;当前没有轮换密钥或前向保密。仅知道频道 ID 或登录账号并不足以获得密钥;新账号必须得到现有持钥 owner 的授权封装。邀请公钥仍来自服务端目录,尚无外部指纹核验。
|
||||
|
||||
|
||||
## 18. 频道加密文件目录
|
||||
|
||||
文件密文帧之后,在同一发送队列追加 CONTROL file.published,正文包含 item.id、kind=file、threadId、folderId(可选)、name、mime、size、ts、deviceId、gated。记录与 thread/folder 控制记录共用 webdrop.workspace v1 正文。名称/类型/大小/原始 thread/folder 必须与文件第 0 帧元数据相符。目录允许先显示名称,但不得因此自动下载 GATED 内容。
|
||||
|
||||
客户端只有在当前日志存在对应文件帧或 announce 时展示目录;文件墓碑清除目录可见状态,全量重放时不恢复不存在/过期文件。目录记录属于 CONTROL,仍受 4096 条控制记录和频道字节配额限制。文件移动覆盖位置索引,不重新加密原始文件,不改变最初认证的内容元数据。
|
||||
|
||||
## 19. 空间内的频道分组
|
||||
|
||||
界面顶层“空间”对应协议 channel,界面“频道”对应 thread。沿用 webdrop.workspace v1 CONTROL 正文:group.create {id,name},group.rename {id,name},thread.group {id,groupId}(null 表示未分组);thread.create 可包含 groupId。ID 为 UUID,name 为 1–80 个非控制字符。分组仅组织频道,成员、内容密钥和日志仍归属于空间。旧创建记录不含 groupId,作为未分组频道重放。
|
||||
|
||||
## 20. 发送者展示快照
|
||||
|
||||
file.published.item 可携带 author {username,displayName,avatar?};另有 item.author {itemId,author} CONTROL,为文本关联展示身份。username 使用当前账号固定用户名,displayName 最多 48 个字符,avatar 仅允许不超过 8000 字符的 PNG/JPEG/WebP data URL。头像在客户端压缩为最多 64×64 后写入加密正文,不改变内容帧、密钥派生或鉴权头。
|
||||
|
||||
快照是持钥成员提供的展示资料,不是发送者签名。无 author 的旧记录回退设备名;客户端不得将此类内容标记为当前账号发送。结构记录随既有 CONTROL 生命周期处理,实际消息可见性仍由条目和墓碑决定。
|
||||
+18
-2
@@ -16,7 +16,7 @@
|
||||
|
||||
| 能看到 | 说明 |
|
||||
|---|---|
|
||||
| 频道 id、模式、设置 | 席位数、权限、TTL、下载上限等,服务端需要据此执行 |
|
||||
| 频道 id、模式、设置 | 包括共享频道名、席位数、权限、TTL、下载上限等,服务端需要据此执行 |
|
||||
| 帧数量、时间、序号 | 追加顺序由服务端分配 |
|
||||
| 密文大小 | 经过档位 padding,见 protocol §6 |
|
||||
| 帧标志 | FINAL、SNAPSHOT、GATED、ENC |
|
||||
@@ -24,12 +24,13 @@
|
||||
| 令牌的哈希、终端的哈希 | 不是原值,但每次请求中会短暂看到令牌原值 |
|
||||
| 下载次数 | 服务端计数 |
|
||||
| 传输层信息 | IP、TLS 指纹、访问时间 |
|
||||
| 账号服务可见 | 用户名、口令 verifier、保险库密文大小和更新时间;不会看到保险库明文、频道密钥或分享链接 |
|
||||
|
||||
| 看不到(enc 模式) | 说明 |
|
||||
|---|---|
|
||||
| 内容 | 文本、JSON、文件字节 |
|
||||
| 文件名、类型、总大小、设备名 | 都在加密的条目元数据里 |
|
||||
| 密钥 K、管理密钥 A | 只在 URL fragment 中,不会发给服务器 |
|
||||
| 密钥 K、管理密钥 A | 由客户端保管,以账号公钥封装的密文可存服务器;可选分享链接使用 fragment |
|
||||
|
||||
`raw` 模式下服务端能看到全部负载,界面必须明确提示。
|
||||
|
||||
@@ -56,10 +57,16 @@
|
||||
| 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 与设备席位;加密选项不会绕开权限 |
|
||||
| 匿名滥用与 DoS | 创建时的 Cap 验证码(工作量证明由 Cap 提供)、限流、频道配额、条目数和字节数上限 |
|
||||
|
||||
## 5. 前端信任根(残余风险)
|
||||
|
||||
邀请密文使用收件账号的 P-256 公钥派生 AES-GCM 密钥,私钥在密码保护的保险库中。但首次邀请公钥来自服务端目录,目前没有外部指纹核验:恶意服务端替换公钥会影响新邀请的保密性。此限制与已有频道内容的密钥/帧认证边界不同。
|
||||
|
||||
端到端加密的前提是浏览器执行的是正确的代码。如果同一个被攻破的后端同时下发前端,它可以换成偷 K 的页面。
|
||||
|
||||
隔离部署的缓解:
|
||||
@@ -81,6 +88,8 @@
|
||||
- **不提供不可否认性**:帧没有逐发送者签名,所有成员共享同一密钥。
|
||||
- **不防已被攻破的终端**。
|
||||
- **不做口令叠加、后量子**,留待后续版本。
|
||||
- **账号密码不是频道访问控制**。账号保险库只同步持有的资源链接;拿到频道链接的人仍按频道自身能力访问。
|
||||
- **无密码重置**。忘记密码会失去保险库解密能力;未来若需要找回,须增加独立恢复密钥,而不能由服务端重置后解密旧保险库。
|
||||
|
||||
## 7. 服务端内存与日志的要求
|
||||
|
||||
@@ -88,3 +97,10 @@
|
||||
- 令牌只做哈希比对,使用常量时间比较,不写日志。
|
||||
- 日志不记录请求头、请求体、完整 URL;频道 id 在日志中截断或哈希。
|
||||
- 错误信息不回显令牌和任何用户提交的字节。
|
||||
|
||||
|
||||
## 8. 账号设备与本机缓存
|
||||
|
||||
同一账号的各设备拥有独立 sessions 和频道 S。撤销设备会删除该设备 sessions 与频道席位;不会移除账号成员或其他设备。撤销不能擦除已下载数据、已持有的频道 K 或被复制的账号私钥。当前未实现密钥轮换,不声称前向保密。
|
||||
|
||||
本机记住登录允许本浏览器使用者访问账号,退出时删除自动恢复凭据。OPFS 存储解密后临时文件,正常退出账号时清理;浏览器崩溃可能留下本机缓存,它不属于服务端密文。
|
||||
Reference in new issue
Block a user