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

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

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "2.1.0"
"version": "6.0.0"
}
File diff suppressed because it is too large. Load diff
+31 -20
View File
@@ -1,6 +1,12 @@
# API 契约 2.1
# API 契约 6.0
URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `2.1.0`。旧主版本客户端会被契约预检拒绝。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。
账户资料使用 `GET/PATCH /accounts/profile`;头像为 PNG/JPEG/WebP data URI,禁止远程 URL 和 SVG。不提供旧资料导入。
空间结构使用 `GET /spaces/{id}/structure`,包含 revision、channels、groups、folders、locations。频道与分组分别通过 `/spaces/{id}/channels` 和 `/spaces/{id}/groups` 创建;响应同时包含完整结构快照和服务端新建实体及 ID。其他字段通过 PATCH 重命名;频道归属通过 `PUT /spaces/{id}/channels/{channelId}/group` 修改。文件夹通过 `/spaces/{id}/folders` 创建及 PATCH 重命名,文件归属通过 `PUT /spaces/{id}/locations` 修改。
频道、分组和目录写操作需要账号会话和空间写权限;允许上传的访客可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。
URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `6.0.0`。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;公开链接仍允许访客按频道加入规则访问。
`GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.seats` 与 `limits.max_seats` 表示成员人数范围,不代表设备数。频道设置 JSON 字段 `seats` 为保持已有频道配置可读而保留其名称;v5 起它限制的是成员数。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。
基础路径 `/api/v1`。所有请求和响应的二进制数据格式见 [protocol.md](protocol.md)。前端托管在独立域名,服务端需要配置 CORS 白名单。
@@ -14,6 +20,7 @@ URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `2.1.0`。旧主
| `X-WD-Cap` | 创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) |
| `X-WD-Max-Downloads` | 第 0 帧上带,条目的下载上限,0 为不限 |
| `X-WD-Account` | 账号 session token。与频道/分享的能力令牌分开;创建、管理和账号成员请求必填,访客省略 |
| `X-WD-Event-*` | 已登记签名设备提交频道对象 FINAL 帧时必填,绑定对象 ID、域/epoch、事件类型与完整密文帧 manifest,见 protocol §21 |
raw 模式的读请求不需要令牌,但仍需要 `X-WD-Endpoint`。
@@ -34,22 +41,26 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
用户名为 3–32 位 ASCII 字母、数字、`_` 或 `-`,大小写不敏感。客户端 PBKDF2-SHA256(600,000 次)派生口令根,再分别派生登录凭据和 AES-GCM 保险库密钥。登录凭据仅经 HTTPS 发送;服务端再用随机 salt 和 PBKDF2-SHA256(210,000 次)存储慢哈希,数据库里的哈希不能直接用于登录。服务端不接收口令根或保险库密钥。二进制字段使用标准 Base64,保险库密文最大 1.3 MB。
- `POST /api/v1/accounts` 注册 `{username,salt,verifier,vault,public_key}`。public_key 为 P-256 ECDH 公钥 JWK 的 JSON 字符串;私钥只在客户端加密保险库中保存。
- `POST /api/v1/accounts` 注册 `{username,salt,verifier,vault,public_key,signing_public_key}`。`public_key` 是 P-256 ECDH 公钥,`signing_public_key` 是 P-256 ECDSA 账号根公钥;对应私钥只保存在客户端加密保险库中。API v6 不兼容旧账号保险库协议,不自动迁移旧签名身份。
- `POST /api/v1/accounts/salt` 请求 `{username}`,返回 `{salt}`,供客户端派生登录凭据。
- `POST /api/v1/accounts/session` 请求 `{username,verifier,device_id?,device_name?}`。成功返回 `{username,token}`;session token 随机生成,只保存 SHA-256,期限 30 天。
- `GET /api/v1/accounts/vault` 使用 `Authorization: Bearer <token>` 读取 `{revision,vault}`。
- `PUT /api/v1/accounts/vault` 使用同一鉴权,发送 `{revision,vault}`。revision 乐观并发控制;冲突返回 `409 vault_conflict`,客户端必须先读取并合并,不能静默覆盖。
- `DELETE /api/v1/accounts/session` 撤销当前 bearer session;退出登录时调用。
- `GET /api/v1/accounts/users/{username}/key` 已登录用户查询收件账号公开邀请密钥。
- `GET /api/v1/accounts/users/{username}/key` 已登录用户查询 ECDH 邀请公钥和可选的账号签名根公钥。
- `PUT /api/v1/accounts/devices/signing-key` 当前设备提交 `{device_id,public_key,signature}`。服务端核对账号 session 绑定的 device_id,并验证账号根密钥对 P-256 设备公钥证书的签名后保存。
- `GET /api/v1/accounts/users/{username}/key` 返回账号 ECDH 公钥与签名根公钥;签名对象携带设备公钥和根签设备证书,客户端首次 TOFU 固定根公钥后离线验设备证书。
- `GET /api/v1/accounts/invitations` 返回当前账号的邀请,payload 为绑定收件用户名和频道 ID 的 ECDH + HKDF + AES-GCM 密文。
没有邮箱找回流程。忘记密码后保险库无法恢复。保险库仅同步资源凭据和草稿清单,不同步设备 S、设备名、缓存、原始文件或发送队列。浏览器存储按账号与访客分库;切换账号会中止订阅并清空内存会话。资源清单移除使用带时间戳的墓碑同步,避免旧设备将条目恢复。
签名根密钥保存在加密保险库;设备签名私钥以不可导出的 CryptoKey 保存在本机账户数据库,不随保险库同步。已登记签名设备写入 Workspace 对象时,FINAL 帧和删除请求必须带签名事件;尚未把签名要求扩展到频道结构和成员/权限变更。
### `GET /api/v1/info`
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力以及创建默认值/选项。`defaults.channel_mode`、`defaults.channel` 和 `choices` 是前端创建表单的初始值与可选项;服务端独立校验提交的设置。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
### `POST /api/v1/channels`
创建者必须登录。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入该频道的 owner 成员记录并占一个设备席位。服务端启用验证码时还需要 `X-WD-Cap`。
创建者必须登录。创建请求必须提供 `settings.name`,服务端拒绝缺失、空白或纯空格名称并返回 `400 space_name_required`,存储时去除首尾空格;名称长度最多 48 个字符。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入 owner 成员记录并占一个成员名额,同时登记当前设备终端;该账号的其他设备不增加名额。服务端启用验证码时还需要 `X-WD-Cap`。
```json
{
@@ -66,32 +77,32 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
raw 模式省略 `read_hash`;`write_hash` 仅在成员可上传时提供。创建者的终端(`X-WD-Endpoint`)自动入席。返回 `201`,响应体与「读取频道信息」相同(`ChannelInfo`)。
### `GET /api/v1/channels/{id}`
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、席位占用、`oldest_seq`、`last_seq`、时间。**不占席位**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
返回 `ChannelInfo`:设置(包含所有成员共享的频道名)、模式、成员名额占用(`seats_used`)、`oldest_seq`、`last_seq`、时间。`seats_used` 统计唯一账号成员和匿名访客终端,不统计账号的设备数。**不占成员名额**,任意角色可调用,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
### `POST /api/v1/channels/{id}/seats`
加入频道,占用一个席位。幂等:同一终端重复调用返回同一席位。席位已满返回 `403 seats_full`。读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。
为一个终端加入频道。每个设备终端各自建立席位以便认证、重命名和撤销,但席位不再受人数上限约束。账号第一次加入时占一个成员名额;同一账号的更多设备不增加名额。同一设备重复调用幂等。成员名额已满时,新的账号成员或访客终端返回 `403 seats_full`。匿名访客无法跨设备识别,每个访客终端各计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、席位信息/设备名等其余操作要求终端已入席,否则返回 `403 not_joined`。
可发送 JSON `{password}`。默认 `join_policy=open,allow_guests=true`,允许持链接的陌生人设备加入。`allow_guests=false` 要求账号登录;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。成员账号和设备身份独立:同一账号可占多个独立设备席位;账号成员的读写请求必须出示对应账号 session 和该设备 S。
可发送 JSON `{password}`。默认 `join_policy=open,allow_guests=true`,允许持链接的陌生人设备加入。`allow_guests=false` 要求账号登录;`join_policy=invite` 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。成员账号和设备终端身份独立:同一账号可拥有不限数量的设备席位,但只占一个成员名额;账号成员的读写请求必须出示对应账号 session 和该设备 S。
`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色。`GET/POST /channels/{id}/invitations` 由频道主查询/发送 `{username,payload}` 邀请;payload 只含客户端加密的成员链接。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权与该账号的设备席位,并阻止重新加入,直到再次邀请。不能移除频道主。设备面板踢出访客也会阻止该 S 重新加入。
`GET /channels/{id}/members` 返回频道内账号成员及 `owner|member` 角色。邀请前,频道主调用 `GET /channels/{id}/invitations/{username}/crypto-plan` 获取服务端根据成员权限和历史设置计算的域/epoch/收件人计划。浏览器只为计划中的收件人生成并封装域密钥;随后 `POST /channels/{id}/invitations` 原子提交账号链接包与 `domain_envelopes`。服务端会重新计算计划并校验每个 epoch 和收件人,不接受前端自行增加授权或选择轮换范围。`DELETE /channels/{id}/invitations/{username}` 撤销邀请、成员授权、域密钥包与该账号的设备席位,并阻止重新加入,直到再次邀请。不能移除频道主。设备面板踢出访客也会阻止该 S 重新加入。
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号或访客设备、频道成员/席位与写入权限,不能靠客户端隐藏按钮绕过。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号或访客设备、频道成员、设备席位与写入权限,不能靠客户端隐藏按钮绕过。移除单台设备只撤销该终端,不释放账号成员名额;移除账号成员才会释放名额。WebSocket 账号 session 经 `wd.account.<base64url(token)>` 子协议传递,并在推送循环中重新检查有效性。
### `PATCH /api/v1/channels/{id}/settings`
所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`ttl_seconds` 的修改从现在起算。
所有者修改设置。可通过 `name` 更新共享频道名,传空字符串清除名称;`seats` 调整成员人数上限,不能改变已有成员身份;`ttl_seconds` 的修改从现在起算。
### `DELETE /api/v1/channels/{id}`
所有者删除整个频道和全部密文。
### `POST /api/v1/channels/{id}/frames`
追加一帧。请求体是完整封装(`application/octet-stream`)。
追加一帧。请求体是完整封装(`application/octet-stream`)。当账号设备已登记签名证书且对象在 `/spaces/{id}/crypto/objects` 声明时,最终帧必须携带 `X-WD-Event-Id/Kind/Channel/Domain/Epoch/Frames/Manifest/Signature`(编辑可带 `X-WD-Event-Target`)。服务端验签并核对此前帧摘要与当前 FINAL 帧的清单;缺少或无效签名拒绝提交。签名随对象元数据进入 `CryptoState`,成员读取时仍按数据域权限过滤。
| 响应 | 含义 |
|---|---|
| `201 {"seq"}` | 新追加 |
| `200 {"seq"}` | 幂等重发,返回原 seq |
| `409` | 同一 `(item_id, frame_index)` 内容不同 |
| `403` | 无权、席位已满、模式或 ENC 标志不符 |
| `403` | 无权、成员人数已满、模式或 ENC 标志不符 |
| `413` | 超出限制 |
| `429` | 限流 |
@@ -115,13 +126,13 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
拉取条目的帧(GATED 条目的取数方式)。`from=0` 计一次下载;`from>0` 只有同一终端此前对同一条目成功启动过 `from=0` 才能续传,否则返回 `410 gone`。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 `410`,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
### `DELETE /api/v1/channels/{id}/items/{item_id}`
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。
按 `delete_policy` 授权后删除条目,并在日志中追加 tombstone。已登记签名设备还必须提交对应 `chat.delete`/`file.delete` 事件头;服务端验证其绑定的设备证书、权限和原对象帧 manifest 后保存删除签名,浏览器验签后才应用 tombstone。
### 席位
- `GET /api/v1/channels/{id}/seats`:所有者查看(终端哈希、加密设备名、加入时间)。
### 设备终端席位
- `GET /api/v1/channels/{id}/seats`:所有者查看已加入设备(终端哈希、加密设备名、加入时间)。设备清单不等于成员人数;一个账号可有多个设备席位。
- `PUT /api/v1/channels/{id}/seats/me`:终端上传设备名(≤256 B);enc 模式加密,raw 模式明文。
- `PUT /api/v1/channels/{id}/seats/{endpoint_hash}/label`:所有者为指定设备更新名称;enc 模式的密文绑定目标 endpoint hash,raw 模式明文。设备也可用 `/seats/me` 更新自己的名称。
- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者移除该设备的席位,其活动订阅随后会被拒绝。
- `DELETE /api/v1/channels/{id}/seats/{endpoint_hash}`:所有者移除该设备的席位,其活动订阅随后会被拒绝;账号成员身份和人数名额保留。撤销账号邀请/成员会移除该账号全部设备并释放成员名额。
## 4. 便捷分享接口
@@ -204,7 +215,7 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
| `not_joined` | 403 | 终端未入席,或席位已被所有者释放 |
| `visitors_full` | 403 | 便捷分享的访客名额已满,且这个终端未登记 |
| `sealed` | 409 | 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误 |
| `seats_full` | 403 | 席位已满 |
| `seats_full` | 403 | 成员人数已满 |
| `mode_mismatch` | 403 | 帧的 ENC 标志与频道模式不符 |
| `not_found` | 404 | 频道或条目不存在,或已过期 |
| `conflict` | 409 | 幂等冲突 |
@@ -219,7 +230,7 @@ WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20
- `GET /api/v1/accounts/channels`:只需 `X-WD-Account`,返回当前账号已加入频道的 `{channel_id,role,payload,created_at}` 密钥包;不返回其他账号或未接受邀请的密钥包,不占席位。
- `PUT /api/v1/channels/{id}/key-package`:需要该频道 owner 能力和 owner 账号,仅更新当前 owner 自己的 `{payload}`,用于补齐已创建频道的账号密钥包。
- 创建频道可携带 `key_package`(1–16384 字符 opaque 密文),与频道和 owner 成员记录原子保存。前端必须提供;底层 API 允许省略供纯协议客户端使用。
- 发送邀请时原子保存收件账号的密钥包;接受后仍可跨设备领取。撤销邀请/成员同时删除密钥包,删除频道清理全部密钥包。
- 发送邀请时原子保存收件账号的空间密钥包、频道数据域 envelopes、默认权限和 epoch 更新;接受后仍可跨设备领取。撤销邀请/成员会删除服务端密钥包与访问记录,但无法收回对方此前保存的密钥,成员移除后的 epoch 轮换仍未实现。删除空间时清理全部密钥包。
### 账号设备与资料
+455 -234
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: WebDrop API
version: 2.1.0
version: 6.0.0
servers:
- url: /api/v1
@@ -15,6 +15,388 @@ tags:
- name: account
paths:
/accounts/profile:
get:
tags: [account]
parameters:
- name: X-WD-Account
in: header
required: true
schema: { type: string }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/AccountProfile'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
patch:
tags: [account]
parameters:
- name: X-WD-Account
in: header
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/AccountProfileRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/AccountProfile'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/accounts/spaces:
get:
tags: [account]
parameters:
- name: X-WD-Account
in: header
required: true
schema: { type: string }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
type: array
items: { $ref: '../contracts/api.schemas.json#/$defs/ChannelInfo' }
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/structure:
get:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: X-WD-Account
in: header
required: false
schema: { type: string }
- $ref: '#/components/parameters/Role'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/channels:
post:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceChannelRequest' }
responses:
'201':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceChannelCreatedBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/channels/{channelId}:
patch:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: channelId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/channels/{channelId}/group:
put:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: channelId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceGroupRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/groups:
post:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' }
responses:
'201':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceGroupCreatedBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/groups/{groupId}:
patch:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: groupId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/folders:
post:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceFolderRequest' }
responses:
'201':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/folders/{folderId}:
patch:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: folderId
in: path
required: true
schema: { type: string, format: uuid }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceNameRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/spaces/{id}/locations:
put:
tags: [channel]
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: X-WD-Account
in: header
required: true
schema: { type: string }
- $ref: '#/components/parameters/RoleWriter'
- $ref: '#/components/parameters/Endpoint'
- name: Authorization
in: header
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '../contracts/api.schemas.json#/$defs/WorkspaceMoveRequest' }
responses:
'200':
description: 服务端权威数据
content:
application/json:
schema:
$ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody'
'400': { $ref: '#/components/responses/Error' }
'401': { $ref: '#/components/responses/Error' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/accounts/channels:
get:
tags: [account]
@@ -244,7 +626,7 @@ paths:
post:
tags: [channel]
security: [{ bearer: [], accountBearer: [] }]
summary: 邀请已注册用户
summary: 原子提交账号邀请及服务端授权计划要求的域密钥包
requestBody:
required: true
content:
@@ -253,6 +635,26 @@ paths:
responses:
'204': { description: 已邀请 }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/channels/{id}/invitations/{username}/crypto-plan:
get:
tags: [channel]
security: [{ bearer: [], accountBearer: [] }]
summary: 获取新成员域密钥封装计划
description: 返回由服务端历史策略和当前成员权限计算的 epoch 与收件人集合;提交时服务端会重新计算并校验。
parameters:
- { $ref: '#/components/parameters/ChannelId' }
- { $ref: '#/components/parameters/RoleOwner' }
- { name: username, in: path, required: true, schema: { type: string } }
responses:
'200':
description: 服务端计算的加密授权计划
content:
application/json:
schema: { $ref: '#/components/schemas/InvitationCryptoPlan' }
'403': { $ref: '#/components/responses/Error' }
'404': { $ref: '#/components/responses/Error' }
'409': { $ref: '#/components/responses/Error' }
/channels/{id}/invitations/{username}:
delete:
tags: [channel]
@@ -299,8 +701,8 @@ paths:
- { $ref: '#/components/parameters/ChannelId' }
get:
tags: [channel]
summary: 读取频道信息(不占席位)
description: 任意角色可调用。用于加入前预览席位占用和核对模式。
summary: 读取频道信息(不占成员名额)
description: 任意角色可调用。`seats_used` 表示唯一账号成员数加匿名访客终端数,不是设备数。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/Role' }
@@ -355,11 +757,11 @@ paths:
- { $ref: '#/components/parameters/ChannelId' }
post:
tags: [seat]
summary: 加入频道(占用席位)
summary: 登记当前设备并加入频道
description: |
幂等:同一终端重复调用返回同一席位,不重复占用。
席位已满返回 403 `seats_full`。
读取频道信息和 owner 管理接口不占席位;频道内容、席位信息/设备名等其余操作要求终端已入席,否则 403 `not_joined`。
幂等:同一终端重复调用返回同一设备席位,不重复登记。
同一账号的设备不限量,账号首次加入时才占一个成员名额;成员名额已满返回 403 `seats_full`。
未登录访客每个终端单独计一个成员名额。读取频道信息和 owner 管理接口不占成员名额;频道内容、设备席位/设备名等操作要求终端已入席,否则 403 `not_joined`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/Role' }
@@ -375,7 +777,7 @@ paths:
'404': { $ref: '#/components/responses/Error' }
get:
tags: [seat]
summary: 查看席位列表(所有者)
summary: 查看已加入设备列表(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。
security: [{ bearer: [] }]
parameters:
@@ -437,8 +839,8 @@ paths:
/channels/{id}/seats/{endpointHash}:
delete:
tags: [seat]
summary: 释放席位(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用席位。被释放终端后续请求返回 `not_joined`;向订阅者推送 `{"t":"seats"}`。
summary: 撤销单台设备(所有者)
description: 有效的 owner 凭据即可调用,不要求 owner 终端占用设备席位。被释放终端后续请求返回 `not_joined`;账号成员身份及人数名额保留;向订阅者推送 `{"t":"seats"}`。
security: [{ bearer: [] }]
parameters:
- { $ref: '#/components/parameters/ChannelId' }
@@ -917,87 +1319,43 @@ components:
schemas:
AccountChannel:
type: object
required: [channel_id, role, payload, created_at]
properties:
channel_id: { $ref: '#/components/schemas/Base64Url' }
role: { type: string, enum: [owner, member] }
payload: { type: string, minLength: 1, maxLength: 16384 }
created_at: { type: integer, format: int64 }
$ref: '../contracts/api.schemas.json#/$defs/AccountChannelBody'
AccountDevice:
type: object
required: [id, name, created_at, last_seen, current]
properties:
id: { type: string, format: uuid }
name: { type: string, minLength: 1, maxLength: 80 }
created_at: { type: integer, format: int64 }
last_seen: { type: integer, format: int64 }
current: { type: boolean }
$ref: '../contracts/api.schemas.json#/$defs/AccountDeviceBody'
PublicKey:
type: object
required: [public_key]
properties:
public_key: { type: string, maxLength: 1024 }
$ref: '../contracts/api.schemas.json#/$defs/PublicKeyBody'
ChannelInvitation:
type: object
required: [channel_id, username, payload, created_at]
properties:
channel_id: { $ref: '#/components/schemas/Base64Url' }
username: { type: string }
payload: { type: string, maxLength: 16384, description: 客户端加密的成员链接 }
created_at: { type: integer, format: int64 }
$ref: '../contracts/api.schemas.json#/$defs/InvitationBody'
InvitationRequest:
type: object
required: [username, payload]
properties:
username: { type: string }
payload: { type: string, maxLength: 16384 }
$ref: '../contracts/api.schemas.json#/$defs/InvitationRequest'
InvitationCryptoPlan:
$ref: '../contracts/api.schemas.json#/$defs/InvitationCryptoPlan'
ChannelMember:
type: object
required: [username, role, joined_at]
properties:
username: { type: string }
role: { type: string, enum: [owner, member] }
joined_at: { type: integer, format: int64 }
$ref: '../contracts/api.schemas.json#/$defs/MemberBody'
AccountRegister:
type: object
required: [username, salt, verifier, vault, public_key]
properties:
username: { type: string, minLength: 3, maxLength: 32, pattern: '^[A-Za-z0-9_-]+$' }
salt: { type: string, contentEncoding: base64, minLength: 24, maxLength: 24 }
verifier: { type: string, contentEncoding: base64, minLength: 44, maxLength: 44 }
vault: { type: string, contentEncoding: base64, maxLength: 1733336 }
public_key: { type: string, maxLength: 1024, description: P-256 ECDH 公钥 JWK 的 JSON 字符串 }
$ref: '../contracts/api.schemas.json#/$defs/AccountRegisterRequest'
AccountSaltRequest:
type: object
required: [username]
properties:
username: { type: string, minLength: 3, maxLength: 32 }
$ref: '../contracts/api.schemas.json#/$defs/AccountSaltRequest'
AccountSalt:
type: object
required: [salt]
properties:
salt: { type: string, contentEncoding: base64 }
$ref: '../contracts/api.schemas.json#/$defs/AccountSaltBody'
AccountSessionRequest:
type: object
required: [username, verifier]
properties:
device_id: { type: string, format: uuid }
device_name: { type: string, minLength: 1, maxLength: 80 }
username: { type: string }
verifier: { type: string, contentEncoding: base64 }
$ref: '../contracts/api.schemas.json#/$defs/AccountSessionRequest'
AccountSession:
type: object
required: [username, token]
properties:
username: { type: string }
token: { type: string, contentEncoding: base64 }
$ref: '../contracts/api.schemas.json#/$defs/AccountSessionBody'
AccountVault:
type: object
required: [revision, vault]
properties:
revision: { type: integer, format: int64, minimum: 0 }
vault: { type: string, contentEncoding: base64, maxLength: 1733336 }
$ref: '../contracts/api.schemas.json#/$defs/AccountVaultBody'
Base64Url:
type: string
pattern: '^[A-Za-z0-9_-]+$'
@@ -1043,186 +1401,49 @@ components:
oldest_seq: { type: integer, format: int64, description: "清理水位 + 1;0 < from < oldest_seq 的续传需要从 0 全量同步" }
ServerInfo:
type: object
required: [protocol, contract_version, limits, captcha, transports]
properties:
protocol: { type: integer, const: 1 }
contract_version: { type: string, example: 2.0.0, description: API 契约的 SemVer。破坏性变更递增主版本。 }
captcha:
type: object
required: [provider, enabled]
properties:
provider: { type: string, enum: [none, cap] }
enabled: { type: boolean }
api_endpoint:
type: string
description: Cap widget 要连接的地址(data-cap-api-endpoint),仅在启用时出现
transports:
type: object
properties:
websocket: { type: boolean }
sse: { type: boolean }
http3: { type: boolean }
limits:
type: object
properties:
max_payload_bytes: { type: integer }
max_frames_per_item: { type: integer }
max_channel_bytes: { type: integer, format: int64 }
max_seats: { type: integer }
max_ttl_seconds: { type: integer }
$ref: '../contracts/api.schemas.json#/$defs/ServerInfoBody'
ChannelMode:
type: string
enum: [enc, raw]
$ref: '../contracts/api.schemas.json#/$defs/ChannelMode'
DeletePolicy:
type: string
enum: [owner_only, own, anyone]
$ref: '../contracts/api.schemas.json#/$defs/DeletePolicy'
ChannelSettings:
type: object
required: [seats, members_can_upload, delete_policy, ttl_seconds, max_items, file_max_downloads, ui_restrict_copy_download]
properties:
join_policy: { type: string, enum: [open, invite], default: open }
allow_guests: { type: boolean, default: true }
name: { type: string, maxLength: 48, description: "共享频道名,所有成员和服务端可见" }
seats: { type: integer, minimum: 1, default: 2 }
members_can_upload: { type: boolean, default: true }
delete_policy: { $ref: '#/components/schemas/DeletePolicy' }
ttl_seconds: { type: integer, minimum: 60, maximum: 604800, default: 86400 }
max_items: { type: integer, minimum: 1, default: 1000 }
file_max_downloads: { type: integer, minimum: 0, default: 10, description: "0 为不限,仅对 GATED 条目生效" }
ui_restrict_copy_download: { type: boolean, default: false, description: "仅供前端展示,服务端不强制" }
$ref: '../contracts/api.schemas.json#/$defs/ChannelSettings'
ChannelSettingsPatch:
type: object
description: ChannelSettings 的全部字段均可选
properties:
join_policy: { type: string, enum: [open, invite] }
allow_guests: { type: boolean }
join_password: { type: string, maxLength: 256, description: 至少 8 字符;空字符串清除;省略保持原值 }
name: { type: string, maxLength: 48, description: "更新共享频道名;空字符串清除名称" }
seats: { type: integer, minimum: 1 }
members_can_upload: { type: boolean }
delete_policy: { $ref: '#/components/schemas/DeletePolicy' }
ttl_seconds: { type: integer, minimum: 60, maximum: 604800 }
max_items: { type: integer, minimum: 1 }
file_max_downloads: { type: integer, minimum: 0 }
ui_restrict_copy_download: { type: boolean }
$ref: '../contracts/api.schemas.json#/$defs/ChannelSettingsPatch'
CreateChannelRequest:
type: object
required: [id, mode, settings, owner_hash]
properties:
key_package: { type: string, minLength: 1, maxLength: 16384, description: 用创建者账号公钥封装的 owner 凭据 }
join_password: { type: string, minLength: 8, maxLength: 256 }
id: { $ref: '#/components/schemas/Base64Url' }
mode: { $ref: '#/components/schemas/ChannelMode' }
settings: { $ref: '#/components/schemas/ChannelSettings' }
read_hash:
$ref: '#/components/schemas/Base64Url'
description: SHA-256(T_read),enc 模式必填,raw 模式省略
write_hash:
$ref: '#/components/schemas/Base64Url'
description: SHA-256(T_write 或 W),仅在成员可上传时提供
owner_hash:
$ref: '#/components/schemas/Base64Url'
description: SHA-256(T_owner)
$ref: '../contracts/api.schemas.json#/$defs/CreateChannelRequest'
ShareSettings:
type: object
required: [ttl_seconds, max_visitors, file_max_downloads]
properties:
ttl_seconds: { type: integer, minimum: 60, maximum: 604800, default: 86400 }
max_visitors: { type: integer, minimum: 0, maximum: 10000, default: 0, description: 0 为不限 }
file_max_downloads: { type: integer, minimum: 0, default: 10, description: 0 为不限 }
$ref: '../contracts/api.schemas.json#/$defs/ShareSettings'
ShareSettingsPatch:
type: object
description: ShareSettings 的字段均可选。
properties:
ttl_seconds: { type: integer, minimum: 60, maximum: 604800 }
max_visitors: { type: integer, minimum: 0, maximum: 10000 }
file_max_downloads: { type: integer, minimum: 0 }
$ref: '../contracts/api.schemas.json#/$defs/ShareSettingsPatch'
CreateShareRequest:
type: object
required: [id, read_hash, owner_hash]
properties:
id: { $ref: '#/components/schemas/Base64Url' }
read_hash: { $ref: '#/components/schemas/Base64Url', description: SHA-256(T_read) }
owner_hash: { $ref: '#/components/schemas/Base64Url', description: SHA-256(T_owner) }
settings: { $ref: '#/components/schemas/ShareSettings' }
$ref: '../contracts/api.schemas.json#/$defs/CreateShareRequest'
FinishShareRequest:
type: object
required: [manifest_item_id]
properties:
manifest_item_id: { $ref: '#/components/schemas/Base64Url' }
$ref: '../contracts/api.schemas.json#/$defs/FinishShareRequest'
ShareItemInfo:
type: object
required: [item_id, bytes, frames, max_downloads, downloads_left]
properties:
complete: { type: boolean }
item_id: { $ref: '#/components/schemas/Base64Url' }
bytes: { type: integer, format: int64 }
frames: { type: integer }
max_downloads: { type: integer, format: int64, description: 0 为不限 }
downloads_left: { type: [integer, 'null'], format: int64, description: "null 表示不限" }
$ref: '../contracts/api.schemas.json#/$defs/ShareItemInfo'
ShareInfo:
type: object
required: [id, state, expires_at, created_at, settings, items]
properties:
id: { $ref: '#/components/schemas/Base64Url' }
state: { type: string, enum: [draft, sealed] }
expires_at: { type: integer, format: int64 }
created_at: { type: integer, format: int64 }
sealed_at: { type: [integer, 'null'], format: int64 }
manifest_item_id: { type: [string, 'null'], description: 清单条目 id }
settings: { $ref: '#/components/schemas/ShareSettings' }
items:
type: array
items: { $ref: '#/components/schemas/ShareItemInfo' }
$ref: '../contracts/api.schemas.json#/$defs/ShareInfo'
ChannelInfo:
type: object
required: [id, mode, settings, seats_used, oldest_seq, last_seq, created_at, expires_at]
properties:
password_required: { type: boolean }
creator_account: { type: string }
id: { $ref: '#/components/schemas/Base64Url' }
mode: { $ref: '#/components/schemas/ChannelMode' }
settings: { $ref: '#/components/schemas/ChannelSettings' }
seats_used: { type: integer }
oldest_seq: { type: integer, format: int64 }
last_seq: { type: integer, format: int64 }
created_at: { type: integer, format: int64 }
expires_at: { type: integer, format: int64 }
$ref: '../contracts/api.schemas.json#/$defs/ChannelInfo'
SeatJoined:
type: object
required: [endpoint_hash, joined_at, seats_used, seats_max]
properties:
endpoint_hash: { $ref: '#/components/schemas/Base64Url' }
joined_at: { type: integer, format: int64 }
seats_used: { type: integer }
seats_max: { type: integer }
$ref: '../contracts/api.schemas.json#/$defs/SeatJoined'
Seat:
type: object
required: [endpoint_hash, joined_at]
properties:
endpoint_hash: { $ref: '#/components/schemas/Base64Url' }
joined_at: { type: integer, format: int64 }
label:
type: [string, 'null']
description: 加密的设备名(base64url),用 K_enc 解密;未设置为 null
$ref: '../contracts/api.schemas.json#/$defs/SeatInfo'
AppendResult:
type: object
required: [seq]
properties:
seq: { type: integer, format: int64 }
$ref: '../contracts/api.schemas.json#/$defs/AppendResult'
+49 -19
View File
@@ -1,5 +1,15 @@
# 协议 v1
### 频道数据域边界
新工作区的空间凭据只用于空间访问与管理,不作为频道内容密钥。每个频道有独立 chat/files 数据域与 epoch;客户端为每个条目生成随机 32 字节对象密钥,以对应数据域的 epoch key 用 AES-256-GCM 封装。封装上下文绑定空间、频道、数据域、epoch 和条目 ID。服务端保存对象归属与密钥封装,不能获得对象明文密钥。客户端解密必须先取得对应数据域授权,禁止回退到空间密钥。
域密钥由创建者随机生成,再通过现有账号 ECDH 公钥信封逐账号封装;域信封上下文包括空间 ID、频道 ID、域名和 epoch。邀请前服务端按成员权限和历史设置生成 epoch/收件人计划;浏览器只为计划中的收件人封装密钥,服务端在单事务中重算并校验邀请、权限、envelopes 和 epoch。服务端根据登录身份只返回该账号可读域的信封和匹配对象索引,并在日志读取、文件下载及写入时执行成员权限检查。默认频道的新成员获得 chat read/write、files read/upload;新增频道的访问不会由空间成员身份自动推导。Owner 可分别决定新成员能否读取聊天和文件历史。关闭某域历史时,邀请流程生成新 epoch,并将新密钥给现有授权成员与新成员;新成员不获旧 epoch 信封。未启用 E2EE 时,后端用服务端维护的 crypto revision 作为访问起点,过滤历史日志和目录位置。该策略不撤回已经交付的密钥;已移除成员的撤销轮换目前尚未实现。
该模型的服务端是授权执行方,而不是可被客户端验证的授权来源:目前没有 Owner 签名 Capability、账号签名身份、设备证书或事件签名。首次邀请公钥也来自服务端目录,公钥替换风险仍存在。不能把 AEAD 校验或服务端权限过滤描述成对恶意服务端的端到端授权验证。
新版本的频道、分组、目录和文件归属通过后端空间结构接口管理。资源内容继续采用浏览器端加密。`file.published` 的 item 使用独立协议结构,不序列化 UI 的 state、preview、remaining 或版本列表。接收端只重建资源渲染状态,不从资源正文构建账户或空间结构。
本文是协议的权威来源。所有实现(服务端、`@webdrop/core`、将来的 CLI)必须通过 `test-vectors/` 中的向量。
## 1. 编码约定
@@ -12,7 +22,7 @@
|---|---|---|
| `channel_id` | 16 B | 随机,客户端生成 |
| `item_id` | 16 B | 随机,客户端生成 |
| `K` | 32 B | 频道密钥,仅 enc 模式,按账号加密封装;可选能力链接使用 fragment |
| `K` | 32 B | 空间/临时频道访问凭据;新工作区内容加密不使用它,可选能力链接使用 fragment |
| `A` | 32 B | 管理密钥,独立随机,只存在于所有者链接 |
| `W` | 32 B | 写密钥,仅 raw 模式且成员可上传时 |
| `S` | 32 B | 终端密钥,每个频道每个终端各生成一个,仅存本地 |
@@ -70,7 +80,7 @@ raw 模式下 `T_owner` 同样由 `A` 派生;`T_write = W`。
| 1 | SNAPSHOT | 该条目是频道的「最新快照」,取代之前的快照条目 |
| 2 | GATED | 不通过实时推送内联分发,只能按条目拉取,并计下载次数 |
| 3 | ENC | 负载为 AES-GCM 密文,必须与频道模式一致 |
| 4 | CONTROL | 频道结构控制条目 |
| 4 | CONTROL | 加密资源元数据记录 |
| 5-7 | 保留 | 必须为 0 |
服务端拒绝不符合上述规则的封装,但从不解析负载。
@@ -126,7 +136,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
第 1 帧起,`data` 全部是内容字节。小内容(文本、JSON)一帧完成,没有额外开销。
**频道 thread 与文件夹**:客户端把 thread/folder 元数据放在上述共享元数据中(enc 时加密,raw 时服务端可见)。thread 的创建、重命名,文件夹的创建/重命名,以及条目移动等操作以 `kind=other` 的 CONTROL 条目追加到同一频道日志;控制正文是版本化 JSON,仅客户端识别。它们不增加服务端表或第二套同步协议。新内容必须归属已创建的 thread;前端新建频道后自动发布“常规”thread 创建记录。GATED 的 announce 不含元数据,但 file.published 加密目录记录可在不下载内容时提供名称和归属;没有目录记录时显示通用占位符。
**频道 thread 与文件夹**:客户端把 thread/folder 元数据放在上述共享元数据中(enc 时加密,raw 时服务端可见)。频道、分组、文件夹和条目归属由后端结构接口管理,不写入内容日志。新建空间与初始“常规”频道由后端原子创建。GATED 的 announce 不含元数据,file.published 加密记录提供名称和原始归属;目录记录未到达时显示等待同步占位符。
**raw 模式(API 契约 2)**:负载使用与 enc 相同的 `u16 meta_len ‖ meta_json ‖ content` 条目结构,只是不加密。名称、thread/folder ID 与其他元数据对服务端可见。旧版“纯字节、无元数据”的 raw 负载不属于新应用契约,旧客户端会被 API 主版本检查拒绝。
@@ -134,7 +144,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
## 8. 日志、序号与条目生命周期
`CONTROL=0x10` 标记频道结构控制条目;保留位为 `0xE0`。控制条目必须是非 GATED、非 SNAPSHOT 的 FINAL 单帧,payload 最多 16,400 B;其生命周期为频道生命周期,不受普通内容 TTL 或 max_items 淘汰。每频道最多 4096 条控制记录,仍计入总字节配额。加密和非加密频道使用相同控制正文与日志处理。
`CONTROL=0x10` 标记加密资源元数据记录;保留位为 `0xE0`。控制条目必须是非 GATED、非 SNAPSHOT 的 FINAL 单帧,payload 最多 16,400 B;其生命周期为频道生命周期,不受普通内容 TTL 或 max_items 淘汰。每频道最多 4096 条控制记录,仍计入总字节配额。加密和非加密频道使用相同控制正文与日志处理。
频道维护一条服务端排序的**日志**,每条记录的序号 `seq` 是 u64,频道内单调递增。
@@ -165,13 +175,15 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
带 SNAPSHOT 的条目取代频道中之前的快照条目(最后写入者胜)。快照必须是**非 GATED 的单帧**(整份状态不超过一帧,约 1 MiB),所以新快照一被接受,服务端就在同一个事务里删除旧快照并产生一条 `reason = 4` 的 tombstone。一个频道在 v1 里只有一个快照槽,多文档请使用多个频道。
## 10. 终端与席位
## 10. 终端与成员人数
- 终端在本地为每个频道生成随机 `S`,请求中带 `S`;服务端保存 `SHA-256(S)` 作为终端标识。
- 终端通过显式的加入请求(`POST .../seats`)占用一个席位;创建者的终端在创建时自动入席。席位已满则拒绝新终端(`seats_full`),已入席的终端可重复加入(幂等)和重连。未入席的终端访问频道返回 `not_joined`。
- 终端通过显式的加入请求(`POST .../seats`)建立设备席位;创建者的当前终端在创建时自动入席。席位用于设备级认证、命名和撤销,不限制同一账号可加入的设备数量。未入席的终端访问频道返回 `not_joined`。
- 频道设置字段 `seats` 保留原线格式名称,但 v5 起表示**最大成员人数**。owner 和每个唯一账号各计一人,同一账号的额外设备不增加人数。成员人数达到上限时,新的账号成员加入返回 `seats_full`。
- 未登录访客没有可跨设备识别的账号身份,每个访客终端按一个访客成员计入上限;因此访客在多台设备加入会占多个名额。若希望严格按自然人去重,应要求加入者登录账号。
- 不同频道使用不同的 `S`,避免服务端把同一设备关联起来。
- 终端可以加密一个设备名上传,所有者能看到成员列表和设备名,并可以释放席位。
- 终端可更新自己的名称;所有者也可重命名任意席位。enc 模式重命名仍使用同一份加密设备标签,并将 AAD 绑定目标 endpoint hash;raw 模式标签仍为明文。所有者移除席位后,该终端不能继续读取频道,但持有成员链接的人仍可重新加入(若有空席位)。
- 终端可以加密一个设备名上传,所有者能看到设备列表和设备名,并可以释放单个设备席位。
- 终端可更新自己的名称;所有者也可重命名任意设备席位。enc 模式重命名仍使用同一份加密设备标签,并将 AAD 绑定目标 endpoint hash;raw 模式标签仍为明文。撤销设备席位后,该终端不能继续读取频道,但账号成员身份和其人数名额保留;撤销账号成员/邀请才释放该账号名额。
- **设备名格式(enc)**:`iv(12 B 随机) ‖ AES-256-GCM(K_label, utf8 名称) ‖ tag(16 B)`,AAD = `"WD1L"`(4 B ASCII)‖ `channel_id` ‖ `endpoint_hash`(32 B,即 `SHA-256(S)`)。总长不超过 256 B,所以名称最多 228 字节 UTF-8。使用派生子密钥 `K_label` 而不是直接用 `K_enc`,避免同一密钥同时用于 HKDF 和 AES-GCM;AAD 绑定终端,服务端无法把一个终端的设备名挪到另一个席位上。raw 模式下设备名是明文 UTF-8。
## 11. 创建与验证码
@@ -193,7 +205,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
| 设置 | 默认 | 范围 |
|---|---|---|
| `name` | 无 | 可选,最多 48 个字符;所有频道成员和服务端可见 |
| `seats` | 2 | 1..64(服务端可配置上限) |
| `seats`(v5 起为成员人数上限) | 2 | 1..64(服务端可配置上限);每个账号计一人,其多设备不增加人数 |
| `members_can_upload` | true | |
| `delete_policy` | `own` | `owner_only` / `own` / `anyone` |
| `ttl_seconds` | 86400 | 最长 604800,每个条目从创建起算 |
@@ -232,7 +244,7 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
| 模式 | enc 或 raw | **只有 enc** |
| 谁能写 | 所有者和成员 | 只有创建者,且只在草稿阶段 |
| 寿命 | 长期(TTL 滚动续期) | 短,封存后到期即清 |
| 访客 | 成员,占席位 | 只读访客,按终端计数,不展示人数 |
| 访客 | 作为访客成员计入人数上限 | 只读访客,按终端计数,不展示人数 |
分享与频道**共用同一个 16 字节 id 空间**,服务端保证全局唯一,所以同一个 id 不可能既是频道又是分享。分享 id 在频道接口上返回 `not_found`,反之亦然。
@@ -330,27 +342,45 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
- `test-vectors/` 提供固定输入、固定输出:密钥派生、令牌、加解密、padding、封装编解码,以及篡改、重排、截断、换频道的负例,必须全部通过。
- 本版**预留**但不实现:口令叠加(Argon2id)、短配对码(PAKE)、多快照槽。
## 17. 按账号分发频道密钥
## 17. 账号凭据与频道域密钥
频道内容仍使用共享 K 加密。创建者用自己的账号 P-256 公钥封装 owner 凭据,随创建请求的 key_package 与频道、owner 成员记录在同一事务内保存。邀请时用收件账号公钥封装 member 凭据,服务端原子保存邀请和密钥包;账号接受邀请并成为成员后,可只凭账号 session 从 /accounts/channels 取回自己的密钥包,不需要含密钥的 URL,也不要求邀请者在线。成员包不得包含管理密钥 A。
空间访问凭据用于服务端资源/终端鉴权,不是频道内容根。创建者用自己的账号 P-256 公钥封装 Owner 凭据,随空间、默认频道、Owner 成员记录原子保存。账号接受空间邀请后,可凭账号 session 从 `/accounts/channels` 取回自己的空间凭据;设备席位仍在实际打开空间时建立。
密钥包沿用邀请的 ECDH、HKDF、AES-GCM,AAD 绑定收件账号与频道 ID。服务端只保存 opaque 密文;成员私钥保存在口令加密的账号保险库。成员登录后解开保险库、取回密钥包,在 Worker 内恢复频道会话;新设备生成独立 S,打开频道时才占设备席位。待接受的邀请不会自动占席位。
内容加密密钥由浏览器生成。每个频道各有独立 `chat` 与 `files` 域,每域从 epoch 1 开始使用随机 32 B key。域信封沿用账号 ECDH、HKDF、AES-GCM,AAD 绑定账号、空间 ID、频道 ID、域名与 epoch。服务端保存 opaque 密文,只向通过频道/域读取授权的账号返回信封。每条消息、文件和加密发布元数据使用独立随机 32 B 对象密钥;对象密钥由域 key 的 AES-GCM 封装,AAD 绑定空间、频道、域、epoch 和 item ID。
撤销成员时同时删除密钥包和席位。此举不能收回已交付的 K 或历史明文;当前没有轮换密钥或前向保密。仅知道频道 ID 或登录账号并不足以获得密钥;新账号必须得到现有持钥 owner 的授权封装。邀请公钥仍来自服务端目录,尚无外部指纹核验。
默认频道成员权限是 chat read/write 与 files read/upload;其它频道不能从空间成员身份推导访问权。Owner 邀请时可分别允许新成员读取已有聊天和文件。关闭历史会对对应域创建新 epoch,把新 key 封装给现有授权账号和新成员,不向新成员分发旧 epoch。此策略不撤回此前已交付的密钥,也不等于成员移除时的完整密码学撤销;已加入成员撤销后的轮换、离线和并发协议仍待实现。
服务端会按账号权限过滤频道结构、域信封、对象索引和日志/下载,并拒绝无写入授权或旧 epoch 的新帧。这是服务端执行的访问策略,不是客户端可独立验证的签名 Capability。域密钥封装目前按账号而非设备,目录公钥替换风险、恶意服务端伪造结构/作者和事件签名缺失仍然存在。
## 18. 频道加密文件目录
文件密文帧之后,在同一发送队列追加 CONTROL file.published,正文包含 item.id、kind=file、threadId、folderId(可选)、name、mime、size、ts、deviceId、gated。记录与 thread/folder 控制记录共用 webdrop.workspace v1 正文。名称/类型/大小/原始 thread/folder 必须与文件第 0 帧元数据相符。目录允许先显示名称,但不得因此自动下载 GATED 内容。
文件密文帧之后,在同一发送队列追加 CONTROL file.published,正文包含 item.id、kind=file、threadId、folderId(可选)、name、mime、size、ts、deviceId、gated。资源记录使用 webdrop.workspace v1 正文。名称/类型/大小/原始 thread/folder 必须与文件第 0 帧元数据相符。目录允许先显示名称,但不得因此自动下载 GATED 内容。
客户端只有在当前日志存在对应文件帧或 announce 时展示目录;文件墓碑清除目录可见状态,全量重放时不恢复不存在/过期文件。目录记录属于 CONTROL,仍受 4096 条控制记录和频道字节配额限制。文件移动覆盖位置索引,不重新加密原始文件,不改变最初认证的内容元数据。
客户端只有在当前日志存在对应文件帧或 announce 时展示目录;文件墓碑清除目录可见状态,全量重放时不恢复不存在/过期文件。目录记录属于 CONTROL,仍受 4096 条控制记录和频道字节配额限制。文件移动由服务端更新归属,不重新加密原始文件,不改变最初认证的内容元数据。
## 19. 空间内的频道分组
界面顶层“空间”对应协议 channel,界面“频道”对应 thread。沿用 webdrop.workspace v1 CONTROL 正文:group.create {id,name},group.rename {id,name},thread.group {id,groupId}(null 表示未分组);thread.create 可包含 groupId。ID 为 UUID,name 为 1–80 个非控制字符。分组仅组织频道,成员、内容密钥和日志仍归属于空间。旧创建记录不含 groupId,作为未分组频道重放。
界面顶层“空间”对应 API space/底层同步资源,界面“频道”是空间中的 WorkspaceChannel,帧元数据 threadId 映射到频道 ID。频道和分组由后端结构接口创建、重命名和管理归属,名称限制与关联验证由后端实施。成员与同步日志归属于空间,频道各自拥有 chat/files 密钥域;分组仅组织频道,不建立新授权或密钥。客户端不重放 group.create、thread.create 等结构记录。
## 20. 发送者展示快照
file.published.item 可携带 author {username,displayName,avatar?};另有 item.author {itemId,author} CONTROL,为文本关联展示身份。username 使用当前账号固定用户名,displayName 最多 48 个字符,avatar 仅允许不超过 8000 字符的 PNG/JPEG/WebP data URL。头像在客户端压缩为最多 64×64 后写入加密正文,不改变内容帧、密钥派生或鉴权头。
聊天发送者由服务端已存条目的 authorAccount 与后端个人资料决定。file.published 不写入作者声明。头像在 Worker 内缩放为最多 64×64 后用于渲染,不影响加密帧或鉴权。
快照是持钥成员提供的展示资料,不是发送者签名。无 author 的旧记录回退设备名;客户端不得将此类内容标记为当前账号发送。结构记录随既有 CONTROL 生命周期处理,实际消息可见性仍由条目和墓碑决定。
服务端提供的作者归属与个人资料不代表端对端发送者签名;资源密文仍由浏览器验证。未关联账号的访客资源回退设备来源,不推测当前用户。
## 21. 签名扩展基础
本节定义后续签名授权和签名事件共用的签名原语;在对应 API 与事件模型完成前,它**不改变现有无签名请求的行为,也不表示频道操作已启用验签**。
- 算法为 ECDSA P-256 / SHA-256。签名线格式为 64 字节 IEEE P1363 `r ‖ s`;公钥按 P-256 JWK 的 `x`、`y` 表示,各为 32 字节 base64url。
- 被签名字节为 `"WDS\x01" ‖ kind_len(u16) ‖ kind(ASCII) ‖ payload_len(u32) ‖ payload`,整数大端。`kind` 为 1–64 字节、匹配 `[a-z][a-z0-9._-]*`;payload 最大 1 MiB。
- `kind` 必须区分对象用途,不能跨类型复用签名。payload 后续按设备证书、Capability、ChatEvent 各自定义唯一字段顺序和二进制编码;禁止直接签未经规范化的 JSON。
- 浏览器和服务端都必须先校验 payload 结构、身份绑定、授权范围和上下文,再验签;任一步失败均拒绝操作。服务端接受请求不代替浏览器验签。
- 此原语只证明某密钥持有者签署了这些字节,不证明公钥属于声称的账号,也不防止服务端隐藏、回滚或向不同客户端展示分叉状态。
`device.certificate` 的 payload 为 `username_len(u8) ‖ lowercase_username(ASCII) ‖ device_id_len(u8) ‖ lowercase_device_id(ASCII UUID D 格式) ‖ device_public_x(32 B) ‖ device_public_y(32 B)`。账号根密钥签发此证书;设备密钥预留用于签署频道结构命令和聊天/文件事件。新设备通过密码/Passkey 解锁保险库恢复账号根密钥,不要求旧设备在线批准。其他账号第一次看到某账号根公钥时采用 TOFU:首次固定并展示“未核验”状态,后续变化不得静默接受;这不能防止首次接触时服务端替换密钥。成员可通过独立渠道比较指纹。透明日志、跨客户端一致性证明和历史检查点属于后续阶段。
`content.event` 的 payload 顺序为 `space_id(16 B) ‖ channel_id_len(u8) ‖ channel_id(ASCII UUID) ‖ username_len(u8) ‖ username(lowercase ASCII) ‖ device_id_len(u8) ‖ device_id(lowercase ASCII UUID) ‖ event_id(16 B) ‖ event_kind_len(u8) ‖ event_kind(ASCII) ‖ domain(u8:chat=1,files=2) ‖ epoch(u32) ‖ item_id(16 B) ‖ frame_count(u32) ‖ manifest_hash(32 B) ‖ target_present(u8) ‖ target_item_id(16 B 或全零)`。`manifest_hash = SHA-256("WD1M" ‖ frame_count(u32) ‖ (frame_index(u32) ‖ SHA-256(完整 frame envelope))...)`,帧必须从 0 连续排列。事件类型包括 `chat.create/edit/snapshot/delete`、`file.publish/catalog/delete`。FINAL 上传头带事件字段;后端确认证书、签名、对象作者/domain/epoch/权限及数据库帧清单一致后,在对象元数据保存签名。`CryptoState` 按域授权过滤后把签名材料返回客户端。浏览器在投影完整帧前验设备证书、TOFU 根 key、事件签名和本地 manifest。
当前已接通新账号根公钥、设备证书校验,以及有账号设备签名密钥的对象最终帧验签/作者 manifest 校验;已登记签名设备的删除也要求签名并把删除证明保存到对象元数据;未注册签名设备的旧账号仍走兼容路径。**频道结构命令和成员/权限变更尚未签名**。TOFU 首次接触风险、服务器分叉/回滚、撤销设备密钥轮换仍未解决,不能宣称已完整抵抗主动恶意服务端。
+7
View File
@@ -1,4 +1,5 @@
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const semver = /^(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-]+)*)?$/;
@@ -8,6 +9,12 @@ const fail = (message) => {
process.exit(1);
};
const dtoSources = ['server/src/WebDrop.Api/ApiJson.cs', 'server/src/WebDrop.Core/Dtos.cs', 'server/src/WebDrop.Core/Settings.cs', 'server/src/WebDrop.Core/Enums.cs', 'server/src/WebDrop.Core/Workspace.cs', 'server/src/WebDrop.Core/AccountProfile.cs', 'server/src/WebDrop.Api/PasskeyDtos.cs', 'server/src/WebDrop.Core/WorkspaceCrypto.cs', 'server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs'];
const dtoDigest = createHash('sha256').update(dtoSources.map(path => `${path}\n${read(path).replace(/\r\n/g, '\n')}\n`).join('')).digest('hex');
const generatedDigest = /backend-contract-sha256: ([a-f0-9]{64})/.exec(read('web/src/core-bridge/generated/api.ts'))?.[1];
if (generatedDigest !== dtoDigest) fail('Backend DTOs changed. Regenerate with dotnet run --project tools/ContractGenerator -- .');
if (JSON.parse(read('contracts/api.schemas.json'))['x-backend-contract-sha256'] !== dtoDigest) fail('Generated API schemas are stale. Run ContractGenerator.');
if (typeof contract.version !== 'string' || !semver.test(contract.version)) {
fail('contracts/api-contract.json must contain a SemVer version.');
}
+1 -1
View File
@@ -42,7 +42,7 @@ python3 scripts/smoke.py out/linux-x64/webdrop # 跨平台版本,只用
| `Captcha:ApiEndpoint` | 空 | 浏览器里 Cap widget 连接的地址,形如 `https://cap.example.com/<site_key>/`,经 `/info` 下发 |
| `Captcha:SiteverifyUrl` | 空 | 本服务调用 Cap 校验 token 的地址,形如 `https://cap.example.com/<site_key>/siteverify`,可以是内网地址 |
| `Captcha:Secret` | 空 | Cap 站点密钥的 secret,只在服务端使用 |
| `Limits:*` | 见 `ServerLimits` | 负载、帧数、频道字节数、席位、TTL 上限等 |
| `Limits:*` | 见 `ServerLimits` | 负载、帧数、频道字节数、成员人数上限、TTL 上限等 |
## 约束
+2 -1
View File
@@ -2,6 +2,7 @@ namespace WebDrop.Api;
public static class ApiContract
{
// v5 changes channel capacity from per-endpoint devices to account members.
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
public const string Version = "2.1.0";
public const string Version = "6.0.0";
}
+97 -3
View File
@@ -21,6 +21,22 @@ public sealed class ServerInfoBody
[JsonPropertyName("captcha")] public required CaptchaBody Captcha { get; init; }
[JsonPropertyName("transports")] public required TransportsBody Transports { get; init; }
[JsonPropertyName("limits")] public required LimitsBody Limits { get; init; }
[JsonPropertyName("defaults")] public required DefaultsBody Defaults { get; init; }
[JsonPropertyName("choices")] public required SettingChoicesBody Choices { get; init; }
}
public sealed class DefaultsBody
{
[JsonPropertyName("channel_mode")] public ChannelMode ChannelMode { get; init; } = CreationDefaults.ChannelMode;
[JsonPropertyName("channel")] public required ChannelSettings Channel { get; init; }
[JsonPropertyName("share")] public required ShareSettings Share { get; init; }
}
public sealed class SettingChoicesBody
{
[JsonPropertyName("seats")] public required int[] Seats { get; init; }
[JsonPropertyName("ttl_seconds")] public required int[] TtlSeconds { get; init; }
[JsonPropertyName("file_max_downloads")] public int[] FileMaxDownloads { get; init; } = ServerSettingChoices.FileMaxDownloads;
[JsonPropertyName("max_visitors")] public required int[] MaxVisitors { get; init; }
}
/// <summary>告诉前端要不要出示验证码、widget 该连哪里(Cap 的 data-cap-api-endpoint)。</summary>
@@ -45,11 +61,15 @@ public sealed class LimitsBody
[JsonPropertyName("max_channel_bytes")] public required long MaxChannelBytes { get; init; }
[JsonPropertyName("max_seats")] public required int MaxSeats { get; init; }
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
[JsonPropertyName("min_ttl_seconds")] public required int MinTtlSeconds { get; init; }
[JsonPropertyName("max_items")] public required int MaxItems { get; init; }
[JsonPropertyName("max_visitors")] public required int MaxVisitors { get; init; }
}
public sealed class AccountRegisterRequest
{
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
[JsonPropertyName("signing_public_key")] public required string SigningPublicKey { 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; }
@@ -83,11 +103,16 @@ public sealed class AccountVaultBody
[JsonPropertyName("vault")] public required string Vault { get; init; }
}
public sealed class PublicKeyBody { [JsonPropertyName("public_key")] public required string PublicKey { get; init; } }
public sealed class PublicKeyBody
{
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
[JsonPropertyName("signing_public_key")] public string? SigningPublicKey { get; init; }
}
public sealed class InvitationRequest
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
[JsonPropertyName("domain_envelopes")] public List<DomainEnvelope>? DomainEnvelopes { get; init; }
}
public sealed class InvitationBody
{
@@ -99,6 +124,12 @@ public sealed class InvitationBody
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 AccountDeviceSigningRequest
{
[JsonPropertyName("device_id")] public required string DeviceId { get; init; }
[JsonPropertyName("public_key")] public required string PublicKey { get; init; }
[JsonPropertyName("signature")] public required string Signature { get; init; }
}
public sealed class AccountDeviceBody
{
[JsonPropertyName("id")] public required string Id { get; init; }
@@ -121,10 +152,73 @@ public sealed class MemberBody
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
}
public sealed class AccountProfileRequest
{
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
}
public sealed class WorkspaceNameRequest { [JsonPropertyName("name")] public required string Name { get; init; } }
public sealed class WorkspaceChannelRequest
{
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("groupId")] public string? GroupId { get; init; }
}
public sealed class WorkspaceGroupRequest { [JsonPropertyName("groupId")] public string? GroupId { get; init; } }
public sealed class WorkspaceFolderRequest
{
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("parentId")] public string? ParentId { get; init; }
}
public sealed class WorkspaceMoveRequest
{
[JsonPropertyName("itemIds")] public required string[] ItemIds { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("folderId")] public string? FolderId { get; init; }
}
public sealed class WorkspaceBody
{
[JsonPropertyName("structure")] public required WorkspaceState Structure { get; init; }
[JsonPropertyName("people")] public required List<AccountProfile> People { get; init; }
}
public sealed class WorkspaceChannelCreatedBody
{
[JsonPropertyName("workspace")] public required WorkspaceBody Workspace { get; init; }
[JsonPropertyName("channel")] public required WorkspaceChannel Channel { get; init; }
}
public sealed class WorkspaceGroupCreatedBody
{
[JsonPropertyName("workspace")] public required WorkspaceBody Workspace { get; init; }
[JsonPropertyName("group")] public required WorkspaceGroup Group { get; init; }
}
[JsonSerializable(typeof(ErrorBody))]
[JsonSerializable(typeof(CryptoState))]
[JsonSerializable(typeof(DomainPublishRequest))]
[JsonSerializable(typeof(ObjectEnvelope))]
[JsonSerializable(typeof(ChannelHistoryRequest))]
[JsonSerializable(typeof(PasskeyBeginRequest))]
[JsonSerializable(typeof(PasskeyOptionsBody))]
[JsonSerializable(typeof(PasskeyFinishRequest))]
[JsonSerializable(typeof(PasskeyInfoBody))]
[JsonSerializable(typeof(List<PasskeyInfoBody>))]
[JsonSerializable(typeof(PasskeySessionBody))]
[JsonSerializable(typeof(PasskeyEnvelopeRequest))]
[JsonSerializable(typeof(AccountProfile))]
[JsonSerializable(typeof(AccountProfileRequest))]
[JsonSerializable(typeof(WorkspaceState))]
[JsonSerializable(typeof(WorkspaceBody))]
[JsonSerializable(typeof(WorkspaceChannelCreatedBody))]
[JsonSerializable(typeof(WorkspaceGroupCreatedBody))]
[JsonSerializable(typeof(WorkspaceNameRequest))]
[JsonSerializable(typeof(WorkspaceChannelRequest))]
[JsonSerializable(typeof(WorkspaceGroupRequest))]
[JsonSerializable(typeof(WorkspaceFolderRequest))]
[JsonSerializable(typeof(WorkspaceMoveRequest))]
[JsonSerializable(typeof(GoneLogBody))]
[JsonSerializable(typeof(ServerInfoBody))]
[JsonSerializable(typeof(ChannelInfo))]
[JsonSerializable(typeof(List<ChannelInfo>))]
[JsonSerializable(typeof(ShareInfo))]
[JsonSerializable(typeof(CreateShareRequest))]
[JsonSerializable(typeof(ShareSettingsPatch))]
@@ -134,9 +228,8 @@ public sealed class MemberBody
[JsonSerializable(typeof(SeatJoined))]
[JsonSerializable(typeof(List<SeatInfo>))]
[JsonSerializable(typeof(AppendResult))]
[JsonSerializable(typeof(SiteverifyRequest))]
[JsonSerializable(typeof(SiteverifyResponse))]
[JsonSerializable(typeof(AccountRegisterRequest))]
[JsonSerializable(typeof(AccountDeviceSigningRequest))]
[JsonSerializable(typeof(AccountSaltRequest))]
[JsonSerializable(typeof(AccountSessionRequest))]
[JsonSerializable(typeof(AccountVaultRequest))]
@@ -145,6 +238,7 @@ public sealed class MemberBody
[JsonSerializable(typeof(AccountVaultBody))]
[JsonSerializable(typeof(PublicKeyBody))]
[JsonSerializable(typeof(InvitationRequest))]
[JsonSerializable(typeof(InvitationCryptoPlan))]
[JsonSerializable(typeof(List<InvitationBody>))]
[JsonSerializable(typeof(JoinRequest))]
[JsonSerializable(typeof(List<MemberBody>))]
+7 -2
View File
@@ -16,6 +16,11 @@ public sealed class SiteverifyResponse
[JsonPropertyName("success")] public bool Success { get; init; }
}
// Backend-to-Cap DTOs are not part of the browser API contract.
[JsonSerializable(typeof(SiteverifyRequest))]
[JsonSerializable(typeof(SiteverifyResponse))]
public sealed partial class CaptchaJson : JsonSerializerContext;
/// <summary>
/// 用 Cap(https://github.com/tiagozip/cap)校验创建频道的验证码。
/// 工作量证明的出题、求解、验证全部由 Cap 完成,本项目不实现任何 PoW。
@@ -36,7 +41,7 @@ public sealed class CapCaptchaVerifier(HttpClient http, CaptchaOptions options,
throw new WebDropException(ErrorCode.CaptchaRequired);
var body = JsonSerializer.Serialize(
new SiteverifyRequest { Secret = options.Secret, Response = token }, ApiJson.Default.SiteverifyRequest);
new SiteverifyRequest { Secret = options.Secret, Response = token }, CaptchaJson.Default.SiteverifyRequest);
try
{
@@ -55,7 +60,7 @@ public sealed class CapCaptchaVerifier(HttpClient http, CaptchaOptions options,
var result = await JsonSerializer.DeserializeAsync(
await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false),
ApiJson.Default.SiteverifyResponse, ct).ConfigureAwait(false);
CaptchaJson.Default.SiteverifyResponse, ct).ConfigureAwait(false);
if (result is not { Success: true }) throw new WebDropException(ErrorCode.CaptchaRequired);
}
+39 -1
View File
@@ -7,6 +7,19 @@ public static partial class Endpoints
{
private static void MapAccounts(RouteGroupBuilder api, SqliteAccountStore accounts)
{
api.MapGet("/accounts/profile", (HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
return Json(accounts.Profile(username), ApiJson.Default.AccountProfile);
});
api.MapPatch("/accounts/profile", 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.AccountProfileRequest, ct);
var profile = accounts.UpdateProfile(username, req.DisplayName, req.Avatar);
svc.AccountProfileChanged(username);
return Json(profile, ApiJson.Default.AccountProfile);
});
api.MapPost("/accounts", async (HttpContext ctx, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountRegisterRequest, ct);
@@ -15,7 +28,8 @@ public static partial class Endpoints
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);
ValidatePublicKey(req.SigningPublicKey);
if (!accounts.Register(username, salt, verifier, vault, req.PublicKey, req.SigningPublicKey)) return Json(new ErrorBody { Error = "username_unavailable" }, ApiJson.Default.ErrorBody, StatusCodes.Status409Conflict);
return Results.NoContent();
}).RequireRateLimiting(CreatePolicy);
@@ -59,6 +73,30 @@ public static partial class Endpoints
if (req.Name is null || !accounts.RenameDevice(username, id, req.Name.Trim())) throw new WebDropException(ErrorCode.BadRequest);
return Results.NoContent();
});
api.MapPut("/accounts/devices/signing-key", async (HttpContext ctx, SqliteAccountStore accountStore, CancellationToken ct) =>
{
if (!TryAccount(ctx, accountStore, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountDeviceSigningRequest, ct);
var token = ctx.Request.Headers["X-WD-Account"].ToString();
var deviceId = accountStore.SessionDevice(token);
if (deviceId is null || !string.Equals(deviceId, req.DeviceId, StringComparison.OrdinalIgnoreCase)) throw new WebDropException(ErrorCode.Forbidden);
ValidatePublicKey(req.PublicKey);
var rootKey = accountStore.GetSigningPublicKey(username);
if (rootKey is null) throw new WebDropException(ErrorCode.BadRequest);
using var root = System.Text.Json.JsonDocument.Parse(rootKey);
var rootObject = root.RootElement;
if (!Codec.TryDecodeExact(rootObject.GetProperty("x").GetString(), 32, out var rootX)
|| !Codec.TryDecodeExact(rootObject.GetProperty("y").GetString(), 32, out var rootY)) throw new WebDropException(ErrorCode.BadRequest);
using var device = System.Text.Json.JsonDocument.Parse(req.PublicKey);
var deviceObject = device.RootElement;
if (!Codec.TryDecodeExact(deviceObject.GetProperty("x").GetString(), 32, out var deviceX)
|| !Codec.TryDecodeExact(deviceObject.GetProperty("y").GetString(), 32, out var deviceY)) throw new WebDropException(ErrorCode.BadRequest);
var payload = SignatureCodec.DeviceCertificatePayload(username, deviceId, deviceX, deviceY);
var signature = DecodeBase64(req.Signature, 64, 64);
if (!SignatureCodec.VerifyP256(rootX, rootY, "device.certificate", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
if (!accountStore.SaveDeviceSigningKey(username, deviceId, req.PublicKey, signature)) throw new WebDropException(ErrorCode.Forbidden);
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);
@@ -7,6 +7,11 @@ public static partial class Endpoints
{
private static void MapChannelAccess(RouteGroupBuilder api, SqliteAccountStore accounts)
{
api.MapGet("/accounts/spaces", (HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
return Json(svc.AccountSpaces(username).ToList(), ApiJson.Default.ListChannelInfo);
});
api.MapGet("/accounts/channels", (HttpContext ctx, ChannelService svc) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
@@ -27,7 +32,7 @@ public static partial class Endpoints
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);
return Json(new PublicKeyBody { PublicKey = key, SigningPublicKey = accounts.GetSigningPublicKey(NormalizeUsername(username)) }, ApiJson.Default.PublicKeyBody);
});
api.MapGet("/accounts/invitations", (HttpContext ctx, ChannelService svc) =>
{
@@ -36,6 +41,12 @@ public static partial class Endpoints
});
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}/invitations/{username}/crypto-plan", (string id, string username, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
{
username = NormalizeUsername(username);
if (accountStore.GetPublicKey(username) is null) throw new WebDropException(ErrorCode.NotFound);
return Json(svc.InvitationPlan(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username), ApiJson.Default.InvitationCryptoPlan);
});
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) =>
@@ -43,7 +54,7 @@ public static partial class Endpoints
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();
svc.InviteUser(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), username, req.Payload, req.DomainEnvelopes ?? []); return Results.NoContent();
});
api.MapDelete("/channels/{id}/invitations/{username}", (string id, string username, HttpContext ctx, ChannelService svc) =>
{
@@ -0,0 +1,93 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
private static PasskeyInfoBody PasskeyInfo(StoredPasskey p) => new() { Id = p.Id, Name = p.Name, PrfSalt = p.PrfSalt, CanRecover = p.KeyEnvelope is not null, CreatedAt = p.CreatedAt };
private static void ValidateRecoveryEnvelope(string? value)
{
if (value is not null) _ = DecodeBase64(value, 28, 512);
}
private static void MapPasskeys(RouteGroupBuilder api, SqliteAccountStore accounts)
{
api.MapGet("/accounts/passkeys", (HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
return Json(accounts.Passkeys(username).Select(PasskeyInfo).ToList(), ApiJson.Default.ListPasskeyInfoBody);
});
api.MapDelete("/accounts/passkeys/{id}", (string id, HttpContext ctx) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
accounts.RemovePasskey(id, username); return Results.NoContent();
});
api.MapPost("/accounts/passkeys/register/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyBeginRequest, ct);
if (NormalizeUsername(req.Username) != username || req.Verifier is null || !accounts.VerifyLogin(username, DecodeBase64(req.Verifier, 32, 32))) throw new WebDropException(ErrorCode.Unauthorized);
var result = await handler.MakeCreationOptionsAsync(new() { Id = username, Name = username, DisplayName = accounts.Profile(username).DisplayName }, ctx);
var salt = Codec.Encode(RandomNumberGenerator.GetBytes(32));
var id = ceremonies.Put(new(username, "register", result.AttestationState ?? throw new WebDropException(ErrorCode.BadRequest), salt, PasskeyCeremonies.Hash(ctx.Request.Headers["X-WD-Account"].ToString()), DateTimeOffset.UtcNow.AddMinutes(2)));
return Json(new PasskeyOptionsBody { Id = id, Options = result.CreationOptionsJson, PrfSalt = salt }, ApiJson.Default.PasskeyOptionsBody);
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/passkeys/register", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyFinishRequest, ct);
var state = ceremonies.Take(req.Id, username, "register", ctx.Request.Headers["X-WD-Account"].ToString());
if (NormalizeUsername(req.Username) != username || req.Credential.Length > 40_000 || req.Name is null || req.Name.Trim().Length is < 1 or > 80 || req.Name.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
ValidateRecoveryEnvelope(req.KeyEnvelope);
var result = await handler.PerformAttestationAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AttestationState = state.State });
if (!result.Succeeded || result.UserEntity?.Id != username || result.Passkey is null) throw new WebDropException(ErrorCode.BadRequest);
result.Passkey.Name = req.Name.Trim();
var row = new StoredPasskey(Codec.Encode(result.Passkey.CredentialId), username, result.Passkey.Name,
JsonSerializer.Serialize(result.Passkey, PasskeyStorageJson.Default.UserPasskeyInfo), state.PrfSalt, req.KeyEnvelope, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
accounts.AddPasskey(row); return Json(PasskeyInfo(row), ApiJson.Default.PasskeyInfoBody, 201);
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/passkeys/login/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.AccountSaltRequest, ct); var username = NormalizeUsername(req.Username);
var options = await handler.MakeRequestOptionsAsync(new PasskeyAccount(username), ctx);
var id = ceremonies.Put(new(username, "login", options.AssertionState ?? throw new WebDropException(ErrorCode.BadRequest), "", null, DateTimeOffset.UtcNow.AddMinutes(2)));
return Json(new PasskeyOptionsBody { Id = id, Options = options.RequestOptionsJson, Credentials = accounts.Passkeys(username).Select(PasskeyInfo).ToList() }, ApiJson.Default.PasskeyOptionsBody);
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/passkeys/login", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, UserManager<PasskeyAccount> manager, PasskeyCeremonies ceremonies, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyFinishRequest, ct); var username = NormalizeUsername(req.Username);
var state = ceremonies.Take(req.Id, username, "login", null);
if (req.Credential.Length > 40_000 || req.DeviceId is null || req.DeviceName is null) throw new WebDropException(ErrorCode.BadRequest);
var result = await handler.PerformAssertionAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AssertionState = state.State });
if (!result.Succeeded || result.User?.Username != username || result.Passkey is null) throw new WebDropException(ErrorCode.Unauthorized);
var id = Codec.Encode(result.Passkey.CredentialId); var row = accounts.Passkey(id) ?? throw new WebDropException(ErrorCode.Unauthorized);
var updated = await manager.AddOrUpdatePasskeyAsync(result.User, result.Passkey);
if (!updated.Succeeded) throw new WebDropException(ErrorCode.Unauthorized);
var token = accounts.CreateDeviceSession(username, req.DeviceId, req.DeviceName) ?? throw new WebDropException(ErrorCode.BadRequest);
return Json(new PasskeySessionBody { Username = username, Token = token, CredentialId = id, PrfSalt = row.PrfSalt, KeyEnvelope = row.KeyEnvelope }, ApiJson.Default.PasskeySessionBody);
}).RequireRateLimiting(CreatePolicy);
api.MapPost("/accounts/passkeys/recovery/options", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, PasskeyCeremonies ceremonies) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var options = await handler.MakeRequestOptionsAsync(new PasskeyAccount(username), ctx);
var id = ceremonies.Put(new(username, "recovery", options.AssertionState ?? throw new WebDropException(ErrorCode.BadRequest), "", PasskeyCeremonies.Hash(ctx.Request.Headers["X-WD-Account"].ToString()), DateTimeOffset.UtcNow.AddMinutes(2)));
return Json(new PasskeyOptionsBody { Id = id, Options = options.RequestOptionsJson, Credentials = accounts.Passkeys(username).Select(PasskeyInfo).ToList() }, ApiJson.Default.PasskeyOptionsBody);
}).RequireRateLimiting(CreatePolicy);
api.MapPut("/accounts/passkeys/recovery", async (HttpContext ctx, IPasskeyHandler<PasskeyAccount> handler, UserManager<PasskeyAccount> manager, PasskeyCeremonies ceremonies, CancellationToken ct) =>
{
if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired);
var req = await ReadJson(ctx.Request, ApiJson.Default.PasskeyEnvelopeRequest, ct);
var state = ceremonies.Take(req.Id, username, "recovery", ctx.Request.Headers["X-WD-Account"].ToString());
if (NormalizeUsername(req.Username) != username || req.Credential.Length > 40_000) throw new WebDropException(ErrorCode.BadRequest);
ValidateRecoveryEnvelope(req.KeyEnvelope);
var result = await handler.PerformAssertionAsync(new() { HttpContext = ctx, CredentialJson = req.Credential, AssertionState = state.State });
if (!result.Succeeded || result.User?.Username != username || result.Passkey is null) throw new WebDropException(ErrorCode.Unauthorized);
if (!(await manager.AddOrUpdatePasskeyAsync(result.User, result.Passkey)).Succeeded) throw new WebDropException(ErrorCode.Unauthorized);
accounts.UpdatePasskeyEnvelope(Codec.Encode(result.Passkey.CredentialId), username, req.KeyEnvelope);
return Results.NoContent();
}).RequireRateLimiting(CreatePolicy);
}
}
@@ -0,0 +1,62 @@
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public static partial class Endpoints
{
private static void MapWorkspace(RouteGroupBuilder api, SqliteAccountStore accounts)
{
WorkspaceBody View(WorkspaceState state) => new()
{
Structure = state,
People = state.Locations.Select(l => l.AuthorAccount).Where(u => u is not null).Distinct()
.Select(u => accounts.Profile(u!)).ToList(),
};
api.MapGet("/spaces/{id}/structure", (string id, HttpContext ctx, ChannelService svc) =>
Json(View(svc.GetWorkspace(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true))), ApiJson.Default.WorkspaceBody));
api.MapPost("/spaces/{id}/channels", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceChannelRequest, ct);
var created = svc.CreateWorkspaceChannelResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name, req.GroupId);
return Json(new WorkspaceChannelCreatedBody { Workspace = View(created.State), Channel = created.Channel }, ApiJson.Default.WorkspaceChannelCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/channels/{channelId}", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPut("/spaces/{id}/channels/{channelId}/group", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceGroupRequest, ct);
return Json(View(svc.GroupWorkspaceChannel(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.GroupId)), ApiJson.Default.WorkspaceBody);
});
api.MapPost("/spaces/{id}/groups", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
var created = svc.CreateWorkspaceGroupResult(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.Name);
return Json(new WorkspaceGroupCreatedBody { Workspace = View(created.State), Group = created.Group }, ApiJson.Default.WorkspaceGroupCreatedBody, 201);
});
api.MapPatch("/spaces/{id}/groups/{groupId}", async (string id, string groupId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), groupId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct);
return Json(View(svc.CreateWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ThreadId, req.ParentId, req.Name)), ApiJson.Default.WorkspaceBody, 201);
});
api.MapPatch("/spaces/{id}/folders/{folderId}", async (string id, string folderId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct);
return Json(View(svc.RenameWorkspaceFolder(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), folderId, req.Name)), ApiJson.Default.WorkspaceBody);
});
api.MapPut("/spaces/{id}/locations", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceMoveRequest, ct);
if (req.ItemIds is null) throw new WebDropException(ErrorCode.BadRequest);
return Json(View(svc.MoveWorkspaceItems(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req.ItemIds, req.ThreadId, req.FolderId)), ApiJson.Default.WorkspaceBody);
});
}
}
@@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
using WebDrop.Core;
namespace WebDrop.Api;
public sealed class ChannelHistoryRequest
{
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; init; }
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; init; }
}
public static partial class Endpoints
{
private static void MapWorkspaceCrypto(RouteGroupBuilder api)
{
api.MapGet("/spaces/{id}/crypto", (string id, HttpContext ctx, ChannelService svc) => Json(svc.ReadCrypto(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true)), ApiJson.Default.CryptoState));
api.MapPut("/spaces/{id}/crypto/domains", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.DomainPublishRequest, ct);
svc.PublishDomain(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req); return Results.NoContent();
});
api.MapPost("/spaces/{id}/crypto/objects", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.ObjectEnvelope, ct);
return Json(svc.DeclareObject(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), req), ApiJson.Default.ObjectEnvelope, 201);
});
api.MapPatch("/spaces/{id}/channels/{channelId}/history", async (string id, string channelId, HttpContext ctx, ChannelService svc, CancellationToken ct) =>
{
var req = await ReadJson(ctx.Request, ApiJson.Default.ChannelHistoryRequest, ct);
svc.SetChannelHistory(ParseId(id), RequestAuth.FromHeaders(ctx.Request, true), channelId, req.ChatHistory, req.FilesHistory); return Results.NoContent();
});
}
}
+102 -4
View File
@@ -1,6 +1,7 @@
using System.Buffers;
using System.Diagnostics;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using WebDrop.Core;
@@ -28,6 +29,14 @@ public static partial class Endpoints
ApiEndpoint = captcha.Enabled ? options.Captcha.ApiEndpoint : null,
},
Transports = new TransportsBody { Http3 = options.EnableHttp3 },
Defaults = new DefaultsBody { Channel = CreationDefaults.Channel(limits), Share = CreationDefaults.Share(limits) },
Choices = new SettingChoicesBody
{
Seats = new[] { 2, 3, 5, 10, limits.MaxSeats }.Where(n => n >= 1 && n <= limits.MaxSeats).Distinct().Order().ToArray(),
TtlSeconds = new[] { 3600, 86_400, 604_800, limits.MinTtlSeconds, limits.MaxTtlSeconds, CreationDefaults.Channel(limits).TtlSeconds }
.Where(n => n >= limits.MinTtlSeconds && n <= limits.MaxTtlSeconds).Distinct().Order().ToArray(),
MaxVisitors = new[] { 0, 2, 5, 10, 20, limits.MaxVisitors }.Where(n => n <= limits.MaxVisitors).Distinct().Order().ToArray(),
},
Limits = new LimitsBody
{
MaxPayloadBytes = limits.MaxPayloadBytes,
@@ -35,10 +44,16 @@ public static partial class Endpoints
MaxChannelBytes = limits.MaxChannelBytes,
MaxSeats = limits.MaxSeats,
MaxTtlSeconds = limits.MaxTtlSeconds,
MinTtlSeconds = limits.MinTtlSeconds,
MaxItems = limits.MaxItemsLimit,
MaxVisitors = limits.MaxVisitors,
},
}, ApiJson.Default.ServerInfoBody));
MapAccounts(api, accounts);
MapPasskeys(api, accounts);
MapWorkspace(api, accounts);
MapWorkspaceCrypto(api);
// ───────── 频道 ─────────
@@ -109,14 +124,15 @@ public static partial class Endpoints
// ───────── 帧与日志 ─────────
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, ServerLimits limits, CancellationToken ct) =>
api.MapPost("/channels/{id}/frames", async (string id, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore, ServerLimits limits, CancellationToken ct) =>
{
var channelId = ParseId(id);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
long? maxDownloads = ParseMaxDownloads(ctx.Request.Headers["X-WD-Max-Downloads"].ToString());
byte[] body = await ReadBody(ctx.Request, Envelope.HeaderSize + limits.MaxPayloadBytes, ct);
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct);
var signedEvent = ParseSignedContentEvent(ctx.Request, accountStore, channelId, creds, body);
var result = await svc.AppendFrameAsync(channelId, creds, body, maxDownloads, ct, signedEvent);
return Json(result, ApiJson.Default.AppendResult, result.Created ? StatusCodes.Status201Created : StatusCodes.Status200OK);
});
@@ -217,9 +233,12 @@ public static partial class Endpoints
return Results.Empty;
});
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc) =>
api.MapDelete("/channels/{id}/items/{itemId}", (string id, string itemId, HttpContext ctx, ChannelService svc, SqliteAccountStore accountStore) =>
{
svc.DeleteItem(ParseId(id), RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true), ParseId(itemId));
var channelId = ParseId(id); var parsedItemId = ParseId(itemId);
var creds = RequestAuth.FromHeaders(ctx.Request, requireEndpoint: true);
var proof = ParseSignedDeleteEvent(ctx.Request, accountStore, svc, channelId, parsedItemId, creds);
svc.DeleteItem(channelId, creds, parsedItemId, proof);
return Results.NoContent();
});
@@ -355,6 +374,85 @@ public static partial class Endpoints
catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException) { throw new WebDropException(ErrorCode.BadRequest); }
}
private static SignedContentEventProof? ParseSignedContentEvent(HttpRequest request, SqliteAccountStore accounts,
Id16 spaceId, Credentials creds, byte[] frame)
{
string[] names = ["X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature", "X-WD-Event-Target"];
var values = names.Select(name => request.Headers[name].ToString()).ToArray();
bool any = values.Any(value => value.Length > 0);
if (!any)
{
if (Envelope.TryParse(frame, out var legacyEnvelope) && legacyEnvelope.IsFinal && creds.AccountName is not null
&& creds.AccountDeviceId is not null && accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) is not null)
throw new WebDropException(ErrorCode.Forbidden);
return null;
}
if (!Envelope.TryParse(frame, out var envelope) || !envelope.IsFinal || creds.AccountName is null || creds.AccountDeviceId is null
|| values.Take(8).Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|| !int.TryParse(values[4], out var epoch) || epoch < 0
|| !int.TryParse(values[5], out var frameCount) || frameCount < 1 || frameCount > 65_536)
throw new WebDropException(ErrorCode.BadRequest);
var domain = values[3] == "chat" ? CryptoDomain.Chat : CryptoDomain.Files;
var eventKind = values[1];
if (domain == CryptoDomain.Chat && !eventKind.StartsWith("chat.", StringComparison.Ordinal)
|| domain == CryptoDomain.Files && !eventKind.StartsWith("file.", StringComparison.Ordinal)) throw new WebDropException(ErrorCode.BadRequest);
if (!Codec.TryDecodeExact(values[0], 16, out var eventId)
|| !Codec.TryDecodeExact(values[6], 32, out var manifestHash)
|| !Codec.TryDecodeExact(values[7], 64, out var signature)) throw new WebDropException(ErrorCode.BadRequest);
byte[] target = [];
if (values[8].Length > 0 && !Codec.TryDecodeExact(values[8], 16, out target)) throw new WebDropException(ErrorCode.BadRequest);
var deviceKey = accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId)
?? throw new WebDropException(ErrorCode.Forbidden);
var rootPublicKey = accounts.GetSigningPublicKey(creds.AccountName) ?? throw new WebDropException(ErrorCode.Forbidden);
using var publicKey = JsonDocument.Parse(deviceKey.PublicKey);
var jwk = publicKey.RootElement;
if (!Codec.TryDecodeExact(jwk.GetProperty("x").GetString(), 32, out var x)
|| !Codec.TryDecodeExact(jwk.GetProperty("y").GetString(), 32, out var y)) throw new WebDropException(ErrorCode.BadRequest);
var payload = SignatureCodec.ContentEventPayload(spaceId.ToArray(), values[2], creds.AccountName,
creds.AccountDeviceId, eventId, eventKind, domain, epoch, envelope.ItemId.ToArray(), frameCount, manifestHash, target);
if (!SignatureCodec.VerifyP256(x, y, "content.event", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
return new SignedContentEventProof(eventId, eventKind, creds.AccountName, values[2], domain, epoch, frameCount,
manifestHash, creds.AccountDeviceId, signature, values[8].Length == 0 ? null : values[8],
rootPublicKey, deviceKey.PublicKey, deviceKey.CertificateSignature);
}
private static SignedContentEventProof? ParseSignedDeleteEvent(HttpRequest request, SqliteAccountStore accounts,
ChannelService service, Id16 spaceId, Id16 itemId, Credentials creds)
{
string[] names = ["X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature"];
var values = names.Select(name => request.Headers[name].ToString()).ToArray();
if (values.All(string.IsNullOrEmpty)) return null;
if (creds.AccountName is null || creds.AccountDeviceId is null || values.Any(string.IsNullOrEmpty)) throw new WebDropException(ErrorCode.BadRequest);
var obj = service.ObjectSigningContext(spaceId, creds, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
var createdEvent = obj.Event ?? throw new WebDropException(ErrorCode.BadRequest);
if (!Guid.TryParseExact(values[2], "D", out _) || values[3] is not ("chat" or "files")
|| !int.TryParse(values[4], out var epoch) || !int.TryParse(values[5], out var frameCount)) throw new WebDropException(ErrorCode.BadRequest);
var domain = values[3] == "chat" ? CryptoDomain.Chat : CryptoDomain.Files;
var kind = domain == CryptoDomain.Chat ? "chat.delete" : "file.delete";
if (values[1] != kind || obj.ChannelId != values[2] || obj.Domain != domain || obj.Epoch != epoch
|| createdEvent.FrameCount != frameCount
|| !Codec.TryDecodeExact(values[0], 16, out var eventId)
|| !Codec.TryDecodeExact(values[6], 32, out var manifest)
|| !Codec.TryDecodeExact(values[7], 64, out var signature)
|| !Codec.TryDecodeExact(createdEvent.ManifestHash, 32, out var objectManifest)
|| !CryptographicOperations.FixedTimeEquals(manifest, objectManifest)) throw new WebDropException(ErrorCode.BadRequest);
var deviceKey = accounts.DeviceSigningKey(creds.AccountName, creds.AccountDeviceId) ?? throw new WebDropException(ErrorCode.Forbidden);
using var publicKey = JsonDocument.Parse(deviceKey.PublicKey); var jwk = publicKey.RootElement;
if (!Codec.TryDecodeExact(jwk.GetProperty("x").GetString(), 32, out var x)
|| !Codec.TryDecodeExact(jwk.GetProperty("y").GetString(), 32, out var y)) throw new WebDropException(ErrorCode.BadRequest);
var payload = SignatureCodec.ContentEventPayload(spaceId.ToArray(), obj.ChannelId, creds.AccountName,
creds.AccountDeviceId, eventId, kind, domain, epoch, itemId.ToArray(), frameCount, manifest, []);
if (!SignatureCodec.VerifyP256(x, y, "content.event", payload, signature)) throw new WebDropException(ErrorCode.Forbidden);
return new SignedContentEventProof(eventId, kind, creds.AccountName, obj.ChannelId, domain, epoch, frameCount,
manifest, creds.AccountDeviceId, signature, null, accounts.GetSigningPublicKey(creds.AccountName)!,
deviceKey.PublicKey, deviceKey.CertificateSignature);
}
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);
@@ -0,0 +1,25 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using WebDrop.Core;
namespace WebDrop.Api;
public sealed record PasskeyCeremony(string Username, string Purpose, string State, string PrfSalt, string? SessionHash, DateTimeOffset Expires);
public sealed class PasskeyCeremonies
{
private readonly ConcurrentDictionary<string, PasskeyCeremony> _pending = new();
public string Put(PasskeyCeremony ceremony)
{
foreach (var entry in _pending) if (entry.Value.Expires <= DateTimeOffset.UtcNow) _pending.TryRemove(entry.Key, out _);
if (_pending.Count >= 1000) throw new WebDropException(ErrorCode.RateLimited);
var id = Codec.Encode(RandomNumberGenerator.GetBytes(32));
_pending[id] = ceremony; return id;
}
public PasskeyCeremony Take(string id, string username, string purpose, string? token)
{
if (string.IsNullOrEmpty(id) || id.Length > 100 || !_pending.TryRemove(id, out var ceremony) || ceremony.Expires <= DateTimeOffset.UtcNow
|| ceremony.Username != username || ceremony.Purpose != purpose || ceremony.SessionHash != Hash(token)) throw new WebDropException(ErrorCode.BadRequest);
return ceremony;
}
public static string? Hash(string? token) => token is null ? null : Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
}
+49
View File
@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
namespace WebDrop.Api;
public sealed class PasskeyBeginRequest
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("verifier")] public string? Verifier { get; init; }
}
public sealed class PasskeyOptionsBody
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("options")] public required string Options { get; init; }
[JsonPropertyName("credentials")] public List<PasskeyInfoBody> Credentials { get; init; } = [];
[JsonPropertyName("prf_salt")] public string? PrfSalt { get; init; }
}
public sealed class PasskeyFinishRequest
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("credential")] public required string Credential { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
[JsonPropertyName("key_envelope")] public string? KeyEnvelope { get; init; }
[JsonPropertyName("device_id")] public string? DeviceId { get; init; }
[JsonPropertyName("device_name")] public string? DeviceName { get; init; }
}
public sealed class PasskeyInfoBody
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("prf_salt")] public required string PrfSalt { get; init; }
[JsonPropertyName("can_recover")] public bool CanRecover { get; init; }
[JsonPropertyName("created_at")] public long CreatedAt { get; init; }
}
public sealed class PasskeySessionBody
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("token")] public required string Token { get; init; }
[JsonPropertyName("credential_id")] public required string CredentialId { get; init; }
[JsonPropertyName("prf_salt")] public required string PrfSalt { get; init; }
[JsonPropertyName("key_envelope")] public string? KeyEnvelope { get; init; }
}
public sealed class PasskeyEnvelopeRequest
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("credential")] public required string Credential { get; init; }
[JsonPropertyName("key_envelope")] public required string KeyEnvelope { get; init; }
}
@@ -0,0 +1,51 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Identity;
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api;
public sealed record PasskeyAccount(string Username);
[JsonSerializable(typeof(UserPasskeyInfo))]
public sealed partial class PasskeyStorageJson : JsonSerializerContext;
/// <summary>Reuse the framework's WebAuthn verifier without replacing the existing account/session system.</summary>
public sealed class PasskeyUserStore(SqliteAccountStore accounts) : IUserPasskeyStore<PasskeyAccount>
{
private readonly Dictionary<string, string> _readVersions = [];
public void Dispose() { }
public Task<string> GetUserIdAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(user.Username);
public Task<string?> GetUserNameAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<string?>(user.Username);
public Task<string?> GetNormalizedUserNameAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<string?>(user.Username.ToUpperInvariant());
public Task SetUserNameAsync(PasskeyAccount user, string? name, CancellationToken ct) => name == user.Username ? Task.CompletedTask : throw new NotSupportedException();
public Task SetNormalizedUserNameAsync(PasskeyAccount user, string? name, CancellationToken ct) => Task.CompletedTask;
public Task<IdentityResult> CreateAsync(PasskeyAccount user, CancellationToken ct) => throw new NotSupportedException();
public Task<IdentityResult> DeleteAsync(PasskeyAccount user, CancellationToken ct) => throw new NotSupportedException();
public Task<IdentityResult> UpdateAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult(IdentityResult.Success);
public Task<PasskeyAccount?> FindByIdAsync(string id, CancellationToken ct) => Task.FromResult(accounts.GetPublicKey(id) is null ? null : new PasskeyAccount(id.ToLowerInvariant()));
public Task<PasskeyAccount?> FindByNameAsync(string name, CancellationToken ct) => FindByIdAsync(name, ct);
public Task<PasskeyAccount?> FindByPasskeyIdAsync(byte[] id, CancellationToken ct) => Task.FromResult(accounts.Passkey(Codec.Encode(id)) is { } record ? new PasskeyAccount(record.Username) : null);
public Task<UserPasskeyInfo?> FindPasskeyAsync(PasskeyAccount user, byte[] id, CancellationToken ct)
{
var record = accounts.Passkey(Codec.Encode(id));
return Task.FromResult(record?.Username == user.Username ? Read(record) : null);
}
public Task<IList<UserPasskeyInfo>> GetPasskeysAsync(PasskeyAccount user, CancellationToken ct) => Task.FromResult<IList<UserPasskeyInfo>>(accounts.Passkeys(user.Username).Select(Read).ToList());
private UserPasskeyInfo Read(StoredPasskey record)
{
_readVersions[record.Id] = record.Data;
return JsonSerializer.Deserialize(record.Data, PasskeyStorageJson.Default.UserPasskeyInfo)!;
}
public Task AddOrUpdatePasskeyAsync(PasskeyAccount user, UserPasskeyInfo passkey, CancellationToken ct)
{
var id = Codec.Encode(passkey.CredentialId);
var latest = accounts.Passkey(id) ?? throw new WebDropException(ErrorCode.Unauthorized);
var previous = JsonSerializer.Deserialize(latest.Data, PasskeyStorageJson.Default.UserPasskeyInfo)!;
if ((previous.SignCount != 0 || passkey.SignCount != 0) && passkey.SignCount <= previous.SignCount) throw new WebDropException(ErrorCode.Unauthorized);
if (!_readVersions.TryGetValue(id, out var old) || !accounts.UpdatePasskeyData(id, user.Username, old, JsonSerializer.Serialize(passkey, PasskeyStorageJson.Default.UserPasskeyInfo))) throw new WebDropException(ErrorCode.Conflict);
return Task.CompletedTask;
}
public Task RemovePasskeyAsync(PasskeyAccount user, byte[] id, CancellationToken ct) { accounts.RemovePasskey(Codec.Encode(id), user.Username); return Task.CompletedTask; }
}
+2 -2
View File
@@ -35,7 +35,7 @@ public static class RequestAuth
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);
return new Credentials(role, token, ParseEndpoint(req.Headers[EndpointHeader].ToString(), requireEndpoint), accountName, deviceId, accountName is not null);
}
/// <summary>
@@ -68,7 +68,7 @@ public static class RequestAuth
}
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);
return new Credentials(parsedRole, token, ParseEndpoint(endpoint, requireEndpoint: true), accountName, deviceId, accountName is not null);
}
public static byte[] ParseEndpointHash(string? header) => ParseEndpoint(header, requireEndpoint: true);
+22 -1
View File
@@ -1,4 +1,6 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using WebDrop.Core;
using WebDrop.Frontend;
using WebDrop.Storage;
@@ -27,6 +29,23 @@ public static class WebDropApp
builder.Services.AddSingleton<IBlobStore>(_ => new DiskBlobStore(Path.Combine(options.DataDir, "v2", "blobs")));
builder.Services.AddSingleton<SqliteChannelStore>(_ => new SqliteChannelStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
builder.Services.AddSingleton<SqliteAccountStore>(_ => new SqliteAccountStore(Path.Combine(options.DataDir, "v2", "webdrop.db")));
builder.Services.AddSingleton<PasskeyCeremonies>();
builder.Services.AddScoped<PasskeyUserStore>();
builder.Services.AddScoped<UserManager<PasskeyAccount>>(sp => new(
sp.GetRequiredService<PasskeyUserStore>(), Options.Create(new IdentityOptions()), new PasswordHasher<PasskeyAccount>(), [], [],
new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), sp, sp.GetRequiredService<ILogger<UserManager<PasskeyAccount>>>()));
builder.Services.Configure<IdentityPasskeyOptions>(o =>
{
o.ServerDomain = options.PasskeyRpId;
o.UserVerificationRequirement = "required"; o.ResidentKeyRequirement = "required";
o.AttestationConveyancePreference = "none"; o.AuthenticatorTimeout = TimeSpan.FromMinutes(2);
o.IsAllowedAlgorithm = algorithm => algorithm == -7;
o.ValidateOrigin = context => ValueTask.FromResult(!context.CrossOrigin && context.TopOrigin is null
&& Uri.TryCreate(context.Origin, UriKind.Absolute, out var origin)
&& (options.CorsOrigins.Contains(origin.GetLeftPart(UriPartial.Authority), StringComparer.Ordinal)
|| origin.GetLeftPart(UriPartial.Authority) == $"{context.HttpContext.Request.Scheme}://{context.HttpContext.Request.Host}"));
});
builder.Services.AddScoped<IPasskeyHandler<PasskeyAccount>, PasskeyHandler<PasskeyAccount>>();
builder.Services.AddSingleton<IResourceStore>(sp => sp.GetRequiredService<SqliteChannelStore>());
builder.Services.AddSingleton<ICaptchaVerifier>(sp => CreateCaptchaVerifier(options.Captcha, sp));
builder.Services.AddSingleton<ChannelHub>();
@@ -43,7 +62,9 @@ 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-Max-Downloads", "X-WD-Account")
"X-WD-Role", "X-WD-Endpoint", "X-WD-Cap", "X-WD-Max-Downloads", "X-WD-Account",
"X-WD-Event-Id", "X-WD-Event-Kind", "X-WD-Event-Channel", "X-WD-Event-Domain",
"X-WD-Event-Epoch", "X-WD-Event-Frames", "X-WD-Event-Manifest", "X-WD-Event-Signature", "X-WD-Event-Target")
.WithExposedHeaders("X-WD-Last-Seq")
.SetPreflightMaxAge(TimeSpan.FromHours(1))));
}
+1
View File
@@ -18,6 +18,7 @@ public sealed class WebDropOptions
/// 服务端进程本身不内置 QUIC:CreateSlimBuilder 不含 HTTP/3,需要时再接入。
/// </summary>
public bool EnableHttp3 { get; set; }
public string? PasskeyRpId { get; set; }
public int RequestsPerMinute { get; set; } = 600;
public int ChannelCreatesPerMinute { get; set; } = 30;
+6
View File
@@ -91,6 +91,11 @@ public interface IResourceTx
// 频道
Channel? GetChannel(Id16 id);
void InsertChannel(Channel channel);
WorkspaceState? GetWorkspace(Id16 id);
CryptoState GetWorkspaceCrypto(Id16 id);
void PutWorkspaceCrypto(Id16 id, CryptoState state);
IReadOnlyList<Id16> ListAccountSpaces(string username, long now);
void PutWorkspace(Id16 id, WorkspaceState state);
void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt);
void SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt);
bool HasInvitation(Id16 id, string username);
@@ -122,6 +127,7 @@ public interface IResourceTx
// 席位
Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
int CountSeats(Id16 channelId);
int CountMembers(Id16 channelId);
void InsertSeat(Id16 channelId, Seat seat);
bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash);
void SetSeatLabel(Id16 channelId, ReadOnlySpan<byte> endpointHash, byte[] label);
+11
View File
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace WebDrop.Core;
public sealed class AccountProfile
{
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("displayName")] public required string DisplayName { get; init; }
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
[JsonPropertyName("modifiedAt")] public required long ModifiedAt { get; init; }
}
+6 -16
View File
@@ -19,7 +19,7 @@ public sealed partial class ChannelService
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.JoinPolicy == 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);
@@ -28,21 +28,6 @@ public sealed partial class ChannelService
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<ChannelInvitation> Invitations(Id16 id, Credentials creds) => store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.None);
@@ -63,6 +48,11 @@ public sealed partial class ChannelService
tx.DeleteKeyPackage(id, username);
tx.DeleteAccountSeats(id, username);
tx.DeleteChannelMember(id, username);
var crypto = tx.GetWorkspaceCrypto(id);
crypto.Access.RemoveAll(a => a.Username == username);
crypto.Envelopes.RemoveAll(e => e.Username == username);
crypto.Revision++;
tx.PutWorkspaceCrypto(id, crypto);
tx.DenyAccount(id, username);
return 0;
});
+212
View File
@@ -0,0 +1,212 @@
namespace WebDrop.Core;
public sealed partial class ChannelService
{
public InvitationCryptoPlan InvitationPlan(Id16 id, Credentials creds, string username) => store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict);
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.Conflict);
var batches = new List<CryptoGrantBatch>();
if (space.Mode == ChannelMode.Enc)
{
var crypto = tx.GetWorkspaceCrypto(id);
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files })
{
var currentEpoch = domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch;
if (currentEpoch < 1) throw new WebDropException(ErrorCode.Conflict);
var history = domain == CryptoDomain.Chat ? channel.ChatHistory : channel.FilesHistory;
if (history)
{
var epochs = crypto.Envelopes.Where(e => e.ChannelId == channelId && e.Domain == domain && e.Username == space.CreatorAccount)
.Select(e => e.Epoch).Append(currentEpoch).Distinct().Order().ToList();
if (epochs.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
foreach (var epoch in epochs) batches.Add(new CryptoGrantBatch { Domain = domain, Epoch = epoch, Recipients = [username], Rotate = false });
}
else
{
var recipients = crypto.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead)
&& (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().Order(StringComparer.Ordinal).ToList();
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
batches.Add(new CryptoGrantBatch { Domain = domain, Epoch = currentEpoch + 1, Recipients = recipients, Rotate = true });
}
}
}
return new InvitationCryptoPlan { ChannelId = channelId, Username = username, Batches = batches };
});
public void InviteUser(Id16 id, Credentials creds, string username, string payload, IReadOnlyList<DomainEnvelope> envelopes)
{
if (payload is null || payload.Length is < 1 or > 16_384 || envelopes is null) throw new WebDropException(ErrorCode.BadRequest);
store.Write(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.None); RequireOwner(principal);
if (tx.GetChannelMember(id, username) is not null || tx.HasInvitation(id, username)) throw new WebDropException(ErrorCode.Conflict);
if (tx.GetChannelMember(id, username)?.Role == "owner") throw new WebDropException(ErrorCode.Forbidden);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channelId = workspace.DefaultChannelId ?? throw new WebDropException(ErrorCode.Conflict);
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.Conflict);
var state = tx.GetWorkspaceCrypto(id);
var expected = new List<(CryptoDomain Domain, int Epoch, string Username)>();
if (space.Mode == ChannelMode.Enc)
{
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files })
{
var currentEpoch = domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch;
if (currentEpoch < 1) throw new WebDropException(ErrorCode.Conflict);
var history = domain == CryptoDomain.Chat ? channel.ChatHistory : channel.FilesHistory;
if (history)
{
var epochs = state.Envelopes.Where(e => e.ChannelId == channelId && e.Domain == domain && e.Username == space.CreatorAccount)
.Select(e => e.Epoch).Append(currentEpoch).Distinct().Order().ToList();
if (epochs.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
expected.AddRange(epochs.Select(epoch => (domain, epoch, username)));
}
else
{
var recipients = state.Access.Where(a => a.ChannelId == channelId && (domain == CryptoDomain.Chat ? a.ChatRead : a.FilesRead)
&& (a.Username == space.CreatorAccount || tx.GetChannelMember(id, a.Username) is not null || tx.HasInvitation(id, a.Username)))
.Select(a => a.Username).Append(space.CreatorAccount!).Append(username).Distinct().ToHashSet(StringComparer.Ordinal);
if (recipients.Count > 256) throw new WebDropException(ErrorCode.TooLarge);
expected.AddRange(recipients.Select(recipient => (domain, currentEpoch + 1, recipient)));
}
}
}
var actual = envelopes.Select(e => (e.Domain, e.Epoch, e.Username)).ToList();
if (actual.Count != expected.Count || actual.Distinct().Count() != actual.Count || !actual.ToHashSet().SetEquals(expected)) throw new WebDropException(ErrorCode.BadRequest);
foreach (var envelope in envelopes)
{
if (envelope.ChannelId != channelId || envelope.Payload is null || envelope.Payload.Length is < 1 or > 16_384
|| !Enum.IsDefined(envelope.Domain) || envelope.Epoch < 1) throw new WebDropException(ErrorCode.BadRequest);
var previous = state.Envelopes.Find(e => e.ChannelId == channelId && e.Domain == envelope.Domain && e.Epoch == envelope.Epoch && e.Username == envelope.Username);
if (previous is not null && previous.Payload != envelope.Payload) throw new WebDropException(ErrorCode.Conflict);
if (previous is null) state.Envelopes.Add(envelope);
if (envelope.Domain == CryptoDomain.Chat) channel.ChatEpoch = Math.Max(channel.ChatEpoch, envelope.Epoch);
else channel.FilesEpoch = Math.Max(channel.FilesEpoch, envelope.Epoch);
}
state.Access.RemoveAll(a => a.ChannelId == channelId && a.Username == username);
state.Access.Add(new ChannelAccess
{
ChannelId = channelId, Username = username, ChatRead = true, ChatWrite = true, FilesRead = true, FilesUpload = true,
ChatFromRevision = channel.ChatHistory ? 0 : state.Revision + 1,
FilesFromRevision = channel.FilesHistory ? 0 : state.Revision + 1,
});
state.Revision++;
workspace.Revision++;
tx.PutWorkspace(id, workspace); tx.PutWorkspaceCrypto(id, state);
tx.PutInvitation(new ChannelInvitation(id, username, payload, clock.NowMs));
tx.PutKeyPackage(id, username, payload, clock.NowMs); tx.AllowAccount(id, username);
return 0;
});
events.SettingsChanged(id);
}
private static bool CanReadObject(IResourceTx tx, Channel space, Principal principal, Id16 itemId)
{
var crypto = tx.GetWorkspaceCrypto(space.Id);
if (crypto.Access.Count == 0 && crypto.Envelopes.Count == 0 && space.Mode == ChannelMode.Raw) return true;
if (crypto.Envelopes.Count == 0 && crypto.Objects.Count == 0 && space.Mode == ChannelMode.Enc) return true;
var item = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url());
if (item is null) return false;
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return true;
var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName);
if (access is null || (item.Domain == CryptoDomain.Chat ? !access.ChatRead || item.CreatedRevision < access.ChatFromRevision : !access.FilesRead || item.CreatedRevision < access.FilesFromRevision)) return false;
if (space.Mode == ChannelMode.Raw) return true;
return crypto.Envelopes.Exists(e => e.ChannelId == item.ChannelId && e.Domain == item.Domain && e.Epoch == item.Epoch && e.Username == principal.AccountName);
}
private static void CheckObjectWrite(IResourceTx tx, Channel space, Principal principal, Envelope envelope)
{
var crypto = tx.GetWorkspaceCrypto(space.Id);
if (space.Mode == ChannelMode.Enc && crypto.Envelopes.Count == 0) return;
if (space.Mode == ChannelMode.Raw && crypto.Access.Count == 0 && crypto.Objects.Count == 0) return;
var item = crypto.Objects.Find(o => o.ItemId == envelope.ItemId.ToBase64Url()) ?? throw new WebDropException(ErrorCode.BadRequest);
if (item.AuthorAccount != principal.AccountName) throw new WebDropException(ErrorCode.Forbidden);
var channel = tx.GetWorkspace(space.Id)?.Channels.Find(c => c.Id == item.ChannelId);
if (space.Mode == ChannelMode.Enc && channel is not null && item.Epoch != (item.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
if (principal.Role != Role.Owner)
{
var access = crypto.Access.Find(a => a.ChannelId == item.ChannelId && a.Username == principal.AccountName);
if (access is null && (space.Mode == ChannelMode.Enc || crypto.Access.Count > 0)
|| access is not null && (item.Domain == CryptoDomain.Chat ? !access.ChatWrite : !access.FilesUpload)) throw new WebDropException(ErrorCode.Forbidden);
}
}
public CryptoState ReadCrypto(Id16 id, Credentials creds) => store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var state = tx.GetWorkspaceCrypto(id);
if (principal.Role == Role.Owner || principal.AccountName == space.CreatorAccount) return state;
var access = state.Access.Where(a => a.Username == principal.AccountName).ToList();
var allowed = access.ToDictionary(a => a.ChannelId);
bool CanRead(string channelId, CryptoDomain domain) => allowed.TryGetValue(channelId, out var grant)
&& (domain == CryptoDomain.Chat ? grant.ChatRead : grant.FilesRead);
var envelopes = state.Envelopes.Where(e => e.Username == principal.AccountName && CanRead(e.ChannelId, e.Domain)).ToList();
var epochs = envelopes.Select(e => (e.ChannelId, e.Domain, e.Epoch)).ToHashSet();
return new CryptoState { Envelopes = envelopes,
Objects = state.Objects.Where(o => CanRead(o.ChannelId, o.Domain)
&& (space.Mode == ChannelMode.Raw || epochs.Contains((o.ChannelId, o.Domain, o.Epoch)))).ToList(),
Access = access };
});
public void PublishDomain(Id16 id, Credentials creds, DomainPublishRequest req)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!Enum.IsDefined(req.Domain) || req.Envelopes is null || req.Envelopes.Count != 1 || req.Epoch != 1) throw new WebDropException(ErrorCode.BadRequest);
var epoch = req.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch;
if (epoch > 1) throw new WebDropException(ErrorCode.Conflict);
if (req.Envelopes[0].Username != principal.AccountName || req.Envelopes[0].ChannelId != req.ChannelId || req.Envelopes[0].Domain != req.Domain || req.Envelopes[0].Epoch != 1
|| req.Envelopes[0].Payload is null || req.Envelopes[0].Payload.Length is < 1 or > 16_384) throw new WebDropException(ErrorCode.BadRequest);
var state = tx.GetWorkspaceCrypto(id);
var envelope = req.Envelopes[0];
var previous = state.Envelopes.Find(e => e.ChannelId == envelope.ChannelId && e.Domain == envelope.Domain && e.Epoch == 1 && e.Username == envelope.Username);
if (previous is not null && previous.Payload != envelope.Payload) throw new WebDropException(ErrorCode.Conflict);
if (previous is null) state.Envelopes.Add(envelope);
var ownerAccess = state.Access.Find(a => a.ChannelId == req.ChannelId && a.Username == principal.AccountName);
if (ownerAccess is null) state.Access.Add(new ChannelAccess { ChannelId = req.ChannelId, Username = principal.AccountName!, ChatRead = true, ChatWrite = true, FilesRead = true, FilesUpload = true });
if (req.Domain == CryptoDomain.Chat) channel.ChatEpoch = Math.Max(channel.ChatEpoch, req.Epoch); else channel.FilesEpoch = Math.Max(channel.FilesEpoch, req.Epoch);
state.Revision++;
workspace.Revision++; tx.PutWorkspace(id, workspace); tx.PutWorkspaceCrypto(id, state); return 0;
});
events.SettingsChanged(id);
}
public ObjectEnvelope DeclareObject(Id16 id, Credentials creds, ObjectEnvelope req) => store.Write(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
if (!ChannelPolicy.CanWrite(space, principal)) throw new WebDropException(ErrorCode.Forbidden);
if (!Id16.TryParse(req.ItemId, out _) || !Enum.IsDefined(req.Domain) || req.KeyEnvelope is null || req.KeyEnvelope.Length > 1024) throw new WebDropException(ErrorCode.BadRequest);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channel = workspace.Channels.Find(c => c.Id == req.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
var state = tx.GetWorkspaceCrypto(id);
if (principal.Role != Role.Owner && (space.Mode == ChannelMode.Enc || state.Access.Count > 0))
{
var access = state.Access.Find(a => a.ChannelId == req.ChannelId && a.Username == principal.AccountName);
if (access is null || req.Domain == CryptoDomain.Chat && !access.ChatWrite || req.Domain == CryptoDomain.Files && !access.FilesUpload) throw new WebDropException(ErrorCode.Forbidden);
}
if (space.Mode == ChannelMode.Enc && req.Epoch != (req.Domain == CryptoDomain.Chat ? channel.ChatEpoch : channel.FilesEpoch)) throw new WebDropException(ErrorCode.Conflict);
var existing = state.Objects.Find(o => o.ItemId == req.ItemId);
if (existing is not null)
{
if (existing.AuthorAccount != principal.AccountName || existing.ChannelId != req.ChannelId || existing.Domain != req.Domain || existing.Epoch != req.Epoch || existing.KeyEnvelope != req.KeyEnvelope) throw new WebDropException(ErrorCode.Conflict);
return existing;
}
if (state.Objects.Count >= 20_000) throw new WebDropException(ErrorCode.TooLarge);
req.AuthorAccount = principal.AccountName; req.CreatedRevision = state.Revision + 1; state.Revision++; state.Objects.Add(req); tx.PutWorkspaceCrypto(id, state); return req;
});
public void SetChannelHistory(Id16 id, Credentials creds, string channelId, bool chat, bool files)
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required); RequireOwner(principal);
var workspace = tx.GetWorkspace(id) ?? throw new WebDropException(ErrorCode.NotFound);
var channel = workspace.Channels.Find(c => c.Id == channelId) ?? throw new WebDropException(ErrorCode.NotFound);
channel.ChatHistory = chat; channel.FilesHistory = files; workspace.Revision++; tx.PutWorkspace(id, workspace); return 0;
});
events.SettingsChanged(id);
}
}
+115 -17
View File
@@ -24,7 +24,9 @@ public sealed partial class ChannelService(
if (!Id16.TryParse(req.Id, out var id)) throw new WebDropException(ErrorCode.BadRequest);
if (req.Mode is not { } mode) throw new WebDropException(ErrorCode.BadRequest);
var settings = req.Settings ?? new ChannelSettings();
var settings = req.Settings ?? CreationDefaults.Channel(limits);
if (string.IsNullOrWhiteSpace(settings.Name)) throw new WebDropException(ErrorCode.SpaceNameRequired);
settings.Name = settings.Name.Trim();
settings.Validate(limits);
if (!Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash))
@@ -54,6 +56,7 @@ public sealed partial class ChannelService(
{
if (tx.HasResourceId(id)) throw new WebDropException(ErrorCode.Conflict);
tx.InsertChannel(channel);
tx.PutWorkspace(id, NewWorkspace(creatorAccount, now));
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));
@@ -64,8 +67,8 @@ public sealed partial class ChannelService(
public ChannelInfo GetChannel(Id16 id, Credentials creds) => store.Read(tx =>
{
var (channel, _) = Authenticate(tx, id, creds, SeatRule.None);
return ToInfo(channel, tx.CountSeats(id));
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.None);
return ToInfo(channel, tx.CountMembers(id), principal);
});
public ChannelInfo UpdateSettings(Id16 id, Credentials creds, ChannelSettingsPatch patch)
@@ -88,7 +91,7 @@ public sealed partial class ChannelService(
tx.SetJoinPassword(id, hash, salt);
channel = channel with { JoinPasswordHash = hash, JoinPasswordSalt = salt };
}
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountSeats(id));
return ToInfo(channel with { Settings = updated, ExpiresAt = expiresAt }, tx.CountMembers(id));
});
events.SettingsChanged(id);
return info;
@@ -117,21 +120,23 @@ public sealed partial class ChannelService(
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);
int membersUsed = tx.CountMembers(id);
bool accountAlreadyMember = creds.AccountName is not null && tx.GetChannelMember(id, creds.AccountName) is not null;
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);
return (new SeatJoined { EndpointHash = Codec.Encode(existing.EndpointHash), JoinedAt = existing.JoinedAt, SeatsUsed = membersUsed, SeatsMax = channel.Settings.Seats }, false);
}
CheckAdmission(tx, channel, creds, password);
if (used >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
bool newMember = creds.AccountName is null || !accountAlreadyMember;
if (newMember && membersUsed >= channel.Settings.Seats) throw new WebDropException(ErrorCode.SeatsFull);
long now = clock.NowMs;
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);
return (new SeatJoined { EndpointHash = Codec.Encode(principal.EndpointHash), JoinedAt = now, SeatsUsed = membersUsed + (newMember ? 1 : 0), SeatsMax = channel.Settings.Seats }, true);
});
if (isNew) events.SeatsChanged(id);
return joined;
@@ -199,7 +204,7 @@ public sealed partial class ChannelService(
public async Task<AppendResult> AppendFrameAsync(
Id16 channelId, Credentials creds, ReadOnlyMemory<byte> body,
long? maxDownloads, CancellationToken ct = default)
long? maxDownloads, CancellationToken ct = default, SignedContentEventProof? signedEvent = null)
{
if (!Envelope.TryParse(body.Span, out var env)) throw new WebDropException(ErrorCode.BadRequest);
if (env.PayloadLength > limits.MaxPayloadBytes) throw new WebDropException(ErrorCode.TooLarge);
@@ -212,6 +217,7 @@ public sealed partial class ChannelService(
{
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
CheckWritable(channel, principal, env);
CheckObjectWrite(tx, channel, principal, env);
return tx.GetFrame(channelId, env.ItemId, env.FrameIndex);
});
if (existing is not null) return Resolve(existing, digest);
@@ -228,7 +234,7 @@ public sealed partial class ChannelService(
AppendOutcome outcome;
try
{
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads));
outcome = store.Write(tx => AppendInTx(tx, channelId, creds, env, body, digest, blobKey, maxDownloads, signedEvent));
}
catch
{
@@ -251,10 +257,11 @@ public sealed partial class ChannelService(
private AppendOutcome AppendInTx(
IResourceTx tx, Id16 channelId, Credentials creds, Envelope env, ReadOnlyMemory<byte> body,
byte[] digest, string? blobKey, long? maxDownloads)
byte[] digest, string? blobKey, long? maxDownloads, SignedContentEventProof? signedEvent)
{
var (channel, principal) = Authenticate(tx, channelId, creds, SeatRule.Required);
CheckWritable(channel, principal, env);
CheckObjectWrite(tx, channel, principal, env);
// 并发下预检之后可能已有同一帧
if (tx.GetFrame(channelId, env.ItemId, env.FrameIndex) is { } dup)
@@ -263,12 +270,54 @@ public sealed partial class ChannelService(
long now = clock.NowMs;
long ttlMs = channel.Settings.TtlSeconds * 1000L;
var item = tx.GetItem(channelId, env.ItemId);
var crypto = tx.GetWorkspaceCrypto(channelId);
var objectEnvelope = crypto.Objects.Find(o => o.ItemId == env.ItemId.ToBase64Url());
if (signedEvent is not null && (objectEnvelope is null || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
if (objectEnvelope is not null && env.IsFinal)
{
if (signedEvent is null)
{
if (creds.RequireSignedContent) throw new WebDropException(ErrorCode.Forbidden);
}
else
{
if (objectEnvelope.AuthorAccount != principal.AccountName || signedEvent.Username != principal.AccountName || creds.AccountDeviceId != signedEvent.DeviceId
|| objectEnvelope.ChannelId != signedEvent.ChannelId || objectEnvelope.Domain != signedEvent.Domain
|| objectEnvelope.Epoch != signedEvent.Epoch || signedEvent.FrameCount != env.FrameIndex + 1
|| signedEvent.EventIdBytes.Length != 16 || signedEvent.ManifestHash.Length != 32 || signedEvent.Signature.Length != 64
|| objectEnvelope.Event is not null
|| signedEvent.Domain == CryptoDomain.Chat && signedEvent.EventKind is not ("chat.create" or "chat.edit" or "chat.snapshot")
|| signedEvent.Domain == CryptoDomain.Files && signedEvent.EventKind is not ("file.publish" or "file.catalog"))
throw new WebDropException(ErrorCode.Forbidden);
if (signedEvent.EventKind == "chat.edit")
{
if (signedEvent.TargetItemId is null || !Codec.TryDecodeExact(signedEvent.TargetItemId, 16, out var targetId)) throw new WebDropException(ErrorCode.BadRequest);
var targetItem = tx.GetItem(channelId, Id16.FromBytes(targetId));
var targetObject = crypto.Objects.Find(o => o.ItemId == signedEvent.TargetItemId);
if (targetItem is null || targetObject?.Domain != CryptoDomain.Chat || !ChannelPolicy.CanDeleteItem(channel, targetItem, principal))
throw new WebDropException(ErrorCode.Forbidden);
}
else if (signedEvent.TargetItemId is not null) throw new WebDropException(ErrorCode.BadRequest);
var manifestFrames = tx.ListFrames(channelId, env.ItemId, 0, limits.MaxFramesPerItem);
if (manifestFrames.Count != env.FrameIndex) throw new WebDropException(ErrorCode.Conflict);
var digests = new List<byte[]>(manifestFrames.Count + 1);
for (int i = 0; i < manifestFrames.Count; i++)
{
if (manifestFrames[i].Index != i) throw new WebDropException(ErrorCode.Conflict);
digests.Add(manifestFrames[i].Digest);
}
digests.Add(digest);
if (!CryptographicOperations.FixedTimeEquals(SignatureCodec.FrameManifestHash(digests), signedEvent.ManifestHash))
throw new WebDropException(ErrorCode.Conflict);
}
}
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;
long max = env.IsGated ? maxDownloads ?? channel.Settings.FileMaxDownloads : 0;
if (env.IsGated && !ServerSettingChoices.IsFileMaxDownloadsAllowed(max, channel.Settings.FileMaxDownloads)) throw new WebDropException(ErrorCode.BadRequest);
item = new Item(channelId, env.ItemId, principal.EndpointHash, env.ItemFlags, max, 0, 0, -1, null, 0,
now, env.IsControl ? long.MaxValue : now + ttlMs, null, principal.AccountName);
tx.InsertItem(item);
@@ -293,6 +342,13 @@ public sealed partial class ChannelService(
tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, body.Length, digest,
blobKey is null ? body.ToArray() : null, blobKey));
if (signedEvent is not null && objectEnvelope is not null)
{
objectEnvelope.Event = ToSignedContentEventRecord(signedEvent);
crypto.Revision++;
tx.PutWorkspaceCrypto(channelId, crypto);
}
if (!env.IsGated)
tx.InsertLog(channelId, new LogEntry(seq, LogKind.Frame, env.ItemId, env.FrameIndex, 0, now));
else if (env.FrameIndex == 0)
@@ -386,6 +442,7 @@ public sealed partial class ChannelService(
var list = new List<(long Seq, byte[]? Bytes, StoredFrame? Frame)>();
foreach (var e in tx.ReadLog(id, from, limit))
{
if (e.ItemId != default && !CanReadObject(tx, channel, principal, e.ItemId)) continue;
switch (e.Kind)
{
case LogKind.Frame:
@@ -420,9 +477,10 @@ public sealed partial class ChannelService(
{
store.Write(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
long now = clock.NowMs;
if (item.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone);
@@ -454,9 +512,10 @@ public sealed partial class ChannelService(
maxFrames = Math.Clamp(maxFrames, 1, 64);
var (frames, next, complete) = store.Read(tx =>
{
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!CanReadObject(tx, space, principal, itemId)) throw new WebDropException(ErrorCode.Forbidden);
if (item.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.Gone);
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
throw new WebDropException(ErrorCode.Gone);
@@ -488,7 +547,13 @@ public sealed partial class ChannelService(
// ───────────────────────── 删除 ─────────────────────────
public void DeleteItem(Id16 id, Credentials creds, Id16 itemId)
public ObjectEnvelope? ObjectSigningContext(Id16 id, Credentials creds, Id16 itemId) => store.Read(tx =>
{
_ = Authenticate(tx, id, creds, SeatRule.Required);
return tx.GetWorkspaceCrypto(id).Objects.Find(o => o.ItemId == itemId.ToBase64Url());
});
public void DeleteItem(Id16 id, Credentials creds, Id16 itemId, SignedContentEventProof? signedEvent = null)
{
var (seq, prefixes) = store.Write(tx =>
{
@@ -496,6 +561,26 @@ public sealed partial class ChannelService(
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
if (!ChannelPolicy.CanDeleteItem(channel, item, principal)) throw new WebDropException(ErrorCode.Forbidden);
var crypto = tx.GetWorkspaceCrypto(id);
var obj = crypto.Objects.Find(o => o.ItemId == itemId.ToBase64Url());
if (creds.RequireSignedContent && obj is not null)
{
if (signedEvent is null || obj?.Event is null || signedEvent.Username != principal.AccountName
|| signedEvent.DeviceId != creds.AccountDeviceId || signedEvent.ChannelId != obj.ChannelId
|| signedEvent.Domain != obj.Domain || signedEvent.Epoch != obj.Epoch
|| signedEvent.FrameCount != obj.Event.FrameCount || signedEvent.ManifestHash.Length != 32
|| !Codec.TryDecodeExact(obj.Event.ManifestHash, 32, out var oldManifest)
|| !CryptographicOperations.FixedTimeEquals(signedEvent.ManifestHash, oldManifest)
|| signedEvent.EventKind != (obj.Domain == CryptoDomain.Chat ? "chat.delete" : "file.delete")
|| signedEvent.TargetItemId is not null || signedEvent.EventIdBytes.Length != 16 || signedEvent.Signature.Length != 64
|| obj.DeleteEvent is not null)
throw new WebDropException(ErrorCode.Forbidden);
obj.DeleteEvent = ToSignedContentEventRecord(signedEvent);
crypto.Revision++;
tx.PutWorkspaceCrypto(id, crypto);
}
else if (signedEvent is not null) throw new WebDropException(ErrorCode.BadRequest);
var list = new List<string>();
long s = DeleteItemCore(tx, id, item, TombstoneReason.Deleted, list);
return (s, list);
@@ -514,6 +599,17 @@ public sealed partial class ChannelService(
return seq;
}
private static SignedContentEventRecord ToSignedContentEventRecord(SignedContentEventProof signedEvent) => new()
{
EventId = Codec.Encode(signedEvent.EventIdBytes), Kind = signedEvent.EventKind, FrameCount = signedEvent.FrameCount,
ManifestHash = Convert.ToBase64String(signedEvent.ManifestHash).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
SignerDeviceId = signedEvent.DeviceId,
Signature = Convert.ToBase64String(signedEvent.Signature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
TargetItemId = signedEvent.TargetItemId,
SignerRootPublicKey = signedEvent.RootPublicKey, SignerDevicePublicKey = signedEvent.DevicePublicKey,
DeviceCertificateSignature = Convert.ToBase64String(signedEvent.DeviceCertificateSignature).TrimEnd('=').Replace('+', '-').Replace('/', '_'),
};
// ───────────────────────── 后台清理 ─────────────────────────
/// <summary>清理过期条目、下载达上限的条目、过期频道;可选清理孤立 blob。返回删除的条目和频道总数。</summary>
@@ -618,14 +714,16 @@ public sealed partial class ChannelService(
if (!ChannelPolicy.IsOwner(principal)) throw new WebDropException(ErrorCode.Forbidden);
}
private static ChannelInfo ToInfo(Channel channel, int seatsUsed) => new()
private static ChannelInfo ToInfo(Channel channel, int membersUsed, Principal? principal = null) => new()
{
CanUpload = ChannelPolicy.CanWrite(channel, principal ?? new Principal(Role.Owner, [], channel.CreatorAccount)),
CanManageStructure = principal is null || principal.Role == Role.Owner,
Id = channel.Id.ToBase64Url(),
CreatorAccount = channel.CreatorAccount,
PasswordRequired = channel.JoinPasswordHash is not null,
Mode = channel.Mode,
Settings = channel.Settings,
SeatsUsed = seatsUsed,
SeatsUsed = membersUsed,
OldestSeq = channel.CompactedThrough + 1,
LastSeq = channel.LastSeq,
CreatedAt = channel.CreatedAt,
+139
View File
@@ -0,0 +1,139 @@
namespace WebDrop.Core;
public sealed partial class ChannelService
{
public void AccountProfileChanged(string username)
{
foreach (var id in store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs))) events.SettingsChanged(id);
}
public IReadOnlyList<ChannelInfo> AccountSpaces(string username) => store.Read(tx => tx.ListAccountSpaces(username, clock.NowMs).Select(id =>
{
var channel = tx.GetChannel(id)!;
var role = tx.GetChannelMember(id, username)?.Role == "owner" ? Role.Owner : channel.WriteHash is not null ? Role.Write : Role.Read;
return ToInfo(channel, tx.CountMembers(id), new Principal(role, [], username));
}).ToList());
private static WorkspaceState NewWorkspace(string username, long now)
{
var id = Guid.NewGuid().ToString();
return new() { Revision = 1, DefaultChannelId = id, Channels = [new WorkspaceChannel { Id = id, Name = "常规", CreatedAt = now, CreatedBy = username }] };
}
public WorkspaceState GetWorkspace(Id16 id, Credentials creds) => store.Read(tx =>
{
var (space, principal) = Authenticate(tx, id, creds, SeatRule.Required);
var state = tx.GetWorkspace(id) ?? new WorkspaceState();
if (principal.AccountName != space.CreatorAccount)
{
var crypto = tx.GetWorkspaceCrypto(id);
var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList();
var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet();
state.Channels.RemoveAll(c => !ids.Contains(c.Id));
state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId));
state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId));
}
state.Locations = state.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is { } item && item.ExpiresAt > clock.NowMs).ToList();
return state;
});
private WorkspaceState ChangeWorkspace(Id16 id, Credentials creds, Action<IResourceTx, WorkspaceState, Principal> change, bool requireAccount = true)
{
var state = store.Write(tx =>
{
var (channel, principal) = Authenticate(tx, id, creds, SeatRule.Required);
if (requireAccount && principal.AccountName is null) throw new WebDropException(ErrorCode.AccountRequired);
if (requireAccount && principal.Role != Role.Owner) throw new WebDropException(ErrorCode.Forbidden);
if (!ChannelPolicy.CanWrite(channel, principal)) throw new WebDropException(ErrorCode.Forbidden);
var current = tx.GetWorkspace(id) ?? new WorkspaceState();
current.Locations = current.Locations.Where(l => Id16.TryParse(l.ItemId, out var itemId) && tx.GetItem(id, itemId) is not null).ToList();
change(tx, current, principal);
ValidateWorkspace(current);
current.Revision++;
tx.PutWorkspace(id, current);
return current;
});
events.SettingsChanged(id);
return state;
}
public (WorkspaceState State, WorkspaceChannel Channel) CreateWorkspaceChannelResult(Id16 id, Credentials creds, string name, string? groupId)
{
WorkspaceChannel? created = null;
var state = ChangeWorkspace(id, creds, (_, workspace, principal) =>
{
created = new WorkspaceChannel { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), GroupId = groupId, CreatedAt = clock.NowMs, CreatedBy = principal.AccountName! };
workspace.Channels.Add(created);
});
return (state, created!);
}
public WorkspaceState CreateWorkspaceChannel(Id16 id, Credentials creds, string name, string? groupId) => CreateWorkspaceChannelResult(id, creds, name, groupId).State;
public WorkspaceState RenameWorkspaceChannel(Id16 id, Credentials creds, string channelId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
FindChannel(state, channelId).Name = WorkspaceName(name));
public WorkspaceState GroupWorkspaceChannel(Id16 id, Credentials creds, string channelId, string? groupId) => ChangeWorkspace(id, creds, (_, state, _) =>
FindChannel(state, channelId).GroupId = groupId);
public (WorkspaceState State, WorkspaceGroup Group) CreateWorkspaceGroupResult(Id16 id, Credentials creds, string name)
{
WorkspaceGroup? created = null;
var state = ChangeWorkspace(id, creds, (_, workspace, _) =>
{
created = new WorkspaceGroup { Id = Guid.NewGuid().ToString(), Name = WorkspaceName(name), CreatedAt = clock.NowMs };
workspace.Groups.Add(created);
});
return (state, created!);
}
public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State;
public WorkspaceState RenameWorkspaceGroup(Id16 id, Credentials creds, string groupId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
(state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) }));
public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) =>
(state.Folders.Find(f => f.Id == folderId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name));
public WorkspaceState MoveWorkspaceItems(Id16 id, Credentials creds, string[] itemIds, string threadId, string? folderId) => ChangeWorkspace(id, creds, (tx, state, principal) =>
{
if (itemIds.Length is < 1 or > 100 || itemIds.Distinct().Count() != itemIds.Length) throw new WebDropException(ErrorCode.BadRequest);
foreach (var itemId in itemIds)
{
if (!Id16.TryParse(itemId, out var parsed)) throw new WebDropException(ErrorCode.BadRequest);
var item = tx.GetItem(id, parsed) ?? throw new WebDropException(ErrorCode.NotFound);
if ((item.Flags & (FrameFlags.Control | FrameFlags.Snapshot)) != 0) throw new WebDropException(ErrorCode.BadRequest);
if (principal.Role != Role.Owner && (item.AuthorAccount is not null ? item.AuthorAccount != principal.AccountName : !item.AuthorHash.AsSpan().SequenceEqual(principal.EndpointHash))) throw new WebDropException(ErrorCode.Forbidden);
state.Locations.RemoveAll(l => l.ItemId == itemId);
state.Locations.Add(new WorkspaceLocation { ItemId = itemId, ThreadId = threadId, FolderId = folderId, AuthorAccount = item.AuthorAccount });
}
}, requireAccount: false);
private static WorkspaceChannel FindChannel(WorkspaceState state, string id) => state.Channels.Find(c => c.Id == id) ?? throw new WebDropException(ErrorCode.NotFound);
private static string WorkspaceName(string name)
{
if (string.IsNullOrWhiteSpace(name) || name.Trim().Length > 80 || name.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
return name.Trim();
}
private static void ValidateWorkspace(WorkspaceState state)
{
if (state.Channels is null || state.Groups is null || state.Folders is null || state.Locations is null
|| state.Channels.Count > 256 || state.Groups.Count > 128 || state.Folders.Count > 2048 || state.Locations.Count > 10_000)
throw new WebDropException(ErrorCode.BadRequest);
if (state.Channels.Any(c => c is null) || state.Groups.Any(g => g is null) || state.Folders.Any(f => f is null) || state.Locations.Any(l => l is null)) throw new WebDropException(ErrorCode.BadRequest);
var allIds = state.Channels.Select(c => c.Id).Concat(state.Groups.Select(g => g.Id)).Concat(state.Folders.Select(f => f.Id)).ToArray();
if (allIds.Any(id => !Guid.TryParseExact(id, "D", out _)) || allIds.Distinct().Count() != allIds.Length) throw new WebDropException(ErrorCode.BadRequest);
foreach (var group in state.Groups) group.Name = WorkspaceName(group.Name);
foreach (var channel in state.Channels)
{
channel.Name = WorkspaceName(channel.Name);
if (channel.GroupId is not null && !state.Groups.Any(g => g.Id == channel.GroupId)) throw new WebDropException(ErrorCode.BadRequest);
}
foreach (var folder in state.Folders)
{
folder.Name = WorkspaceName(folder.Name);
_ = FindChannel(state, folder.ThreadId);
var visited = new HashSet<string> { folder.Id };
for (var parentId = folder.ParentId; parentId is not null;)
{
var parent = state.Folders.Find(f => f.Id == parentId && f.ThreadId == folder.ThreadId);
if (parent is null || !visited.Add(parent.Id)) throw new WebDropException(ErrorCode.BadRequest);
parentId = parent.ParentId;
}
}
if (state.Locations.Select(l => l.ItemId).Distinct().Count() != state.Locations.Count) throw new WebDropException(ErrorCode.BadRequest);
foreach (var location in state.Locations)
{
_ = FindChannel(state, location.ThreadId);
if (location.FolderId is not null && !state.Folders.Any(f => f.Id == location.FolderId && f.ThreadId == location.ThreadId)) throw new WebDropException(ErrorCode.BadRequest);
}
}
}
+4
View File
@@ -52,11 +52,14 @@ public sealed class ShareItemInfo
public sealed class ChannelInfo
{
[JsonPropertyName("can_upload")] public bool CanUpload { get; init; }
[JsonPropertyName("can_manage_structure")] public bool CanManageStructure { get; init; }
[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; }
/// <summary>v5: 账号成员数加匿名访客终端数,不统计账号设备数。</summary>
[JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; }
/// <summary>从该 seq 起的续传有效;0 &lt; from &lt; oldest_seq 的续传会得到 410,需从 0 全量同步。</summary>
@@ -71,6 +74,7 @@ public sealed class SeatJoined
{
[JsonPropertyName("endpoint_hash")] public required string EndpointHash { get; init; }
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
/// <summary>加入后的成员人数,同一账号的其他终端不增加此值。</summary>
[JsonPropertyName("seats_used")] public required int SeatsUsed { get; init; }
[JsonPropertyName("seats_max")] public required int SeatsMax { get; init; }
}
+14
View File
@@ -9,6 +9,20 @@ public enum ChannelMode : byte
[JsonStringEnumMemberName("raw")] Raw = 2,
}
[JsonConverter(typeof(JsonStringEnumConverter<CryptoDomain>))]
public enum CryptoDomain : byte
{
[JsonStringEnumMemberName("chat")] Chat = 1,
[JsonStringEnumMemberName("files")] Files = 2,
}
[JsonConverter(typeof(JsonStringEnumConverter<JoinPolicy>))]
public enum JoinPolicy : byte
{
[JsonStringEnumMemberName("open")] Open = 1,
[JsonStringEnumMemberName("invite")] Invite = 2,
}
[JsonConverter(typeof(JsonStringEnumConverter<DeletePolicy>))]
public enum DeletePolicy : byte
{
+3
View File
@@ -21,6 +21,7 @@ public enum ErrorCode
InvitationRequired,
ChannelPasswordRequired,
ChannelPasswordInvalid,
SpaceNameRequired,
}
/// <summary>预期内的业务失败。消息只含错误码,绝不回显用户提交的内容。</summary>
@@ -61,6 +62,7 @@ public static class ErrorCodes
ErrorCode.InvitationRequired => "invitation_required",
ErrorCode.ChannelPasswordRequired => "channel_password_required",
ErrorCode.ChannelPasswordInvalid => "channel_password_invalid",
ErrorCode.SpaceNameRequired => "space_name_required",
_ => "bad_request",
};
@@ -72,6 +74,7 @@ public static class ErrorCodes
ErrorCode.NotFound => 404,
ErrorCode.Conflict => 409,
ErrorCode.Sealed => 409,
ErrorCode.SpaceNameRequired => 400,
ErrorCode.Gone => 410,
ErrorCode.TooLarge => 413,
ErrorCode.CaptchaRequired => 428,
+2 -1
View File
@@ -104,4 +104,5 @@ public sealed record LogEntry(long Seq, LogKind Kind, Id16 ItemId, uint FrameInd
public sealed record Principal(Role Role, byte[] EndpointHash, string? AccountName = null);
/// <summary>请求携带的凭据,尚未验证。</summary>
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null);
public sealed record Credentials(Role ClaimedRole, byte[]? Token, byte[] EndpointHash, string? AccountName = null, string? AccountDeviceId = null,
bool RequireSignedContent = false);
+33 -4
View File
@@ -8,7 +8,9 @@ public sealed class ServerLimits
public int MaxPayloadBytes { get; set; } = (1 << 20) + 16;
public int MaxFramesPerItem { get; set; } = 65_536;
public long MaxChannelBytes { get; set; } = 1L << 30;
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
public int MaxSeats { get; set; } = 64;
public int MaxVisitors { get; set; } = 10_000;
public int MinTtlSeconds { get; set; } = 60;
public int MaxTtlSeconds { get; set; } = 604_800;
public int MaxItemsLimit { get; set; } = 10_000;
@@ -18,11 +20,19 @@ public sealed class ServerLimits
public int MaxSeatLabelBytes { get; set; } = 256;
}
public static class ServerSettingChoices
{
public static readonly int[] FileMaxDownloads = [0, 1, 3, 5, 10, 20];
public static bool IsFileMaxDownloadsAllowed(long value, int currentDefault) => value == currentDefault
|| value is >= int.MinValue and <= int.MaxValue && Array.IndexOf(FileMaxDownloads, (int)value) >= 0;
}
public sealed class ChannelSettings
{
[JsonPropertyName("join_policy")] public string JoinPolicy { get; set; } = "open";
[JsonPropertyName("join_policy")] public JoinPolicy JoinPolicy { get; set; } = JoinPolicy.Open;
[JsonPropertyName("allow_guests")] public bool AllowGuests { get; set; } = true;
[JsonPropertyName("name")] public string? Name { get; set; }
/// <summary>成员人数上限;线字段名保留为 seats 以兼容已有频道设置。</summary>
[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;
@@ -33,7 +43,7 @@ public sealed class ChannelSettings
public void Validate(ServerLimits limits)
{
if (JoinPolicy is not ("open" or "invite")) throw new WebDropException(ErrorCode.BadRequest);
if (!Enum.IsDefined(JoinPolicy)) 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);
@@ -44,7 +54,7 @@ public sealed class ChannelSettings
public sealed class ChannelSettingsPatch
{
[JsonPropertyName("join_policy")] public string? JoinPolicy { get; init; }
[JsonPropertyName("join_policy")] public JoinPolicy? 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; }
@@ -80,7 +90,7 @@ public sealed class ShareSettings
public void Validate(ServerLimits limits)
{
if (TtlSeconds < limits.MinTtlSeconds || TtlSeconds > limits.MaxTtlSeconds
|| MaxVisitors < 0 || MaxVisitors > 10_000
|| MaxVisitors < 0 || MaxVisitors > limits.MaxVisitors
|| FileMaxDownloads < 0)
throw new WebDropException(ErrorCode.BadRequest);
}
@@ -99,3 +109,22 @@ public sealed class ShareSettingsPatch
FileMaxDownloads = FileMaxDownloads ?? s.FileMaxDownloads,
};
}
/// <summary>创建接口与 /info 使用同一个配置来源;旧存储对象的字段默认值不作迁移。</summary>
public static class CreationDefaults
{
public const ChannelMode ChannelMode = WebDrop.Core.ChannelMode.Enc;
public static ChannelSettings Channel(ServerLimits limits) => new()
{
JoinPolicy = JoinPolicy.Invite, AllowGuests = false,
Seats = Math.Min(10, limits.MaxSeats),
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
MaxItems = Math.Min(1000, limits.MaxItemsLimit),
};
public static ShareSettings Share(ServerLimits limits) => new()
{
TtlSeconds = Math.Clamp(86_400, limits.MinTtlSeconds, limits.MaxTtlSeconds),
MaxVisitors = Math.Min(10, limits.MaxVisitors),
};
}
+2 -1
View File
@@ -18,7 +18,7 @@ public sealed class ShareService(
|| !Codec.TryDecodeExact(req.OwnerHash, TokenHash.Size, out var ownerHash))
throw new WebDropException(ErrorCode.BadRequest);
var settings = req.Settings ?? new ShareSettings();
var settings = req.Settings ?? CreationDefaults.Share(limits);
settings.Validate(limits);
long now = clock.NowMs;
var share = new Share(id, settings, readHash, ownerHash, creatorEndpointHash, now,
@@ -102,6 +102,7 @@ public sealed class ShareService(
if (item is not null) throw new WebDropException(ErrorCode.BadRequest);
if (tx.CountShareItems(id) >= limits.MaxItemsLimit) throw new WebDropException(ErrorCode.TooLarge);
long cap = env.IsGated ? maxDownloads ?? share.Settings.FileMaxDownloads : 0;
if (env.IsGated && !ServerSettingChoices.IsFileMaxDownloadsAllowed(cap, share.Settings.FileMaxDownloads)) throw new WebDropException(ErrorCode.BadRequest);
item = new ShareItem(id, env.ItemId, env.ItemFlags, cap, 0, 0, -1, null,
0, now, share.ExpiresAt, null);
tx.InsertShareItem(item);
+122
View File
@@ -0,0 +1,122 @@
using System.Security.Cryptography;
using System.Text;
using System.Buffers.Binary;
namespace WebDrop.Core;
/// <summary>Protocol signature framing. Payload encodings are defined by their signed statement type.</summary>
public static class SignatureCodec
{
private static ReadOnlySpan<byte> Magic => [0x57, 0x44, 0x53, 0x01]; // WDS\x01
private const int MaxKindBytes = 64;
private const int MaxPayloadBytes = 1_048_576;
public static byte[] DeviceCertificatePayload(string username, string deviceId, ReadOnlySpan<byte> x, ReadOnlySpan<byte> y)
{
if (string.IsNullOrEmpty(username) || username.Length > 32 || username.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-'))
|| !Guid.TryParseExact(deviceId, "D", out _) || x.Length != 32 || y.Length != 32)
throw new ArgumentException("Invalid device certificate fields.");
var user = Encoding.ASCII.GetBytes(username.ToLowerInvariant());
var device = Encoding.ASCII.GetBytes(deviceId.ToLowerInvariant());
var payload = new byte[1 + user.Length + 1 + device.Length + 64];
int offset = 0;
payload[offset++] = (byte)user.Length; user.CopyTo(payload, offset); offset += user.Length;
payload[offset++] = (byte)device.Length; device.CopyTo(payload, offset); offset += device.Length;
x.CopyTo(payload.AsSpan(offset)); y.CopyTo(payload.AsSpan(offset + 32));
return payload;
}
public static byte[] ContentEventPayload(ReadOnlySpan<byte> spaceId, string channelId, string username, string deviceId,
ReadOnlySpan<byte> eventId, string eventKind, CryptoDomain domain, int epoch, ReadOnlySpan<byte> itemId,
int frameCount, ReadOnlySpan<byte> manifestHash, ReadOnlySpan<byte> targetItemId)
{
if (spaceId.Length != 16 || eventId.Length != 16 || itemId.Length != 16 || manifestHash.Length != 32
|| targetItemId.Length is not (0 or 16) || epoch < 0 || frameCount < 1
|| !Guid.TryParseExact(channelId, "D", out _) || !Enum.IsDefined(domain)
|| !IsAsciiIdentity(username, 32) || !Guid.TryParseExact(deviceId, "D", out _)
|| eventKind.Length is < 1 or > 32 || eventKind.Any(c => c is not (>= 'a' and <= 'z') and not '.' and not '-'))
throw new ArgumentException("Invalid signed content event fields.");
byte[] channel = Encoding.ASCII.GetBytes(channelId.ToLowerInvariant());
byte[] user = Encoding.ASCII.GetBytes(username.ToLowerInvariant());
byte[] device = Encoding.ASCII.GetBytes(deviceId.ToLowerInvariant());
byte[] kind = Encoding.ASCII.GetBytes(eventKind);
var payload = new byte[16 + 1 + channel.Length + 1 + user.Length + 1 + device.Length + 16 + 1 + kind.Length + 1 + 4 + 16 + 4 + 32 + 1 + 16];
int offset = 0;
spaceId.CopyTo(payload.AsSpan(offset)); offset += 16;
payload[offset++] = (byte)channel.Length; channel.CopyTo(payload, offset); offset += channel.Length;
payload[offset++] = (byte)user.Length; user.CopyTo(payload, offset); offset += user.Length;
payload[offset++] = (byte)device.Length; device.CopyTo(payload, offset); offset += device.Length;
eventId.CopyTo(payload.AsSpan(offset)); offset += 16;
payload[offset++] = (byte)kind.Length; kind.CopyTo(payload, offset); offset += kind.Length;
payload[offset++] = (byte)domain;
BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(offset), (uint)epoch); offset += 4;
itemId.CopyTo(payload.AsSpan(offset)); offset += 16;
BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(offset), (uint)frameCount); offset += 4;
manifestHash.CopyTo(payload.AsSpan(offset)); offset += 32;
payload[offset++] = targetItemId.IsEmpty ? (byte)0 : (byte)1;
if (!targetItemId.IsEmpty) targetItemId.CopyTo(payload.AsSpan(offset));
return payload;
}
public static byte[] FrameManifestHash(IReadOnlyList<byte[]> frameDigests)
{
if (frameDigests.Count is < 1 or > 65_536 || frameDigests.Any(d => d.Length != 32)) throw new ArgumentException("Invalid frame manifest.");
var data = new byte[8 + frameDigests.Count * 36];
"WD1M"u8.CopyTo(data);
BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(4), (uint)frameDigests.Count);
int offset = 8;
for (int i = 0; i < frameDigests.Count; i++)
{
BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(offset), (uint)i); offset += 4;
frameDigests[i].CopyTo(data, offset); offset += 32;
}
return SHA256.HashData(data);
}
private static bool IsAsciiIdentity(string value, int maxLength) => value.Length is >= 3 && value.Length <= maxLength
&& value.All(c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-');
public static byte[] BuildMessage(string kind, ReadOnlySpan<byte> payload)
{
if (string.IsNullOrEmpty(kind) || kind.Length > MaxKindBytes
|| kind[0] is < 'a' or > 'z'
|| kind.Any(c => c is not (>= 'a' and <= 'z') and not (>= '0' and <= '9') and not '.' and not '_' and not '-'))
throw new ArgumentException("Invalid signature kind.", nameof(kind));
if (payload.Length > MaxPayloadBytes) throw new ArgumentOutOfRangeException(nameof(payload));
var kindBytes = Encoding.ASCII.GetBytes(kind);
var message = new byte[Magic.Length + 2 + kindBytes.Length + 4 + payload.Length];
int offset = 0;
Magic.CopyTo(message.AsSpan(offset)); offset += Magic.Length;
message[offset++] = (byte)(kindBytes.Length >> 8);
message[offset++] = (byte)kindBytes.Length;
kindBytes.CopyTo(message, offset); offset += kindBytes.Length;
uint length = (uint)payload.Length;
message[offset++] = (byte)(length >> 24);
message[offset++] = (byte)(length >> 16);
message[offset++] = (byte)(length >> 8);
message[offset++] = (byte)length;
payload.CopyTo(message.AsSpan(offset));
return message;
}
/// <summary>Verifies WebCrypto ECDSA P-256/SHA-256 signatures in fixed-width IEEE P1363 form.</summary>
public static bool VerifyP256(ReadOnlySpan<byte> x, ReadOnlySpan<byte> y, string kind,
ReadOnlySpan<byte> payload, ReadOnlySpan<byte> signature)
{
if (x.Length != 32 || y.Length != 32 || signature.Length != 64) return false;
try
{
using var key = ECDsa.Create(new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint { X = x.ToArray(), Y = y.ToArray() },
});
return key.VerifyData(BuildMessage(kind, payload), signature, HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
}
catch (ArgumentException) { return false; }
catch (CryptographicException) { return false; }
}
}
+49
View File
@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
namespace WebDrop.Core;
public sealed class WorkspaceChannel
{
[JsonPropertyName("chatEpoch")] public int ChatEpoch { get; set; }
[JsonPropertyName("filesEpoch")] public int FilesEpoch { get; set; }
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; set; } = true;
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; set; } = true;
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
[JsonPropertyName("createdBy")] public required string CreatedBy { get; init; }
[JsonPropertyName("groupId")] public string? GroupId { get; set; }
}
public sealed class WorkspaceGroup
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
}
public sealed class WorkspaceFolder
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("parentId")] public string? ParentId { get; init; }
[JsonPropertyName("name")] public required string Name { get; set; }
}
public sealed class WorkspaceLocation
{
[JsonPropertyName("itemId")] public required string ItemId { get; init; }
[JsonPropertyName("threadId")] public required string ThreadId { get; init; }
[JsonPropertyName("folderId")] public string? FolderId { get; init; }
[JsonPropertyName("authorAccount")] public string? AuthorAccount { get; init; }
}
public sealed class WorkspaceState
{
[JsonPropertyName("defaultChannelId")] public string? DefaultChannelId { get; init; }
[JsonPropertyName("revision")] public long Revision { get; set; }
[JsonPropertyName("channels")] public List<WorkspaceChannel> Channels { get; init; } = [];
[JsonPropertyName("groups")] public List<WorkspaceGroup> Groups { get; init; } = [];
[JsonPropertyName("folders")] public List<WorkspaceFolder> Folders { get; init; } = [];
[JsonPropertyName("locations")] public List<WorkspaceLocation> Locations { get; set; } = [];
}
[JsonSerializable(typeof(WorkspaceState))]
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public sealed partial class WorkspaceJson : JsonSerializerContext;
@@ -0,0 +1,83 @@
using System.Text.Json.Serialization;
namespace WebDrop.Core;
public sealed class DomainEnvelope
{
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("payload")] public required string Payload { get; init; }
}
public sealed class ObjectEnvelope
{
[JsonPropertyName("createdRevision")] public long CreatedRevision { get; set; }
[JsonPropertyName("itemId")] public required string ItemId { get; init; }
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
[JsonPropertyName("keyEnvelope")] public required string KeyEnvelope { get; init; }
[JsonPropertyName("authorAccount")] public string? AuthorAccount { get; set; }
[JsonPropertyName("event")] public SignedContentEventRecord? Event { get; set; }
[JsonPropertyName("deleteEvent")] public SignedContentEventRecord? DeleteEvent { get; set; }
}
public sealed class SignedContentEventRecord
{
[JsonPropertyName("eventId")] public required string EventId { get; init; }
[JsonPropertyName("kind")] public required string Kind { get; init; }
[JsonPropertyName("frameCount")] public required int FrameCount { get; init; }
[JsonPropertyName("manifestHash")] public required string ManifestHash { get; init; }
[JsonPropertyName("signerDeviceId")] public required string SignerDeviceId { get; init; }
[JsonPropertyName("signature")] public required string Signature { get; init; }
[JsonPropertyName("targetItemId")] public string? TargetItemId { get; init; }
[JsonPropertyName("signerRootPublicKey")] public required string SignerRootPublicKey { get; init; }
[JsonPropertyName("signerDevicePublicKey")] public required string SignerDevicePublicKey { get; init; }
[JsonPropertyName("deviceCertificateSignature")] public required string DeviceCertificateSignature { get; init; }
}
public sealed record SignedContentEventProof(byte[] EventIdBytes, string EventKind, string Username, string ChannelId, CryptoDomain Domain, int Epoch,
int FrameCount, byte[] ManifestHash, string DeviceId, byte[] Signature, string? TargetItemId,
string RootPublicKey, string DevicePublicKey, byte[] DeviceCertificateSignature);
public sealed class ChannelAccess
{
[JsonPropertyName("chatFromRevision")] public long ChatFromRevision { get; set; }
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
[JsonPropertyName("filesFromRevision")] public long FilesFromRevision { get; set; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("chatRead")] public bool ChatRead { get; set; } = true;
[JsonPropertyName("chatWrite")] public bool ChatWrite { get; set; } = true;
[JsonPropertyName("filesRead")] public bool FilesRead { get; set; } = true;
[JsonPropertyName("filesUpload")] public bool FilesUpload { get; set; } = true;
}
public sealed class CryptoGrantBatch
{
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
[JsonPropertyName("recipients")] public required List<string> Recipients { get; init; }
[JsonPropertyName("rotate")] public required bool Rotate { get; init; }
}
public sealed class InvitationCryptoPlan
{
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
[JsonPropertyName("username")] public required string Username { get; init; }
[JsonPropertyName("batches")] public required List<CryptoGrantBatch> Batches { get; init; }
}
public sealed class CryptoState
{
[JsonPropertyName("revision")] public long Revision { get; set; }
[JsonPropertyName("envelopes")] public List<DomainEnvelope> Envelopes { get; init; } = [];
[JsonPropertyName("objects")] public List<ObjectEnvelope> Objects { get; init; } = [];
[JsonPropertyName("access")] public List<ChannelAccess> Access { get; init; } = [];
}
public sealed class DomainPublishRequest
{
[JsonPropertyName("channelId")] public required string ChannelId { get; init; }
[JsonPropertyName("domain")] public required CryptoDomain Domain { get; init; }
[JsonPropertyName("epoch")] public required int Epoch { get; init; }
[JsonPropertyName("envelopes")] public required List<DomainEnvelope> Envelopes { get; init; }
}
[JsonSerializable(typeof(CryptoState))]
public sealed partial class WorkspaceCryptoJson : JsonSerializerContext;
@@ -0,0 +1,41 @@
using WebDrop.Core;
namespace WebDrop.Storage;
public sealed partial class SqliteAccountStore
{
public AccountProfile Profile(string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT display_name,avatar,modified_at FROM account_profiles WHERE username=$u";
cmd.Parameters.AddWithValue("$u", username);
using var reader = cmd.ExecuteReader();
return reader.Read() ? new AccountProfile { Username = username, DisplayName = reader.GetString(0), Avatar = reader.IsDBNull(1) ? null : reader.GetString(1), ModifiedAt = reader.GetInt64(2) }
: new AccountProfile { Username = username, DisplayName = username, ModifiedAt = 0 };
}
}
public AccountProfile UpdateProfile(string username, string displayName, string? avatar)
{
if (displayName is null || displayName.Trim().Length > 48 || displayName.Any(char.IsControl)) throw new WebDropException(ErrorCode.BadRequest);
if (avatar is not null)
{
string? prefix = new[] { "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64," }.FirstOrDefault(p => avatar.StartsWith(p, StringComparison.Ordinal));
if (prefix is null || avatar.Length > 350_000 || avatar.Length <= prefix.Length) throw new WebDropException(ErrorCode.BadRequest);
try { _ = Convert.FromBase64String(avatar[prefix.Length..]); }
catch (FormatException) { throw new WebDropException(ErrorCode.BadRequest); }
}
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "INSERT INTO account_profiles(username,display_name,avatar,modified_at) VALUES($u,$n,$a,$t) ON CONFLICT(username) DO UPDATE SET display_name=excluded.display_name,avatar=excluded.avatar,modified_at=MAX(account_profiles.modified_at+1,excluded.modified_at)";
cmd.Parameters.AddWithValue("$u", username);
cmd.Parameters.AddWithValue("$n", displayName.Trim());
cmd.Parameters.AddWithValue("$a", (object?)avatar ?? DBNull.Value);
cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
cmd.ExecuteNonQuery();
return Profile(username);
}
}
}
@@ -0,0 +1,44 @@
using Microsoft.Data.Sqlite;
namespace WebDrop.Storage;
public sealed record AccountDeviceSigningKey(string PublicKey, byte[] CertificateSignature);
public sealed partial class SqliteAccountStore
{
public AccountDeviceSigningKey? DeviceSigningKey(string username, string deviceId)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT public_key,certificate_signature FROM account_device_signing_keys WHERE username=$u AND device_id=$d";
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$d", deviceId);
using var reader = cmd.ExecuteReader();
return reader.Read() ? new(reader.GetString(0), (byte[])reader[1]) : null;
}
}
public bool SaveDeviceSigningKey(string username, string deviceId, string publicKey, byte[] signature)
{
lock (_gate)
{
using var device = _connection.CreateCommand();
device.CommandText = "SELECT 1 FROM account_devices WHERE username=$u AND id=$d";
device.Parameters.AddWithValue("$u", username); device.Parameters.AddWithValue("$d", deviceId);
if (device.ExecuteScalar() is null) return false;
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
INSERT INTO account_device_signing_keys(username,device_id,public_key,certificate_signature,modified_at)
VALUES($u,$d,$p,$s,$m)
ON CONFLICT(username,device_id) DO UPDATE SET public_key=excluded.public_key,
certificate_signature=excluded.certificate_signature,modified_at=excluded.modified_at
""";
cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$d", deviceId);
cmd.Parameters.AddWithValue("$p", publicKey); cmd.Parameters.AddWithValue("$s", signature);
cmd.Parameters.AddWithValue("$m", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
return cmd.ExecuteNonQuery() == 1;
}
}
}
@@ -4,7 +4,7 @@ using Microsoft.Data.Sqlite;
namespace WebDrop.Storage;
/// <summary>Account authentication and opaque client-encrypted vault storage.</summary>
public sealed class SqliteAccountStore : IDisposable
public sealed partial class SqliteAccountStore : IDisposable
{
private readonly SqliteConnection _connection;
private readonly object _gate = new();
@@ -20,8 +20,25 @@ public sealed class SqliteAccountStore : IDisposable
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);
CREATE TABLE IF NOT EXISTS account_device_signing_keys (username TEXT NOT NULL, device_id TEXT NOT NULL, public_key TEXT NOT NULL, certificate_signature BLOB NOT NULL, modified_at INTEGER NOT NULL, PRIMARY KEY(username,device_id));
CREATE TABLE IF NOT EXISTS account_profiles (username TEXT PRIMARY KEY COLLATE NOCASE, display_name TEXT NOT NULL, avatar TEXT, modified_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS account_passkeys (id TEXT PRIMARY KEY, username TEXT NOT NULL, name TEXT NOT NULL, data TEXT NOT NULL, prf_salt TEXT NOT NULL, key_envelope TEXT, created_at INTEGER NOT NULL);
""";
cmd.ExecuteNonQuery();
using (var columns = _connection.CreateCommand())
{
columns.CommandText = "PRAGMA table_info(accounts)";
using var reader = columns.ExecuteReader();
bool hasSigningKey = false;
while (reader.Read()) if (reader.GetString(1) == "signing_public_key") hasSigningKey = true;
reader.Close();
if (!hasSigningKey)
{
using var alter = _connection.CreateCommand();
alter.CommandText = "ALTER TABLE accounts ADD COLUMN signing_public_key TEXT";
alter.ExecuteNonQuery();
}
}
}
private static byte[] HashVerifier(byte[] verifier, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2(verifier, salt, 210_000, HashAlgorithmName.SHA256, 32);
@@ -45,7 +62,7 @@ public sealed class SqliteAccountStore : IDisposable
}
}
public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey)
public bool Register(string username, byte[] salt, byte[] verifier, byte[] vault, string publicKey, string? signingPublicKey = null)
{
lock (_gate)
{
@@ -53,10 +70,11 @@ public sealed class SqliteAccountStore : IDisposable
{
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.CommandText = "INSERT INTO accounts(username,salt,verifier,vault,revision,public_key,verifier_salt,signing_public_key) VALUES($u,$s,$v,$b,1,$p,$vs,$sp)";
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.Parameters.AddWithValue("$sp", (object?)signingPublicKey ?? DBNull.Value);
cmd.ExecuteNonQuery(); return true;
}
catch (SqliteException e) when (e.SqliteErrorCode == 19) { return false; }
@@ -77,6 +95,26 @@ public sealed class SqliteAccountStore : IDisposable
r.Close();
var expected = HashVerifier(suppliedVerifier, verifierSalt);
if (!CryptographicOperations.FixedTimeEquals(verifier, expected) || !found) return null;
return CreateDeviceSession(username, deviceId, deviceName);
}
}
public bool VerifyLogin(string username, byte[] verifier)
{
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 reader = cmd.ExecuteReader();
var found = reader.Read(); var expected = found ? (byte[])reader[0] : new byte[32]; var salt = found ? (byte[])reader[1] : new byte[16];
return CryptographicOperations.FixedTimeEquals(expected, HashVerifier(verifier, salt)) && found;
}
}
public string? CreateDeviceSession(string username, string? deviceId, string? deviceName)
{
lock (_gate)
{
using var exists = _connection.CreateCommand(); exists.CommandText = "SELECT 1 FROM accounts WHERE username=$u";
exists.Parameters.AddWithValue("$u", username); if (exists.ExecuteScalar() is null) 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();
@@ -231,6 +269,16 @@ public sealed class SqliteAccountStore : IDisposable
}
}
public string? GetSigningPublicKey(string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT signing_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);
@@ -44,6 +44,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
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);
CREATE TABLE IF NOT EXISTS workspace_structures (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID;
""");
}
@@ -253,6 +255,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
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, creator_account, join_password_hash, join_password_salt";
public WorkspaceState? GetWorkspace(Id16 id) => One("SELECT state FROM workspace_structures WHERE space_id=@id",
r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceJson.Default.WorkspaceState)!, ("@id", id.ToArray()));
public CryptoState GetWorkspaceCrypto(Id16 id) => One("SELECT state FROM workspace_crypto WHERE space_id=@id",
r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceCryptoJson.Default.CryptoState)!, ("@id", id.ToArray())) ?? new CryptoState();
public void PutWorkspaceCrypto(Id16 id, CryptoState state) => Run(
"INSERT INTO workspace_crypto(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state",
("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceCryptoJson.Default.CryptoState)));
public IReadOnlyList<Id16> ListAccountSpaces(string username, long now) => Many(
"SELECT c.id FROM channels c JOIN channel_members m ON m.channel_id=c.id WHERE m.username=@u AND c.expires_at>@n ORDER BY c.created_at DESC",
r => Id16.FromBytes((byte[])r[0]), ("@u", username), ("@n", now));
public void PutWorkspace(Id16 id, WorkspaceState state) => Run(
"INSERT INTO workspace_structures(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state",
("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceJson.Default.WorkspaceState)));
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";
@@ -487,6 +502,8 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
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 workspace_structures WHERE space_id=@c", ("@c", key));
Run("DELETE FROM workspace_crypto WHERE space_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));
}
@@ -505,6 +522,9 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
public int CountSeats(Id16 channelId) =>
(int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
public int CountMembers(Id16 channelId) =>
(int)(long)Scalar("SELECT (SELECT COUNT(*) FROM channel_members WHERE channel_id=@c) + (SELECT COUNT(*) FROM seats WHERE channel_id=@c AND account_name IS NULL)", ("@c", channelId.ToArray()))!;
public void InsertSeat(Id16 channelId, Seat seat) => Run(
"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));
@@ -0,0 +1,61 @@
using WebDrop.Core;
namespace WebDrop.Storage;
public sealed record StoredPasskey(string Id, string Username, string Name, string Data, string PrfSalt, string? KeyEnvelope, long CreatedAt);
public sealed partial class SqliteAccountStore
{
public IReadOnlyList<StoredPasskey> Passkeys(string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT id,username,name,data,prf_salt,key_envelope,created_at FROM account_passkeys WHERE username=$u ORDER BY created_at";
cmd.Parameters.AddWithValue("$u", username); using var reader = cmd.ExecuteReader(); var rows = new List<StoredPasskey>();
while (reader.Read()) rows.Add(new(reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.GetString(4), reader.IsDBNull(5) ? null : reader.GetString(5), reader.GetInt64(6)));
return rows;
}
}
public StoredPasskey? Passkey(string id)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "SELECT username FROM account_passkeys WHERE id=$i"; cmd.Parameters.AddWithValue("$i", id);
return cmd.ExecuteScalar() is string username ? Passkeys(username).FirstOrDefault(p => p.Id == id) : null;
}
}
public void AddPasskey(StoredPasskey passkey)
{
lock (_gate)
{
if (Passkeys(passkey.Username).Count >= 16 || Passkey(passkey.Id) is not null) throw new WebDropException(ErrorCode.Conflict);
using var cmd = _connection.CreateCommand(); cmd.CommandText = "INSERT INTO account_passkeys(id,username,name,data,prf_salt,key_envelope,created_at) VALUES($i,$u,$n,$d,$s,$k,$t)";
cmd.Parameters.AddWithValue("$i", passkey.Id); cmd.Parameters.AddWithValue("$u", passkey.Username); cmd.Parameters.AddWithValue("$n", passkey.Name);
cmd.Parameters.AddWithValue("$d", passkey.Data); cmd.Parameters.AddWithValue("$s", passkey.PrfSalt); cmd.Parameters.AddWithValue("$k", (object?)passkey.KeyEnvelope ?? DBNull.Value); cmd.Parameters.AddWithValue("$t", passkey.CreatedAt); cmd.ExecuteNonQuery();
}
}
public bool UpdatePasskeyData(string id, string username, string oldData, string newData)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "UPDATE account_passkeys SET data=$n WHERE id=$i AND username=$u AND data=$old";
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$n", newData); cmd.Parameters.AddWithValue("$old", oldData); return cmd.ExecuteNonQuery() == 1;
}
}
public void UpdatePasskeyEnvelope(string id, string username, string envelope)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "UPDATE account_passkeys SET key_envelope=$k WHERE id=$i AND username=$u";
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.Parameters.AddWithValue("$k", envelope);
if (cmd.ExecuteNonQuery() != 1) throw new WebDropException(ErrorCode.NotFound);
}
}
public void RemovePasskey(string id, string username)
{
lock (_gate)
{
using var cmd = _connection.CreateCommand(); cmd.CommandText = "DELETE FROM account_passkeys WHERE id=$i AND username=$u";
cmd.Parameters.AddWithValue("$i", id); cmd.Parameters.AddWithValue("$u", username); cmd.ExecuteNonQuery();
}
}
}
@@ -12,11 +12,14 @@ namespace WebDrop.Api.Tests;
public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<ServerFixture>
{
private sealed record TestSigner(string Username, string DeviceId, byte[] PrivateKey);
private readonly Dictionary<string, TestSigner> _signers = new(StringComparer.Ordinal);
[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 ch = await server.CreateAsync(settings: new { name = "Key package space", seats = 8 });
var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = await 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);
@@ -25,8 +28,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
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 invite = await InviteAsync(ch, username, "opaque-member-package")) Assert.Equal(HttpStatusCode.NoContent, 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))
@@ -46,6 +48,111 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
await AssertErrorAsync(await server.Http.SendAsync(anonymous), 401, "account_required");
}
[Fact]
public async Task DeviceSigningKeyRequiresAndStoresAnAccountRootCertificate()
{
const string username = "signing_device";
var verifier = RandomNumberGenerator.GetBytes(32); var salt = RandomNumberGenerator.GetBytes(16);
using var invitation = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
using var root = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var invitationPoint = invitation.ExportParameters(false).Q;
var rootPoint = root.ExportParameters(false).Q;
string invitePublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(invitationPoint.X!), y = B64(invitationPoint.Y!) });
string rootPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(rootPoint.X!), y = B64(rootPoint.Y!) });
var vault = new byte[28];
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
Assert.True(accounts.Register(username, salt, verifier, vault, invitePublic, rootPublic));
var deviceId = Guid.NewGuid().ToString("D");
var token = accounts.Authenticate(username, verifier, deviceId, "Desktop")!;
using var device = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var devicePoint = device.ExportParameters(false).Q;
byte[] x = devicePoint.X!; byte[] y = devicePoint.Y!;
var certificate = SignatureCodec.DeviceCertificatePayload(username, deviceId, x, y);
var signature = root.SignData(SignatureCodec.BuildMessage("device.certificate", certificate), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
var requestBody = new { device_id = deviceId, public_key = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(x), y = B64(y) }), signature = Convert.ToBase64String(signature) };
using var register = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key") { Content = JsonBody(requestBody) };
register.Headers.Add("X-WD-Account", token);
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(register)).StatusCode);
signature[0] ^= 1;
using var forged = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key")
{ Content = JsonBody(requestBody with { signature = Convert.ToBase64String(signature) }) };
forged.Headers.Add("X-WD-Account", token);
await AssertErrorAsync(await server.Http.SendAsync(forged), 403, "forbidden");
using var publicKeyRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/users/{username}/key");
publicKeyRequest.Headers.Add("X-WD-Account", token);
var identity = await ReadJsonAsync(await server.Http.SendAsync(publicKeyRequest));
Assert.Equal(rootPublic, identity.GetProperty("signing_public_key").GetString());
byte[] ownerToken = RandomNumberGenerator.GetBytes(32); byte[] endpoint = RandomNumberGenerator.GetBytes(32);
var owner = new Who("owner", ownerToken, endpoint); string spaceId = NewId();
using (var create = Request(HttpMethod.Post, "/api/v1/channels", owner, token,
new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", seats = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) }))
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(create)).StatusCode);
string channelId;
using (var structure = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/structure", owner, token))
using (var response = await server.Http.SendAsync(structure))
channelId = (await ReadJsonAsync(response)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!;
string itemId = NewId();
using (var declare = Request(HttpMethod.Post, $"/api/v1/spaces/{spaceId}/crypto/objects", owner, token,
new { itemId, channelId, domain = "chat", epoch = 0, keyEnvelope = "" }))
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(declare)).StatusCode);
byte[] frame = Frame(itemId, 0, 1, 16);
Codec.TryDecodeExact(spaceId, 16, out var spaceBytes); Codec.TryDecodeExact(itemId, 16, out var itemBytes);
byte[] eventId = RandomNumberGenerator.GetBytes(16); byte[] manifest = SignatureCodec.FrameManifestHash([SHA256.HashData(frame)]);
byte[] eventPayload = SignatureCodec.ContentEventPayload(spaceBytes, channelId, username, deviceId, eventId,
"chat.create", CryptoDomain.Chat, 0, itemBytes, 1, manifest, []);
byte[] eventSignature = device.SignData(SignatureCodec.BuildMessage("content.event", eventPayload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
using (var unsigned = Request(HttpMethod.Post, $"/api/v1/channels/{spaceId}/frames", owner, token))
{
unsigned.Content = Bytes(frame);
await AssertErrorAsync(await server.Http.SendAsync(unsigned), 403, "forbidden");
}
using (var upload = Request(HttpMethod.Post, $"/api/v1/channels/{spaceId}/frames", owner, token))
{
upload.Content = Bytes(frame);
upload.Headers.Add("X-WD-Event-Id", B64(eventId)); upload.Headers.Add("X-WD-Event-Kind", "chat.create");
upload.Headers.Add("X-WD-Event-Channel", channelId); upload.Headers.Add("X-WD-Event-Domain", "chat");
upload.Headers.Add("X-WD-Event-Epoch", "0"); upload.Headers.Add("X-WD-Event-Frames", "1");
upload.Headers.Add("X-WD-Event-Manifest", B64(manifest)); upload.Headers.Add("X-WD-Event-Signature", B64(eventSignature));
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(upload)).StatusCode);
}
using (var cryptoRequest = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/crypto", owner, token))
using (var cryptoResponse = await server.Http.SendAsync(cryptoRequest))
{
var objects = (await ReadJsonAsync(cryptoResponse)).GetProperty("objects");
var signedObject = Assert.Single(objects.EnumerateArray());
Assert.Equal("chat.create", signedObject.GetProperty("event").GetProperty("kind").GetString());
Assert.Equal(B64(manifest), signedObject.GetProperty("event").GetProperty("manifestHash").GetString());
}
using (var unsignedDelete = Request(HttpMethod.Delete, $"/api/v1/channels/{spaceId}/items/{itemId}", owner, token))
await AssertErrorAsync(await server.Http.SendAsync(unsignedDelete), 403, "forbidden");
byte[] deleteEventId = RandomNumberGenerator.GetBytes(16);
byte[] deletePayload = SignatureCodec.ContentEventPayload(spaceBytes, channelId, username, deviceId, deleteEventId,
"chat.delete", CryptoDomain.Chat, 0, itemBytes, 1, manifest, []);
byte[] deleteSignature = device.SignData(SignatureCodec.BuildMessage("content.event", deletePayload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
using (var delete = Request(HttpMethod.Delete, $"/api/v1/channels/{spaceId}/items/{itemId}", owner, token))
{
delete.Headers.Add("X-WD-Event-Id", B64(deleteEventId)); delete.Headers.Add("X-WD-Event-Kind", "chat.delete");
delete.Headers.Add("X-WD-Event-Channel", channelId); delete.Headers.Add("X-WD-Event-Domain", "chat");
delete.Headers.Add("X-WD-Event-Epoch", "0"); delete.Headers.Add("X-WD-Event-Frames", "1");
delete.Headers.Add("X-WD-Event-Manifest", B64(manifest)); delete.Headers.Add("X-WD-Event-Signature", B64(deleteSignature));
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(delete)).StatusCode);
}
using (var cryptoRequest = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/crypto", owner, token))
using (var cryptoResponse = await server.Http.SendAsync(cryptoRequest))
{
var signedObject = Assert.Single((await ReadJsonAsync(cryptoResponse)).GetProperty("objects").EnumerateArray());
Assert.Equal("chat.delete", signedObject.GetProperty("deleteEvent").GetProperty("kind").GetString());
}
}
[Fact]
public async Task RevokingOneAccountDevicePreservesOtherDevicesAndChannelMembership()
{
@@ -56,15 +163,22 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
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 ch = await server.CreateAsync(settings: new { name = "Device admission space", seats = 2, join_policy = "open", allow_guests = true });
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, 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);
using var response = await server.Http.SendAsync(joined);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("seats_used").GetInt32());
}
using (var info = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner))
Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("seats_used").GetInt32());
var unrelated = await Register("full_" + Guid.NewGuid().ToString("N")[..8]);
var third = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", third, unrelated))
await AssertErrorAsync(await server.Http.SendAsync(full), 403, "seats_full");
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))
@@ -82,7 +196,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
public void PartialSettingsPreserveAdmissionDefaults()
{
var request = JsonSerializer.Deserialize("{\"settings\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!;
Assert.Equal("open", request.Settings!.JoinPolicy);
Assert.Equal(JoinPolicy.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)!;
@@ -98,15 +212,28 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
await AssertErrorAsync(response, 400, "bad_request");
}
}
private string Register(string username)
private async Task<string> Register(string username)
{
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
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)!;
using var invitation = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
using var root = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var device = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var invitePoint = invitation.ExportParameters(false).Q; var rootPoint = root.ExportParameters(false).Q; var devicePoint = device.ExportParameters(false).Q;
string invitationPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(invitePoint.X!), y = B64(invitePoint.Y!) });
string rootPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(rootPoint.X!), y = B64(rootPoint.Y!) });
string devicePublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(devicePoint.X!), y = B64(devicePoint.Y!) });
Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], invitationPublic, rootPublic));
string deviceId = Guid.NewGuid().ToString("D"); var token = accounts.Authenticate(username, verifier, deviceId, "Test device")!;
var certPayload = SignatureCodec.DeviceCertificatePayload(username, deviceId, devicePoint.X!, devicePoint.Y!);
var cert = root.SignData(SignatureCodec.BuildMessage("device.certificate", certPayload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
using var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key")
{ Content = JsonBody(new { device_id = deviceId, public_key = devicePublic, signature = Convert.ToBase64String(cert) }) };
request.Headers.Add("X-WD-Account", token);
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(request)).StatusCode);
_signers[token] = new TestSigner(username, deviceId, device.ExportPkcs8PrivateKey());
return token;
}
private HttpRequestMessage Request(HttpMethod method, string path, Who who, string? account = null, object? body = null)
{
@@ -116,6 +243,29 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
return request;
}
private Dictionary<string, string> SignedFrameHeaders(string token, string spaceId, string channelId, string itemId,
CryptoDomain domain, int epoch, string kind, byte[] frame)
{
var signer = _signers[token];
Assert.True(Codec.TryDecodeExact(spaceId, 16, out var space));
Assert.True(Codec.TryDecodeExact(itemId, 16, out var item));
const int frameCount = 1;
var manifest = SignatureCodec.FrameManifestHash([SHA256.HashData(frame)]);
var eventId = RandomNumberGenerator.GetBytes(16);
var payload = SignatureCodec.ContentEventPayload(space, channelId, signer.Username, signer.DeviceId, eventId,
kind, domain, epoch, item, frameCount, manifest, []);
using var device = ECDsa.Create(); device.ImportPkcs8PrivateKey(signer.PrivateKey, out _);
var signature = device.SignData(SignatureCodec.BuildMessage("content.event", payload), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
return new Dictionary<string, string>
{
["X-WD-Event-Id"] = B64(eventId), ["X-WD-Event-Kind"] = kind,
["X-WD-Event-Channel"] = channelId, ["X-WD-Event-Domain"] = domain == CryptoDomain.Chat ? "chat" : "files",
["X-WD-Event-Epoch"] = epoch.ToString(), ["X-WD-Event-Frames"] = frameCount.ToString(),
["X-WD-Event-Manifest"] = B64(manifest), ["X-WD-Event-Signature"] = B64(signature),
};
}
[Fact]
public async Task CreationAndOwnerActionsRequireAccountIdentity()
{
@@ -130,7 +280,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
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]);
var unrelated = await 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");
}
@@ -140,7 +290,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
[InlineData(ChannelMode.Raw)]
public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode)
{
var ch = await server.CreateAsync(mode, new { seats = 8 });
var ch = await server.CreateAsync(mode, new { name = "Password space", 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));
@@ -157,20 +307,30 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
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 account = await 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 invite = await InviteAsync(ch, username, "client-encrypted-payload")) Assert.Equal(HttpStatusCode.NoContent, 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 };
string channelId;
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{ch.Id}/structure", ch.Owner))
using (var structureResponse = await server.Http.SendAsync(structureRequest))
channelId = (await ReadJsonAsync(structureResponse)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!;
var itemId = NewId();
using (var declare = Request(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/crypto/objects", writer, account,
new { itemId, channelId, domain = "files", epoch = 1, keyEnvelope = "opaque-object-key" }))
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(declare)).StatusCode);
byte[] frame = Frame(itemId, 0, (byte)(1 | 16 | (mode == ChannelMode.Enc ? 8 : 0)), mode == ChannelMode.Enc ? EncPayload : 16);
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));
upload.Content = Bytes(frame);
foreach (var (name, value) in SignedFrameHeaders(account, ch.Id, channelId, itemId, CryptoDomain.Files, 1, "file.publish", frame))
upload.Headers.Add(name, value);
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))
@@ -183,7 +343,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
public async Task StoredLoginDigestCannotBeUsedAsLoginCredentialAndVaultUpdatesAreConditional()
{
var username = "digest_" + Guid.NewGuid().ToString("N")[..8];
var token = Register(username);
var token = await Register(username);
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
Assert.Null(accounts.Authenticate(username, new byte[32]));
using var database = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(server.App.Services.GetRequiredService<WebDropOptions>().DataDir, "v2", "webdrop.db") }.ToString());
@@ -197,4 +357,34 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
Assert.Equal(vault.Revision + 1, accounts.PutVault(username, new byte[28], vault.Revision));
accounts.RevokeSession(token); Assert.False(accounts.HasSession(token, out _));
}
private async Task<HttpResponseMessage> InviteAsync(Chan channel, string username, string payload)
{
string? defaultChannelId = null;
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{channel.Id}/structure", channel.Owner))
using (var structureResponse = await server.Http.SendAsync(structureRequest))
{
Assert.Equal(HttpStatusCode.OK, structureResponse.StatusCode);
defaultChannelId = (await ReadJsonAsync(structureResponse)).GetProperty("structure").GetProperty("defaultChannelId").GetString();
}
if (channel.Mode == ChannelMode.Enc)
{
foreach (var domain in new[] { "chat", "files" })
{
using var bootstrap = server.Req(HttpMethod.Put, $"/api/v1/spaces/{channel.Id}/crypto/domains", channel.Owner,
JsonBody(new { channelId = defaultChannelId, domain, epoch = 1, envelopes = new[] { new { channelId = defaultChannelId, domain, epoch = 1, username = "fixture_owner", payload = $"owner-{domain}" } } }));
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(bootstrap)).StatusCode);
}
}
using var planRequest = server.Req(HttpMethod.Get, $"/api/v1/channels/{channel.Id}/invitations/{username}/crypto-plan", channel.Owner);
using var planResponse = await server.Http.SendAsync(planRequest); Assert.Equal(HttpStatusCode.OK, planResponse.StatusCode);
var plan = await ReadJsonAsync(planResponse);
var targetChannelId = plan.GetProperty("channelId").GetString();
var envelopes = new List<object>();
foreach (var batch in plan.GetProperty("batches").EnumerateArray())
foreach (var recipient in batch.GetProperty("recipients").EnumerateArray())
envelopes.Add(new { channelId = targetChannelId, domain = batch.GetProperty("domain").GetString(), epoch = batch.GetProperty("epoch").GetInt32(), username = recipient.GetString(), payload = "opaque-domain-key" });
return await server.Http.SendAsync(server.Req(HttpMethod.Post, $"/api/v1/channels/{channel.Id}/invitations", channel.Owner,
JsonBody(new { username, payload, domain_envelopes = envelopes })));
}
}
@@ -0,0 +1,14 @@
using System.Text.Json;
using WebDrop.Api;
namespace WebDrop.Api.Tests;
public sealed class ChannelAccessContractTests
{
[Fact]
public void GeneratedJsonDefaultsDomainEnvelopesForUnencryptedInvitations()
{
var request = JsonSerializer.Deserialize("""{"username":"member","payload":"opaque-grant"}""", ApiJson.Default.InvitationRequest)!;
Assert.Null(request.DomainEnvelopes);
}
}
@@ -0,0 +1,20 @@
using WebDrop.Api;
using WebDrop.Core;
namespace WebDrop.Api.Tests;
public sealed class PasskeyCeremonyTests
{
[Fact]
public void CeremoniesAreOneTimeAndBoundToTheAuthenticatedAccountAndSession()
{
var ceremonies = new PasskeyCeremonies();
PasskeyCeremony state = new("alice", "register", "server-state", "salt", PasskeyCeremonies.Hash("token-a"), DateTimeOffset.UtcNow.AddMinutes(1));
var id = ceremonies.Put(state); Assert.Equal(state, ceremonies.Take(id, "alice", "register", "token-a"));
Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-a"));
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "bob", "register", "token-a"));
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-b"));
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "login", "token-a"));
id = ceremonies.Put(state with { Expires = DateTimeOffset.UtcNow.AddSeconds(-1) }); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-a"));
}
}
+20 -1
View File
@@ -53,6 +53,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
var body = new Dictionary<string, object?>
{
["id"] = ch.Id, ["mode"] = "enc",
["settings"] = new { name = "Duplicate" },
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
["read_hash"] = B64(TokenHash.Compute(ch.ReadToken)),
};
@@ -69,6 +70,24 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
await AssertErrorAsync(await s.Http.SendAsync(badJson), 400, "bad_request");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task CreateRequiresSpaceName(string? name)
{
var ch = new Chan(NewId(), ChannelMode.Raw);
var body = new Dictionary<string, object?>
{
["id"] = ch.Id,
["mode"] = "raw",
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
};
if (name is not null) body["settings"] = new { name };
using var req = s.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
await AssertErrorAsync(await s.Http.SendAsync(req), 400, "space_name_required");
}
[Fact]
public async Task ErrorsUseStableCodes()
{
@@ -367,7 +386,7 @@ public class RateLimitTests
{
var ch = new Chan(NewId(), ChannelMode.Raw);
using var req = server.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner,
JsonBody(new { id = ch.Id, mode = "raw", owner_hash = B64(TokenHash.Compute(ch.OwnerToken)) }));
JsonBody(new { id = ch.Id, mode = "raw", settings = new { name = "Rate limit space" }, owner_hash = B64(TokenHash.Compute(ch.OwnerToken)) }));
using var r = await server.Http.SendAsync(req);
statuses.Add((int)r.StatusCode);
}
@@ -117,7 +117,8 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
["write_hash"] = B64(TokenHash.Compute(ch.WriteToken)),
};
if (mode == ChannelMode.Enc) body["read_hash"] = B64(TokenHash.Compute(ch.ReadToken));
if (settings is not null) body["settings"] = settings;
// General transport tests explicitly use open spaces; creation defaults have separate coverage.
body["settings"] = settings ?? new { name = "Test space", seats = 2, join_policy = "open", allow_guests = true };
using var req = Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
if (capToken is not null) req.Headers.Add("X-WD-Cap", capToken);
+2 -2
View File
@@ -105,14 +105,14 @@ public sealed class ShareTests(ServerFixture server) : IClassFixture<ServerFixtu
byte[] channelOwnerToken = RandomNumberGenerator.GetBytes(32);
var channelOwner = new Who("owner", channelOwnerToken, RandomNumberGenerator.GetBytes(32));
using (var collision = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", channelOwner,
JsonBody(new { id = share.Id, mode = "raw", owner_hash = B64(TokenHash.Compute(channelOwnerToken)) }))))
JsonBody(new { id = share.Id, mode = "raw", settings = new { name = "Collision" }, owner_hash = B64(TokenHash.Compute(channelOwnerToken)) }))))
await AssertErrorAsync(collision, 409, "conflict");
string channelId = NewId();
byte[] secondOwnerToken = RandomNumberGenerator.GetBytes(32);
var secondOwner = new Who("owner", secondOwnerToken, RandomNumberGenerator.GetBytes(32));
using (var channel = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", secondOwner,
JsonBody(new { id = channelId, mode = "raw", owner_hash = B64(TokenHash.Compute(secondOwnerToken)) }))))
JsonBody(new { id = channelId, mode = "raw", settings = new { name = "Second space" }, owner_hash = B64(TokenHash.Compute(secondOwnerToken)) }))))
Assert.Equal(HttpStatusCode.Created, channel.StatusCode);
using var shareCollision = await CreateShare(new ShareClient(channelId));
@@ -0,0 +1,64 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using WebDrop.Core;
using WebDrop.Storage;
using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture<ServerFixture>
{
[Fact]
public async Task AccountProfileIsServerOwnedAndDoesNotChangeTheEncryptedVault()
{
var store = server.App.Services.GetRequiredService<SqliteAccountStore>();
var revision = store.GetVault("fixture_owner")!.Value.Revision;
using var patch = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "资料测试", avatar = "data:image/png;base64,AA==" }) };
patch.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var response = await server.Http.SendAsync(patch);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadJsonAsync(response); Assert.Equal("资料测试", body.GetProperty("displayName").GetString());
Assert.Equal(revision, store.GetVault("fixture_owner")!.Value.Revision);
using var read = new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/profile"); read.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var loaded = await server.Http.SendAsync(read);
Assert.Equal("资料测试", (await ReadJsonAsync(loaded)).GetProperty("displayName").GetString());
using var bad = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "test", avatar = "https://tracker.test/avatar.png" }) };
bad.Headers.Add("X-WD-Account", server.OwnerAccountToken);
using var rejected = await server.Http.SendAsync(bad); Assert.Equal(HttpStatusCode.BadRequest, rejected.StatusCode);
using var anonymous = await server.Http.GetAsync("/api/v1/accounts/profile"); Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task StructureEndpointsPersistAndRejectCrossSpaceGroups()
{
var a = await server.CreateAsync(); var b = await server.CreateAsync();
using var createGroup = server.Req(HttpMethod.Post, $"/api/v1/spaces/{b.Id}/groups", b.Owner, JsonBody(new { name = "另一空间" }));
using var groupResponse = await server.Http.SendAsync(createGroup); Assert.Equal(HttpStatusCode.Created, groupResponse.StatusCode);
var groupBody = await ReadJsonAsync(groupResponse);
var groupId = groupBody.GetProperty("group").GetProperty("id").GetString();
using var wrong = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "频道", groupId }));
using var wrongResponse = await server.Http.SendAsync(wrong); Assert.Equal(HttpStatusCode.BadRequest, wrongResponse.StatusCode);
using var valid = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "讨论" }));
using var response = await server.Http.SendAsync(valid); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var createdBody = await ReadJsonAsync(response);
Assert.Equal("讨论", createdBody.GetProperty("channel").GetProperty("name").GetString());
using var read = server.Req(HttpMethod.Get, $"/api/v1/spaces/{a.Id}/structure", a.Owner);
using var loaded = await server.Http.SendAsync(read); Assert.Equal(2, (await ReadJsonAsync(loaded)).GetProperty("structure").GetProperty("channels").GetArrayLength());
}
[Fact]
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
{
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxSeats"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
await isolated.InitializeAsync();
using var infoResponse = await isolated.Http.GetAsync("/api/v1/info"); var info = await ReadJsonAsync(infoResponse);
var defaults = info.GetProperty("defaults").GetProperty("channel");
Assert.Equal("enc", info.GetProperty("defaults").GetProperty("channel_mode").GetString());
Assert.Equal(3, defaults.GetProperty("seats").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
var ch = new Chan(NewId(), ChannelMode.Enc);
using var create = isolated.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(new { id = ch.Id, mode = "enc", settings = new { name = "Limited space", seats = 3, ttl_seconds = 3600 }, read_hash = B64(TokenHash.Compute(ch.ReadToken)), owner_hash = B64(TokenHash.Compute(ch.OwnerToken)), write_hash = B64(TokenHash.Compute(ch.WriteToken)) }));
using var result = await isolated.Http.SendAsync(create); Assert.Equal(HttpStatusCode.Created, result.StatusCode);
var settings = (await ReadJsonAsync(result)).GetProperty("settings");
Assert.Equal("Limited space", settings.GetProperty("name").GetString());
Assert.Equal(defaults.GetProperty("seats").GetInt32(), settings.GetProperty("seats").GetInt32());
Assert.Equal(defaults.GetProperty("ttl_seconds").GetInt32(), settings.GetProperty("ttl_seconds").GetInt32());
}
}
@@ -0,0 +1,109 @@
using WebDrop.Core;
namespace WebDrop.Core.Tests;
public sealed class ChannelCryptoTests
{
[Fact]
public void ClientMayBootstrapButCannotChooseAChannelEpochOrGrantAccess()
{
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
Bootstrap(env, space, owner, channelId);
Assert.Throws<WebDropException>(() => env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2,
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2, Username = "owner", Payload = "forbidden-rotation" }] }));
Assert.Throws<WebDropException>(() => env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1,
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, Username = "owner", Payload = "owner-key" },
new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, Username = "member", Payload = "unauthorized-grant" }] }));
}
[Fact]
public void ServerPlansAndAtomicallyCommitsNoHistoryRotationAndDefaultAccess()
{
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
Bootstrap(env, space, owner, channelId);
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
Assert.Equal(channelId, plan.ChannelId);
Assert.Equal(new[] { CryptoDomain.Chat, CryptoDomain.Files }, plan.Batches.Select(b => b.Domain).Order());
Assert.All(plan.Batches, batch => { Assert.Equal(2, batch.Epoch); Assert.Equal(new[] { "member", "owner" }, batch.Recipients.Order(StringComparer.Ordinal)); });
var incomplete = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope
{ ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).Skip(1).ToList();
Assert.Throws<WebDropException>(() => env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", incomplete));
Assert.Empty(env.Service.Invitations(space.Id, owner));
var packages = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope
{ ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).ToList();
env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages);
var updatedChannel = env.Service.GetWorkspace(space.Id, owner).Channels.Single();
Assert.Equal(2, updatedChannel.ChatEpoch); Assert.Equal(2, updatedChannel.FilesEpoch);
var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member);
var received = env.Service.ReadCrypto(space.Id, member);
Assert.Equal(2, received.Envelopes.Count);
Assert.All(received.Envelopes, e => Assert.Equal(2, e.Epoch));
Assert.All(received.Access, a => { Assert.True(a.ChatRead); Assert.True(a.ChatWrite); Assert.True(a.FilesRead); Assert.True(a.FilesUpload); });
Assert.Throws<WebDropException>(() => env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = TestEnv.NewId().ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "old" }));
var objectId = TestEnv.NewId().ToBase64Url();
Assert.Equal("member", env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = objectId, ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2, KeyEnvelope = "current" }).AuthorAccount);
}
[Fact]
public void HistoryEnabledPlanGrantsExistingEpochsOnlyToTheNewRecipient()
{
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
Bootstrap(env, space, owner, channelId);
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
Assert.Equal(2, plan.Batches.Count);
Assert.All(plan.Batches, batch => { Assert.Equal(1, batch.Epoch); Assert.Equal(new[] { "member" }, batch.Recipients); });
var packages = plan.Batches.Select(batch => new DomainEnvelope { ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = "member", Payload = $"wrapped-{batch.Domain}" }).ToList();
env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages);
var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member);
var state = env.Service.ReadCrypto(space.Id, member);
Assert.Equal(2, state.Envelopes.Count);
Assert.All(state.Envelopes, e => Assert.Equal("member", e.Username));
Assert.Equal(1, env.Service.GetWorkspace(space.Id, owner).Channels.Single().ChatEpoch);
Assert.Equal(1, env.Service.GetWorkspace(space.Id, owner).Channels.Single().FilesEpoch);
}
[Fact]
public async Task UnencryptedWorkspaceStillUsesServerHistoryCutoffsAndAccessRules()
{
using var env = new TestEnv(); var ownerEndpoint = TestEnv.Endpoint(); var memberEndpoint = TestEnv.Endpoint();
var space = env.Create(ownerEndpoint, ChannelMode.Raw); var owner = space.Owner(ownerEndpoint);
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
var oldFile = TestEnv.NewId();
env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = oldFile.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
await env.Service.AppendFrameAsync(space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16), null);
env.Service.MoveWorkspaceItems(space.Id, owner, [oldFile.ToBase64Url()], channelId, null);
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
Assert.Empty(plan.Batches);
env.Service.InviteUser(space.Id, owner, "member", "account-grant", []);
var member = space.Member(memberEndpoint) with { AccountName = "member" }; env.Service.Join(space.Id, member);
Assert.Empty(env.Service.GetWorkspace(space.Id, member).Locations);
Assert.Empty((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
var newMessage = TestEnv.NewId();
env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = newMessage.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 0, KeyEnvelope = "" });
await env.Service.AppendFrameAsync(space.Id, member, TestEnv.Frame(newMessage, 0, FrameFlags.Final, 16), null);
Assert.Single((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
}
private static void Bootstrap(TestEnv env, TestChannel space, Credentials owner, string channelId)
{
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest
{
ChannelId = channelId, Domain = domain, Epoch = 1,
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = domain, Epoch = 1, Username = "owner", Payload = $"owner-{domain.ToString().ToLowerInvariant()}-key" }],
});
}
}
@@ -41,6 +41,7 @@ public class ChannelServiceTests : IDisposable
var dup = new CreateChannelRequest
{
Id = ch.Id.ToBase64Url(), Mode = ChannelMode.Enc,
Settings = new ChannelSettings { Name = "Duplicate" },
ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)),
OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)),
};
@@ -51,9 +52,9 @@ public class ChannelServiceTests : IDisposable
public void CreateEnforcesModeSpecificHashes()
{
string h() => Codec.Encode(TokenHash.Compute(TestEnv.Random(32)));
var rawWithRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, ReadHash = h(), OwnerHash = h() };
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() };
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw };
var rawWithRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "Raw space" }, ReadHash = h(), OwnerHash = h() };
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, Settings = new ChannelSettings { Name = "Enc space" }, OwnerHash = h() };
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "No owner space" } };
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp, "owner"));
@@ -106,6 +107,32 @@ public class ChannelServiceTests : IDisposable
Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知
}
[Fact]
public void AccountMembersUseOneLimitAcrossUnlimitedDevices()
{
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 2 });
var memberOne = TestEnv.Endpoint();
var memberTwo = TestEnv.Endpoint();
var ownerTwo = TestEnv.Endpoint();
Credentials alice(byte[] endpoint, string device) => new(Role.Read, ch.ReadToken, endpoint, "alice", device);
Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).SeatsUsed);
Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).SeatsUsed);
Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).SeatsUsed);
Assert.Equal(4, Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Count); // 设备仍单独登记,但不计入成员人数
var bob = new Credentials(Role.Read, ch.ReadToken, TestEnv.Endpoint(), "bob", "bob-1");
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob));
Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), memberOne);
Assert.Equal(2, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed); // 移除一台设备不会释放账号成员名额
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }));
Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), "alice");
Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed);
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).SeatsUsed);
}
[Fact]
public async Task DataOperationsRequireASeat()
{
@@ -114,6 +141,28 @@ public class ChannelServiceTests : IDisposable
await AssertCode(ErrorCode.NotJoined, () => LogOf(ch, ch.Reader(_memberEp)));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void CreateRequiresSpaceName(string? name)
{
var request = new CreateChannelRequest
{
Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw,
Settings = new ChannelSettings { Name = name },
OwnerHash = Codec.Encode(TokenHash.Compute(TestEnv.Random(32))),
};
AssertCode(ErrorCode.SpaceNameRequired, () => Svc.CreateChannel(request, _ownerEp, "owner"));
}
[Fact]
public void CreateTrimsSpaceName()
{
var channel = _env.Create(_ownerEp, settings: new ChannelSettings { Name = " Team space " });
Assert.Equal("Team space", Svc.GetChannel(channel.Id, channel.Owner(_ownerEp)).Settings.Name);
}
[Fact]
public async Task ItemIdsAndFramesAreScopedToTheirChannel()
{
@@ -68,6 +68,8 @@ public sealed class TestEnv : IDisposable
var write = Random(32);
var owner = Random(32);
settings ??= new ChannelSettings();
settings.Name ??= "Test space";
Service.CreateChannel(new CreateChannelRequest
{
Id = id.ToBase64Url(),
@@ -67,6 +67,37 @@ public class VectorTests
Assert.Equal(H(item.GetProperty("k_item")), ItemKey(H(item.GetProperty("item_id"))));
}
[Fact]
public void SignatureFramingAndEcdsaMatchTheVectors()
{
var vector = V.GetProperty("signatures");
static byte[] B64Url(string value) => Convert.FromBase64String(value.Replace('-', '+').Replace('_', '/') + new string('=', (4 - value.Length % 4) % 4));
var x = B64Url(vector.GetProperty("x").GetString()!);
var y = B64Url(vector.GetProperty("y").GetString()!);
var payload = H(vector.GetProperty("payload"));
var kind = vector.GetProperty("kind").GetString()!;
var signature = H(vector.GetProperty("signature"));
Assert.Equal(H(vector.GetProperty("message")), SignatureCodec.BuildMessage(kind, payload));
Assert.True(SignatureCodec.VerifyP256(x, y, kind, payload, signature));
Assert.False(SignatureCodec.VerifyP256(x, y, "chat.edit", payload, signature));
payload[0] ^= 1;
Assert.False(SignatureCodec.VerifyP256(x, y, kind, payload, signature));
}
[Fact]
public void ContentEventPayloadAndFrameManifestMatchTheVectors()
{
var vector = V.GetProperty("content_event");
var digests = vector.GetProperty("frame_digests").EnumerateArray().Select(H).ToList();
var manifest = SignatureCodec.FrameManifestHash(digests);
Assert.Equal(H(vector.GetProperty("manifest_hash")), manifest);
byte[] id(string property) => H(vector.GetProperty(property));
Assert.Equal(H(vector.GetProperty("payload")), SignatureCodec.ContentEventPayload(
id("space_id"), vector.GetProperty("channel_id").GetString()!, vector.GetProperty("username").GetString()!,
vector.GetProperty("device_id").GetString()!, id("event_id"), vector.GetProperty("kind").GetString()!, CryptoDomain.Chat,
vector.GetProperty("epoch").GetInt32(), id("item_id"), vector.GetProperty("frame_count").GetInt32(), manifest, id("target_item_id")));
}
[Fact]
public void ServerTokenHashMatchesTheStoredHashes()
{
@@ -0,0 +1,71 @@
using WebDrop.Core;
namespace WebDrop.Core.Tests;
public sealed class WorkspaceStructureTests
{
[Fact]
public void NewSpaceHasAnAtomicInitialChannelAndChangesPersist()
{
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint); var owner = ch.Owner(endpoint);
var state = env.Service.GetWorkspace(ch.Id, owner);
Assert.Equal("常规", Assert.Single(state.Channels).Name);
var group = env.Service.CreateWorkspaceGroup(ch.Id, owner, "项目").Groups.Single();
var changed = env.Service.CreateWorkspaceChannel(ch.Id, owner, "讨论", group.Id);
Assert.Equal(group.Id, changed.Channels.Last().GroupId);
var reloaded = env.Service.GetWorkspace(ch.Id, owner);
Assert.Equal(changed.Revision, reloaded.Revision); Assert.Equal(2, reloaded.Channels.Count);
}
[Fact]
public void InvalidAndCrossSpaceAssociationsRollback()
{
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var a = env.Create(endpoint); var b = env.Create(endpoint);
var group = env.Service.CreateWorkspaceGroup(b.Id, b.Owner(endpoint), "其他空间").Groups.Single();
var previous = env.Service.GetWorkspace(a.Id, a.Owner(endpoint));
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannel(a.Id, a.Owner(endpoint), "讨论", group.Id));
Assert.Equal(previous.Revision, env.Service.GetWorkspace(a.Id, a.Owner(endpoint)).Revision);
var first = previous.Channels.Single().Id;
var second = env.Service.CreateWorkspaceChannel(a.Id, a.Owner(endpoint), "频道2", null).Channels.Last().Id;
var folder = env.Service.CreateWorkspaceFolder(a.Id, a.Owner(endpoint), first, null, "目录").Folders.Single();
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceFolder(a.Id, a.Owner(endpoint), second, folder.Id, "跨频道目录"));
}
[Fact]
public async Task MembersCannotMoveAnotherAccountsItemsAndTheServerSuppliesTheAuthor()
{
using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp);
var member = ch.Member(memberEp) with { AccountName = "member" };
env.Service.Join(ch.Id, member);
var item = TestEnv.NewId();
await env.Service.AppendFrameAsync(ch.Id, ch.Owner(ownerEp), TestEnv.Frame(item, 0, TestEnv.SmallEnc, TestEnv.EncPayload), null);
var thread = env.Service.GetWorkspace(ch.Id, ch.Owner(ownerEp)).Channels.Single().Id;
Assert.Throws<WebDropException>(() => env.Service.MoveWorkspaceItems(ch.Id, member, [item.ToBase64Url()], thread, null));
var moved = env.Service.MoveWorkspaceItems(ch.Id, ch.Owner(ownerEp), [item.ToBase64Url()], thread, null);
Assert.Equal("owner", moved.Locations.Single().AuthorAccount);
Assert.Throws<WebDropException>(() => env.Service.MoveWorkspaceItems(ch.Id, ch.Owner(ownerEp), [TestEnv.NewId().ToBase64Url()], thread, null));
}
[Fact]
public void AnExistingSpaceWithoutBackendStructureIsNotAutomaticallyMigrated()
{
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint);
using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={Path.Combine(env.Dir, "test.db")}");
connection.Open();
using var delete = connection.CreateCommand(); delete.CommandText = "DELETE FROM workspace_structures WHERE space_id=$id";
delete.Parameters.AddWithValue("$id", ch.Id.ToArray()); delete.ExecuteNonQuery();
var empty = env.Service.GetWorkspace(ch.Id, ch.Owner(endpoint));
Assert.Empty(empty.Channels); Assert.Equal(0, empty.Revision);
var added = env.Service.CreateWorkspaceChannel(ch.Id, ch.Owner(endpoint), "新频道", null);
Assert.Equal("新频道", added.Channels.Single().Name); Assert.Equal(1, added.Revision);
}
[Fact]
public async Task AllowedGuestsCanPlaceTheirUploadsButCannotCreateBusinessStructure()
{
using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var guestEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp);
var guest = ch.Member(guestEp); env.Service.Join(ch.Id, guest);
var item = TestEnv.NewId();
await env.Service.AppendFrameAsync(ch.Id, guest, TestEnv.Frame(item, 0, TestEnv.SmallEnc, TestEnv.EncPayload), null);
var channelId = env.Service.GetWorkspace(ch.Id, ch.Owner(ownerEp)).Channels.Single().Id;
Assert.Single(env.Service.MoveWorkspaceItems(ch.Id, guest, [item.ToBase64Url()], channelId, null).Locations);
Assert.Equal(ErrorCode.AccountRequired, Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "访客分组")).Code);
}
}
+42 -1
View File
@@ -9,7 +9,7 @@
//
// 所有输入都是固定值,输出完全确定,可以放心提交到仓库。
import { createCipheriv, createHash, hkdfSync } from 'node:crypto';
import { createCipheriv, createHash, createPublicKey, hkdfSync, verify as verifySignature } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -149,6 +149,47 @@ function encodeItem({ itemId, meta, content, maxFrameData = MAX_FRAME_DATA, snap
const vectors = { version: 1 };
// 固定 ECDSA P-256 验签向量。签名只用于测试,对应私钥不进入向量。
const signatureFixture = {
x: 'OJVV0pkDZHH7F92rxIQt1EuXk4ZesLwoWTaMSANt79A',
y: 'HRP3ZgqoD_8Iv42Mw1oflcJUKeq3H1gEbbOLNJoC_zs',
kind: 'chat.create',
payload: '01020304deadbeef',
signature: '9dad329614d98c97acc5a60622329e57e111fd7579edc7e4bcc535476aae281cc7e3ee00fa2d5d901af525875af653d74bd441ca5dd54d00081b51b7c3b5adcb',
};
const signatureKind = Buffer.from(signatureFixture.kind, 'ascii');
const signaturePayload = unhex(signatureFixture.payload);
const signatureMessage = Buffer.concat([
Buffer.from([0x57, 0x44, 0x53, 0x01]), u16(signatureKind.length), signatureKind,
u32(signaturePayload.length), signaturePayload,
]);
const signaturePublicKey = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: signatureFixture.x, y: signatureFixture.y }, format: 'jwk' });
if (!verifySignature('sha256', signatureMessage, { key: signaturePublicKey, dsaEncoding: 'ieee-p1363' }, unhex(signatureFixture.signature)))
throw new Error('固定签名向量无效');
vectors.signatures = { ...signatureFixture, message: hex(signatureMessage) };
const eventDigests = [seq(0x31, 32), seq(0x71, 32)];
const eventManifestInput = Buffer.concat([Buffer.from('WD1M', 'ascii'), u32(eventDigests.length),
...eventDigests.flatMap((digest, index) => [u32(index), digest])]);
const eventFields = {
space_id: hex(channelId), channel_id: '01234567-89ab-cdef-0123-456789abcdef', username: 'alice',
device_id: '11111111-2222-3333-4444-555555555555', event_id: hex(itemC), kind: 'chat.edit',
domain: 'chat', epoch: 2, item_id: hex(itemA), frame_count: 2, target_item_id: hex(itemB),
};
const eventKind = Buffer.from(eventFields.kind, 'ascii');
const eventChannel = Buffer.from(eventFields.channel_id, 'ascii');
const eventUser = Buffer.from(eventFields.username, 'ascii');
const eventDevice = Buffer.from(eventFields.device_id, 'ascii');
const eventTarget = unhex(eventFields.target_item_id);
const eventManifest = sha256(eventManifestInput);
const eventPayload = Buffer.concat([
channelId, Buffer.from([eventChannel.length]), eventChannel, Buffer.from([eventUser.length]), eventUser,
Buffer.from([eventDevice.length]), eventDevice, itemC, Buffer.from([eventKind.length]), eventKind,
Buffer.from([1]), u32(eventFields.epoch), itemA, u32(eventFields.frame_count), eventManifest,
Buffer.from([1]), eventTarget,
]);
vectors.content_event = { ...eventFields, frame_digests: eventDigests.map(hex), manifest_hash: hex(eventManifest), payload: hex(eventPayload) };
vectors.inputs = {
channel_id: hex(channelId),
channel_id_b64: b64url(channelId),
+27
View File
@@ -1,5 +1,32 @@
{
"version": 1,
"signatures": {
"x": "OJVV0pkDZHH7F92rxIQt1EuXk4ZesLwoWTaMSANt79A",
"y": "HRP3ZgqoD_8Iv42Mw1oflcJUKeq3H1gEbbOLNJoC_zs",
"kind": "chat.create",
"payload": "01020304deadbeef",
"signature": "9dad329614d98c97acc5a60622329e57e111fd7579edc7e4bcc535476aae281cc7e3ee00fa2d5d901af525875af653d74bd441ca5dd54d00081b51b7c3b5adcb",
"message": "57445301000b636861742e6372656174650000000801020304deadbeef"
},
"content_event": {
"space_id": "000102030405060708090a0b0c0d0e0f",
"channel_id": "01234567-89ab-cdef-0123-456789abcdef",
"username": "alice",
"device_id": "11111111-2222-3333-4444-555555555555",
"event_id": "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf",
"kind": "chat.edit",
"domain": "chat",
"epoch": 2,
"item_id": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf",
"frame_count": 2,
"target_item_id": "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
"frame_digests": [
"3132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f50",
"7172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f90"
],
"manifest_hash": "97c6a9995ae971032e62baa1f2388f966c814d8a2e22c7299715879809d6d98c",
"payload": "000102030405060708090a0b0c0d0e0f2430313233343536372d383961622d636465662d303132332d34353637383961626364656605616c6963652431313131313131312d323232322d333333332d343434342d353535353535353535353535d0d1d2d3d4d5d6d7d8d9dadbdcdddedf09636861742e656469740100000002a0a1a2a3a4a5a6a7a8a9aaabacadaeaf0000000297c6a9995ae971032e62baa1f2388f966c814d8a2e22c7299715879809d6d98c01b0b1b2b3b4b5b6b7b8b9babbbcbdbebf"
},
"inputs": {
"channel_id": "000102030405060708090a0b0c0d0e0f",
"channel_id_b64": "AAECAwQFBgcICQoLDA0ODw",
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../server/src/WebDrop.Api/WebDrop.Api.csproj" />
</ItemGroup>
</Project>
+92
View File
@@ -0,0 +1,92 @@
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Text.Json.Nodes;
using WebDrop.Api;
using WebDrop.Core;
// Build-time reflection only; production serialization remains source-generated and AOT-safe.
var root = Path.GetFullPath(args.FirstOrDefault(a => a != "--check") ?? ".");
string[] sources = ["server/src/WebDrop.Api/ApiJson.cs", "server/src/WebDrop.Core/Dtos.cs", "server/src/WebDrop.Core/Settings.cs", "server/src/WebDrop.Core/Enums.cs", "server/src/WebDrop.Core/Workspace.cs", "server/src/WebDrop.Core/AccountProfile.cs", "server/src/WebDrop.Api/PasskeyDtos.cs", "server/src/WebDrop.Core/WorkspaceCrypto.cs", "server/src/WebDrop.Api/Endpoints.WorkspaceCrypto.cs"];
var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("", sources.Select(p => p + "\n" + File.ReadAllText(Path.Combine(root, p)).Replace("\r\n", "\n") + "\n")))));
var pending = new SortedDictionary<string, Type>();
var done = new HashSet<Type>();
foreach (var entry in typeof(ApiJson).GetCustomAttributesData().Where(a => a.AttributeType == typeof(JsonSerializableAttribute))) Add((Type)entry.ConstructorArguments[0].Value!);
var output = new StringBuilder($"// Generated from backend DTOs; do not edit.\n// backend-contract-sha256: {digest}\n\n");
var nullable = new NullabilityInfoContext();
var definitions = new JsonObject();
while (pending.Count > 0)
{
var type = pending.First().Value;
pending.Remove(type.Name);
if (!done.Add(type)) continue;
if (type.IsEnum)
{
var values = type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => "'" + (f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name ?? f.Name) + "'");
output.AppendLine($"export type {type.Name} = {string.Join(" | ", values)};\n");
definitions[type.Name] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray(type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => JsonValue.Create(f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name ?? f.Name) as JsonNode).ToArray()) };
continue;
}
output.AppendLine($"export interface {type.Name} {{");
var properties = new JsonObject();
var required = new JsonArray();
foreach (var prop in type.GetProperties().OrderBy(p => p.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name ?? p.Name, StringComparer.Ordinal))
{
var ignore = prop.GetCustomAttribute<JsonIgnoreAttribute>();
if (ignore?.Condition == JsonIgnoreCondition.Always) continue;
var jsonName = prop.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name;
if (jsonName is null) throw new InvalidOperationException($"Wire property needs an explicit JSON name: {type.Name}.{prop.Name}");
var isNullable = Nullable.GetUnderlyingType(prop.PropertyType) is not null || nullable.Create(prop).ReadState == NullabilityState.Nullable;
var optional = isNullable && ignore?.Condition != JsonIgnoreCondition.Never;
output.AppendLine($" {jsonName}{(optional ? "?" : "")}: {Ts(prop.PropertyType)}{(isNullable ? " | null" : "")};");
properties[jsonName] = isNullable ? new JsonObject { ["anyOf"] = new JsonArray(Schema(prop.PropertyType), new JsonObject { ["type"] = "null" }) } : Schema(prop.PropertyType);
if (!optional) required.Add(jsonName);
}
output.AppendLine("}");
definitions[type.Name] = new JsonObject { ["type"] = "object", ["properties"] = properties, ["required"] = required };
}
var path = Path.Combine(root, "web/src/core-bridge/generated/api.ts");
var text = output.ToString().Replace("\r\n", "\n");
if (args.Contains("--check"))
{
if (!File.Exists(path) || File.ReadAllText(path).Replace("\r\n", "\n") != text) throw new InvalidOperationException("Generated API types are stale. Run ContractGenerator.");
}
else { Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, text, new UTF8Encoding(false)); }
var schemaPath = Path.Combine(root, "contracts/api.schemas.json");
var schemaText = new JsonObject { ["$schema"] = "https://json-schema.org/draft/2020-12/schema", ["x-backend-contract-sha256"] = digest, ["$defs"] = definitions }.ToJsonString(new() { WriteIndented = true }).Replace("\r\n", "\n") + "\n";
if (args.Contains("--check"))
{
if (!File.Exists(schemaPath) || File.ReadAllText(schemaPath).Replace("\r\n", "\n") != schemaText) throw new InvalidOperationException("Generated API schemas are stale.");
}
else File.WriteAllText(schemaPath, schemaText, new UTF8Encoding(false));
Console.WriteLine($"Backend API types {(args.Contains("--check") ? "verified" : "generated")}.");
void Add(Type type)
{
type = Nullable.GetUnderlyingType(type) ?? type;
if (type.IsArray) { Add(type.GetElementType()!); return; }
if (type.IsGenericType) { foreach (var arg in type.GetGenericArguments()) Add(arg); return; }
if ((type.Namespace is "WebDrop.Api" or "WebDrop.Core") && !done.Contains(type)) pending[type.Name] = type;
}
string Ts(Type type)
{
type = Nullable.GetUnderlyingType(type) ?? type;
if (type == typeof(string)) return "string";
if (type == typeof(bool)) return "boolean";
if (type.IsPrimitive || type == typeof(decimal)) return "number";
if (type.IsArray) { Add(type.GetElementType()!); return Ts(type.GetElementType()!) + "[]"; }
if (type.IsGenericType && type.GetGenericArguments().Length == 1) { var item = type.GetGenericArguments()[0]; Add(item); return Ts(item) + "[]"; }
Add(type);
return type.Name;
}
JsonObject Schema(Type type)
{
type = Nullable.GetUnderlyingType(type) ?? type;
if (type == typeof(string)) return new() { ["type"] = "string" };
if (type == typeof(bool)) return new() { ["type"] = "boolean" };
if (type.IsPrimitive || type == typeof(decimal)) return new() { ["type"] = type == typeof(float) || type == typeof(double) || type == typeof(decimal) ? "number" : "integer" };
if (type.IsArray) return new() { ["type"] = "array", ["items"] = Schema(type.GetElementType()!) };
if (type.IsGenericType && type.GetGenericArguments().Length == 1) return new() { ["type"] = "array", ["items"] = Schema(type.GetGenericArguments()[0]) };
return new() { ["$ref"] = "#/$defs/" + type.Name };
}
+13
View File
@@ -0,0 +1,13 @@
# Backend API contract generation
From the repository root:
```sh
dotnet run --project tools/ContractGenerator -- .
dotnet run --project tools/ContractGenerator -- . --check
node scripts/check-api-contract.mjs
```
The tool reads public DTOs registered in `WebDrop.Api.ApiJson` and their referenced types. It writes TypeScript interfaces to `web/src/core-bridge/generated/api.ts` and JSON Schema definitions to `contracts/api.schemas.json`. OpenAPI references these generated schemas. Backend-to-Cap serialization uses a separate context and is excluded from the browser contract.
Reflection is used only by this development tool. The production API keeps source-generated, NativeAOT-compatible serialization. Generated files are committed; no extra runtime dependency is added to the frontend.
+5 -4
View File
@@ -3,14 +3,15 @@ import { supportsApiContract } from './contract';
describe('API contract compatibility', () => {
it('accepts compatible SemVer releases in the supported major', () => {
expect(supportsApiContract('2.0.0')).toBe(true);
expect(supportsApiContract('2.8.2')).toBe(true);
expect(supportsApiContract('2.2.0-rc.1')).toBe(true);
expect(supportsApiContract('6.0.0')).toBe(true);
expect(supportsApiContract('6.8.2')).toBe(true);
expect(supportsApiContract('6.2.0-rc.1')).toBe(true);
});
it('rejects a breaking major and malformed versions', () => {
expect(supportsApiContract('1.0.0')).toBe(false);
expect(supportsApiContract('3.0.0')).toBe(false);
expect(supportsApiContract('4.0.0')).toBe(false);
expect(supportsApiContract('5.0.0')).toBe(false);
expect(supportsApiContract('01.0.0')).toBe(false);
expect(supportsApiContract(undefined)).toBe(false);
});
+1 -1
View File
@@ -1,4 +1,4 @@
export const SUPPORTED_API_CONTRACT_MAJOR = 2;
export const SUPPORTED_API_CONTRACT_MAJOR = 6;
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-]+)*)?$/;
+422
View File
@@ -0,0 +1,422 @@
// Generated from backend DTOs; do not edit.
// backend-contract-sha256: 654ec761e3481b423296b402497a97f4092ce167e7a2ca51a47d25f0fca4466e
export interface AccountChannelBody {
channel_id: string;
created_at: number;
payload: string;
role: string;
}
export interface AccountDeviceBody {
created_at: number;
current: boolean;
id: string;
last_seen: number;
name: string;
}
export interface AccountDeviceNameRequest {
name: string;
}
export interface AccountDeviceSigningRequest {
device_id: string;
public_key: string;
signature: string;
}
export interface AccountProfile {
avatar?: string | null;
displayName: string;
modifiedAt: number;
username: string;
}
export interface AccountProfileRequest {
avatar?: string | null;
displayName: string;
}
export interface AccountRegisterRequest {
public_key: string;
salt: string;
signing_public_key: string;
username: string;
vault: string;
verifier: string;
}
export interface AccountSaltBody {
salt: string;
}
export interface AccountSaltRequest {
username: string;
}
export interface AccountSessionBody {
token: string;
username: string;
}
export interface AccountSessionRequest {
device_id?: string | null;
device_name?: string | null;
username: string;
verifier: string;
}
export interface AccountVaultBody {
revision: number;
vault: string;
}
export interface AccountVaultRequest {
revision: number;
vault: string;
}
export interface AppendResult {
seq: number;
}
export interface ChannelHistoryRequest {
chatHistory: boolean;
filesHistory: boolean;
}
export interface ChannelInfo {
can_manage_structure: boolean;
can_upload: boolean;
created_at: number;
creator_account?: string | null;
expires_at: number;
id: string;
last_seq: number;
mode: ChannelMode;
oldest_seq: number;
password_required: boolean;
seats_used: number;
settings: ChannelSettings;
}
export type ChannelMode = 'enc' | 'raw';
export interface ChannelSettings {
allow_guests: boolean;
delete_policy: DeletePolicy;
file_max_downloads: number;
join_policy: JoinPolicy;
max_items: number;
members_can_upload: boolean;
name?: string | null;
seats: number;
ttl_seconds: number;
ui_restrict_copy_download: boolean;
}
export interface ChannelSettingsPatch {
allow_guests?: boolean | null;
delete_policy?: DeletePolicy | null;
file_max_downloads?: number | null;
join_password?: string | null;
join_policy?: JoinPolicy | null;
max_items?: number | null;
members_can_upload?: boolean | null;
name?: string | null;
seats?: number | null;
ttl_seconds?: number | null;
ui_restrict_copy_download?: boolean | null;
}
export interface CreateChannelRequest {
id?: string | null;
join_password?: string | null;
key_package?: string | null;
mode?: ChannelMode | null;
owner_hash?: string | null;
read_hash?: string | null;
settings?: ChannelSettings | null;
write_hash?: string | null;
}
export interface CreateShareRequest {
id?: string | null;
owner_hash?: string | null;
read_hash?: string | null;
settings?: ShareSettings | null;
}
export interface CryptoState {
access: ChannelAccess[];
envelopes: DomainEnvelope[];
objects: ObjectEnvelope[];
revision: number;
}
export interface ChannelAccess {
channelId: string;
chatFromRevision: number;
chatRead: boolean;
chatWrite: boolean;
filesFromRevision: number;
filesRead: boolean;
filesUpload: boolean;
username: string;
}
export type DeletePolicy = 'owner_only' | 'own' | 'anyone';
export interface DomainEnvelope {
channelId: string;
domain: CryptoDomain;
epoch: number;
payload: string;
username: string;
}
export type CryptoDomain = 'chat' | 'files';
export interface DomainPublishRequest {
channelId: string;
domain: CryptoDomain;
envelopes: DomainEnvelope[];
epoch: number;
}
export interface ErrorBody {
error: string;
}
export interface FinishShareRequest {
manifest_item_id?: string | null;
}
export interface GoneLogBody {
error: string;
oldest_seq: number;
}
export interface InvitationBody {
channel_id: string;
created_at: number;
payload: string;
username: string;
}
export interface InvitationCryptoPlan {
batches: CryptoGrantBatch[];
channelId: string;
username: string;
}
export interface CryptoGrantBatch {
domain: CryptoDomain;
epoch: number;
recipients: string[];
rotate: boolean;
}
export interface InvitationRequest {
domain_envelopes?: DomainEnvelope[] | null;
payload: string;
username: string;
}
export type JoinPolicy = 'open' | 'invite';
export interface JoinRequest {
password?: string | null;
}
export interface KeyPackageRequest {
payload: string;
}
export interface MemberBody {
joined_at: number;
role: string;
username: string;
}
export interface ObjectEnvelope {
authorAccount?: string | null;
channelId: string;
createdRevision: number;
deleteEvent?: SignedContentEventRecord | null;
domain: CryptoDomain;
epoch: number;
event?: SignedContentEventRecord | null;
itemId: string;
keyEnvelope: string;
}
export interface PasskeyBeginRequest {
username: string;
verifier?: string | null;
}
export interface PasskeyEnvelopeRequest {
credential: string;
id: string;
key_envelope: string;
username: string;
}
export interface PasskeyFinishRequest {
credential: string;
device_id?: string | null;
device_name?: string | null;
id: string;
key_envelope?: string | null;
name?: string | null;
username: string;
}
export interface PasskeyInfoBody {
can_recover: boolean;
created_at: number;
id: string;
name: string;
prf_salt: string;
}
export interface PasskeyOptionsBody {
credentials: PasskeyInfoBody[];
id: string;
options: string;
prf_salt?: string | null;
}
export interface PasskeySessionBody {
credential_id: string;
key_envelope?: string | null;
prf_salt: string;
token: string;
username: string;
}
export interface PublicKeyBody {
public_key: string;
signing_public_key?: string | null;
}
export interface SeatInfo {
account_name?: string | null;
endpoint_hash: string;
joined_at: number;
label?: string | null;
}
export interface SeatJoined {
endpoint_hash: string;
joined_at: number;
seats_max: number;
seats_used: number;
}
export interface ServerInfoBody {
captcha: CaptchaBody;
choices: SettingChoicesBody;
contract_version: string;
defaults: DefaultsBody;
limits: LimitsBody;
protocol: number;
transports: TransportsBody;
}
export interface CaptchaBody {
api_endpoint?: string | null;
enabled: boolean;
provider: string;
}
export interface DefaultsBody {
channel: ChannelSettings;
channel_mode: ChannelMode;
share: ShareSettings;
}
export interface LimitsBody {
max_channel_bytes: number;
max_frames_per_item: number;
max_items: number;
max_payload_bytes: number;
max_seats: number;
max_ttl_seconds: number;
max_visitors: number;
min_ttl_seconds: number;
}
export interface SettingChoicesBody {
file_max_downloads: number[];
max_visitors: number[];
seats: number[];
ttl_seconds: number[];
}
export interface ShareInfo {
created_at: number;
expires_at: number;
id: string;
items: ShareItemInfo[];
manifest_item_id?: string | null;
sealed_at?: number | null;
settings: ShareSettings;
state: string;
}
export interface ShareItemInfo {
bytes: number;
complete: boolean;
downloads_left: number | null;
frames: number;
item_id: string;
max_downloads: number;
}
export interface ShareSettings {
file_max_downloads: number;
max_visitors: number;
ttl_seconds: number;
}
export interface ShareSettingsPatch {
file_max_downloads?: number | null;
max_visitors?: number | null;
ttl_seconds?: number | null;
}
export interface SignedContentEventRecord {
deviceCertificateSignature: string;
eventId: string;
frameCount: number;
kind: string;
manifestHash: string;
signature: string;
signerDeviceId: string;
signerDevicePublicKey: string;
signerRootPublicKey: string;
targetItemId?: string | null;
}
export interface TransportsBody {
http3: boolean;
sse: boolean;
websocket: boolean;
}
export interface WorkspaceBody {
people: AccountProfile[];
structure: WorkspaceState;
}
export interface WorkspaceChannelCreatedBody {
channel: WorkspaceChannel;
workspace: WorkspaceBody;
}
export interface WorkspaceChannel {
chatEpoch: number;
chatHistory: boolean;
createdAt: number;
createdBy: string;
filesEpoch: number;
filesHistory: boolean;
groupId?: string | null;
id: string;
name: string;
}
export interface WorkspaceChannelRequest {
groupId?: string | null;
name: string;
}
export interface WorkspaceFolderRequest {
name: string;
parentId?: string | null;
threadId: string;
}
export interface WorkspaceGroupCreatedBody {
group: WorkspaceGroup;
workspace: WorkspaceBody;
}
export interface WorkspaceGroup {
createdAt: number;
id: string;
name: string;
}
export interface WorkspaceGroupRequest {
groupId?: string | null;
}
export interface WorkspaceMoveRequest {
folderId?: string | null;
itemIds: string[];
threadId: string;
}
export interface WorkspaceNameRequest {
name: string;
}
export interface WorkspaceState {
channels: WorkspaceChannel[];
defaultChannelId?: string | null;
folders: WorkspaceFolder[];
groups: WorkspaceGroup[];
locations: WorkspaceLocation[];
revision: number;
}
export interface WorkspaceFolder {
id: string;
name: string;
parentId?: string | null;
threadId: string;
}
export interface WorkspaceLocation {
authorAccount?: string | null;
folderId?: string | null;
itemId: string;
threadId: string;
}
+2 -2
View File
@@ -8,7 +8,7 @@ describe('API contract preflight', () => {
});
it('blocks API requests when the server has a newer breaking major', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '3.0.0' }), { status: 200 }));
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
setApiBase('http://api.test/api/v1');
@@ -19,7 +19,7 @@ describe('API contract preflight', () => {
it('checks the contract once and then sends the API request', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '2.2.0' }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '6.0.0' }), { status: 200 }))
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);