diff --git a/contracts/api-contract.json b/contracts/api-contract.json index 1587a66..87032da 100644 --- a/contracts/api-contract.json +++ b/contracts/api-contract.json @@ -1,3 +1,3 @@ { - "version": "1.0.0" + "version": "2.1.0" } diff --git a/docs/api.md b/docs/api.md index aef0ca1..f97145e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 ` | | `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 ` 读取 `{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": "", "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": "", "write_hash": "", @@ -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.` 子协议传递,并在推送循环中重新检查有效性。 + ### `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 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。 diff --git a/docs/architecture.md b/docs/architecture.md index 3378485..cd5d235 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 日志,不引入新的成员或密钥体系。 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 88d0d1b..9420ec1 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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' } diff --git a/docs/protocol.md b/docs/protocol.md index f5af226..fe80f65 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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=&a=` | `/c/{id}#a=[&w=]` | | 成员链接 | `/c/{id}#k=` | `/c/{id}[#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 生命周期处理,实际消息可见性仍由条目和墓碑决定。 diff --git a/docs/threat-model.md b/docs/threat-model.md index b849552..f98b949 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -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 存储解密后临时文件,正常退出账号时清理;浏览器崩溃可能留下本机缓存,它不属于服务端密文。 diff --git a/packages/core/README.md b/packages/core/README.md index 525d492..2650eaa 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -92,7 +92,6 @@ ws.onmessage = async (m) => { | `itemId` | 重试和续传必须复用同一个 id | | `fromFrame` | 上传断点续传:从第 k 帧开始产出(字节与完整加密完全相同)。仅支持 `Blob` / `Uint8Array` | | `ts`, `dev` | 覆盖时间戳和设备名 | -| `label` | 仅 raw 模式:`X-WD-Label` | **GATED 的判断**:来源长度已知时创建计划时就确定;来源是流时,要产出第 0 帧之后 `plan.gated` 和 `frame0Headers` 才确定,请在拿到第 0 帧后再读取。 diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index b4e5bc8..fe76e99 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -13,6 +13,7 @@ export const Flags = { Snapshot: 2, Gated: 4, Enc: 8, + Control: 16, } as const; /** 档位 padding 的明文长度(protocol §6)。 */ diff --git a/packages/core/src/frame.ts b/packages/core/src/frame.ts index 3e43553..0c40cfa 100644 --- a/packages/core/src/frame.ts +++ b/packages/core/src/frame.ts @@ -49,7 +49,7 @@ export interface Envelope { export function parseEnvelope(data: Uint8Array): Envelope { if (data.length < HEADER_SIZE || data[0] !== 1) throw new IntegrityError('bad_envelope'); const flags = data[1] as number; - if ((flags & 0xf0) !== 0) throw new IntegrityError('bad_envelope'); + if ((flags & 0xe0) !== 0) throw new IntegrityError('bad_envelope'); const payloadLength = readU32(data, 22); if (data.length !== HEADER_SIZE + payloadLength) throw new IntegrityError('bad_envelope'); @@ -109,7 +109,7 @@ export interface SealParams { */ export async function sealFrame(itemKey: CryptoKey, channelId: Uint8Array, p: SealParams): Promise { if ((p.flags & Flags.Enc) === 0) throw new RangeError('enc 帧必须带 ENC 标志'); - if ((p.flags & 0xf0) !== 0) throw new RangeError('保留标志位必须为 0'); + if ((p.flags & 0xe0) !== 0) throw new RangeError('保留标志位必须为 0'); const plaintext = padPlaintext(p.data); const header = concat(new Uint8Array([1, p.flags]), p.itemId, writeU32(p.index), writeU32(plaintext.length + TAG_SIZE)); diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index 572a30f..3efd178 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -7,7 +7,7 @@ import { Labels } from './constants.js'; import { utf8, type Bytes } from './bytes.js'; export { encodeEnvelope, frameAad, frameIv, padPlaintext, parseEnvelope, sealFrame, tierFor, unpadPlaintext } from './frame.js'; -export { encodeMeta, decodeFirstFrame, planEncItem, planRawItem } from './item.js'; +export { encodeMeta, decodeFirstFrame, planItem } from './item.js'; export { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256 } from './kdf.js'; async function hkdfBytes(ikm: Uint8Array, salt: Uint8Array, label: string): Promise { diff --git a/packages/core/src/item.ts b/packages/core/src/item.ts index d4d74e1..c81c6f2 100644 --- a/packages/core/src/item.ts +++ b/packages/core/src/item.ts @@ -1,4 +1,4 @@ -import { Flags, MAX_FRAME_DATA, RAW_MAX_PAYLOAD, type ItemKind } from './constants.js'; +import { Flags, MAX_FRAME_DATA, type ItemKind } from './constants.js'; import { concat, readU16, toBytes, tryDecodeUtf8, utf8, writeU16, toBase64Url, type Bytes } from './bytes.js'; import { IntegrityError, LimitError } from './errors.js'; @@ -14,6 +14,10 @@ export interface ItemMeta { ts: number; /** 设备名。 */ dev?: string; + /** WebDrop 频道中的 thread ID,加密元数据。 */ + threadId?: string; + /** 文件所在的虚拟文件夹 ID,加密元数据。 */ + folderId?: string; } const KINDS: readonly ItemKind[] = ['text', 'json', 'file', 'manifest', 'other']; @@ -26,6 +30,8 @@ export function encodeMeta(meta: ItemMeta): Bytes { if (meta.size !== undefined) o.size = meta.size; o.ts = meta.ts; if (meta.dev !== undefined) o.dev = meta.dev; + if (meta.threadId !== undefined) o.thread_id = meta.threadId; + if (meta.folderId !== undefined) o.folder_id = meta.folderId; const json = utf8(JSON.stringify(o)); if (json.length > 0xffff) throw new LimitError('条目元数据过大'); @@ -64,6 +70,12 @@ export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: B if (typeof v !== 'string') throw new IntegrityError('bad_meta'); meta[key] = v; } + for (const [wire, key] of [['thread_id', 'threadId'], ['folder_id', 'folderId']] as const) { + const v = o[wire]; + if (v === undefined) continue; + if (typeof v !== 'string' || v.length > 80) throw new IntegrityError('bad_meta'); + meta[key] = v; + } if (o.size !== undefined) { if (typeof o.size !== 'number' || !Number.isSafeInteger(o.size) || o.size < 0) throw new IntegrityError('bad_meta'); meta.size = o.size; @@ -76,6 +88,7 @@ export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: B export type ContentSource = Uint8Array | Blob | ReadableStream | AsyncIterable; export interface PlanOptions { + control?: boolean; /** 指定条目 id(重试、续传时必须复用同一个);默认随机生成。 */ itemId?: Uint8Array; /** 作为频道的「最新快照」。快照必须是单帧,所以内容不能超过一帧。 */ @@ -109,7 +122,7 @@ export interface ItemPlan { * 要等第 0 帧产出之后才确定,所以请在拿到第 0 帧之后再读取它和 frame0Headers。 */ readonly gated: boolean; - /** 第 0 帧要带的附加请求头(X-WD-Max-Downloads / X-WD-Label)。 */ + /** 第 0 帧要带的附加请求头(X-WD-Max-Downloads)。 */ frame0Headers: Record; /** 按序产出加密后的帧。同一个计划只能迭代一次。 */ frames: AsyncGenerator; @@ -256,11 +269,12 @@ function resolveGating(opts: PlanOptions, fitsInOneFrame: boolean | undefined): * enc 模式:把内容加密成一个条目(protocol §7)。 * 小内容单帧、非 GATED;放不下就拆成多帧并标记 GATED。第 0 帧携带元数据。 */ -export function planEncItem( +export function planItem( seal: (p: { flags: number; itemId: Uint8Array; index: number; data: Uint8Array }) => Promise, source: ContentSource, meta: ItemMeta, opts: PlanOptions = {}, + encrypted = true, ): ItemPlan { const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId(); const maxData = opts.maxFrameData ?? MAX_FRAME_DATA; @@ -281,9 +295,10 @@ export function planEncItem( if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads); const snapshot = opts.snapshot === true; + if (opts.control && (snapshot || gated)) throw new RangeError('控制条目必须是非快照的单帧'); async function* frames(): AsyncGenerator { - const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | Flags.Enc; + const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (opts.control ? Flags.Control : 0) | (encrypted ? Flags.Enc : 0); const emit = async (index: number, last: boolean, data: Uint8Array, gatedNow: boolean): Promise => ({ index, final: last, @@ -326,42 +341,3 @@ export function planEncItem( return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() }; } -/** - * raw 模式:负载原样,没有加密、没有元数据。单帧放不下时拆成 GATED 多帧。 - * 注意 raw 内容对服务端完全可见。 - */ -export function planRawItem( - build: (p: { flags: number; itemId: Uint8Array; index: number; payload: Uint8Array }) => Bytes, - source: ContentSource, - opts: PlanOptions & { label?: string } = {}, -): ItemPlan { - const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId(); - const cap = Math.min(opts.maxFrameData ?? RAW_MAX_PAYLOAD, RAW_MAX_PAYLOAD); - const snapshot = opts.snapshot === true; - - const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined; - let gated = resolveGating(opts, size === undefined ? undefined : size <= cap); - - const frame0Headers: Record = {}; - if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads); - if (opts.label !== undefined) frame0Headers['X-WD-Label'] = opts.label; - - async function* frames(): AsyncGenerator { - let index = 0; - for await (const chunk of rechunk(source, cap, cap)) { - if (index === 0) { - if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false; - else if (!chunk.last) { - if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧'); - gated = true; - } - } - const flags = (gated ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (chunk.last ? Flags.Final : 0); - yield { index, final: chunk.last, bytes: build({ flags, itemId: idBytes, index, payload: chunk.data }) }; - index++; - } - } - - return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() }; -} - diff --git a/packages/core/src/reader.ts b/packages/core/src/reader.ts index b35e725..4d9e16e 100644 --- a/packages/core/src/reader.ts +++ b/packages/core/src/reader.ts @@ -14,7 +14,7 @@ export interface ReaderContext { itemKey(itemId: Uint8Array): Promise; } -export type ReceivedKind = ItemKind | 'raw'; +export type ReceivedKind = ItemKind; export interface ReceivedItem { itemId: string; @@ -124,7 +124,7 @@ export class ItemReader { if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode'); if (this.#done.has(itemId)) return []; - const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot); + const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot | Flags.Control); const gated = (env.flags & Flags.Gated) !== 0; const isFinal = (env.flags & Flags.Final) !== 0; // 非 GATED 条目必须是单帧且 FINAL(协议规则,服务端也强制) @@ -156,7 +156,7 @@ export class ItemReader { let meta = state?.meta; let content = data; - if (this.#ctx.mode === 'enc' && env.frameIndex === 0) { + if (env.frameIndex === 0) { const first = decodeFirstFrame(data); meta = first.meta; content = first.content; @@ -179,7 +179,7 @@ export class ItemReader { const item: ReceivedItem = { itemId, seq, - kind: this.#ctx.mode === 'raw' ? 'raw' : (state.meta?.kind ?? 'other'), + kind: state.meta?.kind ?? 'other', snapshot: (itemFlags & Flags.Snapshot) !== 0, gated, complete, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4673452..c9f3a83 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -4,7 +4,7 @@ import { CapabilityError, IntegrityError, LimitError, LinkError, ModeMismatchErr import { encodeEnvelope, sealFrame } from './frame.js'; import { deriveChannelKeys, deriveItemKey, deriveOwnerToken, sha256, type ChannelKeys } from './kdf.js'; import { buildLink, parseLink, type ParsedLink } from './link.js'; -import { planEncItem, planRawItem, type ContentSource, type ItemMeta, type ItemPlan, type PlanOptions } from './item.js'; +import { planItem, type ContentSource, type ItemMeta, type ItemPlan, type PlanOptions } from './item.js'; import { ItemReader, type ReaderContext } from './reader.js'; import { decryptItemStream, type DecryptStreamOptions, type ItemDecodeResult } from './stream.js'; @@ -12,6 +12,9 @@ export type AuthRole = 'read' | 'write' | 'owner'; /** 频道设置,字段名与 API 一致(snake_case)。 */ export interface ChannelSettings { + join_policy?: 'open' | 'invite'; + allow_guests?: boolean; + name?: string; seats?: number; members_can_upload?: boolean; delete_policy?: 'owner_only' | 'own' | 'anyone'; @@ -56,8 +59,10 @@ export interface PlanItemOptions extends PlanOptions { ts?: number; /** 覆盖设备名。默认使用 session.deviceName。 */ dev?: string; - /** 仅 raw 模式:条目标签,作为 X-WD-Label 随第 0 帧发送(明文,服务端可见)。enc 模式下忽略,名字放在加密的元数据里。 */ - label?: string; + /** Workspace thread ID, encoded only in encrypted item metadata. */ + threadId?: string; + /** Virtual folder ID for files, encoded only in encrypted item metadata. */ + folderId?: string; } export interface FileMetaOverrides { @@ -255,22 +260,17 @@ export class ChannelSession { #meta(kind: ItemMeta['kind'], opts: PlanItemOptions, extra: Partial = {}): ItemMeta { const meta: ItemMeta = { kind, ts: opts.ts ?? Date.now(), ...extra }; + if (opts.threadId !== undefined) meta.threadId = opts.threadId; + if (opts.folderId !== undefined) meta.folderId = opts.folderId; const dev = opts.dev ?? this.deviceName; if (dev !== undefined) meta.dev = dev; return meta; } #plan(source: ContentSource, meta: ItemMeta, opts: PlanItemOptions): ItemPlan { - if (this.mode === 'enc') { - return planEncItem( - async (p) => sealFrame(await this.itemKey(p.itemId), this.idBytes, p), - source, - meta, - opts, - ); - } - // raw:没有元数据,内容原样放进负载 - return planRawItem((p) => encodeEnvelope(p.flags, p.itemId, p.index, p.payload), source, opts); + return planItem(async (p) => this.mode === 'enc' + ? sealFrame(await this.itemKey(p.itemId), this.idBytes, p) + : encodeEnvelope(p.flags, p.itemId, p.index, p.data), source, meta, opts, this.mode === 'enc'); } encryptBytes(content: Uint8Array, meta: Partial & { kind?: ItemMeta['kind'] } = {}, opts: PlanItemOptions = {}): ItemPlan { @@ -331,11 +331,16 @@ export class ChannelSession { // ───────────────────────── 设备名(protocol §10) ───────────────────────── /** - * 加密设备名,作为 PUT /seats/me 的请求体。格式:iv(12) ‖ AES-GCM(K_label, 名称) ‖ tag(16), + * 加密设备名,作为席位名称 PUT 请求体。格式:iv(12) ‖ AES-GCM(K_label, 名称) ‖ tag(16), * AAD = "WD1L" ‖ channel_id ‖ endpoint_hash。raw 模式下设备名是明文 UTF-8。 + * @param opts.endpointHash 指定目标席位;所有者可给其他终端重命名。 * @param opts.iv 仅用于测试向量,真实使用必须让库随机生成。 */ - async sealLabel(name: string, opts: { iv?: Uint8Array } = {}): Promise { + async sealLabel(name: string, opts: { iv?: Uint8Array; endpointHash?: Uint8Array | string } = {}): Promise { + const endpointHash = typeof opts.endpointHash === 'string' + ? fromBase64Url(opts.endpointHash, 32) + : opts.endpointHash ?? this.endpointHash; + if (!endpointHash || endpointHash.length !== 32) throw new RangeError('终端标识必须是 32 字节'); const plain = utf8(name); if (this.mode === 'raw') { if (plain.length > MAX_LABEL_BYTES) throw new LimitError('设备名过长'); @@ -347,7 +352,7 @@ export class ChannelSession { } const iv = opts.iv ?? randomBytes(LABEL_IV_SIZE); const sealed = await globalThis.crypto.subtle.encrypt( - { name: 'AES-GCM', iv: iv as Bytes, additionalData: this.#labelAad(this.endpointHash), tagLength: 128 }, + { name: 'AES-GCM', iv: iv as Bytes, additionalData: this.#labelAad(endpointHash), tagLength: 128 }, (this.#keys as ChannelKeys).labelKey, plain, ); diff --git a/packages/core/src/stream.ts b/packages/core/src/stream.ts index 4fbd9fb..d25979a 100644 --- a/packages/core/src/stream.ts +++ b/packages/core/src/stream.ts @@ -57,13 +57,15 @@ class ByteQueue { async function* bytesOf(source: ReadableStream | AsyncIterable): AsyncGenerator { if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) { const reader = source.getReader(); + let finished = false; try { for (;;) { const { done, value } = await reader.read(); - if (done) return; + if (done) { finished = true; return; } yield value; } } finally { + if (!finished) await reader.cancel().catch(() => {}); reader.releaseLock(); } } @@ -136,21 +138,19 @@ export async function* decryptItemFrames( if (finalSeen) throw new IntegrityError('frame_after_final'); if (env.frameIndex !== expected) throw new IntegrityError('gap'); - const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot); + const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot | Flags.Control); if (flagsMask === undefined) flagsMask = itemFlags; else if (flagsMask !== itemFlags) throw new IntegrityError('flags_changed'); let data: Bytes; if (ctx.mode === 'enc') { data = await openFrame(await ctx.itemKey(env.itemId), ctx.channelId, env); - if (env.frameIndex === 0) { - const first = decodeFirstFrame(data); - meta = first.meta; - data = first.content; - } } else { data = env.payload; } + if (env.frameIndex === 0) { + const first = decodeFirstFrame(data); meta = first.meta; data = first.content; + } expected++; if ((env.flags & Flags.Final) !== 0) finalSeen = true; @@ -190,7 +190,8 @@ export function decryptItemStream( controller.error(e); } }, - async cancel() { + async cancel(reason) { + reject(reason ?? new DOMException('解密已取消', 'AbortError')); await gen.return({ complete: false, nextIndex: opts.fromIndex ?? 0, meta: undefined }); }, }); diff --git a/packages/core/test/e2e.test.ts b/packages/core/test/e2e.test.ts index 2ee6e09..ac277b3 100644 --- a/packages/core/test/e2e.test.ts +++ b/packages/core/test/e2e.test.ts @@ -30,7 +30,9 @@ const exe = process.env.WEBDROP_SERVER ?? join(root, 'out', 'win-x64', 'webdrop. const linuxExe = join(root, 'out', 'linux-x64', 'webdrop'); const dll = process.env.WEBDROP_SERVER_DLL ?? join(root, 'src', 'WebDrop.Api', 'bin', 'Debug', 'net10.0', 'webdrop.dll'); -const command: [string, string[]] | undefined = existsSync(exe) +const command: [string, string[]] | undefined = !process.env.WEBDROP_SERVER && existsSync(dll) + ? ['dotnet', [dll]] + : existsSync(exe) ? [exe, []] : existsSync(linuxExe) ? [linuxExe, []] @@ -41,6 +43,10 @@ const command: [string, string[]] | undefined = existsSync(exe) let proc: ChildProcess | undefined; let dataDir = ''; let base = ''; +let accountToken = ''; +function headersFor(session: ChannelSession, role: AuthRole): Record { + return { ...session.authHeaders(role), ...(session.role === 'owner' ? { 'X-WD-Account': accountToken } : {}) }; +} beforeAll(async () => { if (!command) return; @@ -69,6 +75,16 @@ beforeAll(async () => { }); proc!.on('exit', (code) => reject(new Error(`服务端提前退出,代码 ${code}`))); }); + const verifier = crypto.getRandomValues(new Uint8Array(32)); + const keys = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); + const pubkey = await crypto.subtle.exportKey('jwk', keys.publicKey); + const encoded = (value: Uint8Array) => Buffer.from(value).toString('base64'); + const registered = await fetch(`${base}/api/v1/accounts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + username: 'core_e2e', salt: encoded(crypto.getRandomValues(new Uint8Array(16))), verifier: encoded(verifier), vault: encoded(new Uint8Array(28)), public_key: JSON.stringify(pubkey), + }) }); + expect(registered.status).toBe(204); + const session = await fetch(`${base}/api/v1/accounts/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'core_e2e', verifier: encoded(verifier) }) }); + expect(session.status).toBe(200); accountToken = ((await session.json()) as { token: string }).token; }, 30_000); afterAll(async () => { @@ -85,7 +101,7 @@ async function createChannel(opts: Parameters[0] = const created = await ChannelSession.create(opts); const res = await fetch(api('/channels'), { method: 'POST', - headers: { ...created.request.headers, 'Content-Type': 'application/json' }, + headers: { ...created.request.headers, 'X-WD-Account': accountToken, 'Content-Type': 'application/json' }, body: JSON.stringify(created.request.body), }); expect(res.status).toBe(201); @@ -95,7 +111,7 @@ async function createChannel(opts: Parameters[0] = } async function joinChannel(session: ChannelSession, role: AuthRole = 'read') { - const res = await fetch(api(`/channels/${session.id}/seats`), { method: 'POST', headers: session.authHeaders(role) }); + const res = await fetch(api(`/channels/${session.id}/seats`), { method: 'POST', headers: headersFor(session, role) }); expect(res.status, await res.clone().text()).toBe(200); } @@ -105,7 +121,7 @@ async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) { for await (const frame of plan.frames) { const res = await fetch(api(`/channels/${session.id}/frames`), { method: 'POST', - headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...(first ? plan.frame0Headers : {}) }, + headers: { ...headersFor(session, role), 'Content-Type': 'application/octet-stream', ...(first ? plan.frame0Headers : {}) }, body: frame.bytes, }); expect(res.status, await res.clone().text()).toBeLessThan(300); @@ -116,16 +132,17 @@ async function send(session: ChannelSession, plan: ItemPlan, role: AuthRole) { } async function readLog(session: ChannelSession, role: AuthRole, from = 0) { - const res = await fetch(api(`/channels/${session.id}/log?from=${from}`), { headers: session.authHeaders(role) }); + const res = await fetch(api(`/channels/${session.id}/log?from=${from}`), { headers: headersFor(session, role) }); expect(res.status).toBe(200); return parseLogRecords(new Uint8Array(await res.arrayBuffer())); } /** 连接 WebSocket(凭据放在子协议里),收集事件直到满足 until。 */ function subscribe(session: ChannelSession, role: AuthRole, until: (events: ItemEvent[]) => boolean, from = 0) { - const h = session.authHeaders(role); + const h = headersFor(session, role); const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`]; if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`); + if (session.role === 'owner') protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`); const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols); ws.binaryType = 'arraybuffer'; @@ -227,7 +244,7 @@ suite('端到端(真实服务端)', () => { const frame = (await collectFrames(again))[0]!; const res = await fetch(api(`/channels/${owner.id}/frames`), { method: 'POST', - headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, + headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' }, body: frame.bytes, }); expect(res.status).toBe(200); @@ -239,7 +256,7 @@ suite('端到端(真实服务端)', () => { const marker = 'UNIQUE-PLAINTEXT-MARKER-7f3a9c'; await send(owner, owner.encryptText(marker), 'owner'); - const res = await fetch(api(`/channels/${owner.id}/log`), { headers: owner.authHeaders('owner') }); + const res = await fetch(api(`/channels/${owner.id}/log`), { headers: headersFor(owner, 'owner') }); const wire = new Uint8Array(await res.arrayBuffer()); expect(new TextDecoder('latin1').decode(wire)).not.toContain(marker); expect(new TextDecoder('latin1').decode(wire)).not.toContain('"kind"'); @@ -260,7 +277,7 @@ suite('端到端(真实服务端)', () => { expect(log.map((r) => r.kind)).toEqual(['announce']); // 流式下载 + 流式解密 - const res = await fetch(api(`/channels/${owner.id}/items/${plan.itemId}/frames`), { headers: member.authHeaders('read') }); + const res = await fetch(api(`/channels/${owner.id}/items/${plan.itemId}/frames`), { headers: headersFor(member, 'read') }); expect(res.status).toBe(200); const { content: stream, result } = member.decryptItemStream(res.body!, { itemId: plan.idBytes }); const out = await readAll(stream); @@ -278,15 +295,15 @@ suite('端到端(真实服务端)', () => { await send(owner, plan, 'owner'); const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`); - const first = await fetch(url, { headers: owner.authHeaders('owner') }); + const first = await fetch(url, { headers: headersFor(owner, 'owner') }); const decoded = owner.decryptItemStream(first.body!); expect(Buffer.compare(await readAll(decoded.content), content)).toBe(0); - const second = await fetch(url, { headers: owner.authHeaders('owner') }); + const second = await fetch(url, { headers: headersFor(owner, 'owner') }); expect(second.status).toBe(410); expect(((await second.json()) as { error: string }).error).toBe('gone'); - const resumed = await fetch(`${url}?from=1`, { headers: owner.authHeaders('owner') }); + const resumed = await fetch(`${url}?from=1`, { headers: headersFor(owner, 'owner') }); expect(resumed.status).toBe(200); await resumed.arrayBuffer(); }, 60_000); @@ -308,7 +325,7 @@ suite('端到端(真实服务端)', () => { if (sent === 2) break; const res = await fetch(api(`/channels/${owner.id}/frames`), { method: 'POST', - headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream', ...(isFirst ? first.frame0Headers : {}) }, + headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream', ...(isFirst ? first.frame0Headers : {}) }, body: f.bytes, }); expect(res.status).toBe(201); @@ -322,14 +339,14 @@ suite('端到端(真实服务端)', () => { for await (const f of rest.frames) { const res = await fetch(api(`/channels/${owner.id}/frames`), { method: 'POST', - headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, + headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' }, body: f.bytes, }); statuses.push(res.status); } expect(statuses).toEqual([200, 201, 201]); // 第 1 帧已存在(幂等),第 2、3 帧是新的 - const res = await fetch(api(`/channels/${owner.id}/items/${toBase64Url(itemId)}/frames`), { headers: owner.authHeaders('owner') }); + const res = await fetch(api(`/channels/${owner.id}/items/${toBase64Url(itemId)}/frames`), { headers: headersFor(owner, 'owner') }); const { content: stream, result } = owner.decryptItemStream(res.body!); expect(Buffer.compare(await readAll(stream), content)).toBe(0); expect((await result).complete).toBe(true); @@ -343,7 +360,7 @@ suite('端到端(真实服务端)', () => { const url = api(`/channels/${owner.id}/items/${plan.itemId}/frames`); // 第一次:只读前约 1.5 帧就断开 - const first = await fetch(url, { headers: owner.authHeaders('owner') }); + const first = await fetch(url, { headers: headersFor(owner, 'owner') }); const reader = first.body!.getReader(); const chunks: Uint8Array[] = []; let got = 0; @@ -361,7 +378,7 @@ suite('端到端(真实服务端)', () => { })(); expect(part1.result.complete).toBe(false); - const second = await fetch(`${url}?from=${part1.result.nextIndex}`, { headers: owner.authHeaders('owner') }); + const second = await fetch(`${url}?from=${part1.result.nextIndex}`, { headers: headersFor(owner, 'owner') }); expect(second.status).toBe(200); const dec2 = owner.decryptItemStream(second.body!, { fromIndex: part1.result.nextIndex }); const part2 = await readAll(dec2.content); @@ -378,12 +395,12 @@ suite('端到端(真实服务端)', () => { const blob = await member.sealLabel('我的 iPhone'); const put = await fetch(api(`/channels/${owner.id}/seats/me`), { method: 'PUT', - headers: { ...member.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, + headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/octet-stream' }, body: blob, }); expect(put.status).toBe(204); - const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: owner.authHeaders('owner') }); + const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: headersFor(owner, 'owner') }); const seats = (await res.json()) as Array<{ endpoint_hash: string; label?: string }>; const mine = seats.find((s) => s.endpoint_hash === toBase64Url(member.endpointHash))!; expect(await owner.openLabel(mine.label!, mine.endpoint_hash)).toBe('我的 iPhone'); @@ -396,7 +413,7 @@ suite('端到端(真实服务端)', () => { const blob = await owner.sealLabel('x'.repeat(228)); const put = await fetch(api(`/channels/${owner.id}/seats/me`), { method: 'PUT', - headers: { ...owner.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, + headers: { ...headersFor(owner, 'owner'), 'Content-Type': 'application/octet-stream' }, body: blob, }); expect(put.status).toBe(204); @@ -415,11 +432,11 @@ suite('端到端(真实服务端)', () => { for (const r of records) out.push(...(await reader.push(r))); return out; })()); - expect(item!.kind).toBe('raw'); + expect(item!.kind).toBe('text'); expect(itemText(item!)).toBe('raw 明文 visible'); // 对比 enc:raw 的明文确实出现在服务端返回的字节里 - const res = await fetch(api(`/channels/${owner.id}/log`), { headers: member.authHeaders('read') }); + const res = await fetch(api(`/channels/${owner.id}/log`), { headers: headersFor(member, 'read') }); expect(new TextDecoder().decode(await res.arrayBuffer())).toContain('raw 明文 visible'); }); @@ -428,8 +445,8 @@ suite('端到端(真实服务端)', () => { const member = await ChannelSession.open(owner.link(ORIGIN)); await joinChannel(member, 'read'); - const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: { ...member.authHeaders('read') } }); + const res = await fetch(api(`/channels/${owner.id}/seats`), { headers: { ...headersFor(member, 'read') } }); expect(res.status).toBe(403); - expect(() => member.authHeaders('owner')).toThrow(); + expect(() => headersFor(member, 'owner')).toThrow(); }); }); diff --git a/packages/core/test/session.test.ts b/packages/core/test/session.test.ts index d3de0eb..d23c9ee 100644 --- a/packages/core/test/session.test.ts +++ b/packages/core/test/session.test.ts @@ -315,29 +315,30 @@ describe('设备名', () => { }); describe('raw 模式往返', () => { - it('单帧:负载就是内容,没有加密标志', async () => { + it('单帧:保留统一元数据,没有加密标志', async () => { const { session: owner } = await ChannelSession.create({ mode: 'raw' }); const member = await ChannelSession.open(owner.link(ORIGIN)); const [frame] = await collectFrames(owner.encryptText('明文内容')); expect(frame!.bytes[1]! & 8).toBe(0); // 没有 ENC 标志 - expect(new TextDecoder().decode(frame!.bytes.slice(26))).toBe('明文内容'); + expect(new TextDecoder().decode(frame!.bytes.slice(26))).toContain('明文内容'); const item = itemOf(await member.createItemReader().pushFrame(frame!.bytes)); - expect(item.kind).toBe('raw'); - expect(item.meta).toBeUndefined(); + expect(item.kind).toBe('text'); + expect(item.meta?.kind).toBe('text'); expect(itemText(item)).toBe('明文内容'); }); it('大内容拆成 GATED 多帧', async () => { const { session } = await ChannelSession.create({ mode: 'raw' }); const content = pseudoRandom(100, 5); - const plan = session.encryptBytes(content, {}, { maxFrameData: 40 }); + const plan = session.encryptBytes(content, {}, { maxFrameData: 80 }); const frames = await collectFrames(plan); expect(plan.gated).toBe(true); - expect(frames.map((f) => f.bytes.length - 26)).toEqual([40, 40, 20]); - expect(frames.map((f) => f.final)).toEqual([false, false, true]); + expect(frames.length).toBeGreaterThan(1); + expect(frames.every((f) => f.bytes.length - 26 <= 80)).toBe(true); + expect(frames.at(-1)?.final).toBe(true); const reader = session.createItemReader(); let last: ReceivedItem | undefined; @@ -346,10 +347,11 @@ describe('raw 模式往返', () => { expect(toHex(last!.content)).toBe(toHex(content)); }); - it('label 放进第 0 帧的附加请求头', async () => { + it('名称使用与加密条目一致的元数据', async () => { const { session } = await ChannelSession.create({ mode: 'raw' }); - const plan = session.encryptBytes(new Uint8Array(3), {}, { label: 'note.txt' }); - expect(plan.frame0Headers['X-WD-Label']).toBe('note.txt'); + const plan = session.encryptBytes(new Uint8Array(3), { name: 'note.txt' }); + const [frame] = await collectFrames(plan); + expect(itemOf(await session.createItemReader().pushFrame(frame!.bytes)).meta?.name).toBe('note.txt'); }); }); diff --git a/packages/core/test/vectors.test.ts b/packages/core/test/vectors.test.ts index 99cd0ab..dd5f8c7 100644 --- a/packages/core/test/vectors.test.ts +++ b/packages/core/test/vectors.test.ts @@ -149,7 +149,10 @@ describe('条目分帧', () => { itemId: h(item.item_id), ts: m.ts, ...(m.dev !== undefined && { dev: m.dev }), + ...(m.thread_id !== undefined && { threadId: m.thread_id }), + ...(m.folder_id !== undefined && { folderId: m.folder_id }), snapshot: item.snapshot, + control: item.control, gated: item.force_gated ? true : 'auto', maxFrameData: item.max_frame_data, }, diff --git a/server/src/WebDrop.Api/ApiContract.cs b/server/src/WebDrop.Api/ApiContract.cs index 319d267..9604002 100644 --- a/server/src/WebDrop.Api/ApiContract.cs +++ b/server/src/WebDrop.Api/ApiContract.cs @@ -3,5 +3,5 @@ namespace WebDrop.Api; public static class ApiContract { // Keep this aligned with contracts/api-contract.json and docs/openapi.yaml. - public const string Version = "1.0.0"; + public const string Version = "2.1.0"; } diff --git a/server/src/WebDrop.Api/ApiJson.cs b/server/src/WebDrop.Api/ApiJson.cs index 22d903b..3dcc593 100644 --- a/server/src/WebDrop.Api/ApiJson.cs +++ b/server/src/WebDrop.Api/ApiJson.cs @@ -47,6 +47,80 @@ public sealed class LimitsBody [JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; } } +public sealed class AccountRegisterRequest +{ + [JsonPropertyName("public_key")] public required string PublicKey { get; init; } + [JsonPropertyName("username")] public required string Username { get; init; } + [JsonPropertyName("salt")] public required string Salt { get; init; } + [JsonPropertyName("verifier")] public required string Verifier { get; init; } + [JsonPropertyName("vault")] public required string Vault { get; init; } +} +public sealed class AccountSaltRequest { [JsonPropertyName("username")] public required string Username { get; init; } } +public sealed class AccountSessionRequest +{ + [JsonPropertyName("device_id")] public string? DeviceId { get; init; } + [JsonPropertyName("device_name")] public string? DeviceName { get; init; } + [JsonPropertyName("username")] public required string Username { get; init; } + [JsonPropertyName("verifier")] public required string Verifier { get; init; } +} +public sealed class AccountVaultRequest +{ + [JsonPropertyName("revision")] public required long Revision { get; init; } + [JsonPropertyName("vault")] public required string Vault { get; init; } +} +public sealed class AccountSaltBody +{ + [JsonPropertyName("salt")] public required string Salt { get; init; } +} +public sealed class AccountSessionBody +{ + [JsonPropertyName("token")] public required string Token { get; init; } + [JsonPropertyName("username")] public required string Username { get; init; } +} +public sealed class AccountVaultBody +{ + [JsonPropertyName("revision")] public required long Revision { get; init; } + [JsonPropertyName("vault")] public required string Vault { get; init; } +} + +public sealed class PublicKeyBody { [JsonPropertyName("public_key")] public required string PublicKey { get; init; } } +public sealed class InvitationRequest +{ + [JsonPropertyName("username")] public required string Username { get; init; } + [JsonPropertyName("payload")] public required string Payload { get; init; } +} +public sealed class InvitationBody +{ + [JsonPropertyName("channel_id")] public required string ChannelId { get; init; } + [JsonPropertyName("username")] public required string Username { get; init; } + [JsonPropertyName("payload")] public required string Payload { get; init; } + [JsonPropertyName("created_at")] public required long CreatedAt { get; init; } +} +public sealed class JoinRequest { [JsonPropertyName("password")] public string? Password { get; init; } } +public sealed class KeyPackageRequest { [JsonPropertyName("payload")] public required string Payload { get; init; } } +public sealed class AccountDeviceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } } +public sealed class AccountDeviceBody +{ + [JsonPropertyName("id")] public required string Id { get; init; } + [JsonPropertyName("name")] public required string Name { get; init; } + [JsonPropertyName("created_at")] public required long CreatedAt { get; init; } + [JsonPropertyName("last_seen")] public required long LastSeen { get; init; } + [JsonPropertyName("current")] public required bool Current { get; init; } +} +public sealed class AccountChannelBody +{ + [JsonPropertyName("channel_id")] public required string ChannelId { get; init; } + [JsonPropertyName("role")] public required string Role { get; init; } + [JsonPropertyName("payload")] public required string Payload { get; init; } + [JsonPropertyName("created_at")] public required long CreatedAt { get; init; } +} +public sealed class MemberBody +{ + [JsonPropertyName("username")] public required string Username { get; init; } + [JsonPropertyName("role")] public required string Role { get; init; } + [JsonPropertyName("joined_at")] public required long JoinedAt { get; init; } +} + [JsonSerializable(typeof(ErrorBody))] [JsonSerializable(typeof(GoneLogBody))] [JsonSerializable(typeof(ServerInfoBody))] @@ -62,5 +136,21 @@ public sealed class LimitsBody [JsonSerializable(typeof(AppendResult))] [JsonSerializable(typeof(SiteverifyRequest))] [JsonSerializable(typeof(SiteverifyResponse))] +[JsonSerializable(typeof(AccountRegisterRequest))] +[JsonSerializable(typeof(AccountSaltRequest))] +[JsonSerializable(typeof(AccountSessionRequest))] +[JsonSerializable(typeof(AccountVaultRequest))] +[JsonSerializable(typeof(AccountSaltBody))] +[JsonSerializable(typeof(AccountSessionBody))] +[JsonSerializable(typeof(AccountVaultBody))] +[JsonSerializable(typeof(PublicKeyBody))] +[JsonSerializable(typeof(InvitationRequest))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(JoinRequest))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(KeyPackageRequest))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(AccountDeviceNameRequest))] +[JsonSerializable(typeof(List))] [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] public sealed partial class ApiJson : JsonSerializerContext; diff --git a/server/src/WebDrop.Api/Endpoints.Accounts.cs b/server/src/WebDrop.Api/Endpoints.Accounts.cs new file mode 100644 index 0000000..6a65087 --- /dev/null +++ b/server/src/WebDrop.Api/Endpoints.Accounts.cs @@ -0,0 +1,91 @@ +using WebDrop.Core; +using WebDrop.Storage; + +namespace WebDrop.Api; + +public static partial class Endpoints +{ + private static void MapAccounts(RouteGroupBuilder api, SqliteAccountStore accounts) + { + api.MapPost("/accounts", async (HttpContext ctx, CancellationToken ct) => + { + var req = await ReadJson(ctx.Request, ApiJson.Default.AccountRegisterRequest, ct); + var username = NormalizeUsername(req.Username); + var salt = DecodeBase64(req.Salt, 16, 16); + var verifier = DecodeBase64(req.Verifier, 32, 32); + var vault = DecodeBase64(req.Vault, 28, 1_300_000); + ValidatePublicKey(req.PublicKey); + if (!accounts.Register(username, salt, verifier, vault, req.PublicKey)) return Json(new ErrorBody { Error = "username_unavailable" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict); + return Results.NoContent(); + }).RequireRateLimiting(CreatePolicy); + + api.MapPost("/accounts/salt", async (HttpContext ctx, CancellationToken ct) => + { + var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSaltRequest, ct); + var username = NormalizeUsername(req.Username); + var salt = accounts.LoginSalt(username); + return Json(new AccountSaltBody { Salt = Convert.ToBase64String(salt) }, ApiJson.Default.AccountSaltBody); + }).RequireRateLimiting(CreatePolicy); + + api.MapPost("/accounts/session", async (HttpContext ctx, CancellationToken ct) => + { + var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSessionRequest, ct); + var username = NormalizeUsername(req.Username); + var verifier = DecodeBase64(req.Verifier, 32, 32); + var token = accounts.Authenticate(username, verifier, req.DeviceId, req.DeviceName); + if (token is null) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized); + return Json(new AccountSessionBody { Token = token, Username = username }, ApiJson.Default.AccountSessionBody); + }).RequireRateLimiting(CreatePolicy); + + api.MapGet("/accounts/vault", (HttpContext ctx) => + { + if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized); + var vault = accounts.GetVault(username); + return vault is null ? Results.NotFound() : Json(new AccountVaultBody { Revision = vault.Value.Revision, Vault = Convert.ToBase64String(vault.Value.Vault) }, ApiJson.Default.AccountVaultBody); + }); + + api.MapGet("/accounts/devices", (HttpContext ctx) => + { + if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); + var current = accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString()); + var devices = accounts.Devices(username, current).Select(d => new AccountDeviceBody + { Id = d.Id, Name = d.Name, CreatedAt = d.CreatedAt, LastSeen = d.LastSeen, Current = d.Current }).ToList(); + return Json(devices, ApiJson.Default.ListAccountDeviceBody); + }); + api.MapPatch("/accounts/devices/{id}", async (string id, HttpContext ctx, CancellationToken ct) => + { + if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); + var req = await ReadJson(ctx.Request, ApiJson.Default.AccountDeviceNameRequest, ct); + if (req.Name is null || !accounts.RenameDevice(username, id, req.Name.Trim())) throw new WebDropException(ErrorCode.BadRequest); + return Results.NoContent(); + }); + api.MapDelete("/accounts/devices/{id}", (string id, HttpContext ctx, ChannelService svc) => + { + if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); + accounts.RevokeDevice(username, id); + svc.ReleaseAccountDevice(username, id); + return Results.NoContent(); + }); + + api.MapPut("/accounts/vault", async (HttpContext ctx, CancellationToken ct) => + { + if (!TryAccount(ctx, accounts, out var username)) return Json(new ErrorBody { Error = "account_invalid" }, ApiJson.Default.ErrorBody, StatusCodes.Status401Unauthorized); + var req = await ReadJson(ctx.Request, ApiJson.Default.AccountVaultRequest, ct); + if (req.Revision < 0) return Results.BadRequest(); + var vault = DecodeBase64(req.Vault, 28, 1_300_000); + var next = accounts.PutVault(username, vault, req.Revision); + return next is null ? Json(new ErrorBody { Error = "vault_conflict" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict) : Json(new AccountVaultBody { Revision = next.Value, Vault = req.Vault }, ApiJson.Default.AccountVaultBody); + }); + + api.MapDelete("/accounts/session", (HttpContext ctx) => + { + var accountToken = ctx.Request.Headers["X-WD-Account"].ToString(); + if (accountToken.Length > 0) { accounts.RevokeSession(accountToken); return Results.NoContent(); } + var header = ctx.Request.Headers["Authorization"].ToString(); + if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Results.NoContent(); + accounts.RevokeSession(header[7..]); + return Results.NoContent(); + }); + + } +} diff --git a/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs new file mode 100644 index 0000000..2d0cd05 --- /dev/null +++ b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs @@ -0,0 +1,54 @@ +using WebDrop.Core; +using WebDrop.Storage; + +namespace WebDrop.Api; + +public static partial class Endpoints +{ + private static void MapChannelAccess(RouteGroupBuilder api, SqliteAccountStore accounts) + { + api.MapGet("/accounts/channels", (HttpContext ctx, ChannelService svc) => + { + if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); + var packages = svc.AccountChannels(username).Select(k => new AccountChannelBody + { + ChannelId = k.ChannelId.ToBase64Url(), Role = k.Role, Payload = k.Payload, CreatedAt = k.CreatedAt, + }).ToList(); + return Json(packages, ApiJson.Default.ListAccountChannelBody); + }); + api.MapPut("/channels/{id}/key-package", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => + { + var req = await ReadJson(ctx.Request, ApiJson.Default.KeyPackageRequest, ct); + svc.SaveOwnerKeyPackage(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), req.Payload); + return Results.NoContent(); + }); + api.MapGet("/accounts/users/{username}/key", (string username, HttpContext ctx) => + { + if (!TryAccount(ctx, accounts, out _)) throw new WebDropException(ErrorCode.AccountRequired); + var key = accounts.GetPublicKey(NormalizeUsername(username)); + if (key is null) throw new WebDropException(ErrorCode.NotFound); + return Json(new PublicKeyBody { PublicKey = key }, ApiJson.Default.PublicKeyBody); + }); + api.MapGet("/accounts/invitations", (HttpContext ctx, ChannelService svc) => + { + if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); + return Json(InvitationBodies(svc.UserInvitations(username)), ApiJson.Default.ListInvitationBody); + }); + api.MapGet("/channels/{id}/invitations", (string id, HttpContext ctx, ChannelService svc) => + Json(InvitationBodies(svc.Invitations(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false))), ApiJson.Default.ListInvitationBody)); + api.MapGet("/channels/{id}/members", (string id, HttpContext ctx, ChannelService svc) => + Json(svc.Members(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)).Select(m => new MemberBody { Username = m.Username, Role = m.Role, JoinedAt = m.JoinedAt }).ToList(), ApiJson.Default.ListMemberBody)); + api.MapPost("/channels/{id}/invitations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => + { + var req = await ReadJson(ctx.Request, ApiJson.Default.InvitationRequest, ct); + var username = NormalizeUsername(req.Username); + if (accounts.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound); + svc.InviteUser(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username, req.Payload); return Results.NoContent(); + }); + api.MapDelete("/channels/{id}/invitations/{username}", (string id, string username, HttpContext ctx, ChannelService svc) => + { + svc.RevokeInvitation(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), NormalizeUsername(username)); return Results.NoContent(); + }); + + } +} diff --git a/server/src/WebDrop.Api/Endpoints.cs b/server/src/WebDrop.Api/Endpoints.cs index eec6968..a7b3397 100644 --- a/server/src/WebDrop.Api/Endpoints.cs +++ b/server/src/WebDrop.Api/Endpoints.cs @@ -4,17 +4,18 @@ using System.Net.WebSockets; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using WebDrop.Core; +using WebDrop.Storage; namespace WebDrop.Api; -public static class Endpoints +public static partial class Endpoints { public const string CreatePolicy = "create"; private const string LastSeqHeader = "X-WD-Last-Seq"; private const string OctetStream = "application/octet-stream"; private static readonly TimeSpan MaxLongPoll = TimeSpan.FromSeconds(25); - public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha) + public static void MapApi(this IEndpointRouteBuilder app, WebDropOptions options, ICaptchaVerifier captcha, SqliteAccountStore accounts) { var api = app.MapGroup("/api/v1"); @@ -37,15 +38,18 @@ public static class Endpoints }, }, ApiJson.Default.ServerInfoBody)); + MapAccounts(api, accounts); + // ───────── 频道 ───────── api.MapPost("/channels", async (HttpContext ctx, ChannelService svc, CancellationToken ct) => { + if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); var req = await ReadJson(ctx.Request, ApiJson.Default.CreateChannelRequest, ct); var endpoint = RequestAuth.ParseEndpointHash(ctx.Request.Headers[RequestAuth.EndpointHeader]); string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString(); await captcha.VerifyAsync(token.Length > 0 ? token : null, ct); - return Json(svc.CreateChannel(req, endpoint), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created); + return Json(svc.CreateChannel(req, endpoint, username, accounts.SessionDevice(ctx.Request.Headers["X-WD-Account"].ToString())), ApiJson.Default.ChannelInfo, StatusCodes.Status201Created); }).RequireRateLimiting(CreatePolicy); api.MapGet("/channels/{id}", (string id, HttpContext ctx, ChannelService svc) => @@ -66,8 +70,13 @@ public static class Endpoints // ───────── 席位 ───────── - api.MapPost("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) => - Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true)), ApiJson.Default.SeatJoined)); + api.MapPost("/channels/{id}/seats", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => + { + var request = ctx.Request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true ? await ReadJson(ctx.Request, ApiJson.Default.JoinRequest, ct) : new JoinRequest(); + return Json(svc.Join(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), request.Password), ApiJson.Default.SeatJoined); + }); + + MapChannelAccess(api, accounts); api.MapGet("/channels/{id}/seats", (string id, HttpContext ctx, ChannelService svc) => { @@ -83,6 +92,14 @@ public static class Endpoints return Results.NoContent(); }); + api.MapPut("/channels/{id}/seats/{endpointHash}/label", async (string id, string endpointHash, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) => + { + if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest); + byte[] label = await ReadBody(ctx.Request, limits.MaxSeatLabelBytes, ct); + svc.SetSeatLabelForEndpoint(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: false), hash, label); + return Results.NoContent(); + }); + api.MapDelete("/channels/{id}/seats/{endpointHash}", (string id, string endpointHash, HttpContext ctx, ChannelService svc) => { if (!Codec.TryDecodeExact(endpointHash, TokenHash.Size, out var hash)) throw new WebDropException(ErrorCode.BadRequest); @@ -97,10 +114,9 @@ public static class Endpoints var channelId = ParseId(id); var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true); long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString()); - string label = ctx.Request.Headers["X-WD-Label"].ToString(); byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct); - var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, label.Length > 0 ? label : null, ct); + var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct); return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK); }); @@ -131,7 +147,7 @@ public static class Endpoints throw new WebDropException(ErrorCode.BadRequest); var channelId = ParseId(id); - var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols); + var creds = RequestAuth.FromSubprotocols(ctx.WebSockets.WebSocketRequestedProtocols, accounts); long from = QueryLong(ctx.Request, "from", 0); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted); @@ -143,7 +159,7 @@ public static class Endpoints ws = await ctx.WebSockets.AcceptWebSocketAsync("wd.v1"); _ = WatchClose(ws, cts); return new WebSocketSink(ws); - }, cts.Token); + }, cts.Token, () => StreamAccountValid(ctx, accounts, creds)); } finally { @@ -166,7 +182,7 @@ public static class Endpoints ctx.Response.Headers["X-Accel-Buffering"] = "no"; await ctx.Response.StartAsync(); return new SseSink(ctx.Response); - }, ctx.RequestAborted); + }, ctx.RequestAborted, () => StreamAccountValid(ctx, accounts, creds)); return Results.Empty; }); @@ -211,11 +227,12 @@ public static class Endpoints api.MapPost("/shares", async (HttpContext ctx, ShareService svc, CancellationToken ct) => { + if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); var req = await ReadJson(ctx.Request, ApiJson.Default.CreateShareRequest, ct); string token = ctx.Request.Headers[RequestAuth.CaptchaHeader].ToString(); await captcha.VerifyAsync(token.Length > 0 ? token : null, ct); byte[] endpoint = RequestAuth.ParseEndpointHashOptional(ctx.Request.Headers[RequestAuth.EndpointHeader]); - return Json(svc.Create(req, endpoint.Length == 0 ? null : endpoint), ApiJson.Default.ShareInfo, StatusCodes.Status201Created); + return Json(svc.Create(req, endpoint.Length == 0 ? null : endpoint, username), ApiJson.Default.ShareInfo, StatusCodes.Status201Created); }).RequireRateLimiting(CreatePolicy); api.MapPatch("/shares/{id}/settings", async (string id, HttpContext ctx, ShareService svc, CancellationToken ct) => @@ -287,6 +304,68 @@ public static class Endpoints private static IResult Json(T value, JsonTypeInfo info, int? status = null) => Results.Json(value, info, statusCode: status); + private static bool TryAccount(HttpContext ctx, SqliteAccountStore accounts, out string username) + { + username = ""; + var accountToken = ctx.Request.Headers["X-WD-Account"].ToString(); + if (accountToken.Length > 0) return accounts.HasSession(accountToken, out username); + var header = ctx.Request.Headers["Authorization"].ToString(); + return header.StartsWith("Bearer ", StringComparison.Ordinal) && accounts.HasSession(header[7..], out username); + } + + private static bool StreamAccountValid(HttpContext ctx, SqliteAccountStore accounts, Credentials creds) + { + if (creds.AccountName is null) return true; + string token = ctx.Request.Headers["X-WD-Account"].ToString(); + if (ctx.WebSockets.IsWebSocketRequest) + { + var protocol = ctx.WebSockets.WebSocketRequestedProtocols.FirstOrDefault(p => p.StartsWith("wd.account.", StringComparison.Ordinal)); + if (protocol is null || !Codec.TryDecodeExact(protocol[11..], 32, out var raw)) return false; + token = Convert.ToBase64String(raw); + } + return accounts.HasSession(token, out var username) && username == creds.AccountName; + } + + private static string NormalizeUsername(string? value) + { + if (value is null) throw new WebDropException(ErrorCode.BadRequest); + var name = value.Trim().ToLowerInvariant(); + if (name.Length is < 3 or > 32 || name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-'))) + throw new WebDropException(ErrorCode.BadRequest); + return name; + } + + private static List InvitationBodies(IReadOnlyList invitations) => invitations.Select(i => new InvitationBody + { + ChannelId = i.ChannelId.ToBase64Url(), Username = i.Username, Payload = i.Payload, CreatedAt = i.CreatedAt, + }).ToList(); + + private static void ValidatePublicKey(string? value) + { + if (value is null) throw new WebDropException(ErrorCode.BadRequest); + if (value.Length > 1024) throw new WebDropException(ErrorCode.BadRequest); + try + { + using var doc = JsonDocument.Parse(value); var root = doc.RootElement; + if (root.GetProperty("kty").GetString() != "EC" || root.GetProperty("crv").GetString() != "P-256" + || !Codec.TryDecodeExact(root.GetProperty("x").GetString(), 32, out _) + || !Codec.TryDecodeExact(root.GetProperty("y").GetString(), 32, out _) || root.TryGetProperty("d", out _)) + throw new WebDropException(ErrorCode.BadRequest); + } + catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException) { throw new WebDropException(ErrorCode.BadRequest); } + } + + private static byte[] DecodeBase64(string? value, int minLength, int maxLength) + { + if (value is null || value.Length > ((maxLength + 2) / 3 * 4)) throw new WebDropException(ErrorCode.BadRequest); + try + { + var bytes = Convert.FromBase64String(value); + return bytes.Length >= minLength && bytes.Length <= maxLength ? bytes : throw new WebDropException(ErrorCode.BadRequest); + } + catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); } + } + private static Id16 ParseId(string text) => Id16.TryParse(text, out var id) ? id : throw new WebDropException(ErrorCode.BadRequest); diff --git a/server/src/WebDrop.Api/LogStreamer.cs b/server/src/WebDrop.Api/LogStreamer.cs index ec60dff..ca0abca 100644 --- a/server/src/WebDrop.Api/LogStreamer.cs +++ b/server/src/WebDrop.Api/LogStreamer.cs @@ -25,7 +25,7 @@ public static class LogStreamer /// public static async Task RunAsync( ChannelService svc, ChannelHub hub, Id16 id, Credentials creds, long from, - Func> open, CancellationToken ct) + Func> open, CancellationToken ct, Func? validateAccount = null) { using var sub = hub.Subscribe(id); ILogSink? sink = null; @@ -36,6 +36,7 @@ public static class LogStreamer { while (!ct.IsCancellationRequested) { + if (validateAccount is not null && !validateAccount()) throw new WebDropException(ErrorCode.AccountRequired); var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢 var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false); sink ??= await open().ConfigureAwait(false); diff --git a/server/src/WebDrop.Api/RequestAuth.cs b/server/src/WebDrop.Api/RequestAuth.cs index 3c9942f..cdfb563 100644 --- a/server/src/WebDrop.Api/RequestAuth.cs +++ b/server/src/WebDrop.Api/RequestAuth.cs @@ -1,4 +1,5 @@ using WebDrop.Core; +using WebDrop.Storage; namespace WebDrop.Api; @@ -23,28 +24,51 @@ public static class RequestAuth throw new WebDropException(ErrorCode.BadRequest); } - return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint)); + string? accountName = null; + string? deviceId = null; + var accountToken = req.Headers["X-WD-Account"].ToString(); + if (accountToken.Length > 0) + { + var accounts = req.HttpContext.RequestServices.GetRequiredService(); + if (!accounts.HasSession(accountToken, out var username)) throw new WebDropException(ErrorCode.AccountRequired); + accountName = username; + deviceId = accounts.SessionDevice(accountToken); + } + if (role == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired); + return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint), accountName, deviceId); } /// /// 浏览器的 WebSocket 不能设请求头,凭据放在子协议里: /// wd.v1, wd.role.<role>, wd.auth.<b64url>, wd.ep.<b64url> /// - public static Credentials FromSubprotocols(IEnumerable protocols) + public static Credentials FromSubprotocols(IEnumerable protocols, SqliteAccountStore? accounts = null) { - string? role = null, auth = null, endpoint = null; + string? role = null, auth = null, endpoint = null, accountToken = null; foreach (string p in protocols) { if (p.StartsWith("wd.role.", StringComparison.Ordinal)) role = p["wd.role.".Length..]; else if (p.StartsWith("wd.auth.", StringComparison.Ordinal)) auth = p["wd.auth.".Length..]; else if (p.StartsWith("wd.ep.", StringComparison.Ordinal)) endpoint = p["wd.ep.".Length..]; + else if (p.StartsWith("wd.account.", StringComparison.Ordinal)) accountToken = p["wd.account.".Length..]; } byte[]? token = null; if (auth is not null && !Codec.TryDecodeExact(auth, TokenHash.Size, out token)) throw new WebDropException(ErrorCode.BadRequest); - return new Credentials(ParseRole(role), token, ParseEndpoint(endpoint, requireEndpoint: true)); + string? accountName = null; + string? deviceId = null; + if (accountToken is not null) + { + if (accounts is null || !Codec.TryDecodeExact(accountToken, 32, out var raw) + || !accounts.HasSession(Convert.ToBase64String(raw), out var username)) throw new WebDropException(ErrorCode.AccountRequired); + accountName = username; + deviceId = accounts.SessionDevice(Convert.ToBase64String(raw)); + } + var parsedRole = ParseRole(role); + if (parsedRole == Role.Owner && accountName is null) throw new WebDropException(ErrorCode.AccountRequired); + return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: true), accountName, deviceId); } public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true); diff --git a/server/src/WebDrop.Api/WebDropApp.cs b/server/src/WebDrop.Api/WebDropApp.cs index 086301c..2eca804 100644 --- a/server/src/WebDrop.Api/WebDropApp.cs +++ b/server/src/WebDrop.Api/WebDropApp.cs @@ -24,8 +24,9 @@ public static class WebDropApp builder.Services.AddSingleton(options); builder.Services.AddSingleton(options.Limits); builder.Services.AddSingleton(); - builder.Services.AddSingleton(_ => new DiskBlobStore(Path.Combine(options.DataDir, "blobs"))); - builder.Services.AddSingleton(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "webdrop.db"))); + builder.Services.AddSingleton(_ => new DiskBlobStore(Path.Combine(options.DataDir, "v2", "blobs"))); + builder.Services.AddSingleton(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "v2", "webdrop.db"))); + builder.Services.AddSingleton(_ => new SqliteAccountStore(Path.Combine(options.DataDir, "v2", "webdrop.db"))); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => CreateCaptchaVerifier(options.Captcha, sp)); builder.Services.AddSingleton(); @@ -42,7 +43,7 @@ public static class WebDropApp .WithOrigins(options.CorsOrigins) .AllowAnyMethod() .WithHeaders("Authorization", "Content-Type", "Last-Event-ID", - "X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Label", "X-WD-Max-Downloads") + "X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account") .WithExposedHeaders("X-WD-Last-Seq") .SetPreflightMaxAge(TimeSpan.FromHours(1)))); } @@ -64,6 +65,7 @@ public static class WebDropApp }); var app = builder.Build(); + _ = app.Services.GetRequiredService(); if (options.TrustForwardedHeaders) { @@ -82,7 +84,7 @@ public static class WebDropApp app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = LogStreamer.Heartbeat }); app.UseRateLimiter(); EmbeddedFrontend.Use(app, options.Captcha.ApiEndpoint); - app.MapApi(options, app.Services.GetRequiredService()); + app.MapApi(options, app.Services.GetRequiredService(), app.Services.GetRequiredService()); return app; } diff --git a/server/src/WebDrop.Core/Abstractions.cs b/server/src/WebDrop.Core/Abstractions.cs index deb5c82..f9624fa 100644 --- a/server/src/WebDrop.Core/Abstractions.cs +++ b/server/src/WebDrop.Core/Abstractions.cs @@ -92,6 +92,26 @@ public interface IResourceTx Channel? GetChannel(Id16 id); void InsertChannel(Channel channel); void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt); + void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt); + bool HasInvitation(Id16 id, string username); + void PutInvitation(ChannelInvitation invitation); + IReadOnlyList ListInvitations(Id16 id); + IReadOnlyList ListUserInvitations(string username); + void DeleteInvitation(Id16 id, string username); + void PutKeyPackage(Id16 id, string username, string payload, long createdAt); + void DeleteKeyPackage(Id16 id, string username); + IReadOnlyList ListUserKeyPackages(string username, long now); + IReadOnlyList DeleteDeviceSeats(string username, string deviceId); + void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId); + void DeleteAccountSeats(Id16 id, string username); + ChannelMember? GetChannelMember(Id16 id, string username); + IReadOnlyList ListChannelMembers(Id16 id); + void PutChannelMember(Id16 id, ChannelMember member); + void DeleteChannelMember(Id16 id, string username); + bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash); + void DenyAccount(Id16 id, string username); + void AllowAccount(Id16 id, string username); + void DenyEndpoint(Id16 id, byte[] endpointHash); void TouchChannel(Id16 id, long expiresAt); void AddChannelBytes(Id16 id, long delta); void SetCompactedThrough(Id16 id, long seq); @@ -113,6 +133,7 @@ public interface IResourceTx void UpdateItem(Item item); void DeleteItemRows(Id16 channelId, Id16 itemId); int CountItems(Id16 channelId); + int CountControlItems(Id16 channelId); IReadOnlyList ListOldestItems(Id16 channelId, int limit); Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId); IReadOnlyList ListExpiredItems(long now, int limit); diff --git a/server/src/WebDrop.Core/ChannelAdmission.cs b/server/src/WebDrop.Core/ChannelAdmission.cs new file mode 100644 index 0000000..856661f --- /dev/null +++ b/server/src/WebDrop.Core/ChannelAdmission.cs @@ -0,0 +1,99 @@ +using System.Security.Cryptography; + +namespace WebDrop.Core; + +public sealed partial class ChannelService +{ + private static (byte[]? Hash, byte[]? Salt) HashJoinPassword(string? password) + { + if (string.IsNullOrEmpty(password)) return (null, null); + if (password.Length is < 8 or > 256) throw new WebDropException(ErrorCode.BadRequest); + var salt = RandomNumberGenerator.GetBytes(16); + return (Rfc2898DeriveBytes.Pbkdf2(password, salt, 210_000, HashAlgorithmName.SHA256, 32), salt); + } + + private static void CheckAdmission(IResourceTx tx, Channel channel, Credentials creds, string? password) + { + if (creds.AccountName is not null && creds.AccountName == channel.CreatorAccount) return; + if (tx.IsJoinDenied(channel.Id, creds.AccountName, creds.EndpointHash)) throw new WebDropException(ErrorCode.Forbidden); + bool invited = creds.AccountName is not null && tx.HasInvitation(channel.Id, creds.AccountName); + if (invited) return; + if (creds.AccountName is not null && tx.GetChannelMember(channel.Id, creds.AccountName) is not null) return; + if (channel.Settings.JoinPolicy == "invite") throw new WebDropException(ErrorCode.InvitationRequired); + if (!channel.Settings.AllowGuests && creds.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired); + if (channel.JoinPasswordHash is null) return; + if (string.IsNullOrEmpty(password)) throw new WebDropException(ErrorCode.ChannelPasswordRequired); + if (password.Length > 256) throw new WebDropException(ErrorCode.ChannelPasswordInvalid); + var actual = Rfc2898DeriveBytes.Pbkdf2(password, channel.JoinPasswordSalt!, 210_000, HashAlgorithmName.SHA256, 32); + if (!CryptographicOperations.FixedTimeEquals(actual, channel.JoinPasswordHash)) throw new WebDropException(ErrorCode.ChannelPasswordInvalid); + } + + public void InviteUser(Id16 id, Credentials creds, string username, string payload) + { + if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest); + store.Write(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + RequireOwner(principal); + tx.PutInvitation(new ChannelInvitation(id, username, payload, clock.NowMs)); + if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden); + tx.PutKeyPackage(id, username, payload, clock.NowMs); + tx.AllowAccount(id, username); + return 0; + }); + } + + public IReadOnlyList Invitations(Id16 id, Credentials creds) => store.Read(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + RequireOwner(principal); + return tx.ListInvitations(id); + }); + + public IReadOnlyList UserInvitations(string username) => store.Read(tx => tx.ListUserInvitations(username)); + + public void RevokeInvitation(Id16 id, Credentials creds, string username) + { + store.Write(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + RequireOwner(principal); + if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden); + tx.DeleteInvitation(id, username); + tx.DeleteKeyPackage(id, username); + tx.DeleteAccountSeats(id, username); + tx.DeleteChannelMember(id, username); + tx.DenyAccount(id, username); + return 0; + }); + events.SeatsChanged(id); + } + + public IReadOnlyList Members(Id16 id, Credentials creds) => store.Read(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + if (principal.Role != Role.Owner) _ = Authenticate(tx, id, creds, SeatRule.Required); + return tx.ListChannelMembers(id); + }); + + public IReadOnlyList AccountChannels(string username) => + store.Read(tx => tx.ListUserKeyPackages(username, clock.NowMs)); + + public void ReleaseAccountDevice(string username, string deviceId) + { + var channels = store.Write(tx => tx.DeleteDeviceSeats(username, deviceId)); + foreach (var id in channels) events.SeatsChanged(id); + } + + public void SaveOwnerKeyPackage(Id16 id, Credentials creds, string payload) + { + if (payload is null || payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest); + store.Write(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + RequireOwner(principal); + tx.PutKeyPackage(id, principal.AccountName!, payload, clock.NowMs); + return 0; + }); + } +} diff --git a/server/src/WebDrop.Core/ChannelPolicy.cs b/server/src/WebDrop.Core/ChannelPolicy.cs index 401d14f..e9cbe79 100644 --- a/server/src/WebDrop.Core/ChannelPolicy.cs +++ b/server/src/WebDrop.Core/ChannelPolicy.cs @@ -19,7 +19,9 @@ public static class ChannelPolicy return channel.Settings.DeletePolicy switch { DeletePolicy.Anyone => true, - DeletePolicy.Own => item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash), + DeletePolicy.Own => item.AuthorAccount is not null + ? item.AuthorAccount == p.AccountName + : item.AuthorHash.AsSpan().SequenceEqual(p.EndpointHash), _ => false, }; } diff --git a/server/src/WebDrop.Core/ChannelService.cs b/server/src/WebDrop.Core/ChannelService.cs index 6dfd803..edaf945 100644 --- a/server/src/WebDrop.Core/ChannelService.cs +++ b/server/src/WebDrop.Core/ChannelService.cs @@ -6,7 +6,7 @@ namespace WebDrop.Core; /// 频道的全部业务逻辑。依赖抽象(存储、blob、时钟、事件),不含 HTTP、SQL、文件细节。 /// 约定:所有失败以 表达;提交成功后才发事件;blob 先写后提交。 /// -public sealed class ChannelService( +public sealed partial class ChannelService( IResourceStore store, IBlobStore blobs, IClock clock, @@ -17,8 +17,10 @@ public sealed class ChannelService( // ───────────────────────── 频道 ───────────────────────── - public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash) + public ChannelInfo CreateChannel(CreateChannelRequest req, byte[] endpointHash, string creatorAccount, string? accountDeviceId = null) { + if (string.IsNullOrWhiteSpace(creatorAccount)) throw new WebDropException(ErrorCode.AccountRequired); + if (req.KeyPackage is not null && req.KeyPackage.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest); if (!Id16.TryParse(req.Id, out var id)) throw new WebDropException(ErrorCode.BadRequest); if (req.Mode is not { } mode) throw new WebDropException(ErrorCode.BadRequest); @@ -44,14 +46,17 @@ public sealed class ChannelService( throw new WebDropException(ErrorCode.BadRequest); long now = clock.NowMs; + var (passwordHash, passwordSalt) = HashJoinPassword(req.JoinPassword); var channel = new Channel(id, mode, settings, readHash, writeHash, ownerHash, - now, now + settings.TtlSeconds * 1000L, 0, 0, 0); + now, now + settings.TtlSeconds * 1000L, 0, 0, 0, creatorAccount, passwordHash, passwordSalt); store.Write(tx => { if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict); tx.InsertChannel(channel); - tx.InsertSeat(id, new Seat(endpointHash, now, null)); + tx.PutChannelMember(id, new ChannelMember(creatorAccount, "owner", now)); + if (req.KeyPackage is not null) tx.PutKeyPackage(id, creatorAccount, req.KeyPackage, now); + tx.InsertSeat(id, new Seat(endpointHash, now, null, creatorAccount, accountDeviceId)); return 0; }); return ToInfo(channel, 1); @@ -77,6 +82,12 @@ public sealed class ChannelService( ? clock.NowMs + updated.TtlSeconds * 1000L : channel.ExpiresAt; tx.UpdateChannel(id, updated, expiresAt); + if (patch.JoinPassword is not null) + { + var (hash, salt) = HashJoinPassword(patch.JoinPassword); + tx.SetJoinPassword(id, hash, salt); + channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt }; + } return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountSeats(id)); }); events.SettingsChanged(id); @@ -98,19 +109,28 @@ public sealed class ChannelService( // ───────────────────────── 席位 ───────────────────────── - public SeatJoined Join(Id16 id, Credentials creds) + public SeatJoined Join(Id16 id, Credentials creds, string? password = null) { var (joined, isNew) = store.Write(tx => { var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None); var existing = tx.GetSeat(id, principal.EndpointHash); + if (existing?.AccountName is not null && existing.AccountName != creds.AccountName) throw new WebDropException(ErrorCode.AccountRequired); + if (existing?.AccountDeviceId is not null && existing.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired); int used = tx.CountSeats(id); if (existing is not null) + { + if (existing.AccountName is not null && existing.AccountDeviceId is null && creds.AccountDeviceId is not null) + tx.BindSeatDevice(id, principal.EndpointHash, creds.AccountDeviceId); return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = used, SeatsMax = channel.Settings.Seats }, false); + } + + CheckAdmission(tx, channel, creds, password); if (used >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull); long now = clock.NowMs; - tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null)); + tx.InsertSeat(id, new Seat(principal.EndpointHash, now, null, creds.AccountName, creds.AccountDeviceId)); + if (creds.AccountName is not null) tx.PutChannelMember(id, new ChannelMember(creds.AccountName, "member", now)); return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = used + 1, SeatsMax = channel.Settings.Seats }, true); }); if (isNew) events.SeatsChanged(id); @@ -126,13 +146,13 @@ public sealed class ChannelService( EndpointHash = Codec.Encode(s.EndpointHash), JoinedAt = s.JoinedAt, Label = s.Label is null ? null : Codec.Encode(s.Label), + AccountName = s.AccountName, }).ToList(); }); public void SetSeatLabel(Id16 id, Credentials creds, byte[] label) { - if (label.Length == 0) throw new WebDropException(ErrorCode.BadRequest); - if (label.Length > limits.MaxSeatLabelBytes) throw new WebDropException(ErrorCode.TooLarge); + ValidateSeatLabel(label); store.Write(tx => { var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); @@ -142,6 +162,26 @@ public sealed class ChannelService( events.SeatsChanged(id); } + public void SetSeatLabelForEndpoint(Id16 id, Credentials creds, byte[] endpointHash, byte[] label) + { + ValidateSeatLabel(label); + store.Write(tx => + { + var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); + RequireOwner(principal); + if (tx.GetSeat(id, endpointHash) is null) throw new WebDropException(ErrorCode.NotFound); + tx.SetSeatLabel(id, endpointHash, label); + return 0; + }); + events.SeatsChanged(id); + } + + private void ValidateSeatLabel(byte[] label) + { + if (label.Length == 0) throw new WebDropException(ErrorCode.BadRequest); + if (label.Length > limits.MaxSeatLabelBytes) throw new WebDropException(ErrorCode.TooLarge); + } + public void ReleaseSeat(Id16 id, Credentials creds, byte[] endpointHash) { store.Write(tx => @@ -149,6 +189,7 @@ public sealed class ChannelService( var (_, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal); if (!tx.DeleteSeat(id, endpointHash)) throw new WebDropException(ErrorCode.NotFound); + tx.DenyEndpoint(id, endpointHash); return 0; }); events.SeatsChanged(id); @@ -158,12 +199,10 @@ public sealed class ChannelService( public async Task AppendFrameAsync( Id16 channelId, Credentials creds, ReadOnlyMemory body, - long? maxDownloads, string? label, CancellationToken ct = default) + long? maxDownloads, CancellationToken ct = default) { if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest); if (env.PayloadLength > limits.MaxPayloadBytes) throw new WebDropException(ErrorCode.TooLarge); - if (label is not null && System.Text.Encoding.UTF8.GetByteCount(label) > limits.MaxLabelBytes) - throw new WebDropException(ErrorCode.BadRequest); ValidateShape(env); byte[] digest = SHA256.HashData(body.Span); @@ -189,7 +228,7 @@ public sealed class ChannelService( AppendOutcome outcome; try { - outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads, label)); + outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads)); } catch { @@ -212,7 +251,7 @@ public sealed class ChannelService( private AppendOutcome AppendInTx( IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory body, - byte[] digest, string? blobKey, long? maxDownloads, string? label) + byte[] digest, string? blobKey, long? maxDownloads) { var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required); CheckWritable(channel, principal, env); @@ -227,10 +266,11 @@ public sealed class ChannelService( if (env.FrameIndex == 0) { + if (env.IsControl && tx.CountControlItems(channelId) >= 4096) throw new WebDropException(ErrorCode.TooLarge); if (item is not null) throw new WebDropException(ErrorCode.BadRequest); long max = env.IsGated ? Math.Clamp(maxDownloads ?? channel.Settings.FileMaxDownloads, 0, 1_000_000) : 0; item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0, - now, now + ttlMs, null, label); + now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName); tx.InsertItem(item); } else @@ -305,6 +345,7 @@ public sealed class ChannelService( private void ValidateShape(Envelope env) { + if (env.IsControl && (env.IsGated || env.IsSnapshot || env.FrameIndex != 0 || !env.IsFinal || env.PayloadLength > 16_400)) throw new WebDropException(ErrorCode.BadRequest); // 非 GATED 条目只能是单帧且必须 FINAL;快照只能是非 GATED 的单帧 if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest); if (env.IsSnapshot && env.IsGated) throw new WebDropException(ErrorCode.BadRequest); @@ -541,9 +582,16 @@ public sealed class ChannelService( if (channel is null || channel.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.NotFound); var role = VerifyRole(channel, creds); - if (seat == SeatRule.Required && tx.GetSeat(id, creds.EndpointHash) is null) - throw new WebDropException(ErrorCode.NotJoined); - return (channel, new Principal(role, creds.EndpointHash)); + var member = creds.AccountName is null ? null : tx.GetChannelMember(id, creds.AccountName); + if (role == Role.Owner && member?.Role != "owner") throw new WebDropException(ErrorCode.AccountRequired); + if (seat == SeatRule.Required) + { + var endpoint = tx.GetSeat(id, creds.EndpointHash); + if (endpoint is null) throw new WebDropException(ErrorCode.NotJoined); + if (endpoint.AccountName is not null && (endpoint.AccountName != creds.AccountName || member is null)) throw new WebDropException(ErrorCode.AccountRequired); + if (endpoint.AccountDeviceId is not null && endpoint.AccountDeviceId != creds.AccountDeviceId) throw new WebDropException(ErrorCode.AccountRequired); + } + return (channel, new Principal(role, creds.EndpointHash, creds.AccountName)); } private static Role VerifyRole(Channel channel, Credentials creds) @@ -573,6 +621,8 @@ public sealed class ChannelService( private static ChannelInfo ToInfo(Channel channel, int seatsUsed) => new() { Id = channel.Id.ToBase64Url(), + CreatorAccount = channel.CreatorAccount, + PasswordRequired = channel.JoinPasswordHash is not null, Mode = channel.Mode, Settings = channel.Settings, SeatsUsed = seatsUsed, diff --git a/server/src/WebDrop.Core/Dtos.cs b/server/src/WebDrop.Core/Dtos.cs index f441d54..7e49ca2 100644 --- a/server/src/WebDrop.Core/Dtos.cs +++ b/server/src/WebDrop.Core/Dtos.cs @@ -5,6 +5,8 @@ namespace WebDrop.Core; public sealed class CreateChannelRequest { + [JsonPropertyName("key_package")] public string? KeyPackage { get; init; } + [JsonPropertyName("join_password")] public string? JoinPassword { get; init; } [JsonPropertyName("id")] public string? Id { get; init; } [JsonPropertyName("mode")] public ChannelMode? Mode { get; init; } [JsonPropertyName("settings")] public ChannelSettings? Settings { get; init; } @@ -40,6 +42,7 @@ public sealed class ShareInfo public sealed class ShareItemInfo { + [JsonPropertyName("complete")] public bool Complete { get; init; } [JsonPropertyName("item_id")] public required string ItemId { get; init; } [JsonPropertyName("bytes")] public required long Bytes { get; init; } [JsonPropertyName("frames")] public required int Frames { get; init; } @@ -49,6 +52,8 @@ public sealed class ShareItemInfo public sealed class ChannelInfo { + [JsonPropertyName("password_required")] public bool PasswordRequired { get; init; } + [JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; } [JsonPropertyName("id")] public required string Id { get; init; } [JsonPropertyName("mode")] public required ChannelMode Mode { get; init; } [JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; } @@ -72,6 +77,7 @@ public sealed class SeatJoined public sealed class SeatInfo { + [JsonPropertyName("account_name")] public string? AccountName { get; init; } [JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; } [JsonPropertyName("joined_at")] public required long JoinedAt { get; init; } [JsonPropertyName("label")] public string? Label { get; init; } diff --git a/server/src/WebDrop.Core/Enums.cs b/server/src/WebDrop.Core/Enums.cs index e380a3b..3b098d4 100644 --- a/server/src/WebDrop.Core/Enums.cs +++ b/server/src/WebDrop.Core/Enums.cs @@ -34,6 +34,7 @@ public enum FrameFlags : byte Snapshot = 2, Gated = 4, Enc = 8, + Control = 16, } public enum LogKind : byte diff --git a/server/src/WebDrop.Core/Envelope.cs b/server/src/WebDrop.Core/Envelope.cs index 0214297..4b6b7a3 100644 --- a/server/src/WebDrop.Core/Envelope.cs +++ b/server/src/WebDrop.Core/Envelope.cs @@ -17,7 +17,7 @@ public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint Frame if (data.Length < HeaderSize || data[0] != Version) return false; byte flags = data[1]; - if ((flags & 0xF0) != 0) return false; + if ((flags & 0xE0) != 0) return false; uint payloadLen = BinaryPrimitives.ReadUInt32BigEndian(data[22..]); if (payloadLen > int.MaxValue || data.Length != HeaderSize + (int)payloadLen) return false; @@ -34,9 +34,10 @@ public readonly record struct Envelope(FrameFlags Flags, Id16 ItemId, uint Frame public bool IsGated => (Flags & FrameFlags.Gated) != 0; public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0; public bool IsEnc => (Flags & FrameFlags.Enc) != 0; + public bool IsControl => (Flags & FrameFlags.Control) != 0; /// 条目级标志:同一条目的所有帧必须一致。 - public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated); + public FrameFlags ItemFlags => Flags & (FrameFlags.Snapshot | FrameFlags.Gated | FrameFlags.Control); } public static class PayloadTiers diff --git a/server/src/WebDrop.Core/Errors.cs b/server/src/WebDrop.Core/Errors.cs index e138d35..ee17952 100644 --- a/server/src/WebDrop.Core/Errors.cs +++ b/server/src/WebDrop.Core/Errors.cs @@ -17,6 +17,10 @@ public enum ErrorCode RateLimited, VisitorsFull, Sealed, + AccountRequired, + InvitationRequired, + ChannelPasswordRequired, + ChannelPasswordInvalid, } /// 预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。 @@ -53,6 +57,10 @@ public static class ErrorCodes ErrorCode.RateLimited => "rate_limited", ErrorCode.VisitorsFull => "visitors_full", ErrorCode.Sealed => "sealed", + ErrorCode.AccountRequired => "account_required", + ErrorCode.InvitationRequired => "invitation_required", + ErrorCode.ChannelPasswordRequired => "channel_password_required", + ErrorCode.ChannelPasswordInvalid => "channel_password_invalid", _ => "bad_request", }; @@ -70,6 +78,8 @@ public static class ErrorCodes ErrorCode.CaptchaUnavailable => 503, ErrorCode.RateLimited => 429, ErrorCode.VisitorsFull => 403, + ErrorCode.AccountRequired => 401, + ErrorCode.InvitationRequired or ErrorCode.ChannelPasswordRequired or ErrorCode.ChannelPasswordInvalid => 403, _ => 400, }; } diff --git a/server/src/WebDrop.Core/Models.cs b/server/src/WebDrop.Core/Models.cs index dd89701..6fad787 100644 --- a/server/src/WebDrop.Core/Models.cs +++ b/server/src/WebDrop.Core/Models.cs @@ -11,7 +11,10 @@ public sealed record Channel( long ExpiresAt, long LastSeq, long CompactedThrough, - long Bytes); + long Bytes, + string CreatorAccount, + byte[]? JoinPasswordHash = null, + byte[]? JoinPasswordSalt = null); public sealed record Share( Id16 Id, @@ -23,7 +26,8 @@ public sealed record Share( long ExpiresAt, long? SealedAt, Id16? ManifestItemId, - long Bytes) + long Bytes, + string CreatorAccount) { public bool IsSealed => SealedAt is not null; } @@ -71,14 +75,17 @@ public sealed record Item( long CreatedAt, long ExpiresAt, long? CloseDeadline, - string? Label) + string? AuthorAccount) { public bool IsGated => (Flags & FrameFlags.Gated) != 0; public bool IsSnapshot => (Flags & FrameFlags.Snapshot) != 0; public bool IsComplete => FinalIndex is { } f && FrameCount == f + 1; } -public sealed record Seat(byte[] EndpointHash, long JoinedAt, byte[]? Label); +public sealed record Seat(byte[] EndpointHash, long JoinedAt, byte[]? Label, string? AccountName = null, string? AccountDeviceId = null); +public sealed record ChannelInvitation(Id16 ChannelId, string Username, string Payload, long CreatedAt); +public sealed record ChannelMember(string Username, string Role, long JoinedAt); +public sealed record ChannelKeyPackage(Id16 ChannelId, string Username, string Role, string Payload, long CreatedAt); /// 已存储的一帧(完整封装)。Inline 与 BlobKey 二选一。 public sealed record StoredFrame( @@ -94,7 +101,7 @@ public sealed record StoredFrame( public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameIndex, byte Aux, long CreatedAt); /// 一次请求验证通过后的身份。 -public sealed record Principal(Role Role, byte[] EndpointHash); +public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null); /// 请求携带的凭据,尚未验证。 -public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash); +public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null); diff --git a/server/src/WebDrop.Core/Settings.cs b/server/src/WebDrop.Core/Settings.cs index 876cf3a..2c03fed 100644 --- a/server/src/WebDrop.Core/Settings.cs +++ b/server/src/WebDrop.Core/Settings.cs @@ -15,22 +15,26 @@ public sealed class ServerLimits public int InlineThresholdBytes { get; set; } = 64 * 1024; public int DownloadGraceSeconds { get; set; } = 300; public int TombstoneRetentionSeconds { get; set; } = 604_800; - public int MaxLabelBytes { get; set; } = 255; public int MaxSeatLabelBytes { get; set; } = 256; } public sealed class ChannelSettings { - [JsonPropertyName("seats")] public int Seats { get; init; } = 2; - [JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; init; } = true; - [JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; init; } = DeletePolicy.Own; - [JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400; - [JsonPropertyName("max_items")] public int MaxItems { get; init; } = 1000; - [JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10; - [JsonPropertyName("ui_restrict_copy_download")] public bool UiRestrictCopyDownload { get; init; } + [JsonPropertyName("join_policy")] public string JoinPolicy { get; set; } = "open"; + [JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true; + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("seats")] public int Seats { get; set; } = 2; + [JsonPropertyName("members_can_upload")] public bool MembersCanUpload { get; set; } = true; + [JsonPropertyName("delete_policy")] public DeletePolicy DeletePolicy { get; set; } = DeletePolicy.Own; + [JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400; + [JsonPropertyName("max_items")] public int MaxItems { get; set; } = 1000; + [JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; set; } = 10; + [JsonPropertyName("ui_restrict_copy_download")] public bool UiRestrictCopyDownload { get; set; } public void Validate(ServerLimits limits) { + if (JoinPolicy is not ("open" or "invite")) throw new WebDropException(ErrorCode.BadRequest); + if (Name is { } name && (name.Length > 48 || name.Any(char.IsControl))) throw new WebDropException(ErrorCode.BadRequest); if (Seats < 1 || Seats > limits.MaxSeats) throw new WebDropException(ErrorCode.BadRequest); if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds) throw new WebDropException(ErrorCode.BadRequest); if (MaxItems < 1 || MaxItems > limits.MaxItemsLimit) throw new WebDropException(ErrorCode.BadRequest); @@ -40,6 +44,10 @@ public sealed class ChannelSettings public sealed class ChannelSettingsPatch { + [JsonPropertyName("join_policy")] public string? JoinPolicy { get; init; } + [JsonPropertyName("allow_guests")] public bool? AllowGuests { get; init; } + [JsonPropertyName("join_password")] public string? JoinPassword { get; init; } + [JsonPropertyName("name")] public string? Name { get; init; } [JsonPropertyName("seats")] public int? Seats { get; init; } [JsonPropertyName("members_can_upload")] public bool? MembersCanUpload { get; init; } [JsonPropertyName("delete_policy")] public DeletePolicy? DeletePolicy { get; init; } @@ -50,6 +58,9 @@ public sealed class ChannelSettingsPatch public ChannelSettings ApplyTo(ChannelSettings s) => new() { + JoinPolicy = JoinPolicy ?? s.JoinPolicy, + AllowGuests = AllowGuests ?? s.AllowGuests, + Name = Name is null ? s.Name : string.IsNullOrWhiteSpace(Name) ? null : Name.Trim(), Seats = Seats ?? s.Seats, MembersCanUpload = MembersCanUpload ?? s.MembersCanUpload, DeletePolicy = DeletePolicy ?? s.DeletePolicy, @@ -62,9 +73,9 @@ public sealed class ChannelSettingsPatch public sealed class ShareSettings { - [JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; init; } = 86_400; - [JsonPropertyName("max_visitors")] public int MaxVisitors { get; init; } - [JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; init; } = 10; + [JsonPropertyName("ttl_seconds")] public int TtlSeconds { get; set; } = 86_400; + [JsonPropertyName("max_visitors")] public int MaxVisitors { get; set; } + [JsonPropertyName("file_max_downloads")] public int FileMaxDownloads { get; set; } = 10; public void Validate(ServerLimits limits) { diff --git a/server/src/WebDrop.Core/ShareService.cs b/server/src/WebDrop.Core/ShareService.cs index ff9b65e..b422344 100644 --- a/server/src/WebDrop.Core/ShareService.cs +++ b/server/src/WebDrop.Core/ShareService.cs @@ -10,8 +10,9 @@ public sealed class ShareService( ServerLimits limits, int draftTtlSeconds) { - public ShareInfo Create(CreateShareRequest req, byte[]? creatorEndpointHash) + public ShareInfo Create(CreateShareRequest req, byte[]? creatorEndpointHash, string creatorAccount) { + if (string.IsNullOrWhiteSpace(creatorAccount)) throw new WebDropException(ErrorCode.AccountRequired); if (!Id16.TryParse(req.Id, out var id) || !Codec.TryDecodeExact(req.ReadHash, TokenHash.Size, out var readHash) || !Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash)) @@ -21,7 +22,7 @@ public sealed class ShareService( settings.Validate(limits); long now = clock.NowMs; var share = new Share(id, settings, readHash, ownerHash, creatorEndpointHash, now, - now + Math.Max(60, draftTtlSeconds) * 1000L, null, null, 0); + now + Math.Max(60, draftTtlSeconds) * 1000L, null, null, 0, creatorAccount); store.Write(tx => { @@ -48,6 +49,7 @@ public sealed class ShareService( return ToInfo(share, tx.ListShareItems(id)); }); + public ShareInfo UpdateSettings(Id16 id, Credentials creds, ShareSettingsPatch patch) => store.Write(tx => { var share = Draft(tx.GetShare(id), creds); @@ -66,7 +68,7 @@ public sealed class ShareService( throw new WebDropException(ErrorCode.TooLarge); if (!env.IsEnc) throw new WebDropException(ErrorCode.ModeMismatch); if (!PayloadTiers.IsValidEncPayloadLength(env.PayloadLength)) throw new WebDropException(ErrorCode.BadRequest); - if (env.IsSnapshot) throw new WebDropException(ErrorCode.BadRequest); + if (env.IsSnapshot || env.IsControl) throw new WebDropException(ErrorCode.BadRequest); if (!env.IsGated && (env.FrameIndex != 0 || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest); if (maxDownloads is < 0) throw new WebDropException(ErrorCode.BadRequest); @@ -198,7 +200,7 @@ public sealed class ShareService( var frame = store.Write(tx => { var share = SealedForRead(tx.GetShare(id), creds); - RegisterVisitor(tx, share, creds.EndpointHash); + RegisterVisitor(tx, share, creds.EndpointHash, creds.AccountName); var manifestId = share.ManifestItemId ?? throw new WebDropException(ErrorCode.NotFound); return tx.GetShareFrame(id, manifestId, 0) ?? throw new WebDropException(ErrorCode.Gone); }); @@ -217,7 +219,7 @@ public sealed class ShareService( long now = clock.NowMs; if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline)) throw new WebDropException(ErrorCode.Gone); - RegisterVisitor(tx, share, creds.EndpointHash); + RegisterVisitor(tx, share, creds.EndpointHash, creds.AccountName); if (fromIndex == 0) { if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) throw new WebDropException(ErrorCode.Gone); @@ -310,6 +312,8 @@ public sealed class ShareService( private void RequireOwner(Share share, Credentials creds) { + if (share.CreatorAccount != creds.AccountName) + throw new WebDropException(ErrorCode.AccountRequired); if (creds.ClaimedRole != Role.Owner || !TokenHash.Matches(creds.Token ?? [], share.OwnerHash)) throw new WebDropException(ErrorCode.Unauthorized); } @@ -320,8 +324,9 @@ public sealed class ShareService( return candidate; } - private void RegisterVisitor(IResourceTx tx, Share share, byte[] endpointHash) + private void RegisterVisitor(IResourceTx tx, Share share, byte[] endpointHash, string? accountName) { + if (accountName == share.CreatorAccount) return; if (share.CreatorEndpointHash is { } creator && CryptographicOperations.FixedTimeEquals(creator, endpointHash)) return; if (tx.HasShareVisitor(share.Id, endpointHash)) return; if (share.Settings.MaxVisitors > 0 && tx.CountShareVisitors(share.Id) >= share.Settings.MaxVisitors) @@ -360,6 +365,7 @@ public sealed class ShareService( ItemId = i.ItemId.ToBase64Url(), Bytes = i.Bytes, Frames = i.FrameCount, + Complete = i.IsComplete, MaxDownloads = i.MaxDownloads, DownloadsLeft = i.MaxDownloads == 0 ? null : Math.Max(0, i.MaxDownloads - i.Downloads), }).ToList(), diff --git a/server/src/WebDrop.Storage/SqliteAccountStore.cs b/server/src/WebDrop.Storage/SqliteAccountStore.cs new file mode 100644 index 0000000..9e15b1f --- /dev/null +++ b/server/src/WebDrop.Storage/SqliteAccountStore.cs @@ -0,0 +1,236 @@ +using System.Security.Cryptography; +using Microsoft.Data.Sqlite; + +namespace WebDrop.Storage; + +/// Account authentication and opaque client-encrypted vault storage. +public sealed class SqliteAccountStore : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly object _gate = new(); + + public SqliteAccountStore(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + _connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path, Pooling = true }.ToString()); + _connection.Open(); + using var cmd = _connection.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS accounts (username TEXT PRIMARY KEY COLLATE NOCASE, salt BLOB NOT NULL, verifier BLOB NOT NULL, verifier_salt BLOB NOT NULL, public_key TEXT NOT NULL, vault BLOB NOT NULL, revision INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS account_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, expires INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS account_devices (username TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL, PRIMARY KEY(username,id)); + CREATE TABLE IF NOT EXISTS account_device_sessions (token_hash BLOB PRIMARY KEY, username TEXT NOT NULL, device_id TEXT NOT NULL); + """; + cmd.ExecuteNonQuery(); + } + + private static byte[] HashVerifier(byte[] verifier, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(verifier, salt, 210_000, HashAlgorithmName.SHA256, 32); + + public byte[] LoginSalt(string username) + { + lock (_gate) + { + using (var cleanup = _connection.CreateCommand()) + { + cleanup.CommandText = "DELETE FROM account_sessions WHERE expires<=$n; DELETE FROM account_device_sessions WHERE token_hash NOT IN (SELECT token_hash FROM account_sessions);"; + cleanup.Parameters.AddWithValue("$n", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + cleanup.ExecuteNonQuery(); + } + using var q = _connection.CreateCommand(); + q.CommandText = "SELECT salt FROM accounts WHERE username=$u"; + q.Parameters.AddWithValue("$u", username); + var value = q.ExecuteScalar(); + byte[] salt = value is byte[] found ? found : RandomNumberGenerator.GetBytes(16); + return salt; + } + } + + public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey) + { + lock (_gate) + { + try + { + using var cmd = _connection.CreateCommand(); + var verifierSalt = RandomNumberGenerator.GetBytes(16); + cmd.CommandText = "INSERT INTO accounts(username,salt,verifier,vault,revision,public_key,verifier_salt) VALUES($u,$s,$v,$b,1,$p,$vs)"; + cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$s", salt); + cmd.Parameters.AddWithValue("$v", HashVerifier(verifier, verifierSalt)); cmd.Parameters.AddWithValue("$b", vault); + cmd.Parameters.AddWithValue("$p", publicKey); cmd.Parameters.AddWithValue("$vs", verifierSalt); + cmd.ExecuteNonQuery(); return true; + } + catch (SqliteException e) when (e.SqliteErrorCode == 19) { return false; } + } + } + + public string? Authenticate(string username, byte[] suppliedVerifier, string? deviceId = null, string? deviceName = null) + { + lock (_gate) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT verifier,verifier_salt FROM accounts WHERE username=$u"; + cmd.Parameters.AddWithValue("$u", username); + using var r = cmd.ExecuteReader(); + bool found = r.Read(); + var verifier = found ? (byte[])r[0] : new byte[32]; + var verifierSalt = found ? (byte[])r[1] : new byte[16]; + r.Close(); + var expected = HashVerifier(suppliedVerifier, verifierSalt); + if (!CryptographicOperations.FixedTimeEquals(verifier, expected) || !found) return null; + if (deviceId is not null && (!Guid.TryParseExact(deviceId, "D", out _) || deviceName is null || deviceName.Length is < 1 or > 80 || deviceName.Any(char.IsControl))) return null; + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + using var transaction = _connection.BeginTransaction(); + using var add = _connection.CreateCommand(); + add.Transaction = transaction; + add.CommandText = "INSERT INTO account_sessions(token_hash,username,expires) VALUES($h,$u,$e)"; + add.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token))); + add.Parameters.AddWithValue("$u", username); add.Parameters.AddWithValue("$e", DateTimeOffset.UtcNow.AddDays(30).ToUnixTimeSeconds()); + add.ExecuteNonQuery(); + if (deviceId is not null) + { + using var device = _connection.CreateCommand(); + device.Transaction = transaction; + device.CommandText = """ + INSERT INTO account_devices(username,id,name,created_at,last_seen) VALUES($u,$d,$name,$now,$now) + ON CONFLICT(username,id) DO UPDATE SET name=excluded.name,last_seen=excluded.last_seen; + INSERT INTO account_device_sessions(token_hash,username,device_id) VALUES($h,$u,$d); + """; + device.Parameters.AddWithValue("$u", username); device.Parameters.AddWithValue("$d", deviceId); + device.Parameters.AddWithValue("$name", deviceName!); device.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + device.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token))); + device.ExecuteNonQuery(); + } + transaction.Commit(); + return token; + } + } + + public bool HasSession(string token, out string username) + { + username = ""; + if (token.Length != 44) return false; + byte[] raw; + try { raw = Convert.FromBase64String(token); } catch (FormatException) { return false; } + if (raw.Length != 32) return false; + lock (_gate) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT username FROM account_sessions WHERE token_hash=$h AND expires>$n"; + cmd.Parameters.AddWithValue("$h", SHA256.HashData(raw)); cmd.Parameters.AddWithValue("$n", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + var found = cmd.ExecuteScalar(); if (found is not string name) return false; + username = name; + using var seen = _connection.CreateCommand(); + seen.CommandText = """ + UPDATE account_devices SET last_seen=$now WHERE username=$u AND last_seen<$cutoff + AND id=(SELECT device_id FROM account_device_sessions WHERE token_hash=$h) + """; + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + seen.Parameters.AddWithValue("$now", now); seen.Parameters.AddWithValue("$cutoff", now - 60_000); + seen.Parameters.AddWithValue("$u", name); seen.Parameters.AddWithValue("$h", SHA256.HashData(raw)); seen.ExecuteNonQuery(); + return true; + } + } + + public void RevokeSession(string token) + { + if (token.Length != 44) return; + byte[] raw; + try { raw = Convert.FromBase64String(token); } catch (FormatException) { return; } + lock (_gate) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "DELETE FROM account_sessions WHERE token_hash=$h; DELETE FROM account_device_sessions WHERE token_hash=$h;"; + cmd.Parameters.AddWithValue("$h", SHA256.HashData(raw)); + cmd.ExecuteNonQuery(); + } + } + + public (byte[] Vault, long Revision)? GetVault(string username) + { + lock (_gate) + { + using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT vault,revision FROM accounts WHERE username=$u"; cmd.Parameters.AddWithValue("$u", username); + using var r = cmd.ExecuteReader(); return r.Read() ? ((byte[])r[0], r.GetInt64(1)) : null; + } + } + + public long? PutVault(string username, byte[] vault, long revision) + { + lock (_gate) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "UPDATE accounts SET vault=$v,revision=revision+1 WHERE username=$u AND revision=$r RETURNING revision"; + cmd.Parameters.AddWithValue("$v", vault); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$r", revision); + var updated = cmd.ExecuteScalar(); return updated is long value ? value : null; + } + } + + public void Dispose() { _connection.Dispose(); SqliteConnection.ClearPool(_connection); } + + public string? SessionDevice(string token) + { + if (!HasSession(token, out _)) return null; + lock (_gate) + { + using var q = _connection.CreateCommand(); + q.CommandText = "SELECT device_id FROM account_device_sessions WHERE token_hash=$h"; + q.Parameters.AddWithValue("$h", SHA256.HashData(Convert.FromBase64String(token))); + return q.ExecuteScalar() as string; + } + } + + public IReadOnlyList Devices(string username, string? currentId) + { + lock (_gate) + { + using var q = _connection.CreateCommand(); + q.CommandText = """ + SELECT d.id,d.name,d.created_at,d.last_seen FROM account_devices d + WHERE d.username=$u AND EXISTS (SELECT 1 FROM account_device_sessions ds JOIN account_sessions s ON s.token_hash=ds.token_hash + WHERE ds.username=d.username AND ds.device_id=d.id AND s.expires>$now) + ORDER BY d.last_seen DESC + """; + q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$now", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + using var r = q.ExecuteReader(); var result = new List(); + while (r.Read()) result.Add(new(r.GetString(0), r.GetString(1), r.GetInt64(2), r.GetInt64(3), r.GetString(0) == currentId)); + return result; + } + } + + public bool RenameDevice(string username, string id, string name) + { + if (name.Length is < 1 or > 80 || name.Any(char.IsControl)) return false; + lock (_gate) + { + using var q = _connection.CreateCommand(); q.CommandText = "UPDATE account_devices SET name=$n WHERE username=$u AND id=$d"; + q.Parameters.AddWithValue("$n", name); q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$d", id); + return q.ExecuteNonQuery() > 0; + } + } + + public void RevokeDevice(string username, string id) + { + lock (_gate) + { + using var q = _connection.CreateCommand(); + q.CommandText = """ + DELETE FROM account_sessions WHERE token_hash IN (SELECT token_hash FROM account_device_sessions WHERE username=$u AND device_id=$d); + DELETE FROM account_device_sessions WHERE username=$u AND device_id=$d; + DELETE FROM account_devices WHERE username=$u AND id=$d; + """; + q.Parameters.AddWithValue("$u", username); q.Parameters.AddWithValue("$d", id); q.ExecuteNonQuery(); + } + } + + public string? GetPublicKey(string username) + { + lock (_gate) + { + using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT public_key FROM accounts WHERE username=$u"; + cmd.Parameters.AddWithValue("$u", username); return cmd.ExecuteScalar() as string; + } + } + +} + +public sealed record AccountDevice(string Id, string Name, long CreatedAt, long LastSeen, bool Current); diff --git a/server/src/WebDrop.Storage/SqliteChannelStore.cs b/server/src/WebDrop.Storage/SqliteChannelStore.cs index ca8951c..7b7419b 100644 --- a/server/src/WebDrop.Storage/SqliteChannelStore.cs +++ b/server/src/WebDrop.Storage/SqliteChannelStore.cs @@ -10,7 +10,7 @@ namespace WebDrop.Storage; /// public sealed class SqliteChannelStore : IResourceStore, IDisposable { - private const int SchemaVersion = 3; + private const int SchemaVersion = 1; private readonly string _connectionString; private readonly SqliteConnection _writeConn; @@ -31,7 +31,20 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable _writeConn = Open(); Exec(_writeConn, "PRAGMA journal_mode=WAL;"); - Migrate(); + InitializeSchema(); + using (var columns = _writeConn.CreateCommand()) + { + columns.CommandText = "SELECT COUNT(*) FROM pragma_table_info('seats') WHERE name='account_device_id'"; + if ((long)columns.ExecuteScalar()! == 0) Exec(_writeConn, "ALTER TABLE seats ADD COLUMN account_device_id TEXT;"); + } + // Additive table: retain the running v2 database and its existing channel data. + Exec(_writeConn, """ + CREATE TABLE IF NOT EXISTS channel_key_packages ( + channel_id BLOB NOT NULL, username TEXT NOT NULL, payload TEXT NOT NULL, + created_at INTEGER NOT NULL, PRIMARY KEY (channel_id, username) + ) WITHOUT ROWID; + CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username); + """); } public T Read(Func action) @@ -65,7 +78,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable public void Dispose() { _writeConn.Dispose(); - SqliteConnection.ClearAllPools(); + SqliteConnection.ClearPool(_writeConn); } private SqliteConnection Open() @@ -83,23 +96,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable cmd.ExecuteNonQuery(); } - private void Migrate() + private void InitializeSchema() { using var cmd = _writeConn.CreateCommand(); cmd.CommandText = "PRAGMA user_version;"; long version = (long)cmd.ExecuteScalar()!; - if (version > SchemaVersion) throw new InvalidOperationException("数据库版本高于本程序支持的版本"); if (version == SchemaVersion) return; - + if (version != 0) throw new InvalidOperationException("存储版本不兼容当前应用契约"); using var tx = _writeConn.BeginTransaction(); - if (version == 0) Exec(_writeConn, SchemaV1); - if (version < 2) Exec(_writeConn, SchemaV2); - if (version < 3) Exec(_writeConn, SchemaV3); + Exec(_writeConn, Schema); Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};"); tx.Commit(); } - - private const string SchemaV1 = """ + private const string Schema = """ CREATE TABLE channels ( id BLOB NOT NULL PRIMARY KEY, mode INTEGER NOT NULL, @@ -111,7 +120,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable expires_at INTEGER NOT NULL, last_seq INTEGER NOT NULL DEFAULT 0, compacted_through INTEGER NOT NULL DEFAULT 0, - bytes INTEGER NOT NULL DEFAULT 0 + bytes INTEGER NOT NULL DEFAULT 0, + creator_account TEXT NOT NULL, + join_password_hash BLOB, + join_password_salt BLOB ); CREATE INDEX ix_channels_expires ON channels(expires_at); @@ -119,6 +131,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable channel_id BLOB NOT NULL, endpoint_hash BLOB NOT NULL, label BLOB, + account_name TEXT, joined_at INTEGER NOT NULL, PRIMARY KEY (channel_id, endpoint_hash) ) WITHOUT ROWID; @@ -127,6 +140,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable channel_id BLOB NOT NULL, item_id BLOB NOT NULL, author_hash BLOB NOT NULL, + author_account TEXT, flags INTEGER NOT NULL, max_downloads INTEGER NOT NULL, downloads INTEGER NOT NULL, @@ -137,7 +151,6 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, close_deadline INTEGER, - label TEXT, PRIMARY KEY (channel_id, item_id) ); CREATE INDEX ix_items_channel_created ON items(channel_id, created_at); @@ -168,9 +181,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable PRIMARY KEY (channel_id, seq) ) WITHOUT ROWID; CREATE INDEX ix_log_item ON log(channel_id, item_id); - """; - private const string SchemaV2 = """ CREATE TABLE shares ( id BLOB NOT NULL PRIMARY KEY, settings TEXT NOT NULL, @@ -181,7 +192,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable expires_at INTEGER NOT NULL, sealed_at INTEGER, manifest_item_id BLOB, - bytes INTEGER NOT NULL DEFAULT 0 + bytes INTEGER NOT NULL DEFAULT 0, + creator_account TEXT NOT NULL ); CREATE INDEX ix_shares_expires ON shares(expires_at); @@ -222,9 +234,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable PRIMARY KEY (share_id, item_id, frame_index) ) WITHOUT ROWID; CREATE INDEX ix_share_frames_blob ON share_frames(blob_key) WHERE blob_key IS NOT NULL; - """; - private const string SchemaV3 = """ CREATE TABLE download_grants ( resource_id BLOB NOT NULL, item_id BLOB NOT NULL, @@ -232,12 +242,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable PRIMARY KEY (resource_id, item_id, endpoint_hash) ) WITHOUT ROWID; CREATE INDEX ix_download_grants_resource ON download_grants(resource_id); + + CREATE TABLE channel_invitations(channel_id BLOB NOT NULL,username TEXT NOT NULL,payload TEXT NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username)); + CREATE INDEX ix_channel_invitations_username ON channel_invitations(username); + + CREATE TABLE channel_join_denials(channel_id BLOB NOT NULL,target TEXT NOT NULL,PRIMARY KEY(channel_id,target)); + CREATE TABLE channel_members(channel_id BLOB NOT NULL,username TEXT NOT NULL,role TEXT NOT NULL,joined_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username)); """; private sealed class SqliteTx(SqliteConnection conn) : IResourceTx { - private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes"; - private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, label"; + private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes, creator_account, join_password_hash, join_password_salt"; + private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, author_account"; private const string FrameCols = "channel_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key"; private const string ShareItemCols = "share_id, item_id, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline"; private const string ShareFrameCols = "share_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key"; @@ -291,10 +307,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable IdAt(r, 0), JsonSerializer.Deserialize(r.GetString(1), CoreJsonContext.Default.ShareSettings)!, Blob(r, 2), BlobOrNull(r, 3), BlobOrNull(r, 4), r.GetInt64(5), r.GetInt64(6), - LongOrNull(r, 7), r.IsDBNull(8) ? null : IdAt(r, 8), r.GetInt64(9)); + LongOrNull(r, 7), r.IsDBNull(8) ? null : IdAt(r, 8), r.GetInt64(9), r.GetString(10)); public Share? GetShare(Id16 id) => One( - "SELECT id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes FROM shares WHERE id=@id", + "SELECT id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes,creator_account FROM shares WHERE id=@id", MapShare, ("@id", id.ToArray())); public bool HasResourceId(Id16 id) => Scalar( @@ -302,17 +318,17 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable ("@id", id.ToArray())) is not null; public void InsertShare(Share s) => Run( - "INSERT INTO shares (id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes) VALUES (@id,@settings,@read,@owner,@creator,@created,@expires,@sealed,@manifest,@bytes)", + "INSERT INTO shares (id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes,creator_account) VALUES (@id,@settings,@read,@owner,@creator,@created,@expires,@sealed,@manifest,@bytes,@account)", ("@id", s.Id.ToArray()), ("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)), ("@read", s.ReadHash), ("@owner", s.OwnerHash), ("@creator", s.CreatorEndpointHash), ("@created", s.CreatedAt), ("@expires", s.ExpiresAt), ("@sealed", s.SealedAt), - ("@manifest", s.ManifestItemId?.ToArray()), ("@bytes", s.Bytes)); + ("@manifest", s.ManifestItemId?.ToArray()), ("@bytes", s.Bytes), ("@account", s.CreatorAccount)); public void UpdateShare(Share s) => Run( - "UPDATE shares SET settings=@settings,owner_hash=@owner,expires_at=@expires,sealed_at=@sealed,manifest_item_id=@manifest,bytes=@bytes WHERE id=@id", + "UPDATE shares SET settings=@settings,owner_hash=@owner,expires_at=@expires,sealed_at=@sealed,manifest_item_id=@manifest,bytes=@bytes,creator_account=@account WHERE id=@id", ("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)), ("@owner", s.OwnerHash), ("@expires", s.ExpiresAt), ("@sealed", s.SealedAt), ("@manifest", s.ManifestItemId?.ToArray()), - ("@bytes", s.Bytes), ("@id", s.Id.ToArray())); + ("@bytes", s.Bytes), ("@id", s.Id.ToArray()), ("@account", s.CreatorAccount)); public IReadOnlyList ListExpiredShares(long now, int limit) => Many( "SELECT id FROM shares WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit)); @@ -400,18 +416,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable BlobOrNull(r, 3), BlobOrNull(r, 4), Blob(r, 5), - r.GetInt64(6), r.GetInt64(7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10)); + r.GetInt64(6), r.GetInt64(7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10), r.GetString(11), BlobOrNull(r, 12), BlobOrNull(r, 13)); public Channel? GetChannel(Id16 id) => One($"SELECT {ChannelCols} FROM channels WHERE id=@id", MapChannel, ("@id", id.ToArray())); public void InsertChannel(Channel c) => Run( - $"INSERT INTO channels ({ChannelCols}) VALUES (@id,@mode,@settings,@rh,@wh,@oh,@created,@expires,@seq,@comp,@bytes)", + $"INSERT INTO channels ({ChannelCols}) VALUES (@id,@mode,@settings,@rh,@wh,@oh,@created,@expires,@seq,@comp,@bytes,@account,@ph,@ps)", ("@id", c.Id.ToArray()), ("@mode", (int)c.Mode), ("@settings", JsonSerializer.Serialize(c.Settings, CoreJsonContext.Default.ChannelSettings)), ("@rh", c.ReadHash), ("@wh", c.WriteHash), ("@oh", c.OwnerHash), ("@created", c.CreatedAt), ("@expires", c.ExpiresAt), - ("@seq", c.LastSeq), ("@comp", c.CompactedThrough), ("@bytes", c.Bytes)); + ("@seq", c.LastSeq), ("@comp", c.CompactedThrough), ("@bytes", c.Bytes), ("@account", c.CreatorAccount), ("@ph", c.JoinPasswordHash), ("@ps", c.JoinPasswordSalt)); public void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt) => Run( "UPDATE channels SET settings=@s, expires_at=@e WHERE id=@id", @@ -421,6 +437,36 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable public void TouchChannel(Id16 id, long expiresAt) => Run("UPDATE channels SET expires_at=@e WHERE id=@id", ("@e", expiresAt), ("@id", id.ToArray())); + public void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt) => Run("UPDATE channels SET join_password_hash=@h,join_password_salt=@s WHERE id=@id", ("@h", hash), ("@s", salt), ("@id", id.ToArray())); + public bool HasInvitation(Id16 id, string username) => Scalar("SELECT 1 FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)) is not null; + public void PutInvitation(ChannelInvitation i) => Run("INSERT INTO channel_invitations(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at", ("@id", i.ChannelId.ToArray()), ("@u", i.Username), ("@p", i.Payload), ("@t", i.CreatedAt)); + private static ChannelInvitation MapInvitation(SqliteDataReader r) => new(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetInt64(3)); + public IReadOnlyList ListInvitations(Id16 id) => Many("SELECT channel_id,username,payload,created_at FROM channel_invitations WHERE channel_id=@id", MapInvitation, ("@id", id.ToArray())); + public IReadOnlyList ListUserInvitations(string username) => Many("SELECT i.channel_id,i.username,i.payload,i.created_at FROM channel_invitations i JOIN channels c ON c.id=i.channel_id WHERE i.username=@u AND c.expires_at>@now AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id=i.channel_id AND m.username=i.username)", MapInvitation, ("@u", username), ("@now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())); + public void DeleteInvitation(Id16 id, string username) => Run("DELETE FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)); + public void PutKeyPackage(Id16 id, string username, string payload, long createdAt) => Run( + "INSERT INTO channel_key_packages(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at", + ("@id", id.ToArray()), ("@u", username), ("@p", payload), ("@t", createdAt)); + public void DeleteKeyPackage(Id16 id, string username) => Run("DELETE FROM channel_key_packages WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)); + public IReadOnlyList ListUserKeyPackages(string username, long now) => Many( + """ + SELECT k.channel_id,k.username,m.role,k.payload,k.created_at FROM channel_key_packages k + JOIN channels c ON c.id=k.channel_id + JOIN channel_members m ON m.channel_id=k.channel_id AND m.username=k.username + WHERE k.username=@u AND c.expires_at>@now + """, r => new ChannelKeyPackage(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetInt64(4)), + ("@u", username), ("@now", now)); + public void DeleteAccountSeats(Id16 id, string username) => Run("DELETE FROM seats WHERE channel_id=@id AND account_name=@u", ("@id", id.ToArray()), ("@u", username)); + private static ChannelMember MapMember(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetInt64(2)); + public ChannelMember? GetChannelMember(Id16 id, string username) => One("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id AND username=@u", MapMember, ("@id", id.ToArray()), ("@u", username)); + public IReadOnlyList ListChannelMembers(Id16 id) => Many("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id ORDER BY joined_at,username", MapMember, ("@id", id.ToArray())); + public void PutChannelMember(Id16 id, ChannelMember member) => Run("INSERT OR IGNORE INTO channel_members(channel_id,username,role,joined_at) VALUES(@id,@u,@r,@t)", ("@id", id.ToArray()), ("@u", member.Username), ("@r", member.Role), ("@t", member.JoinedAt)); + public void DeleteChannelMember(Id16 id, string username) => Run("DELETE FROM channel_members WHERE channel_id=@id AND username=@u AND role<>'owner'", ("@id", id.ToArray()), ("@u", username)); + public bool IsJoinDenied(Id16 id, string? username, byte[] endpointHash) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND (target=@a OR target=@e) LIMIT 1", ("@id", id.ToArray()), ("@a", username is null ? null : "account:" + username), ("@e", "endpoint:" + Codec.Encode(endpointHash))) is not null; + public void DenyAccount(Id16 id, string username) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "account:" + username)); + public void AllowAccount(Id16 id, string username) => Run("DELETE FROM channel_join_denials WHERE channel_id=@id AND target=@t", ("@id", id.ToArray()), ("@t", "account:" + username)); + public void DenyEndpoint(Id16 id, byte[] endpointHash) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "endpoint:" + Codec.Encode(endpointHash))); + public void AddChannelBytes(Id16 id, long delta) => Run("UPDATE channels SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray())); @@ -439,6 +485,10 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable Run("DELETE FROM items WHERE channel_id=@c", ("@c", key)); Run("DELETE FROM seats WHERE channel_id=@c", ("@c", key)); Run("DELETE FROM channels WHERE id=@c", ("@c", key)); + Run("DELETE FROM channel_invitations WHERE channel_id=@c", ("@c", key)); + Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key)); + Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key)); + Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key)); } public IReadOnlyList ListExpiredChannels(long now, int limit) => @@ -446,18 +496,18 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable // ───────── 席位 ───────── - private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), BlobOrNull(r, 2)); + private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), BlobOrNull(r, 2), r.IsDBNull(3) ? null : r.GetString(3), r.IsDBNull(4) ? null : r.GetString(4)); public Seat? GetSeat(Id16 channelId, ReadOnlySpan endpointHash) => One( - "SELECT endpoint_hash, joined_at, label FROM seats WHERE channel_id=@c AND endpoint_hash=@e", + "SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c AND endpoint_hash=@e", MapSeat, ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())); public int CountSeats(Id16 channelId) => (int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!; public void InsertSeat(Id16 channelId, Seat seat) => Run( - "INSERT INTO seats (channel_id, endpoint_hash, label, joined_at) VALUES (@c,@e,@l,@j)", - ("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt)); + "INSERT INTO seats (channel_id, endpoint_hash, label, joined_at,account_name,account_device_id) VALUES (@c,@e,@l,@j,@a,@d)", + ("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt), ("@a", seat.AccountName), ("@d", seat.AccountDeviceId)); public bool DeleteSeat(Id16 channelId, ReadOnlySpan endpointHash) => Run( "DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e", @@ -468,27 +518,36 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable ("@l", label), ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())); public IReadOnlyList ListSeats(Id16 channelId) => Many( - "SELECT endpoint_hash, joined_at, label FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash", + "SELECT endpoint_hash, joined_at, label, account_name,account_device_id FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash", MapSeat, ("@c", channelId.ToArray())); + public IReadOnlyList DeleteDeviceSeats(string username, string deviceId) + { + var ids = Many("SELECT DISTINCT channel_id FROM seats WHERE account_name=@u AND account_device_id=@d", r => IdAt(r, 0), ("@u", username), ("@d", deviceId)); + Run("DELETE FROM seats WHERE account_name=@u AND account_device_id=@d", ("@u", username), ("@d", deviceId)); + return ids; + } + public void BindSeatDevice(Id16 id, byte[] endpointHash, string deviceId) => Run( + "UPDATE seats SET account_device_id=@d WHERE channel_id=@c AND endpoint_hash=@e", + ("@d", deviceId), ("@c", id.ToArray()), ("@e", endpointHash)); + // ───────── 条目 ───────── private static Item MapItem(SqliteDataReader r) => new( IdAt(r, 0), IdAt(r, 1), Blob(r, 2), (FrameFlags)r.GetInt32(3), r.GetInt64(4), r.GetInt64(5), r.GetInt32(6), r.GetInt64(7), LongOrNull(r, 8), - r.GetInt64(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12), - r.IsDBNull(13) ? null : r.GetString(13)); + r.GetInt64(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12), r.IsDBNull(13) ? null : r.GetString(13)); public Item? GetItem(Id16 channelId, Id16 itemId) => One( $"SELECT {ItemCols} FROM items WHERE channel_id=@c AND item_id=@i", MapItem, ("@c", channelId.ToArray()), ("@i", itemId.ToArray())); public void InsertItem(Item i) => Run( - $"INSERT INTO items ({ItemCols}) VALUES (@c,@i,@a,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd,@l)", + $"INSERT INTO items ({ItemCols}) VALUES (@c,@i,@a,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd,@account)", ("@c", i.ChannelId.ToArray()), ("@i", i.ItemId.ToArray()), ("@a", i.AuthorHash), ("@f", (int)i.Flags), ("@md", i.MaxDownloads), ("@d", i.Downloads), ("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex), - ("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@l", i.Label)); + ("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@account", i.AuthorAccount)); public void UpdateItem(Item i) => Run( "UPDATE items SET downloads=@d, frame_count=@fc, max_index=@mi, final_index=@fi, bytes=@b, expires_at=@ex, close_deadline=@cd WHERE channel_id=@c AND item_id=@i", @@ -507,10 +566,11 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable } public int CountItems(Id16 channelId) => - (int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c", ("@c", channelId.ToArray()))!; + (int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c AND (flags & 16)=0", ("@c", channelId.ToArray()))!; + public int CountControlItems(Id16 channelId) => (int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c AND (flags & 16)<>0", ("@c", channelId.ToArray()))!; public IReadOnlyList ListOldestItems(Id16 channelId, int limit) => Many( - $"SELECT {ItemCols} FROM items WHERE channel_id=@c ORDER BY created_at, rowid LIMIT @n", + $"SELECT {ItemCols} FROM items WHERE channel_id=@c AND (flags & 16)=0 ORDER BY created_at, rowid LIMIT @n", MapItem, ("@c", channelId.ToArray()), ("@n", limit)); public Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId) => One( diff --git a/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs b/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs new file mode 100644 index 0000000..05a359e --- /dev/null +++ b/server/tests/WebDrop.Api.Tests/AccountAdmissionTests.cs @@ -0,0 +1,200 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using WebDrop.Core; +using WebDrop.Storage; +using WebDrop.Api; +using Microsoft.Data.Sqlite; +using static WebDrop.Api.Tests.ServerFixture; + +namespace WebDrop.Api.Tests; + +public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture +{ + [Fact] + public async Task AccountKeyPackagesAreScopedToAcceptedMembersAndRemovedOnRevocation() + { + var ch = await server.CreateAsync(settings: new { seats = 8 }); + var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = Register(username); + var member = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); + using (var ownerPackage = server.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, JsonBody(new { payload = "opaque-owner-package" }))) + Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(ownerPackage)).StatusCode); + using (var ownerList = server.Req(HttpMethod.Get, "/api/v1/accounts/channels", ch.Owner)) + { + var rows = await ReadJsonAsync(await server.Http.SendAsync(ownerList)); + Assert.Contains(rows.EnumerateArray(), row => row.GetProperty("channel_id").GetString() == ch.Id && row.GetProperty("role").GetString() == "owner"); + } + using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "opaque-member-package" }))) + Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode); + using (var pending = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token)) + Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(pending))).EnumerateArray()); + using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, token)) + Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accept)).StatusCode); + using (var delivered = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token)) + { + var rows = await ReadJsonAsync(await server.Http.SendAsync(delivered)); + Assert.Equal("opaque-member-package", rows[0].GetProperty("payload").GetString()); Assert.Equal("member", rows[0].GetProperty("role").GetString()); + } + using (var stealOwner = Request(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, token, new { payload = "replaced" })) + await AssertErrorAsync(await server.Http.SendAsync(stealOwner), 401, "account_required"); + using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner)) + Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode); + using (var removed = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token)) + Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(removed))).EnumerateArray()); + using (var anonymous = Request(HttpMethod.Get, "/api/v1/accounts/channels", member)) + await AssertErrorAsync(await server.Http.SendAsync(anonymous), 401, "account_required"); + } + + [Fact] + public async Task RevokingOneAccountDevicePreservesOtherDevicesAndChannelMembership() + { + var username = "devices_" + Guid.NewGuid().ToString("N")[..8]; + var verifier = RandomNumberGenerator.GetBytes(32); var accounts = server.App.Services.GetRequiredService(); + using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); var point = key.ExportParameters(false).Q; + Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) }))); + var firstId = Guid.NewGuid().ToString(); var secondId = Guid.NewGuid().ToString(); + var firstToken = accounts.Authenticate(username, verifier, firstId, "Desktop")!; + var secondToken = accounts.Authenticate(username, verifier, secondId, "Phone")!; + var ch = await server.CreateAsync(settings: new { seats = 8 }); + using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "opaque-key-package" }))) + Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode); + var first = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); var second = first with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }; + foreach (var (who, token) in new[] { (first, firstToken), (second, secondToken) }) + { + using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who, token); + Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(joined)).StatusCode); + } + Assert.Equal(2, accounts.Devices(username, firstId).Count); + Assert.Single(accounts.Devices(username, firstId), d => d.Current); + using (var impersonate = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken)) + await AssertErrorAsync(await server.Http.SendAsync(impersonate), 401, "account_required"); + using (var revoke = Request(HttpMethod.Delete, $"/api/v1/accounts/devices/{secondId}", first, firstToken)) + Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode); + Assert.True(accounts.HasSession(firstToken, out _)); Assert.False(accounts.HasSession(secondToken, out _)); + Assert.Single(accounts.Devices(username, firstId)); + using (var read = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", first, firstToken)) + Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(read)).StatusCode); + using (var packages = Request(HttpMethod.Get, "/api/v1/accounts/channels", first, firstToken)) + Assert.Single((await ReadJsonAsync(await server.Http.SendAsync(packages))).EnumerateArray()); + } + [Fact] + public void PartialSettingsPreserveAdmissionDefaults() + { + var request = JsonSerializer.Deserialize("{\"settings\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!; + Assert.Equal("open", request.Settings!.JoinPolicy); + Assert.True(request.Settings.AllowGuests); + request.Settings.Validate(new ServerLimits()); + var share = JsonSerializer.Deserialize("{\"settings\":{\"max_visitors\":1}}", ApiJson.Default.CreateShareRequest)!; + Assert.Equal(86400, share.Settings!.TtlSeconds); share.Settings.Validate(new ServerLimits()); + } + + [Fact] + public async Task NullCredentialsAreRejectedWith400InsteadOf500() + { + foreach (var path in new[] { "/api/v1/accounts/salt", "/api/v1/accounts/session" }) + { + using var response = await server.Http.PostAsync(path, JsonBody(new { username = (string?)null, verifier = (string?)null })); + await AssertErrorAsync(response, 400, "bad_request"); + } + } + private string Register(string username) + { + var accounts = server.App.Services.GetRequiredService(); + var verifier = RandomNumberGenerator.GetBytes(32); + using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); + var point = key.ExportParameters(false).Q; + Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], + JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) }))); + return accounts.Authenticate(username, verifier)!; + } + private HttpRequestMessage Request(HttpMethod method, string path, Who who, string? account = null, object? body = null) + { + var request = server.Req(method, path, who, body is null ? null : JsonBody(body)); + request.Headers.Remove("X-WD-Account"); + if (account is not null) request.Headers.Add("X-WD-Account", account); + return request; + } + + [Fact] + public async Task CreationAndOwnerActionsRequireAccountIdentity() + { + var ch = await server.CreateAsync(); + foreach (var path in new[] { "/api/v1/channels", "/api/v1/shares" }) + { + using var request = Request(HttpMethod.Post, path, ch.Owner, body: new { }); + await AssertErrorAsync(await server.Http.SendAsync(request), 401, "account_required"); + } + using var infoResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)); + Assert.Equal("fixture_owner", (await ReadJsonAsync(infoResponse)).GetProperty("creator_account").GetString()); + using var membersResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner)); + var members = await ReadJsonAsync(membersResponse); + Assert.Equal("owner", members[0].GetProperty("role").GetString()); + var unrelated = Register("unrelated_" + Guid.NewGuid().ToString("N")[..8]); + using var copied = Request(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, unrelated, new { name = "stolen" }); + await AssertErrorAsync(await server.Http.SendAsync(copied), 401, "account_required"); + } + + [Theory] + [InlineData(ChannelMode.Enc)] + [InlineData(ChannelMode.Raw)] + public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode) + { + var ch = await server.CreateAsync(mode, new { seats = 8 }); + using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_password = "channel-secret" }))) + Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode); + var guest = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32)); + using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest)) + await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required"); + using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "wrong" })) + await AssertErrorAsync(await server.Http.SendAsync(wrong), 403, "channel_password_invalid"); + using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "channel-secret" })) + Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(correct)).StatusCode); + using (var closed = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { allow_guests = false }))) + Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(closed)).StatusCode); + using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" })) + await AssertErrorAsync(await server.Http.SendAsync(stranger), 401, "account_required"); + using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite", allow_guests = false }))) + Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode); + var username = "invited_" + Guid.NewGuid().ToString("N")[..8]; + var account = Register(username); + var member = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32)); + using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account)) + await AssertErrorAsync(await server.Http.SendAsync(uninvited), 403, "invitation_required"); + using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "client-encrypted-payload" }))) + Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode); + using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account)) + Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode); + using (var impersonated = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member)) + await AssertErrorAsync(await server.Http.SendAsync(impersonated), 401, "account_required"); + var writer = member with { Role = "write", Token = ch.WriteToken }; + using (var upload = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", writer, account)) + { + upload.Content = Bytes(Frame(NewId(), 0, (byte)(1 | 16 | (mode == ChannelMode.Enc ? 8 : 0)), mode == ChannelMode.Enc ? EncPayload : 16)); + Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(upload)).StatusCode); + } + using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner)) + Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode); + using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account)) + await AssertErrorAsync(await server.Http.SendAsync(rejoin), 403, "forbidden"); + } + + [Fact] + public async Task StoredLoginDigestCannotBeUsedAsLoginCredentialAndVaultUpdatesAreConditional() + { + var username = "digest_" + Guid.NewGuid().ToString("N")[..8]; + var token = Register(username); + var accounts = server.App.Services.GetRequiredService(); + Assert.Null(accounts.Authenticate(username, new byte[32])); + using var database = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(server.App.Services.GetRequiredService().DataDir, "v2", "webdrop.db") }.ToString()); + database.Open(); + using var query = database.CreateCommand(); query.CommandText = "SELECT verifier FROM accounts WHERE username=$u"; query.Parameters.AddWithValue("$u", username); + var storedDigest = (byte[])query.ExecuteScalar()!; + Assert.Null(accounts.Authenticate(username, storedDigest)); + Assert.True(accounts.HasSession(token, out var actual)); Assert.Equal(username, actual); + var vault = accounts.GetVault(username)!.Value; + Assert.Null(accounts.PutVault(username, new byte[28], vault.Revision + 1)); + Assert.Equal(vault.Revision + 1, accounts.PutVault(username, new byte[28], vault.Revision)); + accounts.RevokeSession(token); Assert.False(accounts.HasSession(token, out _)); + } +} diff --git a/server/tests/WebDrop.Api.Tests/ServerFixture.cs b/server/tests/WebDrop.Api.Tests/ServerFixture.cs index 88f0215..9d92dcc 100644 --- a/server/tests/WebDrop.Api.Tests/ServerFixture.cs +++ b/server/tests/WebDrop.Api.Tests/ServerFixture.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using WebDrop.Api; using WebDrop.Core; +using WebDrop.Storage; namespace WebDrop.Api.Tests; @@ -21,6 +22,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable public WebApplication App { get; private set; } = null!; public HttpClient Http { get; private set; } = null!; public Uri BaseUri { get; private set; } = null!; + public string OwnerAccountToken { get; private set; } = null!; public ServerFixture() : this([]) { } @@ -45,6 +47,12 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable string address = App.Services.GetRequiredService().Features.Get()!.Addresses.First(); BaseUri = new Uri(address); Http = new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(30) }; + var accounts = App.Services.GetRequiredService(); + var verifier = RandomNumberGenerator.GetBytes(32); + using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); + var point = key.ExportParameters(false).Q; + accounts.Register("fixture_owner", RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) })); + OwnerAccountToken = accounts.Authenticate("fixture_owner", verifier)!; } public async Task DisposeAsync() @@ -52,7 +60,6 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable Http.Dispose(); await App.StopAsync(); await App.DisposeAsync(); - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); try { Directory.Delete(_dir, recursive: true); } catch (IOException) { } } @@ -86,6 +93,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable req.Headers.Add("X-WD-Role", who.Role); if (who.Token is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", B64(who.Token)); req.Headers.Add("X-WD-Endpoint", B64(who.EndpointSecret)); + if (who.Role == "owner") req.Headers.Add("X-WD-Account", OwnerAccountToken); return req; } diff --git a/server/tests/WebDrop.Api.Tests/StreamingTests.cs b/server/tests/WebDrop.Api.Tests/StreamingTests.cs index df78590..fc6b865 100644 --- a/server/tests/WebDrop.Api.Tests/StreamingTests.cs +++ b/server/tests/WebDrop.Api.Tests/StreamingTests.cs @@ -20,6 +20,7 @@ public class StreamingTests(ServerFixture s) : IClassFixture ws.Options.AddSubProtocol($"wd.role.{who.Role}"); if (who.Token is not null) ws.Options.AddSubProtocol($"wd.auth.{B64(who.Token)}"); ws.Options.AddSubProtocol($"wd.ep.{B64(who.EndpointSecret)}"); + if (who.Role == "owner") ws.Options.AddSubProtocol($"wd.account.{B64(Convert.FromBase64String(s.OwnerAccountToken))}"); var uri = new UriBuilder(s.BaseUri) { Scheme = "ws", Path = $"/api/v1/channels/{ch.Id}/ws", Query = $"from={from}" }.Uri; using var cts = new CancellationTokenSource(Timeout); diff --git a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs index 58576f5..eb71531 100644 --- a/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs +++ b/server/tests/WebDrop.Core.Tests/ChannelServiceTests.cs @@ -20,8 +20,8 @@ public class ChannelServiceTests : IDisposable private static void AssertCode(ErrorCode code, Action act) => Assert.Equal(code, Assert.Throws(act).Code); - private Task Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null, string? label = null) => - Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads, label); + private Task Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null) => + Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads); private Task AppendSmall(TestChannel ch, Credentials who, Id16? item = null, byte fill = 0x42, FrameFlags extra = FrameFlags.None) => Append(ch, who, TestEnv.Frame(item ?? TestEnv.NewId(), 0, TestEnv.SmallEnc | extra, TestEnv.EncPayload, fill)); @@ -44,7 +44,7 @@ public class ChannelServiceTests : IDisposable ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)), OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)), }; - AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp)); + AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp, "owner")); } [Fact] @@ -55,9 +55,9 @@ public class ChannelServiceTests : IDisposable var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() }; var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw }; - AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp)); - AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp)); - AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp)); + AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp, "owner")); + AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp, "owner")); + AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp, "owner")); } [Fact] diff --git a/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs b/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs index 42f25aa..defd22e 100644 --- a/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs +++ b/server/tests/WebDrop.Core.Tests/PrimitivesTests.cs @@ -60,7 +60,7 @@ public class EnvelopeTests public void RejectsReservedFlagBits() { var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4); - bytes[1] |= 0x10; + bytes[1] |= 0x20; Assert.False(Envelope.TryParse(bytes, out _)); } @@ -110,8 +110,17 @@ public class AuthTests public class PolicyTests { + [Fact] + public void AuthenticatedAuthorsCanManageOwnItemsAcrossDevices() + { + var first = TestEnv.Endpoint(); var second = TestEnv.Endpoint(); + var item = ItemBy(first) with { AuthorAccount = "member" }; + var channel = Chan(new ChannelSettings { DeletePolicy = DeletePolicy.Own }); + Assert.True(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, second, "member"))); + Assert.False(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, first, "other-account"))); + } private static Channel Chan(ChannelSettings s, bool writeHash = true) => new( - TestEnv.NewId(), ChannelMode.Enc, s, new byte[32], writeHash ? new byte[32] : null, new byte[32], 0, 0, 0, 0, 0); + TestEnv.NewId(), ChannelMode.Enc, s, new byte[32], writeHash ? new byte[32] : null, new byte[32], 0, 0, 0, 0, 0, "owner"); private static Item ItemBy(byte[] author) => new( TestEnv.NewId(), TestEnv.NewId(), author, FrameFlags.None, 0, 0, 0, -1, null, 0, 0, 0, null, null); diff --git a/server/tests/WebDrop.Core.Tests/TestEnv.cs b/server/tests/WebDrop.Core.Tests/TestEnv.cs index efee175..21f751b 100644 --- a/server/tests/WebDrop.Core.Tests/TestEnv.cs +++ b/server/tests/WebDrop.Core.Tests/TestEnv.cs @@ -22,7 +22,7 @@ public sealed class RecordingEvents : IChannelEvents } /// 一个频道的全部凭据,模拟客户端从 K / A 派生出的令牌。 -public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] write, byte[] owner) +public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] write, byte[] owner, byte[] ownerEndpoint) { public Id16 Id { get; } = id; public ChannelMode Mode { get; } = mode; @@ -30,9 +30,9 @@ public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] w public byte[] WriteToken { get; } = write; public byte[] OwnerToken { get; } = owner; - public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint); - public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint); - public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint); + public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint, "owner"); + public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint, endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : null); + public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint, endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : null); } public sealed class TestEnv : IDisposable @@ -76,9 +76,9 @@ public sealed class TestEnv : IDisposable ReadHash = mode == ChannelMode.Enc ? Codec.Encode(TokenHash.Compute(read)) : null, WriteHash = withWriteHash ? Codec.Encode(TokenHash.Compute(write)) : null, OwnerHash = Codec.Encode(TokenHash.Compute(owner)), - }, ownerEndpoint); + }, ownerEndpoint, "owner"); - return new TestChannel(id, mode, read, write, owner); + return new TestChannel(id, mode, read, write, owner, ownerEndpoint); } /// 构造一个封装。enc 模式的负载长度必须是 档位+16。 diff --git a/server/tests/WebDrop.Core.Tests/WorkspaceControlTests.cs b/server/tests/WebDrop.Core.Tests/WorkspaceControlTests.cs new file mode 100644 index 0000000..8373f3d --- /dev/null +++ b/server/tests/WebDrop.Core.Tests/WorkspaceControlTests.cs @@ -0,0 +1,24 @@ +using WebDrop.Core; + +namespace WebDrop.Core.Tests; +public sealed class WorkspaceControlTests +{ + [Theory] + [InlineData(ChannelMode.Enc)] + [InlineData(ChannelMode.Raw)] + public async Task ThreadStructureSurvivesContentExpiryAndEviction(ChannelMode mode) + { + using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); + var channel = env.Create(endpoint, mode, new ChannelSettings { TtlSeconds = 60, MaxItems = 1 }); + var id = TestEnv.NewId(); var control = FrameFlags.Final | FrameFlags.Control | (mode == ChannelMode.Enc ? FrameFlags.Enc : 0); + var normal = FrameFlags.Final | (mode == ChannelMode.Enc ? FrameFlags.Enc : 0); + int payload = mode == ChannelMode.Enc ? TestEnv.EncPayload : 16; + await env.Service.AppendFrameAsync(channel.Id, channel.Owner(endpoint), TestEnv.Frame(id, 0, control, payload), null); + env.Clock.Advance(TimeSpan.FromSeconds(59)); + for (int i = 0; i < 3; i++) await env.Service.AppendFrameAsync(channel.Id, channel.Owner(endpoint), TestEnv.Frame(TestEnv.NewId(), 0, normal, payload), null); + env.Clock.Advance(TimeSpan.FromSeconds(2)); env.Service.Sweep(); + Assert.NotNull(env.Store.Read(tx => tx.GetItem(channel.Id, id))); + Assert.Equal(1, env.Store.Read(tx => tx.CountControlItems(channel.Id))); + Assert.Equal(1, env.Store.Read(tx => tx.CountItems(channel.Id))); + } +} diff --git a/test-vectors/generate.mjs b/test-vectors/generate.mjs index 53e88a8..aeb0bf2 100644 --- a/test-vectors/generate.mjs +++ b/test-vectors/generate.mjs @@ -32,7 +32,7 @@ const hkdf = (ikm, salt, info) => Buffer.from(hkdfSync('sha256', ikm, salt, Buff // ───────────── 协议常量(protocol.md) ───────────── -const FLAG = { FINAL: 1, SNAPSHOT: 2, GATED: 4, ENC: 8 }; +const FLAG = { FINAL: 1, SNAPSHOT: 2, GATED: 4, ENC: 8, CONTROL: 16 }; const TIERS = [1024, 4096, 16384, 65536, 262144, 1048576]; const MAX_FRAME_DATA = 1048576 - 4; const LABEL = { @@ -111,11 +111,13 @@ function metaJson(m) { if (m.size !== undefined) o.size = m.size; o.ts = m.ts; if (m.dev !== undefined) o.dev = m.dev; + if (m.threadId !== undefined) o.thread_id = m.threadId; + if (m.folderId !== undefined) o.folder_id = m.folderId; return Buffer.from(JSON.stringify(o), 'utf8'); } /** 条目编码的参照实现:小内容单帧(非 GATED),放不下则多帧 GATED。 */ -function encodeItem({ itemId, meta, content, maxFrameData = MAX_FRAME_DATA, snapshot = false, forceGated = false }) { +function encodeItem({ itemId, meta, content, maxFrameData = MAX_FRAME_DATA, snapshot = false, forceGated = false, control = false }) { const mj = metaJson(meta); const head = Buffer.concat([u16(mj.length), mj]); const cap0 = maxFrameData - head.length; @@ -123,7 +125,7 @@ function encodeItem({ itemId, meta, content, maxFrameData = MAX_FRAME_DATA, snap const frames = []; if (content.length <= cap0 && !forceGated) { - frames.push({ index: 0, flags: FLAG.FINAL | FLAG.ENC | (snapshot ? FLAG.SNAPSHOT : 0), data: Buffer.concat([head, content]) }); + frames.push({ index: 0, flags: FLAG.FINAL | FLAG.ENC | (snapshot ? FLAG.SNAPSHOT : 0) | (control ? FLAG.CONTROL : 0), data: Buffer.concat([head, content]) }); return { gated: false, frames }; } if (snapshot) throw new Error('快照不能是 GATED'); @@ -216,6 +218,7 @@ vectors.frames = frameCases.map((c) => { // 条目向量:meta + 内容 + 分帧 const TS = 1700000000000; const itemCases = [ + { name: 'workspace_control', itemId: itemC, meta: { kind: 'other', ts: TS }, control: true, content: Buffer.from('{"app":"webdrop.workspace","v":1,"op":"thread.create"}', 'utf8') }, { name: 'text_small', itemId: itemA, @@ -241,6 +244,13 @@ const itemCases = [ meta: { kind: 'file', name: 'note.txt', mime: 'text/plain', size: 11, ts: TS }, content: Buffer.from('hello world', 'utf8'), }, + { + name: 'file_in_thread_folder', + itemId: itemC, + meta: { kind: 'file', name: 'readme.txt', mime: 'text/plain', size: 11, ts: TS, dev: 'Windows 11', + threadId: '01234567-89ab-cdef-0123-456789abcdef', folderId: 'fedcba98-7654-3210-fedc-ba9876543210' }, + content: Buffer.from('thread test', 'utf8'), + }, { name: 'empty_text', itemId: itemB, @@ -296,6 +306,7 @@ vectors.items = itemCases.map((c) => { max_frame_data: c.maxFrameData ?? MAX_FRAME_DATA, snapshot: !!c.snapshot, force_gated: !!c.forceGated, + control: !!c.control, gated: plan.gated, frames: envelopes.map(hex), }; @@ -381,7 +392,7 @@ vectors.negative = [ { name: 'header_only', envelope: hex(base.subarray(0, 26)), expect: 'bad_envelope' }, { name: 'too_short', envelope: hex(base.subarray(0, 10)), expect: 'bad_envelope' }, { name: 'bad_version', envelope: mutate((b) => { b[0] = 2; }), expect: 'bad_envelope' }, - { name: 'reserved_flag_bit', envelope: mutate((b) => { b[1] |= 0x10; }), expect: 'bad_envelope' }, + { name: 'reserved_flag_bit', envelope: mutate((b) => { b[1] |= 0x20; }), expect: 'bad_envelope' }, { name: 'payload_len_mismatch', envelope: mutate((b) => { b[25] ^= 1; }), expect: 'bad_envelope' }, // 没有 ENC 标志的帧,在 enc 会话里必须被拒绝(防降级:服务端不能注入明文帧) { diff --git a/test-vectors/vectors.json b/test-vectors/vectors.json index 1e4b522..6300218 100644 --- a/test-vectors/vectors.json +++ b/test-vectors/vectors.json @@ -214,6 +214,25 @@ } ], "items": [ + { + "name": "workspace_control", + "item_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf", + "meta": { + "v": 1, + "kind": "other", + "ts": 1700000000000 + }, + "meta_json": "{\"v\":1,\"kind\":\"other\",\"ts\":1700000000000}", + "content": "7b22617070223a2277656264726f702e776f726b7370616365222c2276223a312c226f70223a227468726561642e637265617465227d", + "max_frame_data": 1048572, + "snapshot": false, + "force_gated": false, + "control": true, + "gated": false, + "frames": [ + "0119d0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c763b62f571aed03d822e5fc9b5febf11d186b288c4333bb4622d815cc7cce6685fe3c4df5bf933b74c83d8a0fcb68b52a822fde949134cf7f03787fe214f5d406e832550e291da980b3cba47c775acd9f640b38c528915a896571569a7c8b68b3bd68b040bf8fa99f302268fd0eca75c5bd627b3e4e35ab40fbc0b895caafc2c5d3b3d3e44098064e17a4eb1895055bf9f7936c2a33021c7da0078528167b2c00f299814ef5bfdb6bbfe70d25731d200bcbb92af971eea0824667f896d6376e0aab89dd5a1228be6bccb9379dfdc79e7074d4d5f3c604b3c709348c35c92de47f30ec82073c9a8d935dd83e3ad300677e5e1261da074c4378f7b521a84934b3dc7664f7e26b6b9e0c6c52f2200172923ea3794bd93243829d26e532f2c0c50dde1a46d3ff87127ba1702d73a0323913523e8a8aabcca2c1315739e93bfc783773c186b7ff50c91e9dd18cdc38de9690499fd05adad02db38e1dbc58b12968f001ec162248cba9d84286b3f413f27b98243875aeab677bf6b740267e642598331534d1c0c5c03a2cbaec39677ab3c12069c93b617d286aa85268a42d9d181b0e2c1381b6a1d5f4c008b1ec849c33fe12c3f53e3a74f9ee1f262c47c8adcf60081b5444e4a91d88e99131e3d8067fab78615704d335e0a6881eff89a5053af201fb2d1eadc53ea18a32c1cd1a1043ae9074821e396788550d6a403f257515340c424a86d62622409127c7faae301b589c2e31da258b0e9552cff0b0ca986320e46a3f97649848faddf038cda376c0fb69ed7cda1e2abeb33cc1ee9cbaf187a2dc33a6d896c909897e3ce543b38478d4e97a0c7a78837abb0ecf06044bfc75e8bdcd5bc7d3d94b18a459cb8f7973ea7047407a9e755cda9b276fa0a45e1e1ed9dd1172a05d5684576305c7bd10e79b5aa621556215b6b662884f78274c5631261de9dedadb6bcada81a309d8792179dab25096ceedfc15fca3b3209b011075393df693fe6edc12cfab29a138ddae1cc0022a172461a420c4d0a60b7b056d82f44775a298e2322ab7106e5fa62378c902ae9aece2819de44c5e9ce46bb597a427e78f6bdc21b9e7bbe153fa4c030a13bf156ba506220a66f2df12b9e9c4aa574fe3cd231c910e3ada3416ed604ddd1debc3029ab903f1555108c02d36b3073c89b79c118c875e0c4f8af3bae2d0697e05efe7e5a7e7a9f360820f854b7835012e8913c91f5cca77dfa8bcef5f1384bd7bdf7767061b4c332b52020f1f8f0f36fa126c52f6c36d1689a5e99dde6b02d92834eece6eba94e997591714a0a692652539894d6caa1640d8ec3d28e699fc4f4f1fae62ddd40c46adfe891c2d6142f89fea0329c5b9504a9069a8aa29b85374dac5acfc5be7c7683ae7d98ce0826164c8d8b21f75f06e2c5648bad52d5878596cde0a948d9fdccccbd53ec3b1c3e3922da83bb0ec65c24537d83371b236dd6d7f" + ] + }, { "name": "text_small", "item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", @@ -227,6 +246,7 @@ "max_frame_data": 1048572, "snapshot": false, "force_gated": false, + "control": false, "gated": false, "frames": [ "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a28e3d72b8892fa67302d87ec18c8cc4c994d9de36816339181f976623149c97875b81538c552b91afc51004ad6e248676e829e4961a7de3055b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67053d1bece4e7ae471253b03e4702dc5b" @@ -246,6 +266,7 @@ "max_frame_data": 1048572, "snapshot": false, "force_gated": false, + "control": false, "gated": false, "frames": [ "0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cae5dcd32135fd5a443bcd7dd5f1c052c986f5512cccd54163df53fbd7a420a42a32a38827e0ccf6a303778ff3dfacb0cad8ceebd64e092bed2663427a091204f6b5f186f71a1bd5c534976aba8b313018197269d810f5bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b84d13bb985883794cf8485891a1b2e69d5" @@ -264,6 +285,7 @@ "max_frame_data": 1048572, "snapshot": true, "force_gated": false, + "control": false, "gated": false, "frames": [ "010ba0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a59e3d72b8892fa67302d87ec18c8cc4c994d83f57f0c339181f976623149c97875b81538c552b91afc51005991fa4c6d2fc758cbd5242ace3e9544d563cc01a765b6d92c1dbe6a539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab676ca576ae90cf2fe459e7372edf815e5d" @@ -285,11 +307,37 @@ "max_frame_data": 1048572, "snapshot": false, "force_gated": false, + "control": false, "gated": false, "frames": [ "0109d0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c763b22f261aed03d822e5fc9b5febf11d186b28855e37bb162cd659d962897e8eeb6212b1ea8d7f3c8c2f961ddb7afa2ed065dedad63bde32176671fb54a09756f0285f0a6842ba9dbd9ff235644cdec7241932d76cc918dc34254884668b65abb76aba2fcde3cd9f302268fd0eca75c5bd627b3e4e35ab40fbc0b895caafc2c5d3b3d3e44098064e17a4eb1895055bf9f7936c2a33021c7da0078528167b2c00f299814ef5bfdb6bbfe70d25731d200bcbb92af971eea0824667f896d6376e0aab89dd5a1228be6bccb9379dfdc79e7074d4d5f3c604b3c709348c35c92de47f30ec82073c9a8d935dd83e3ad300677e5e1261da074c4378f7b521a84934b3dc7664f7e26b6b9e0c6c52f2200172923ea3794bd93243829d26e532f2c0c50dde1a46d3ff87127ba1702d73a0323913523e8a8aabcca2c1315739e93bfc783773c186b7ff50c91e9dd18cdc38de9690499fd05adad02db38e1dbc58b12968f001ec162248cba9d84286b3f413f27b98243875aeab677bf6b740267e642598331534d1c0c5c03a2cbaec39677ab3c12069c93b617d286aa85268a42d9d181b0e2c1381b6a1d5f4c008b1ec849c33fe12c3f53e3a74f9ee1f262c47c8adcf60081b5444e4a91d88e99131e3d8067fab78615704d335e0a6881eff89a5053af201fb2d1eadc53ea18a32c1cd1a1043ae9074821e396788550d6a403f257515340c424a86d62622409127c7faae301b589c2e31da258b0e9552cff0b0ca986320e46a3f97649848faddf038cda376c0fb69ed7cda1e2abeb33cc1ee9cbaf187a2dc33a6d896c909897e3ce543b38478d4e97a0c7a78837abb0ecf06044bfc75e8bdcd5bc7d3d94b18a459cb8f7973ea7047407a9e755cda9b276fa0a45e1e1ed9dd1172a05d5684576305c7bd10e79b5aa621556215b6b662884f78274c5631261de9dedadb6bcada81a309d8792179dab25096ceedfc15fca3b3209b011075393df693fe6edc12cfab29a138ddae1cc0022a172461a420c4d0a60b7b056d82f44775a298e2322ab7106e5fa62378c902ae9aece2819de44c5e9ce46bb597a427e78f6bdc21b9e7bbe153fa4c030a13bf156ba506220a66f2df12b9e9c4aa574fe3cd231c910e3ada3416ed604ddd1debc3029ab903f1555108c02d36b3073c89b79c118c875e0c4f8af3bae2d0697e05efe7e5a7e7a9f360820f854b7835012e8913c91f5cca77dfa8bcef5f1384bd7bdf7767061b4c332b52020f1f8f0f36fa126c52f6c36d1689a5e99dde6b02d92834eece6eba94e997591714a0a692652539894d6caa1640d8ec3d28e699fc4f4f1fae62ddd40c46adfe891c2d6142f89fea0329c5b9504a9069a8aa29b85374dac5acfc5be7c7683ae7d98ce0826164c8d8b21f75f06e2c5648bad52d5878596cde0a948d9fdccccbd53ec3b1c3e3922d46f5736a5fe59b5ee5cabff0aea533d6" ] }, + { + "name": "file_in_thread_folder", + "item_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf", + "meta": { + "v": 1, + "kind": "file", + "name": "readme.txt", + "mime": "text/plain", + "size": 11, + "ts": 1700000000000, + "dev": "Windows 11", + "thread_id": "01234567-89ab-cdef-0123-456789abcdef", + "folder_id": "fedcba98-7654-3210-fedc-ba9876543210" + }, + "meta_json": "{\"v\":1,\"kind\":\"file\",\"name\":\"readme.txt\",\"mime\":\"text/plain\",\"size\":11,\"ts\":1700000000000,\"dev\":\"Windows 11\",\"thread_id\":\"01234567-89ab-cdef-0123-456789abcdef\",\"folder_id\":\"fedcba98-7654-3210-fedc-ba9876543210\"}", + "content": "7468726561642074657374", + "max_frame_data": 1048572, + "snapshot": false, + "force_gated": false, + "control": false, + "gated": false, + "frames": [ + "0109d0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c763372fad1aed03d822e5fc9b5febf11d186b28855e37bb162cd659d962897e8eeb7e18a4ebce6e6a8c75ce1d9a31fa229f3ade949137cf65132560fe5bebd556af635606301da996a08caa643205cdca251e32d76cc918dc342548c93ec22ba3bd3cef7a9dd8c0f1544d1f8e2efb44e791400f563c50ca24a4a9dcb7f08df2f4e180e7d176af2b762ec58935f6613e9fdaa35d18002f28489630bd1177194f6497ffa362d7d9b407db827f7a1a790231e9df4f9d128cc1bb7e4acfa0e303433999b8ed77744dda08e1db56a4c5f0a84540e7e7c2f626ceb36146e954ad0d901a439882073c9a8d935dd83e3ad300677e5e1261da074c4378f7b521a84934b3dc7664f7e26b6b9e0c6c52f2200172923ea3794bd93243829d26e532f2c0c50dde1a46d3ff87127ba1702d73a0323913523e8a8aabcca2c1315739e93bfc783773c186b7ff50c91e9dd18cdc38de9690499fd05adad02db38e1dbc58b12968f001ec162248cba9d84286b3f413f27b98243875aeab677bf6b740267e642598331534d1c0c5c03a2cbaec39677ab3c12069c93b617d286aa85268a42d9d181b0e2c1381b6a1d5f4c008b1ec849c33fe12c3f53e3a74f9ee1f262c47c8adcf60081b5444e4a91d88e99131e3d8067fab78615704d335e0a6881eff89a5053af201fb2d1eadc53ea18a32c1cd1a1043ae9074821e396788550d6a403f257515340c424a86d62622409127c7faae301b589c2e31da258b0e9552cff0b0ca986320e46a3f97649848faddf038cda376c0fb69ed7cda1e2abeb33cc1ee9cbaf187a2dc33a6d896c909897e3ce543b38478d4e97a0c7a78837abb0ecf06044bfc75e8bdcd5bc7d3d94b18a459cb8f7973ea7047407a9e755cda9b276fa0a45e1e1ed9dd1172a05d5684576305c7bd10e79b5aa621556215b6b662884f78274c5631261de9dedadb6bcada81a309d8792179dab25096ceedfc15fca3b3209b011075393df693fe6edc12cfab29a138ddae1cc0022a172461a420c4d0a60b7b056d82f44775a298e2322ab7106e5fa62378c902ae9aece2819de44c5e9ce46bb597a427e78f6bdc21b9e7bbe153fa4c030a13bf156ba506220a66f2df12b9e9c4aa574fe3cd231c910e3ada3416ed604ddd1debc3029ab903f1555108c02d36b3073c89b79c118c875e0c4f8af3bae2d0697e05efe7e5a7e7a9f360820f854b7835012e8913c91f5cca77dfa8bcef5f1384bd7bdf7767061b4c332b52020f1f8f0f36fa126c52f6c36d1689a5e99dde6b02d92834eece6eba94e997591714a0a692652539894d6caa1640d8ec3d28e699fc4f4f1fae62ddd40c46adfe891c2d6142f89fea0329c5b9504a9069a8aa29b85374dac5acfc5be7c7683ae7d98ce0826164c8d8b21f75f06e2c5648bad52d5878596cde0a948d9fdccccbd53ec3b1c3e3922d93d89c6d34b8a4e0e896f8c5874856ee" + ] + }, { "name": "empty_text", "item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf", @@ -303,6 +351,7 @@ "max_frame_data": 1048572, "snapshot": false, "force_gated": false, + "control": false, "gated": false, "frames": [ "0109b0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cd15ddb32135fd5a443bcd7dd5f1c052c986f5512cccd54163df53fbd7a420a42a32a38827e0ccf6a303729dd599fbd2e97ae0835f507083af20f644fcfff456d16baa0c9990f03b9f7ce933b255489856701a17b1da35bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b84cfc631583cbee2e31f9a66775ccc580f" @@ -324,6 +373,7 @@ "max_frame_data": 1000, "snapshot": false, "force_gated": false, + "control": false, "gated": true, "frames": [ "010cd0d1d2d3d4d5d6d7d8d9dadbdcdddedf0000000000000410b7c7603f2f1b1aed03d822e5fc9b5febf11d186b28855e37bb162cd659d962897e8eeb6d53a7e6cd2968da60d352d331ad69932f8cc2da20cb690e657ebd55e1cf11f76c561b381deac1b391a4352f0c8ad22e1b37d76cd50a98773742c839de39f7e87afd708fbf99af4d2269ff0dce70c3ba6a72344539a64ef4d0a987d9bbd7d3c4abcafe5b841b500884ca3ab6217edfd0bb4500182e31538f37b41a254f1936c5a1b874ce83e65580a74c673059654d8cf163b33aa2edcc0937a9c485633b5cfcd184004974e33593d956ff9ea3fb1613bcbc99ad68dea96644fd47ba5991094794fb7d47e6f0ed2258bfb85084e2f8d99ae8508cc0cef67825b03adaa0264ae1fc6e78f0f70392f3f25382a2d6379804d1e27399ef2f33895583407371b868adfe6a453caec61fcfedb262f1fdd694f9424361076e0cff98e938e92face2a5165e6e258b15c3430e6c3dda3d7275af7838b3303bc15e60f24ca943da9c05f71beedbb2cba8da4182b6f214fa72922f3478a0a4776ae4a4543368733d81290e28ccdedae01b0e99c81c415d9be80a42e5164f52185b9a615c911baa202234172fbc889e95b5824bf5a9c2db7bb75888b973743ba9bf4d7578129efa973952400819baf67de98bf25586be6117c2120a3b69bd5a90d7fa6d8bfcd372428b7b805163d3babe2008b145489c97cb271aff0e93b7e818c49ff9d4aab3e28dad96d9d61b48b982e13384635f0897b3f136859d778b24be24e07c44057c2fdb995ed1832ada27883b1f33fc0865b10832a326b017d0e56e62ee123a496c265f7b06e87a054816e9689cdf01a65563903d0391e097966c8a4afc3cf2f1bd0b8d1147cd5ac5d0dd4e1ea351c285727fe77e48506b8c6648cf8d3077b9be450203c7c23153827e72a171442dee973bcbb6748911645026828354bf77411d776a0c1822a99f98982f8f9cc6eb4092326d3494fd00c79cbea840aaf4eb79c15a4c28676296f29c0db877a9cc41c852b6c271ae6d5a665612d055b2a7de72017e11ff8a38f5231a61b6af3197e6d62ca8f4448c210a7d70120971dac9047df12e0b39b9782fca7e821d421d46fb53e6a8a6be11badb14b491bed34468aa00537f16eaf15c0de2de52caff1cf3de24aa8611d0250cd24b6bd0258087df18f4ec68dbe157687cf06e64bae9a96d1b53083b8593eb00171455145d069675f77cb17834032d8d16cf1854c37dd4a4b1e1500395af68cb627111035529304e1f1100af2e14d9364974d1eb443ca289c4b3f15b33eb1b00dbf85982add3ac652a2a9fe6d327667dcc0b2be25f0a93a07066a9c9ad1d1c4bfb348a8c551cf6a2d4427201239afc8e664fa2d13920fb05c5c446c82206a9b1d98a2c9fbe12419ba4f29f02e0e64b5c3799f278e7a6ddc4375ba25878596cde0a948d9fdccccbd53ec3b1c3e3922dd672eb64712415b3dbfc6596618f5bb1", @@ -345,6 +395,7 @@ "max_frame_data": 1000, "snapshot": false, "force_gated": false, + "control": false, "gated": false, "frames": [ "0109a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee49f7e3cc2b8892fa67302d87ec18c8cc4c994d8fef7c07339181e3642d6e5ac46a27aa092a8111ab10fd564d1283be143872924ac1c73d4eba52bf61fd06e236f25b8afa123df702458b5567c3ac27d5835623394e87e11bcdb74dad9329d88ca850c9d29627417c6fa0a872582a6dfafbed838ffaee3d5098774eaaa2eede5a6c3bf8d6124e168cd86cdd40e13528699100d8e6d711f5c381621c2211c6f70b2a68fd60bc692e559f51c51ac1043b70f45801fdb1f8d86d1dc75ca54a7b6df9fb8728057308d0d9ad641ded456a80fc9f6d618694ca933ce93e43d16ab46c8c0351ffb65885f6c1ab8bc69194278c3c158dd8faf1189b283da786f3d48b6635cd96ff860ab3331dd50783a958173e0535ec6a44b9688684ea3a9bd5e7c7be84e89e71710a64c16340bc45cde109328446c6924354f165f92d8d5e98fe65e9b7714118b735c7d2b916f012570eedc69af9420e2ad04bd2f39a64602ec2e5364b27261d1e54e8b0b64894ec1828294169fb8b098aa27a27c22c7f941d5af4bd57c7de5cad5276739f457057e19633aa0809153fefab5bcd0e83a6f435905a96a61c52ca082427d51c8f24a8498d086bbc9803c74f9e0f146821f921d5ad786119fd1d387a6552d19d54265ed3b924add48344b7a2122a92ba9125c3f78cd2b53198c7bc191e65e158598d75f57326cb00144b7a14a85864b566e659615e43faedddce2d35492fe01711ec183492e1283f7366b4a6857bfe9110871e70518d7c5a51aadd42ec886318d9bde49222155dea2c40bda0e0e31dee0067630d5d250b1bfa9c17fe155947774b29a175d9fbfb1e9f21abf32d8e12436e8de907afa3a8f16adf40c0ec3d939185ad3ed5a815a87f046604f54590c2c866fc5d1802c13b1d12234dbc92e14e68349c111a20e7bf39d6291aee856ff3c29e86342d67b09d14e084f5bcb887e468ecf3f2c8f1f46d0f3779022db8aaee0ecac0789095406e76e639c7cc1ac949d219701e676cd4c02e5793a7fa7b7b29a56730abc8ed7a52d80425c96e1cd57ed2ff2902f532adecbb18dbaa21ae03b07e82bbc57b75762444bbf6ca0f7b3eb5e0bc2cf0cccfc3c232d6bcfb473303d80c1d5348136c79a34a68ee646f442734a0e52f5c07b78db4ef77b406ba19ce2a278dd0b5b40f0c2ca68360aaf345f42848a0aeaea28e7893ed126c91415c30d54c0ea110f79ffe525abbf9e548f49a05e4f95d9f8ffd38c0481ac682c4c042a0ac5b1716b853f8b2af5fb6f2358dffe9df5300e1a49bfcb8eb31dada59ae98d4c66d9dea53b8f1ec8af980ad28564e30799e276f71c581a9e0cd0d7e2f2b84837cdeb3b1c023e75405b8a33a1e35aa471ef7847013d0f4c7404a46c6a11e96d03d889fdab81462c34e849240de96805d312e566393b33914b30dbb37f32cb4cfbf9f2af171ab675ad6bd81fb6f247b08a9250f920b4660" @@ -364,6 +415,7 @@ "max_frame_data": 1000, "snapshot": false, "force_gated": false, + "control": false, "gated": true, "frames": [ "010ca0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee49f7e3cc2b8892fa67302d87ec18c8cc4c994d8fef7c07339181e3642d6e5ac46a27aa092a8111ab10fd564d1283be143872924ac1c73d4eba52bf61fd06e236f25b8afa123df702458b5567c3ac27d5835623394e87e11bcdb74dad9329d88ca850c9d29627417c6fa0a872582a6dfafbed838ffaee3d5098774eaaa2eede5a6c3bf8d6124e168cd86cdd40e13528699100d8e6d711f5c381621c2211c6f70b2a68fd60bc692e559f51c51ac1043b70f45801fdb1f8d86d1dc75ca54a7b6df9fb8728057308d0d9ad641ded456a80fc9f6d618694ca933ce93e43d16ab46c8c0351ffb65885f6c1ab8bc69194278c3c158dd8faf1189b283da786f3d48b6635cd96ff860ab3331dd50783a958173e0535ec6a44b9688684ea3a9bd5e7c7be84e89e71710a64c16340bc45cde109328446c6924354f165f92d8d5e98fe65e9b7714118b735c7d2b916f012570eedc69af9420e2ad04bd2f39a64602ec2e5364b27261d1e54e8b0b64894ec1828294169fb8b098aa27a27c22c7f941d5af4bd57c7de5cad5276739f457057e19633aa0809153fefab5bcd0e83a6f435905a96a61c52ca082427d51c8f24a8498d086bbc9803c74f9e0f146821f921d5ad786119fd1d387a6552d19d54265ed3b924add48344b7a2122a92ba9125c3f78cd2b53198c7bc191e65e158598d75f57326cb00144b7a14a85864b566e659615e43faedddce2d35492fe01711ec183492e1283f7366b4a6857bfe9110871e70518d7c5a51aadd42ec886318d9bde49222155dea2c40bda0e0e31dee0067630d5d250b1bfa9c17fe155947774b29a175d9fbfb1e9f21abf32d8e12436e8de907afa3a8f16adf40c0ec3d939185ad3ed5a815a87f046604f54590c2c866fc5d1802c13b1d12234dbc92e14e68349c111a20e7bf39d6291aee856ff3c29e86342d67b09d14e084f5bcb887e468ecf3f2c8f1f46d0f3779022db8aaee0ecac0789095406e76e639c7cc1ac949d219701e676cd4c02e5793a7fa7b7b29a56730abc8ed7a52d80425c96e1cd57ed2ff2902f532adecbb18dbaa21ae03b07e82bbc57b75762444bbf6ca0f7b3eb5e0bc2cf0cccfc3c232d6bcfb473303d80c1d5348136c79a34a68ee646f442734a0e52f5c07b78db4ef77b406ba19ce2a278dd0b5b40f0c2ca68360aaf345f42848a0aeaea28e7893ed126c91415c30d54c0ea110f79ffe525abbf9e548f49a05e4f95d9f8ffd38c0481ac682c4c042a0ac5b1716b853f8b2af5fb6f2358dffe9df5300e1a49bfcb8eb31dada59ae98d4c66d9dea53b8f1ec8af980ad28564e30799e276f71c581a9e0cd0d7e2f2b84837cdeb3b1c023e75405b8a33a1e35aa471ef7847013d0f4c7404a46c6a11e96d03d889fdab81462c34e849240de96805d312e566393b33914b30dbb37f32cb4cfbf9f2af171ab6752de780d3621900034a5e664d343c68d", @@ -384,6 +436,7 @@ "max_frame_data": 1048572, "snapshot": false, "force_gated": true, + "control": false, "gated": true, "frames": [ "010db0b1b2b3b4b5b6b7b8b9babbbcbdbebf000000000000041012c89cbf5dc432135fd5a443bcd7dd5f1c052c986f471ed8dc54163def2df225510750f0347cca3a1ed378747476e768a88d1ea79e3805c537380ac272032ebb9a214d65d7c1a5f50f03b9f7ce933b255489856701a17b1da35bc6fe0ca03bfc9b05a1aec5ddc34855519f3d2d6837f1d859dbe9543e2b188e5cd19037aed96ecb53247ab126e58cee2fcddd50c78cd46453f19a6df767c110a1a59ceaf4090e1f49d3af460d539376d0e173f736b54a871843c2bfc7d18e60d1df6ada80f29ba6e0b5f33753fe162da11ed1c10dff3f0eb3d29c643f02e319cbae99911f3e9dbe163db41e7dbee7b5b92fe07794a1cbaadccf488b7ac1f41328bb02077ca0ad6d1dc4bfd92a072dad9567ddd98455915fb81bf4997a1bd78e4d960b1e944b56746520afd73cabe651625c060da92a499366ec0e81df8e73ab7a1ab6354a97c9807d3f329ce19242b4e16ec4eb65e49af4e4a9c0ed54464522c98b98a30c45b5acce7b76033953602ce26c358c78e1c8875d44e0bae41243089f5408a2a945a8fb408b2dd0b96d05ad14448c3334b2d5c27b73ef4131fb04b1ecda32422ab9620e3a114fe8ef11dd3856d095b8e6974eb53926b293134200336e72a92d665af622fcdec19fd072cfc493905a73dc5d856433293a24c60fdcbe9e7ea45b394e60d2fbfdef69f0d1e4fa430c297e81fa01498b3d9781c33ad80d8c5fa933e81f8bdd3d9afd0d47259970af52e5b01d636ea92d75fbd671b0342434573a83bf919c1d14a55b2f3e3c67e3cf809be5b50ce4bc19571a50c70c6fb86ed9951f98f03d3d6af1fcd5832a148859bb895a91fe9c905816e33df43b5c666b990baa1f97526620a1c87fdf518713222e618815023d5db0b0066dc81f3b13884545d6a510f5d11947e9a89c90baeee052cd3578d14999eac3eb2139e263bb8f3c6a8ccc5e935b2fced80b5803f7bb76a48ddc560758a6d7c6db681fef00a6377afd2d5aa2e77a60fb5bde864f4f180090ba2948e6429a8acbb3be6a5e240521a445b41da8d29eb5f1171dfd3248121f882aa0ea35c5bfa3b464d1088ee841f8c140564076d5ee342b20cde2218aa673c9c55fb3317696692e40d7895b1a8ecf6ecb9d308d72e0ef39bf97bdce8025d02faf27dda3b1cf99ccbaf628d090f195da8e2ddae82df934cfb7862f9d54beb2b2dafe38d5f9950128b387f2b0a6cfbc604c844175c642707ec35f3b27ba693211be15ff995b545ce1d2d16adcd5ca23eb4613bd1cf1c5d13ce9caeb9e154ca0eab40c01b11d6506a9a6a5b0b285ff553903691242b85ad2b5e5718d24b8c8853baf605c44a2156bfc4b734f2d4e1b3caf30299ef464e83a0bbec01ae74743657b63be4d9998a18d49d6a48c926c51c2b6347057eeee6ebe27b7f00d9403729e88734e9b6f65d3b6795fa278765376e8732853173b8443f6f752e2e1fac46a20c4f005725253" @@ -650,7 +703,7 @@ }, { "name": "reserved_flag_bit", - "envelope": "0119a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", + "envelope": "0129a0a1a2a3a4a5a6a7a8a9aaabacadaeaf000000000000041005ee4a1a8b9a3cc68bd85d0101a58771a6a86ea36fe986106211bda38d05400b78fe4845882508f562892acc617d22b38e240842a27af1f7404bbc55b768f70dee3bfc549aeb002ee317539c4d7ed9b73bc89d4903186ca4c53eeb906584b902f4a1867ff9e3a41475495997904b621151c7c5d2c3ceb8ad7915de3006e3e8a592172274a887401d42d98e3b8519bb6e7434cf5fb887b57291a6e705744b7bad9b6644078d11ce1a5a20e926bd63bb7f470d8a27817c337b5ce89b40d42cc0f0e1747508b894e19b444c3bf38574dff11c6101f2c127366937994f99eb78c01fc021adfe4f07ea3642741d3c7e282e9c3081ab32183b33db5fedfb604e3a1e40aaf803592f57d860e7c803d05b7082cce2d8eb338aa55b8b62610cdd733c0d2c526906718180f8973596b64bbd341bf2ce79b839924256f261fc2b8a5691f46ee5ba7f4e08a627d4c6ac00e70a4e14f6da87e75d2e0bf268f6d6bc434807e8ce1a6609092d2f66db84837ea3d42112127d54c5b449cbe03963876a38dc5410bff11a89910cfc002527ca13270fb8cc68f655574a5f8ec938a96be5c19c5cfa31facb723dba795654a169f953d030f77317c1e67c47ce1c8c90eda77ea95c27f3ed947392a8ebf7c14508c2b1c64a23bf31491ddb1703b089361f37826b5e2679199c36680ca8acd655edef3acd4cc99d77bdaaf4bad56a9ba070a02191a8948836201301fde49bfc34c2c73bc0ed483af4e2adb9544767981a74f660f699bb6690b7bf452db31e7b97ed2f4a1c6bd8eea117d2bfbda1e2e019eb06606b04572e0716f49307ef074a53625d3eb96cc3e0e703813e8bd20fad366648aac12e858884dc44f070f1de0ea7a4b39a06ec922e94423a5944b407d3868d20bb1551488a77505c6c1dedc0b21a3d62cb49437abce36488767a8fe70c97a7f8e15c440ddbf1798eeb85cdcaf4901d9a848ab18b8f1172490682ac3a296a8b4c47f0191fcbe2fb68b6575d885add478fe786fef64f5cb3c90c075bd9d88d00c197036147d1fe75aa8a79dfae66ca67499eba4c88165006a6646ae06cc074bb447c0db2bfbde88975391adea9ed61356afb281515181eef08631ba6d1e03ce9fbb4a0fa86924fa78601949eb6d4c05513d8a4fe4d764812894b06bb1bcd2e228bd7bdbd050720ab8d6fbae257e73c5db6b9b6bb94638ff00c73b1607e13f169288638deb5d57e7795d6d579c6a931d1cf6aa7b6c703fc7524f9c2858201e4e91d505ef119b3fee211f9a264dfacbd8a0557b9fdc1a7e4b66f85ba38ccfbb0a30bfa823ad29a80e797efdd59243d440ce8501708bffad59db372feaea90707f958343949a96cd888362caa8fa739d38b6113e88a4a6f5bddd4d96600bc3574982e387211bec96fe32a3df06f2433e98498e16393b33914b30dbb37f32cb4cfbf9f2af171ab67b4a51368bae07810a9cd8b5f49f24553", "expect": "bad_envelope" }, { diff --git a/web/src/core-bridge/contract.test.ts b/web/src/core-bridge/contract.test.ts index d153bc8..1b948db 100644 --- a/web/src/core-bridge/contract.test.ts +++ b/web/src/core-bridge/contract.test.ts @@ -3,13 +3,14 @@ import { supportsApiContract } from './contract'; describe('API contract compatibility', () => { it('accepts compatible SemVer releases in the supported major', () => { - expect(supportsApiContract('1.0.0')).toBe(true); - expect(supportsApiContract('1.8.2')).toBe(true); - expect(supportsApiContract('1.2.0-rc.1')).toBe(true); + expect(supportsApiContract('2.0.0')).toBe(true); + expect(supportsApiContract('2.8.2')).toBe(true); + expect(supportsApiContract('2.2.0-rc.1')).toBe(true); }); it('rejects a breaking major and malformed versions', () => { - expect(supportsApiContract('2.0.0')).toBe(false); + expect(supportsApiContract('1.0.0')).toBe(false); + expect(supportsApiContract('3.0.0')).toBe(false); expect(supportsApiContract('01.0.0')).toBe(false); expect(supportsApiContract(undefined)).toBe(false); }); diff --git a/web/src/core-bridge/contract.ts b/web/src/core-bridge/contract.ts index ab367a7..11e3220 100644 --- a/web/src/core-bridge/contract.ts +++ b/web/src/core-bridge/contract.ts @@ -1,4 +1,4 @@ -export const SUPPORTED_API_CONTRACT_MAJOR = 1; +export const SUPPORTED_API_CONTRACT_MAJOR = 2; const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;