From 55b80f0a97f4944f56e200889b216875a93c8e16 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 20:45:23 +0800 Subject: [PATCH 01/13] docs: access control and enrollment trust chain design [CI SKIP] --- README.md | 3 +- docs/access.md | 357 +++++++++++++++++++++++++++++++++++++++++++++++ docs/security.md | 64 ++++----- 3 files changed, 383 insertions(+), 41 deletions(-) create mode 100644 docs/access.md diff --git a/README.md b/README.md index 019004a..22cf301 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,8 @@ Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent | 文档 | 内容 | | --- | --- | | [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 | -| [`docs/security.md`](docs/security.md) | 零信任设计稿(尚未实现):威胁模型、名册和操作员凭证、监听隐身、extension 授权 | +| [`docs/security.md`](docs/security.md) | 零信任设计稿(部分已实现):威胁模型、入网信任链、监听隐身、extension 授权 | +| [`docs/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 | | [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) | | [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 | | [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 | diff --git a/docs/access.md b/docs/access.md new file mode 100644 index 0000000..250b7b5 --- /dev/null +++ b/docs/access.md @@ -0,0 +1,357 @@ +# 访问控制与入网信任链 + +这份文档定义**谁能做什么**(账户、角色、能力、API key)和**谁能让节点入网**(passkey 到节点的签名链)。它是 [security.md](security.md) 第 5、6 节的展开:security.md 讲为什么(缺口和威胁),这里讲怎么做。**状态:设计已定,未实现**(实施顺序见第 12 节)。 + +一句话:登录和授权分开。登录证明「你是谁」,密码、passkey、OIDC 都可以;改变信任(加节点、吊销、改凭证)只认 passkey 的签名,并且这个签名要一路传到节点,Backend 只搬运,伪造不了。 + +## 1. 名词 + +| 名词 | 含义 | +| --- | --- | +| 账户 | 一个人。有角色、若干登录方式、若干 passkey | +| 角色 | `anonymous`、`admin`、`owner`,决定能力的**上限** | +| 能力(capability) | 一项具体权限,如 `fleet.write`。端点声明自己需要哪项能力 | +| 会话 | 登录得到的状态,只带 L1 能力 | +| 提权窗口 | 用 passkey 触控换来的、有时限的信任类能力(类似 sudo) | +| 操作员集合(OpSet) | 节点固定的、带版本的 passkey 公钥列表,每个带角色和限制 | +| 委托(Grant) | passkey 签名授权一把临时密钥,在范围、时间内代表它签名 | +| API key | 一串种子;`enroll` 类的 key 创建时由 passkey 签出一份委托 | +| 入网券(Ticket) | 委托的密钥签的「允许一个持有 K_code 的节点入网」 | +| 属主(controllers) | 一个节点绑定的一组 passkey:只有它们能授权这个节点上的 extension(web ssh 等)。管理员没有这份权力,见第 8 节 | + +## 2. 角色与能力 + +### 2.1 能力表 + +| 组 | 能力 | 说明 | 信任类 | +| --- | --- | --- | --- | +| 展示 | `public.read` | 看隐藏线路;站点私有时看展示页 | | +| 节点 | `fleet.read` | 看 Agent、Probe、线路、加入代码 | | +| | `fleet.write` | 改名、标签、可见性、手填地址、线路开关、断开会话 | | +| | `fleet.enroll` | 生成加入代码(签入网券);可以吊销**自己加的**节点 | 是 | +| | `fleet.revoke` | 吊销或删除任意节点 | 是 | +| 任务 | `tasks.read` · `tasks.write` | 任务模板、覆盖、发起测试 | | +| 数据 | `data.read` · `data.write` | 原始块、导出;清理 | | +| 设置 | `settings.read` · `settings.write` | 站点设置、保留策略、重启计划 | | +| 更新 | `update.read` · `update.confirm` | 看 Backend 更新、确认更新 | | +| | `release.publish` | CI 登记发布清单(只此一件事) | | +| 令牌 | `keys.read` · `keys.manage` | 列出、创建、撤销 API key | | +| 账户 | `accounts.manage` | 账户、passkey、操作员集合 | 是 | +| 运维 | `audit.read` · `ops.metrics` | 审计日志;`/metrics` | | +| 集成(预留) | `events.read` · `data.export` | 拉取事件流和批量数据,见 5.4 | | +| | `webhooks.read` · `webhooks.manage` | 外部回调订阅,见 5.4 | | + +- **Backend 更新确认是 L1**:更新清单由 CI 的发布密钥签名,`seq` 防回滚,真正的门是发布密钥,不是确认这一步。 +- **一个能力只在一个地方声明**:`Capability` 常量(`MoShiro.Protocol`),端点用 `.Requires(Capability.X)`;`Principal.Has(capability)` 是唯一的判断入口,会话、API key、委托都走它。 +- **对账测试**:所有 `/api/admin` 端点必须声明能力,没声明测试就失败;OpenAPI 的每个操作带 `x-moshiro-capability`。 + +### 2.2 角色 + +| 角色 | 能力 | +| --- | --- | +| `anonymous` | 只有 `public.read`,且站点公开时才有。不是账户 | +| `admin` | 除 `accounts.manage` 外的全部;信任类(`fleet.enroll`、`fleet.revoke`)要提权 | +| `owner` | 全部;`accounts.manage` 每次都要当场触控 | + +- 角色是上限:能力是否可用取决于角色和当前窗口。 +- **不能越权**:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集;带信任类能力的 key 创建时要 passkey 签一次。 +- 角色写在操作员集合里,**节点自己执行**:admin 凭证签的入网券有效;只有 owner 凭证签的操作员集合更新才有效。被攻破的 Backend 给自己造一个 admin 账户也加不进节点。 + +## 3. 登录与会话 + +| 登录方式 | 能得到 | 说明 | +| --- | --- | --- | +| passkey | L1 会话,并且可以提权 | WebAuthn,只支持 ES256,attestation 选 `none` | +| 密码 | L1 会话 | 保留用于首次引导和兜底;PBKDF2 | +| OIDC | L1 会话 | 账户绑定 `iss + sub`,登录时按它找账户,不自动建账户。放在 passkey 之后做 | + +- 密码和 OIDC **永远不能提权**:节点验证不了 IdP 的断言,也不该让 IdP 或密码进入信任域。 +- **引导**:初始管理员用密码登录,注册第一个 passkey;这是唯一一次不需要提权的注册。之后新增 passkey 都要提权,并写进操作员集合(见第 7 节)。 +- 建议每个账户至少注册两个 passkey(手机加硬件密钥)。 +- **离线恢复密钥**:创建 owner 时同时生成一把离线的 ECDSA 恢复密钥,作为 `type=ecdsa`、`recovery=true` 的 owner 凭证写进操作员集合;私钥打印或放进密码管理器,平时不用。节点强制它**只能签操作员集合更新**,不能签委托、入网券和吊销表,所以即使被偷走,也加不了节点;它的用处是 passkey 全部丢失时签一版新的操作员集合、重新加 passkey,节点不用重装。 + +## 4. 提权窗口(信任类能力各管各的) + +| 窗口 | 覆盖 | 默认时长 | 行为 | +| --- | --- | --- | --- | +| 接入 | `fleet.enroll` | 15 分钟(5–60 可调) | 窗口内连续生成代码不再弹 passkey | +| 吊销 | `fleet.revoke` | 5 分钟 | 破坏性操作,窗口更短 | +| 凭证 | `accounts.manage`、创建带信任类能力的 key | 无窗口 | 每次都要当场触控 | + +- 窗口互不覆盖:开了接入窗口不能吊销节点。 +- 「用到才提权」:页面照常打开,点信任类操作时没有窗口才弹 passkey,通过后接着做原来的动作。 +- 管理端顶部显示生效的窗口和剩余时间,每个窗口有「立即结束」。 +- 接入窗口的实现:一次触控让 passkey 签一份委托(见第 7 节),授权浏览器里生成的不可导出临时密钥 D;窗口内用 D 签入网券,到期自动失效。 +- **默认值待确认**(15 分钟、5 分钟)。 + +## 5. API key + +### 5.1 类型 + +| 类型 | 创建时要不要 passkey | 能力 | +| --- | --- | --- | +| 普通 key | 不要 | 勾选的非信任类能力(只读、运维、CI 发布等),只能是创建者能力的子集 | +| `enroll` key | **要,签一次** | 勾选的非信任类能力 + `fleet.enroll`(可吊销自己加的节点) | + +- 现有令牌迁移(破坏性):`read` → 全部 `*.read`;`admin` → 全部非信任类能力;`release` → `release.publish`。迁移后,旧的 admin 令牌不能再生成加入代码,需要换成 `enroll` key。 +- 每把 key 可设:有效期、可选的 IP 白名单(CIDR,**待确认**)、`enroll` 类的数量上限和单券 TTL 上限;预留:资源范围、速率和数据配额(见 5.4)。 + +### 5.2 `enroll` key 怎么工作 + +```text + 创建(浏览器 + passkey 一次触控) + 浏览器生成种子 S,只显示一次(形如 mok_…) + 认证值 = HKDF(S, "auth") → Backend 只存哈希,用来识别这把 key + 签名密钥 = HKDF(S, "sign") → 公钥 P 写进委托;私钥只有拿着 S 的人算得出 + passkey 对委托签名:{P, scope=enroll, not_after, max_ticket_ttl, 数量上限, 签发凭证 id} + Backend 存委托和认证值哈希,不存 S + + 使用(和普通令牌一样的 HTTP 调用) + POST /api/admin/join-tokens Authorization: Bearer mok_S + Backend 用请求带来的 S 现算签名密钥,签出入网券和 K_code,返回一行安装命令;用完即忘 +``` + +- 数据库被偷走拿不到任何签名密钥。被攻破的 Backend 最多在 key 被使用的那一刻拿到 S,权力受这把 key 的有效期和单券 TTL 限制,key 过期或被撤销就没用了。 +- 数量上限由 Backend 执行,**节点强制不了**;节点只强制时间(见第 7.3 节)。 +- CI 和测试:夹具自己生成 key 种子,把公钥写进测试的操作员集合,不需要真 passkey。 + +### 5.3 创建界面 + +- 名称、有效期、可选 IP 白名单;`enroll` 类多出「最多加几个节点」「单券有效期」。 +- 能力用分组复选框:全局**全选**(带半选状态)、**组内全选**、**反选**、搜索;预设按钮一键套用:只读、运维、接入、CI 发布、全部;超出创建者能力的项灰色不可选,需要 passkey 的项带标记(⚿),勾选后创建时提示。 +- 创建后只显示一次 key,同时列出它拥有的能力和过期时间。 +- 列表:API key、加入代码、节点、审计日志都支持多选和全选做批量操作(撤销、吊销、删除、筛选)。API key 列表显示能力数、最后使用时间、过期时间。 + +### 5.4 预留:外部 webhook 回调与数据拉取 + +后面要让外部系统能**订阅事件、触发回调、拉数据**。接口和权限现在先留,实现放后面;全部发生在 Backend 里,**节点零开销**。 + +- **能力(已列入能力表,标预留)**: + - `events.read`:拉取事件流,游标分页(`GET /api/admin/events?after=`),适合外部系统轮询。 + - `data.export`:按线路和时间范围批量拉数据,有限速和配额;`data.read` 仍然是交互式查询。 + - `webhooks.read` · `webhooks.manage`:创建、修改、删除外部回调订阅,以及手动触发一次测试回调(`POST /api/admin/webhooks/{id}/test`)。 + 能力在目录里先存在,旧 key 以后可以直接带上它们,不需要再迁移。 +- **key 上预留的字段**: + - `resource_filter {agent_ids?, labels?}`:限制这把 key 能看到哪些节点、线路;事件和 webhook 同样受它限制。 + - `rate_limit`(每分钟请求数)和 `quota`(每天拉取的行数或字节数):超出返回 429 带 `Retry-After`,计入审计。 +- **webhook 的形状**:订阅 `{id, url, events[], secret, 创建者(key 或账户), enabled}`。事件:节点上线或下线、线路告警阈值、安全事件(节点准入、吊销、key 创建、提权)等。投递是 `POST` JSON,头带 `X-MoShiro-Signature: sha256=`、时间戳和投递 id;至少一次投递、指数退避重试、接收方按投递 id 幂等。 +- **订阅受创建者能力约束**:只能订阅创建者自己读得到的事件,不能借 webhook 越权。 +- **SSRF 约束**(TODO 里已有):URL 解析后检查地址,只放行公网地址;不跟随重定向到内网;连接和响应有超时和大小上限;`secret` 只在创建时显示一次。 +- 这些都属于 L1:改的是 Backend 的集成配置,不改变节点信任;但每次增删订阅和改 URL 都进审计日志。 + +## 6. 归属与审计 + +- 账户表:`id`、名字、角色、登录方式、passkey;加入代码记 `created_by`,节点记 `added_by`;管理页的 Agent、Probe 详情显示「由 xxx 于 xxx 添加」。 +- 审计日志 `(时间, 账户, 凭证 id, 动作, 对象, 详情)`:创建或撤销 key、加入代码、吊销、删除、手填地址、确认更新、提权、因能力不足被拒绝的请求。保留 365 天(同事件表)。 +- **可验证的一层**:委托里写进签名它的 passkey 凭证 id,入网券和节点绑定带着它,节点在 hello 里报告「由哪个凭证准入」。「谁加的」不只是 Backend 的一面之词。 + +## 7. 入网信任链 + +### 7.1 文档 + +```text + ① 操作员集合 OpSet {version, creds:[{id, type=webauthn|ecdsa, 公钥, 角色, 账户, recovery?, max_ticket_ttl, 作用域}]} + 签名人:owner 的 passkey;创世版本由安装时固定的第一个 owner 公钥自证 + ② 委托 Grant {grant_id, 委托公钥 D 或 P, 作用域, 额度, not_before, not_after, 签发凭证 id} + 签名人:passkey(WebAuthn 断言,challenge = SHA256(Grant)) + ③ 入网券 Ticket {ticket_id, K_code 公钥, 角色, controllers, issued_at, expires_at, grant_id} + 签名人:委托私钥。生成加入代码时签,用户那时不用在场 + ④ 节点绑定 Binding {node_id, 角色, 节点密钥公钥, 对等面公钥, ticket_id} (controllers 随 Ticket 一起被签名,节点绑定引用它) + 签名人:K_code 私钥。它就在安装命令里,节点注册时用一次 + ⑤ 吊销表 RL {version, issued_at, 被吊销的节点公钥指纹[]} + 签名人:passkey 当场签;`enroll` 委托的密钥只能签针对「自己加的节点」的条目(节点校验条目指向的 Binding 属于这份委托) +``` + +- 安装命令里的代码就是 `K_code` 的种子;Backend 的一次性加入令牌由它派生,所以命令仍然是一行。 +- 所有签名带用途前缀(域隔离),规范化编码;格式不协商、不降级。 + +### 7.2 节点验证(本地,Backend 不参与信任) + +节点固定创世 owner 公钥。链随期望状态下发(Agent 的 `peers`、Probe 的 `probe_agents` 里每个成员带着自己的链),节点在**状态同步时**验,每个成员验一次、结果进成员表;对等握手(敲门加现有的对端认证)完全不变,没有额外字节和运算。Agent 接纳 Probe、Probe 接纳 Agent,各自验同一条链: + +1. OpSet:从创世公钥起,每个版本由 owner 凭证签、版本递增;节点持久化见过的最大版本。 +2. Grant:签发凭证在 OpSet 里且角色够;WebAuthn 断言有效(`rpIdHash`、UP/UV 标志、`clientData.type = webauthn.get`、`challenge = SHA256(Grant)`、ES256 验签);当前时间在有效期内。 +3. Ticket:由 Grant 的委托公钥签;`issued_at` 落在 Grant 窗口内;`expires_at − issued_at ≤ max_ticket_ttl`(来自 OpSet 里签发凭证的限制);节点当前时间 ≤ `expires_at`。 +4. Binding:由 Ticket 的 `K_code` 签;节点密钥和对等面公钥与这个成员条目里的一致;角色匹配(Probe 只能是 probe)。握手时对端证明自己持有这把对等面密钥,用的是现有的认证。 +5. RL:指纹不在最新吊销表里(持久化见过的最大版本)。 + +通过后记进本地成员表(持久化,离线也认),同时进入敲门的名册(`KnockGate` 的 `peers`);没通过的成员条目直接丢弃并计数,不回详细原因。Probe 只拨公网地址(拒绝私网、回环、链路本地),解决「被攻破的 Backend 让 Probe 探测客户内网」。 + +### 7.3 能强制什么,不能强制什么 + +- 节点能强制:谁是成员(链)、时间(Grant 窗口、Ticket 过期、单券 TTL 上限)、角色、吊销。 +- 节点强制不了:数量上限(它不知道别的节点)。由签名人执行(浏览器或 Backend 的 key 路径)。 + +## 8. 节点属主与 extension 授权(预留) + +后面会做 extension(web ssh、文件、抓包,见 [security.md](security.md) 第 11 节)。这类能力**连管理员都不信任**:管理员能删,不能读,也不能用。所以入网链现在就要给每个节点留出「属主」这个概念,即使 extension 还没做,字段也先固定下来,之后不用重装节点。 + +```text + 属主 controllers = 一组 passkey 凭证 [{credential_id, sign_pubkey(ES256), enc_pubkey?}] + 默认:添加者(签入网券的那个账户)的全部 passkey + 添加节点时可以从已有的 passkey 里选(含全选):允许为该节点绑定一组 passkey,而不是只绑一个人的一把 + 写在哪:入网券里(由添加者的委托签名),节点绑定引用它;节点本地持久化,随成员表一起保存 + + extension 的授权(每次调用) + 操作员签名的一次性能力令牌 {节点 ID, extension, 命令哈希, not_before, not_after, nonce} + 签名必须是属主里某把 passkey 的 WebAuthn 断言(challenge = 令牌哈希),节点用自己存的属主公钥验 + —— 不是「操作员集合里任意 admin/owner 的 passkey」,而是「这个节点的属主」 + + 谁能做什么 + 属主 启用或使用 extension;增删属主(需要现有属主中的一把签名的 ControllerUpdate) + admin / owner 只能删除节点或卸载 extension(吊销表,照常当场 passkey);读不到、用不了 + Backend 只搬运。交互式会话的内容端到端加密(浏览器 ↔ extension),Backend 看不到明文 +``` + +要点: + +- **删除和使用分开**:吊销表没有「使用」的含义;账户角色(owner、admin)不赋予任何 extension 权力。owner 想用某个节点的 web ssh,也得是那个节点的属主。 +- **改属主不经过 Backend 的账户系统**:`ControllerUpdate {node_id, version, controllers}` 由现有属主的 passkey 签名,节点持久化见过的最大版本;账户被删除、角色被改都改不了某个节点的属主。 +- **属主是 passkey 凭证,不是账户**:账户只是 Backend 里给人看的标签;节点只认公钥。一个人丢了全部属主 passkey,这个节点上的 extension 就没人能用了,要用就得重装节点(和第 3 节的恢复一致),所以添加时建议绑两把以上。 +- 属主字段在**步骤 4(入网链)**就写进 Ticket;extension 本身、ControllerUpdate 和能力令牌放在阶段六。 + +### 8.1 添加节点时选属主(接口现在就留) + +- API:`POST /api/admin/join-tokens` 和 `enroll` key 的签发请求都带可选的 `controllers: [credential_id…]`;缺省 = 签发账户的全部 passkey。校验:每个 id 必须是已注册的 passkey(任何账户的都可以,比如把同事的一把加进来),数量不超过上限。 +- 界面:「添加 Agent / Probe」对话框里一个「属主 passkey」多选列表,按账户分组,带**全选**、组内全选和反选;默认勾选自己的全部;至少选一把;提示「只有这些 passkey 能授权这个节点上的 web ssh 等敏感能力,管理员也不能」。 +- 节点详情页显示属主(凭证 id 和所属账户),以及「由谁绑定」;改属主走 `ControllerUpdate`,需要现有属主签名,管理员做不了。 +- 没有 extension 的现阶段,这组字段只是被签名、被节点持久化,不起别的作用。 + +### 8.2 数据用属主的私钥解密:Backend 解不开,也改不了 + +目标:交互式会话(web ssh、终端)、以及以后 extension 产生的数据(文件、抓包),**只有属主 passkey 对应的私钥能解开**。Backend 被篡改或被攻破,拿到的只有密文,且改动密文会被发现。 + +```text + 浏览器(属主) Backend(只搬运) 节点上的 extension + ① 生成会话临时 ECDH 密钥 e + ② passkey 对「e 的公钥 ‖ 节点 ID ‖ 命令哈希 ‖ nonce ‖ not_after」做 WebAuthn 断言(能力令牌) + ──────────── 能力令牌 + e 公钥 ───────▶ 转发 ─────────────────────────▶ ③ 验:断言来自本节点属主 + + 令牌新鲜、nonce 未用 + ◀── 节点的会话公钥(节点身份密钥签名)── 转发 ◀────────────────────────── ④ 节点生成临时密钥 n,签名后返回 + ⑤ 双方 ECDH(e, n) → HKDF(绑定两边公钥、令牌哈希、用途标签)→ 会话密钥 + ⑥ 之后双向都是 AEAD 密文(ChaCha20-Poly1305 或 AES-GCM,BCL 自带),每帧带序号, + 序号进 AAD,重放、乱序、丢帧、篡改都会解密失败 +``` + +- 终端输出等**从节点流向属主的数据**,只能被拿着会话私钥 e 的浏览器解开;Backend 没有 e,也没有节点的 n,算不出会话密钥。 +- 临时密钥带来前向保密:长期的 passkey 只用来**签名授权**,不参与解密,所以以后 passkey 泄露也解不开历史会话。 +- Backend 篡改密文、重放、乱序:AEAD 校验失败,会话立即断开并计数。Backend 仍然能做的:拒绝服务、延迟、丢包,以及看到元数据(谁连哪台节点、什么时候、流量大小)。 +- **静态数据(以后的抓包文件、会话录像)**需要事后解密,不能只靠临时密钥:属主记录里的 `enc_pubkey`(P-256 ECDH 公钥)就是为此预留的,数据用它封装(ECIES:临时密钥 + HKDF + AEAD)。私钥有两种来源,按设备能力选: + 1. **WebAuthn PRF 扩展**:由 passkey 派生私钥种子(浏览器用 `prf` 取 32 字节,经 HKDF 得到 P-256 私钥);私钥不落盘,每次要解密时再触控一次。设备不支持 PRF 就退到下一种。 + 2. **浏览器里的不可导出密钥**:添加属主时在这台浏览器生成,只能在这台浏览器用,丢了就解不开。 + 属主记录里没有 `enc_pubkey` 的节点只支持实时会话,不能存加密的静态数据。 +- 加密发生在属主这一侧的浏览器和节点上的 extension 之间,不经过 Backend 的任何账户、角色、会话机制,所以改 Backend 的数据库或代码也无从下手。 + +## 9. 流程 + +| 流程 | 步骤 | +| --- | --- | +| 引导 | 初始 owner 密码登录 → 注册第一个 passkey → 一行安装命令带创世公钥 → 节点首次注册时固定(和加入代码同一信任通道) | +| 加节点(人) | 接入窗口内点「添加」→ 浏览器用 D 签入网券 → 生成命令(含 K_code 种子)→ 用户一行安装 → 节点用 K_code 签 Binding → Backend 存好并随 `peers`、`probe_agents` 下发。人不在场也行(券在有效期内);过期就重新生成 | +| 加节点(自动化) | `enroll` key 调 `POST /api/admin/join-tokens` → Backend 用种子现算签名密钥签券 → 其余同上 | +| 吊销 | 吊销窗口内当场 passkey 签 RL → 全部节点持久化最大版本 → 被吊销的节点连不进来,旧表复活不了它。`enroll` key 可以吊销自己加的节点 | +| 增删 passkey、换角色 | owner 当场触控签新 OpSet 版本 → 节点自己同步,不用重装 | +| 恢复 | passkey 全丢:用离线恢复密钥签新的操作员集合,重新加 passkey(节点不用重装)。恢复密钥也丢了才重装节点并换创世公钥(部署脚本 `--operator-key`) | +| 弱信任(开发、实验室、升级过渡) | 节点安装时显式选择**不固定操作员**,行为回到「信任 Backend」;管理页标「弱信任」。这是显式降级,不会静默发生 | + +## 10. 存储 + +- Backend:`accounts`、`credentials`(passkey 公钥、计数器)、`sessions`、`api_keys`(认证值哈希、能力、委托、IP 白名单、有效期、最后使用)、`grants`、`tickets`、`opsets`、`revocations`、`audit_log`;`join_tokens`、`agents`、`probes` 加归属列和 Binding。 +- 节点本地(`ConfigStore`):创世公钥、见过的最大 OpSet 和 RL 版本、成员表(含准入凭证 id)。 + +## 11. 落点(不新增项目) + +| 机制 | 落点 | +| --- | --- | +| WebAuthn 断言、OpSet、Grant、Ticket、Binding、RL 的编码和验签(纯函数,只用 BCL 的 ECDSA) | `Fleet.Identity` | +| `trust` 状态类型(OpSet + RL)、成员条目带链、同步时验证、成员表持久化 | `Fleet.Link` 的 `StateHandler`(对等握手不变) | +| 能力常量、`Principal.Has`、端点 `.Requires`、对账测试 | `MoShiro.Protocol`、`Backend.Api` | +| 账户、passkey 注册与登录、会话分级、提权窗口、审计、API key | `Backend.Application`、`Storage.Sqlite`、`Backend.Api` | +| 管理端:passkey 登录与注册、提权状态条、API key 创建界面(预设、全选)、审计页 | `web/admin` | +| 安装命令带创世公钥和 `K_code`;`fleet-enroll` 签 Binding | `packaging/install`、`Fleet.Enrollment.Client` | + +## 12. 实施顺序(每步可独立验证和推送) + +1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、现有令牌迁移、API key 创建界面(预设、全选、能力子集校验)、IP 白名单。不改节点。 +2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。 +3. **操作员集合与创世公钥固定**:`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。 +4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。 +5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。 + +## 13. 局限 + +- 数量上限只能由签名人执行,节点只强制时间;窗口过期后设备被攻破的人,最多还能多签有效期内的入网券(受节点时钟约束)。 +- 节点的时钟被攻破或严重偏差会影响 Ticket 过期判断。 +- WebAuthn 只验 ES256 和 attestation = `none`,不验设备证明。 +- passkey 和恢复密钥都丢失才需要重装节点;某个节点的属主 passkey 全部丢失,这个节点上的 extension 就没人能授权。 +- `enroll` key 是持有者凭证:谁拿到种子 S,谁就能在它的范围和有效期内加节点,必须按密码对待。 + +## 14. 节点信任状态上报 + +Backend 被篡改时,你需要一个**不经过 Backend 账户系统**的信号。节点把自己的信任状态报给面板,面板显示对比: + +- 节点在 `hello`、状态变化时和每 5 分钟(不进每次心跳)上报:创世公钥指纹(前 8 字节)、见过的操作员集合版本和吊销表版本、成员数、是否弱信任、各原因的拒绝计数。每次不到 100 字节。 +- 面板的节点列表显示一个信任状态:**正常**、**版本落后**(节点的操作员集合或吊销表版本低于面板持有的)、**弱信任**、**时钟偏差过大**(超过入网券 TTL 的一半,或超过 60 秒;面板用 `hello` 的 `NowUtcMs` 算)、**拒绝计数上升**、**超过 10 分钟没有上报**。 +- 这些是节点经认证的链路上报的,被攻破的 Backend 伪造不了,但可以压住不显示——所以「长时间没有上报」本身也是红色,面板同时显示 Backend 自己持有的版本和节点上报的版本,对不上就是信号。 +- 被攻破的节点可以说谎,但它说不了别的节点的状态。 + +## 15. 安装脚本的供应链 + +一行安装要执行 Backend 吐出的脚本、拉仓库里的镜像。Backend 或仓库被攻破,就能在新节点上执行任意命令。收束办法: + +- **脚本是静态产物**:不在服务端做字符串替换(替换会破坏签名)。CI 用发布密钥给 `install.sh`、`install.ps1` 签名,随 Backend 一起发布;参数(代码、面板地址)通过命令行参数传入,代码也因此不再出现在 URL 和反向代理的访问日志里:`curl -fsSL https://panel/install | sh -s -- ''`。 +- **镜像按 digest 拉取**:脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用脚本里内置的发布公钥验签,再按清单里的 digest 拉镜像。Backend 改不了 digest,最多让节点装到一个旧的合法版本。 +- 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。 +- 剩下的第一跳信任(脚本本身):想要严格的人可以先下载脚本,对照 Gitea 发布页公布的 SHA-256 再执行;管理端同时给出「先下载再核对」的写法。Windows 版同理,另外可以加 Authenticode 签名。 +- 放在阶段五(Backend 应用层身份固定)一起做。 + +## 16. 效率(运行时开销与日常摩擦) + +目标机器是 **1 核 1 GB 的小鸡**,甚至路由器。原则:**重活留在 Backend 和浏览器,节点只做最小的验证**;节点不增加线程、定时器、依赖,数据路径上不增加任何东西。 + +**节点的预算(实现时当成验收标准)** + +| 项 | 预算 | 怎么做到 | +| --- | --- | --- | +| 数据路径 | **零增量**:UDP 数据面、测量、对等握手、敲门都不变 | 链随期望状态下发,在状态同步时验,不在握手里验(见 7.2) | +| 常驻内存 | 增量 ≤ 1 MB | 成员表上限 256 条(每条几百字节)、操作员集合上限 64 个凭证;不缓存原始链,只存验过的结果和哈希 | +| 二进制 | 增量 ≤ 150 KB | 只用 BCL 的 ECDSA、HKDF、SHA-256;WebAuthn 只验断言(固定结构的 `authenticatorData`、对 `clientDataJSON` 只比三个字段),**不带通用 CBOR 解析**;注册和 attestation 解析只在 Backend 和浏览器里 | +| CPU | 每个成员一次完整验证(约 4–6 次 ECDSA 验签),小核心(Cortex-A53、MIPS 级)上 ≤ 100 毫秒;不重复验 | 只在状态同步时做;文档版本或成员链哈希没变就跳过;操作员集合或吊销表版本变化时,只重验受影响的成员 | +| 线程和定时器 | **不新增** | 信任状态上报搭现有心跳计数(每 N 次心跳带一次,约 5 分钟);验证在已有的状态处理里串行完成 | +| 流量 | 链约 2 KB,只在成员首次出现或变化时随期望状态下发;上报每次 < 100 字节 | 同步时带成员标识(哈希),未变化的不重发 | +| 输入上限 | 单条链 ≤ 8 KB、成员 ≤ 256、凭证 ≤ 64、单次同步最多验 N 个成员 | 超限在做任何密码学运算**之前**拒绝;Backend 也只下发它自己预先验过的链(挡掉垃圾,节点仍然独立验证) | + +**把预算变成测试** + +- `Fleet.Tests`:链验证耗时断言(CI 机器上每个成员 ≤ 20 毫秒)、输入上限的拒绝测试、同一份状态重复同步不重复验证(计数断言)。 +- 现有 `ArchitectureTests` 继续禁止节点引用新包;加一条检查:节点项目不引用 WebAuthn/CBOR 之类的库。 +- CI 记录节点二进制大小和启动后的常驻内存,增量超出预算就失败。 + +**Backend 侧** + +- WebAuthn 验证只发生在登录、提权、创建 key;API 请求认证是一次哈希查表,主体缓存 30 秒并在撤销时主动失效。 +- `enroll` key 签一张券是一次 HKDF 加一次 ECDSA 签名(不到 1 毫秒)。 +- 审计日志的写入并进 SQLite 单写入者的批量提交,不拖慢摄取路径。 +- webhook 投递、数据拉取都在 Backend 里完成(见 5.4),对节点零开销。 + +**日常摩擦** + +- 进页面不要求 passkey,信任类操作发生时才提权;窗口内连续操作不再弹。 +- 批量操作一次触控:批量吊销是一张吊销表一次签名,批量添加是窗口内连续签券;列表都支持全选。 +- 自动化走 `enroll` key,不需要人在场;其余自动化走普通 key。 +- 离线恢复密钥(见第 3 节)避免因为 passkey 丢失而重装节点。 + +## 17. 待确认 + +1. 窗口默认值:接入 15 分钟、吊销 5 分钟、凭证类不设窗口。 +2. API key 的 IP 白名单要不要做(倾向做,放在第 1 步)。 +3. 迁移后,现有 admin 令牌不能生成加入代码,需要触控创建 `enroll` key 来替换。 +4. `owner` 与 `admin` 之外要不要加只读的认证角色(目前用 `read` 能力的 API key 满足)。 +5. 属主的默认值:添加者的全部 passkey,还是只有签这次入网券的那一把;添加时最多允许绑定几把。 +6. `ControllerUpdate` 要几把属主签名(一把还是多数)。 +7. 静态数据的私钥来源:优先 WebAuthn PRF,不支持时是否接受「浏览器里的不可导出密钥」(只在这台浏览器能用)。 +8. 会话 AEAD 选 ChaCha20-Poly1305 还是 AES-GCM(两者 BCL 都有;小机器没有 AES 硬件指令时前者更快)。 +9. 信任状态上报的阈值:时钟偏差红线(入网券 TTL 的一半与 60 秒取小)、「长时间没有上报」是 10 分钟,是否合适。 +10. 安装脚本的哈希在哪里公布(Gitea 发布页之外还要不要放 DNS TXT 或另一个独立渠道)。 +11. 节点预算的数字(常驻内存 ≤ 1 MB、二进制 ≤ 150 KB、每个成员验证 ≤ 100 毫秒、成员上限 256)是否合适;超出预算时优先砍什么(比如先去掉操作员集合的 64 个凭证上限换成更小的)。 +12. webhook 和数据拉取的配额默认值(每分钟请求数、每天行数)和事件清单。 diff --git a/docs/security.md b/docs/security.md index 6c9795a..b6b755a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -71,45 +71,28 @@ Agent、Backend、Probe 之间互相不信任。这份文档只回答三个问 不能做的:加入新节点、改地址让 Probe 去探内网、塞恶意更新、冒充节点。 ``` -## 5. 名册(roster) +## 5. 入网信任链 -操作员签名的舰队成员表,节点本地固定操作员的公钥,只采用验签通过的名册。 +下文和其他章节里的「名册」指节点本地验过链的成员表。**详细设计见 [access.md](access.md)**(账户、角色、能力、API key、提权窗口、签名链、流程和实施顺序)。这里只留结论: -```text - 管理端(管理员) Backend Agent / Probe - ┌──────────────┐ ①提交变更 ┌─────────┐ ┌──────────────┐ - │批准节点、改地址│───────────────▶│只存、转发│─② 名册 + 签名─▶ │本地固定操作员 │ - │或吊销 │◀─③ 挑战(哈希)─│ │ │公钥;验签通过 │ - │触控或确认 │ ④ 签名 │ │ │且 version 更大│ - │ │───────────────▶│ │ │才采用并持久化 │ - └──────────────┘ └─────────┘ └──────────────┘ -``` - -内容:`{version, expires_at?, 成员: [{id, 角色, 公钥(SPKI), Agent 的公网地址、端口和 TLS 指纹}]}`。 - -- Agent 只接受名册里的 Probe;Probe 只拨名册里的 Agent,并且只用名册里写的地址(缺口 G3、G6 因此消失,不需要另外的拨号白名单)。 -- **不可回放**:节点持久化见过的最大 `version`,低于它的名册一律拒绝,所以 Backend 无法拿旧名册复活已吊销的节点。 -- 可选的 `expires_at` 防止 Backend 截留新名册(冻结攻击)。 -- 每次改动成员或地址都要操作员签名一次:批准新节点(加入令牌的自动批准只让它连上,进名册仍要签)、公网地址变了、吊销。 -- 任务集、测试请求这类内容不进名册:它们由 Backend 计算,被节点本地硬上限兜住。 +- 名册不再是「每次变更都要操作员签一整张表」,而是一条可离线验证的链:节点固定创世 owner 公钥 → 操作员集合(带版本,owner 签)→ 委托(passkey 签,限定范围和时间)→ 入网券(委托的密钥签)→ 节点绑定(节点注册时用安装命令里的 `K_code` 签)。加上 passkey 当场签的吊销表。 +- Agent 只接纳链验得过的 Probe,Probe 只拨链验得过的 Agent,并且只拨公网地址(缺口 G3、G6 因此消失,不需要另外的拨号白名单)。 +- **不可回放**:节点持久化见过的最大操作员集合版本和吊销表版本,Backend 无法拿旧文档复活已吊销的节点。 +- 人在场时一次 passkey 触控换一个有时限的提权窗口,窗口内加节点不再确认;自动化用创建时经 passkey 签名的 `enroll` API key。被攻破的 Backend 在窗口和 key 过期后,一个节点都加不进来。 ## 6. 操作员凭证 -节点本地配置里固定一串操作员凭证,每条带类型,验签代码按类型分支;任一条通过即可,所以丢了一个设备不会被锁死,换凭证是改节点本地配置(重新运行部署脚本)。 +操作员凭证就是 passkey(WebAuthn,ES256,attestation 选 `none`)。节点本地固定创世 owner 公钥,其余凭证靠 owner 签名的操作员集合同步,不用重装节点;每条凭证带角色(`owner`、`admin`)和限制(例如单张入网券的最长有效期),由节点自己执行。 -| 类型 | 适合 | 节点怎么验 | 强度 | -| --- | --- | --- | --- | -| `webauthn` | 有通行密钥的设备 | 固定凭证公钥,验 `authenticatorData ‖ SHA256(clientDataJSON)` 的 ECDSA 签名,检查 `challenge` 等于名册哈希;注册时 attestation 选 `none`,信任浏览器实现,不验证设备证明 | 最强,每次改动需要本人触控 | -| `ecdsa` | 没有通行密钥的设备 | 管理端用 WebCrypto 生成不可导出的 P-256 密钥,存在受信任的 Chromium 里,直接对名册哈希签名 | 中:本机被入侵或页面被 XSS 时可被静默调用 | -| `oidc` | 完全不方便装密钥的设备 | 授权请求的 `nonce` 填名册哈希,节点验 ID Token 的签名(固定 IdP 的签名公钥)、`aud`、`sub`、`nonce` 和新鲜度 | 较弱:IdP(例如 Gitea)被攻破就等于操作员被攻破;节点没有 HTTP 客户端,IdP 公钥只能固定在本地,换密钥要改配置 | - -三种在节点上都只用 BCL 的 ECDSA 或 RSA 验签,不增加依赖和资源。 +- 登录可以用密码、passkey 或 OIDC,但**只有 passkey 能提权**:节点验证不了 IdP 的断言,也不该让密码和 IdP 进入信任域。OIDC 只是账户的另一种登录方式(绑定 `iss + sub`)。 +- 自动化凭证:`enroll` API key 创建时由 passkey 签一份委托;CI 和测试用软件 ECDSA 凭证,写进测试的操作员集合。 +- 恢复:passkey 全部丢失时,重装节点并换创世公钥;所以每个账户至少注册两个 passkey。 ## 7. 各类下行消息怎么处置 | 下行内容 | 授权 | 被攻破的 Backend 最多能做什么 | | --- | --- | --- | -| 舰队成员、对端公钥、Agent 地址 | **操作员签名**(名册) | 什么都改不了 | +| 舰队成员、对端公钥 | **操作员签名**的入网链和吊销表 | 什么都改不了;Agent 地址由 Backend 下发,但 Probe 只拨公网地址 | | 更新清单 | 发布密钥(已有) | 不能伪造;能转发旧的合法清单,`seq` 防回滚 | | 任务集、测试请求 | Backend 计算;节点本地硬上限兜底(已有) | 只能在名册成员之间、本地速率和端口上限内发测量流量 | | 重启计划(`moshiro.schedule`) | Backend;节点本地启动后 10 分钟内不触发 | 最多让节点每 10 分钟重启一次 | @@ -251,13 +234,14 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec ``` - **独立进程,不是插件**:extension 是单独的二进制和镜像,和核心之间**没有进程内接口**,核心不加载任何外部代码、不用反射和动态加载,所以核心的 NativeAOT 和「不能 exec」都不受影响。extension 自己用什么技术实现都行(可以是 NativeAOT,也可以不是)。 +- **授权只认节点的属主**:每次调用的能力令牌必须由这个节点绑定的属主 passkey 签名,账户角色(owner、admin)不赋予权力;管理员只能删除节点或卸载 extension,读不到、用不了;交互式会话端到端加密。属主的设计见 [access.md](access.md) 第 8 节。 - **独立身份**:extension 有自己的密钥、自己向 Backend 注册,在名册里是单独的成员(角色 `extension`,记录它依附的节点 ID)。核心被攻破碰不到 extension 的密钥,反过来也一样。 - **独立发布**:沿用签名更新的流程,`component` 不同(例如 `ext-ssh`),独立的镜像、独立的清单;装不装、升不升级都不影响核心的更新。 - **独立命名空间**:extension 的方法只能是 `ext.<名字>.*`,同样进方向声明表;核心的声明表里没有 `ext.*`,核心收到一律拒绝。extension 只响应自己命名空间的方法。 - **安装像装一个选项**:部署脚本加一个选项(例如 `--extension ssh`)拉起对应容器并写好配置;管理端在节点详情里出现对应入口。卸载就是停掉那个容器。 - **本地显式启用**:extension 的本地配置里有 `enabled`、允许的操作员凭证、允许的命令集或目标范围(例如允许登录的用户、允许访问的路径),默认关闭,没有配置就什么都不做。 - **每次调用都要授权**:操作员签名的一次性能力令牌,`{节点 ID, extension, 命令哈希, not_before, not_after, nonce}`,有效期以秒计;extension 用本地固定的操作员凭证验签。被攻破的 Backend 不能启用 extension,也伪造不了一次调用。 -- **交互式会话的内容对 Backend 不透明**(例如 web ssh):浏览器和 extension 在能力令牌携带的公钥基础上协商一次性会话密钥,Backend 只转发密文,不应该能读到终端里的内容。具体做法还没定,见第 14 节。 +- **交互式会话的内容对 Backend 不透明**(例如 web ssh):浏览器和 extension 在能力令牌携带的公钥基础上协商一次性会话密钥,Backend 只转发密文,不应该能读到终端里的内容。做法见 [access.md](access.md) 第 8.2 节:属主 passkey 授权、临时 ECDH 加 AEAD,静态数据用属主的 `enc_pubkey` 封装。 - **不做的**:核心里的任何 exec、文件写、加载 extension;用 extension 绕过核心的本地硬上限(extension 有它自己的上限,核心的不放宽)。 ## 12. 实施顺序 @@ -266,7 +250,7 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec 零 协议方向声明表 + 收发校验 + 对账测试 G7,第 8 节;同时把 NodeConnection 按职责拆开(第 13.2 节) 已完成,不破坏 一 Probe 自己生成密钥 + 加入令牌注册 + 密钥按用途拆开 G2、G11 已完成,破坏性:旧 probe.json 失效 二 预握手敲门(等级一) G4;先基于现有的对端公钥列表,名册出来后换成名册 已完成,破坏性:旧 Probe 被拒绝 - 三 操作员凭证 + 名册 + 节点验签与持久化 G1 的大部分、G3、G6;webauthn 和 ecdsa 先做,oidc 随后 破坏性:节点要固定操作员公钥并重新注册 + 三 访问控制 + 入网信任链 G1 的大部分、G3、G6;拆成 5 步,见 [access.md](access.md) 第 12 节:能力模型与 API key → 账户与 passkey → 操作员集合 → 入网链 → 吊销 破坏性:节点要固定创世公钥并重新注册;旧 admin 令牌不能再生成加入代码 四 测量数据的可信 + 入库配额和输入校验 + CSP G5、G8、G9、G10、T5(第 9.1、9.2 节) 破坏性:块格式加签名 五 Backend 应用层身份固定 + 下行消息签名 G1 的剩余部分(链路劫持) 破坏性 六 extension 框架 独立身份和名册条目、`ext.*` 命名空间、能力令牌、部署脚本的 --extension 选项(先不做具体的 ssh) @@ -316,13 +300,13 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec ## 14. 还没定的事 -1. 操作员凭证第一批做 `webauthn` 和 `ecdsa`,`oidc` 放在名册稳定之后,是否这样;还是 `oidc` 一起做。 -2. 敲门先做等级一,等级二先记 TODO,是否这样。 -3. 严格程度:节点没有固定操作员公钥就拒绝一切 Backend 给的对端和地址(推荐),还是保留一个过渡期兼容旧行为。 -4. 管理员域(T4)的审计日志、令牌过期、危险操作二次确认:还没有核对现状,也没有设计。 -5. 节点面和外部面是否拆成两个监听端口(8.1 的目标):拆的话部署文档、Caddy 和隧道的配置都要跟着改。 -6. extension 的交互式会话要不要做到「Backend 只转发密文」(端到端加密),还是先做成 Backend 可见明文、以后再加。 -7. extension 的第一个具体实现做不做 web ssh,放在哪个阶段。 -8. 块摘要固定 SHA-256,不预留算法字段(不协商、不降级,见第 9.4 节)。 -9. Agent 的 `max_total_mbps` 默认值调到多少(第 9.5 节),比如 100。 -10. 块的签名放进格式的哪个位置、是否影响现有 `MSRC` 容器版本(第 9.1 节)。 +访问控制与入网信任链的细节、待确认的默认值见 [access.md](access.md) 第 17 节。这里只列其余的: + +1. 敲门先做等级一,等级二先记 TODO(已按此实现等级一)。 +2. 严格程度:节点没有固定操作员公钥就拒绝一切 Backend 给的对端(默认),弱信任模式是安装时的显式选择(见 access.md 第 8 节)。 +3. 节点面和外部面是否拆成两个监听端口(8.1 的目标):拆的话部署文档、Caddy 和隧道的配置都要跟着改。 +4. extension 的交互式会话**已定**:端到端加密,Backend 只转发密文(见 access.md 第 8.2 节);剩下的是静态数据私钥来源和 AEAD 的选择。 +5. extension 的第一个具体实现做不做 web ssh,放在哪个阶段。 +6. 块摘要固定 SHA-256,不预留算法字段(不协商、不降级,见第 9.4 节)。 +7. Agent 的 `max_total_mbps` 默认值调到多少(第 9.5 节),比如 100。 +8. 块的签名放进格式的哪个位置、是否影响现有 `MSRC` 容器版本(第 9.1 节)。 From ddb6608e3281458196d2fe11ab69a1e959d32bce Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 20:56:36 +0800 Subject: [PATCH 02/13] feat!: capabilities replace API token scopes; every admin endpoint declares one, with a picker UI [CI] --- README.md | 2 +- backend/openapi/v1.json | 313 ++++++++++++++---- .../Admin/AgentEndpoints.cs | 51 ++- .../Admin/AuthEndpoints.cs | 9 +- .../Admin/BuildEndpoints.cs | 21 +- .../Admin/DataEndpoints.cs | 12 +- .../Admin/LinkEndpoints.cs | 6 +- .../Admin/SystemEndpoints.cs | 40 ++- .../Admin/TaskEndpoints.cs | 24 +- backend/src/MoShiro.Backend.Api/ApiModule.cs | 4 +- .../Common/EndpointConventions.cs | 13 + .../src/MoShiro.Backend.Api/Common/Guards.cs | 35 +- .../src/MoShiro.Backend.Api/Common/Mappers.cs | 2 +- .../Contracts/AdminDtos.cs | 16 +- .../Contracts/ApiJsonContext.cs | 1 + .../src/MoShiro.Backend.Api/OpsEndpoints.cs | 10 +- .../Auth/AuthModels.cs | 22 +- .../Auth/AuthService.cs | 47 ++- .../Auth/Capabilities.cs | 135 ++++++++ .../Ports/IAuthStore.cs | 2 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 23 +- .../SqliteAuthStore.cs | 8 +- .../MoShiro.Backend.Tests/AotSmokeTests.cs | 2 +- .../MoShiro.Backend.Tests/ApiBasicsTests.cs | 4 +- .../MoShiro.Backend.Tests/BackendFixture.cs | 7 +- .../MoShiro.Backend.Tests/CapabilityTests.cs | 132 ++++++++ .../OperationsAndSecurityTests.cs | 2 +- .../MoShiro.Backend.Tests/UpdateTests.cs | 8 +- docs/access.md | 2 +- docs/api.md | 7 +- docs/enrollment.md | 2 +- web/admin/src/App.tsx | 2 +- web/admin/src/BackendUpdateNotice.tsx | 4 +- web/admin/src/auth.tsx | 3 +- web/admin/src/pages/CapabilityPicker.tsx | 174 ++++++++++ web/admin/src/pages/System.tsx | 131 ++++++-- web/shared/api/schema.ts | 87 ++++- web/shared/api/types.ts | 3 +- 38 files changed, 1148 insertions(+), 218 deletions(-) create mode 100644 backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs create mode 100644 web/admin/src/pages/CapabilityPicker.tsx diff --git a/README.md b/README.md index 22cf301..9c5c48c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P ## 信任关系 ```text - 管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend + 管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend 访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口 节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥) diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index a4801fb..28a6789 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -1255,7 +1255,8 @@ } } } - } + }, + "x-moshiro-capability": "release.publish" }, "get": { "tags": [ @@ -1294,7 +1295,8 @@ } } } - } + }, + "x-moshiro-capability": "update.read" } }, "/api/admin/auth/logout": { @@ -1323,7 +1325,8 @@ } } } - } + }, + "x-moshiro-capability": "authenticated" } }, "/api/admin/auth/me": { @@ -1354,7 +1357,8 @@ } } } - } + }, + "x-moshiro-capability": "authenticated" } }, "/api/admin/agents": { @@ -1395,7 +1399,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" } }, "/api/admin/agents/{id}": { @@ -1456,7 +1461,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" }, "patch": { "tags": [ @@ -1560,7 +1566,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" }, "delete": { "tags": [ @@ -1607,7 +1614,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.revoke" } }, "/api/admin/agents/{id}/approve": { @@ -1678,7 +1686,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.enroll" } }, "/api/admin/agents/{id}/disable": { @@ -1749,7 +1758,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" } }, "/api/admin/agents/{id}/enable": { @@ -1820,7 +1830,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" } }, "/api/admin/agents/{id}/takeover": { @@ -1881,7 +1892,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.enroll" } }, "/api/admin/join-tokens": { @@ -1922,7 +1934,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" }, "post": { "tags": [ @@ -1981,7 +1994,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.enroll" } }, "/api/admin/join-tokens/{id}": { @@ -2037,7 +2051,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.enroll" } }, "/api/admin/probes": { @@ -2078,7 +2093,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" } }, "/api/admin/probes/{id}": { @@ -2139,7 +2155,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" }, "patch": { "tags": [ @@ -2228,7 +2245,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" }, "delete": { "tags": [ @@ -2275,7 +2293,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.revoke" } }, "/api/admin/probes/{id}/revoke": { @@ -2336,7 +2355,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.revoke" } }, "/api/admin/agents/{id}/probes/{probe_id}/disconnect": { @@ -2413,7 +2433,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" } }, "/api/admin/tasks": { @@ -2470,7 +2491,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.read" }, "post": { "tags": [ @@ -2539,7 +2561,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.write" } }, "/api/admin/tasks/{id}": { @@ -2600,7 +2623,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.read" }, "patch": { "tags": [ @@ -2694,7 +2718,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.write" }, "delete": { "tags": [ @@ -2741,7 +2766,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.write" } }, "/api/admin/lines/{line_id}/tests": { @@ -2832,7 +2858,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.write" }, "get": { "tags": [ @@ -2893,7 +2920,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.read" } }, "/api/admin/tests/{id}/cancel": { @@ -2954,7 +2982,8 @@ } } } - } + }, + "x-moshiro-capability": "tasks.write" } }, "/api/admin/raw/chunks": { @@ -3061,7 +3090,8 @@ } } } - } + }, + "x-moshiro-capability": "data.read" } }, "/api/admin/raw/chunks/{chunk_id}": { @@ -3125,7 +3155,8 @@ } } } - } + }, + "x-moshiro-capability": "data.read" } }, "/api/admin/events": { @@ -3225,7 +3256,8 @@ } } } - } + }, + "x-moshiro-capability": "events.read" } }, "/api/admin/analysis/recompute": { @@ -3266,7 +3298,8 @@ } } } - } + }, + "x-moshiro-capability": "data.write" } }, "/api/admin/api-tokens": { @@ -3307,13 +3340,14 @@ } } } - } + }, + "x-moshiro-capability": "keys.read" }, "post": { "tags": [ "admin-api-tokens" ], - "summary": "Create an API token (scope read or admin); the plaintext is returned only once", + "summary": "Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once", "operationId": "api_tokens_create", "requestBody": { "content": { @@ -3366,7 +3400,8 @@ } } } - } + }, + "x-moshiro-capability": "keys.manage" } }, "/api/admin/api-tokens/{id}": { @@ -3415,7 +3450,50 @@ } } } - } + }, + "x-moshiro-capability": "keys.manage" + } + }, + "/api/admin/capabilities": { + "get": { + "tags": [ + "admin-api-tokens" + ], + "summary": "The capability catalog: groups, which ones change trust, which are reserved, and the presets", + "operationId": "capabilities_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CapabilityCatalogDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "keys.read" } }, "/api/admin/settings": { @@ -3456,7 +3534,8 @@ } } } - } + }, + "x-moshiro-capability": "settings.read" }, "patch": { "tags": [ @@ -3520,7 +3599,8 @@ } } } - } + }, + "x-moshiro-capability": "settings.write" } }, "/api/admin/users": { @@ -3561,7 +3641,8 @@ } } } - } + }, + "x-moshiro-capability": "accounts.manage" }, "post": { "tags": [ @@ -3630,7 +3711,8 @@ } } } - } + }, + "x-moshiro-capability": "accounts.manage" } }, "/api/admin/users/{id}": { @@ -3699,7 +3781,8 @@ } } } - } + }, + "x-moshiro-capability": "accounts.manage" } }, "/api/admin/build-info": { @@ -3740,7 +3823,8 @@ } } } - } + }, + "x-moshiro-capability": "update.read" } }, "/api/admin/nodes/{id}/update": { @@ -3799,7 +3883,8 @@ } } } - } + }, + "x-moshiro-capability": "update.confirm" } }, "/api/admin/backend/update": { @@ -3840,7 +3925,8 @@ } } } - } + }, + "x-moshiro-capability": "update.read" }, "post": { "tags": [ @@ -3907,7 +3993,8 @@ } } } - } + }, + "x-moshiro-capability": "update.confirm" } }, "/api/admin/backend/update/live": { @@ -3941,7 +4028,8 @@ } } } - } + }, + "x-moshiro-capability": "update.read" } }, "/api/admin/links": { @@ -3982,7 +4070,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.read" } }, "/api/admin/links/{probe_id}/{agent_id}": { @@ -4071,7 +4160,8 @@ } } } - } + }, + "x-moshiro-capability": "fleet.write" } }, "/healthz": { @@ -4110,7 +4200,7 @@ "tags": [ "ops" ], - "summary": "Prometheus metrics (admin token)", + "summary": "Prometheus metrics (requires ops.metrics)", "operationId": "metrics_get", "responses": { "200": { @@ -4512,9 +4602,10 @@ "required": [ "id", "name", - "scope", + "capabilities", "created_at", "last_used_at", + "expires_at", "revoked_at" ], "type": "object", @@ -4525,8 +4616,11 @@ "name": { "type": "string" }, - "scope": { - "type": "string" + "capabilities": { + "type": "array", + "items": { + "type": "string" + } }, "created_at": { "format": "date-time" @@ -4534,6 +4628,9 @@ "last_used_at": { "format": "date-time" }, + "expires_at": { + "format": "date-time" + }, "revoked_at": { "format": "date-time" } @@ -4680,6 +4777,89 @@ } } }, + "CapabilityCatalogDto": { + "required": [ + "groups", + "presets" + ], + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapabilityGroupDto" + } + }, + "presets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapabilityPresetDto" + } + } + } + }, + "CapabilityDto": { + "required": [ + "id", + "trust", + "reserved", + "implies" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "trust": { + "type": "boolean" + }, + "reserved": { + "type": "boolean" + }, + "implies": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CapabilityGroupDto": { + "required": [ + "id", + "capabilities" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapabilityDto" + } + } + } + }, + "CapabilityPresetDto": { + "required": [ + "id", + "capabilities" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "ChangePasswordRequest": { "required": [ "password" @@ -4845,15 +5025,27 @@ "CreateApiTokenRequest": { "required": [ "name", - "scope" + "capabilities" ], "type": "object", "properties": { "name": { "type": "string" }, - "scope": { - "type": "string" + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "expires_in_days": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32" } } }, @@ -5988,7 +6180,7 @@ "MeDto": { "required": [ "kind", - "scope", + "capabilities", "user_id", "username", "csrf_token" @@ -5998,8 +6190,11 @@ "kind": { "type": "string" }, - "scope": { - "type": "string" + "capabilities": { + "type": "array", + "items": { + "type": "string" + } }, "user_id": { "type": [ diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 63ed0c3..669d2ac 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -17,50 +17,67 @@ public static class AgentEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/agents", ListAsync) - .Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns>().ProducesProblems(401, 403); + .Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); group.MapGet("/agents/{id}", GetAsync) - .Api("agents_get", "One agent", AgentsTag).Returns().ProducesProblems(401, 403, 404); + .Api("agents_get", "One agent", AgentsTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.FleetRead); group.MapPatch("/agents/{id}", PatchAsync) .Accepts("application/merge-patch+json", "application/json") .Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag) - .Returns().ProducesProblems(400, 401, 403, 404, 409, 412); + .Returns().ProducesProblems(400, 401, 403, 404, 409, 412) + .Requires(Capability.FleetWrite); group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct))) - .Api("agents_approve", "Approve a pending agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409); + .Api("agents_approve", "Approve a pending agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409) + .Requires(Capability.FleetEnroll); group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct))) - .Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409); + .Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409) + .Requires(Capability.FleetWrite); group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct))) - .Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409); + .Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns().ProducesProblems(401, 403, 404, 409) + .Requires(Capability.FleetWrite); group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct))) .Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag) - .Returns().ProducesProblems(401, 403, 404); + .Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.FleetEnroll); group.MapDelete("/agents/{id}", DeleteAsync) .Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag) - .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.FleetRevoke); group.MapGet("/join-tokens", ListTokensAsync) - .Api("join_tokens_list", "Join tokens", TokensTag).Returns>().ProducesProblems(401, 403); + .Api("join_tokens_list", "Join tokens", TokensTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); group.MapPost("/join-tokens", CreateTokenAsync) .Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403); + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403) + .Requires(Capability.FleetEnroll); group.MapDelete("/join-tokens/{id}", RevokeTokenAsync) .Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag) - .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.FleetEnroll); group.MapGet("/probes", ListProbesAsync) - .Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns>().ProducesProblems(401, 403); + .Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); group.MapGet("/probes/{id}", GetProbeAsync) - .Api("probes_get", "One probe", ProbeTag).Returns().ProducesProblems(401, 403, 404); + .Api("probes_get", "One probe", ProbeTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.FleetRead); group.MapPatch("/probes/{id}", PatchProbeAsync) - .Api("probes_update", "Rename a probe", ProbeTag).Returns().ProducesProblems(400, 401, 403, 404, 409); + .Api("probes_update", "Rename a probe", ProbeTag).Returns().ProducesProblems(400, 401, 403, 404, 409) + .Requires(Capability.FleetWrite); group.MapPost("/probes/{id}/revoke", RevokeProbeAsync) .Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag) - .Returns().ProducesProblems(401, 403, 404); + .Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.FleetRevoke); group.MapDelete("/probes/{id}", DeleteProbeAsync) .Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag) - .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.FleetRevoke); group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync) .Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag) - .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409) + .Requires(Capability.FleetWrite); } private static async Task ListAsync(AgentService agents, CancellationToken ct) => diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 54a47d4..6782cff 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -16,6 +16,7 @@ public static class AuthEndpoints public static void MapLogin(RouteGroupBuilder open) { open.MapPost("/auth/login", LoginAsync) + .AllowsAnonymous() .Api("auth_login", "Sign in with a username and password", Tag) .Returns().ProducesProblems(400, 401, 429); } @@ -24,10 +25,12 @@ public static class AuthEndpoints { guarded.MapPost("/auth/logout", LogoutAsync) .Api("auth_logout", "Sign out and clear the session cookie", Tag) - .Returns(StatusCodes.Status204NoContent).ProducesProblems(401); + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401) + .Requires(Capability.Authenticated); guarded.MapGet("/auth/me", Me) .Api("auth_me", "The current identity", Tag) - .Returns().ProducesProblems(401); + .Returns().ProducesProblems(401) + .Requires(Capability.Authenticated); } private static async Task LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options) @@ -85,7 +88,7 @@ public static class AuthEndpoints { Principal principal = http.GetPrincipal(); return TypedResults.Json( - new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), principal.UserId, principal.Username, principal.CsrfToken), + new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken), ApiJsonContext.Default.MeDto); } } diff --git a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index 652f721..501c6b5 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -17,17 +17,23 @@ public static class BuildEndpoints { group.MapGet("/build-info", GetAsync) .Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag) - .Returns().ProducesProblems(401, 403); + .Returns().ProducesProblems(401, 403) + .Requires(Capability.UpdateRead); group.MapGet("/releases", ListReleasesAsync) - .Api("releases_list", "The latest signed release manifest of each component", Tag).Returns>().ProducesProblems(401, 403); + .Api("releases_list", "The latest signed release manifest of each component", Tag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.UpdateRead); group.MapPost("/nodes/{id}/update", RequestUpdateAsync) - .Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409); + .Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409) + .Requires(Capability.UpdateConfirm); group.MapGet("/backend/update", BackendUpdateAsync) - .Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns().ProducesProblems(401, 403); + .Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns().ProducesProblems(401, 403) + .Requires(Capability.UpdateRead); group.MapPost("/backend/update", ConfirmBackendUpdateAsync) - .Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412); + .Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412) + .Requires(Capability.UpdateConfirm); group.MapGet("/backend/update/live", LiveBackendUpdateAsync) - .Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403); + .Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403) + .Requires(Capability.UpdateRead); } /// 登记发布清单:挂在 下,除了管理员,CI 用的 release 令牌也能调用(它只能做这一件事)。 @@ -35,7 +41,8 @@ public static class BuildEndpoints { group.MapPost("/releases", PublishReleaseAsync) .Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.ReleasePublish); } private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs index 3dda979..a7c5181 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/DataEndpoints.cs @@ -16,14 +16,18 @@ public static class DataEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/raw/chunks", ListChunksAsync) - .Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns>().ProducesProblems(400, 401, 403); + .Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns>().ProducesProblems(400, 401, 403) + .Requires(Capability.DataRead); group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync) .Api("raw_chunks_get", "Download a raw chunk (application/vnd.moshiro.rawchunk)", Tag) - .Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410); + .Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410) + .Requires(Capability.DataRead); group.MapGet("/events", ListEventsAsync) - .Api("events_list", "Agent events", Tag).Returns>().ProducesProblems(400, 401, 403); + .Api("events_list", "Agent events", Tag).Returns>().ProducesProblems(400, 401, 403) + .Requires(Capability.EventsRead); group.MapGet("/analysis/recompute", RecomputeAsync) - .Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns().ProducesProblems(401, 403); + .Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns().ProducesProblems(401, 403) + .Requires(Capability.DataWrite); } private static async Task ListChunksAsync( diff --git a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs index 7ee75d6..f459f46 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/LinkEndpoints.cs @@ -15,9 +15,11 @@ public static class LinkEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/links", ListAsync) - .Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns().ProducesProblems(401, 403); + .Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); group.MapPut("/links/{probe_id}/{agent_id}", SetAsync) - .Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns().ProducesProblems(400, 401, 403, 404); + .Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns().ProducesProblems(400, 401, 403, 404) + .Requires(Capability.FleetWrite); } private static async Task ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs index 8c67d36..f52f95a 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs @@ -18,33 +18,53 @@ public static class SystemEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/api-tokens", ListTokensAsync) - .Api("api_tokens_list", "API tokens", TokensTag).Returns>().ProducesProblems(401, 403); + .Api("api_tokens_list", "API tokens", TokensTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.KeysRead); group.MapPost("/api-tokens", CreateTokenAsync) - .Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403); + .Api("api_tokens_create", "Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once", TokensTag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403) + .Requires(Capability.KeysManage); group.MapDelete("/api-tokens/{id}", RevokeTokenAsync) - .Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.KeysManage); + + group.MapGet("/capabilities", CapabilitiesAsync) + .Api("capabilities_list", "The capability catalog: groups, which ones change trust, which are reserved, and the presets", TokensTag) + .Returns().ProducesProblems(401, 403) + .Requires(Capability.KeysRead); group.MapGet("/settings", ListSettingsAsync) - .Api("settings_list", "Site settings and retention periods", SettingsTag).Returns>().ProducesProblems(401, 403); + .Api("settings_list", "Site settings and retention periods", SettingsTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.SettingsRead); group.MapPatch("/settings", PatchSettingsAsync) .Accepts>("application/merge-patch+json", "application/json") - .Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns>().ProducesProblems(400, 401, 403); + .Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns>().ProducesProblems(400, 401, 403) + .Requires(Capability.SettingsWrite); group.MapGet("/users", ListUsersAsync) - .Api("users_list", "Panel users", UsersTag).Returns>().ProducesProblems(401, 403); + .Api("users_list", "Panel users", UsersTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.AccountsManage); group.MapPost("/users", CreateUserAsync) - .Api("users_create", "Create a panel user", UsersTag).Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409); + .Api("users_create", "Create a panel user", UsersTag).Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.AccountsManage); group.MapPatch("/users/{id}", ChangePasswordAsync) - .Api("users_update", "Change a user's password", UsersTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403); + .Api("users_update", "Change a user's password", UsersTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403) + .Requires(Capability.AccountsManage); } + private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult CapabilitiesAsync() => + TypedResults.Json( + new CapabilityCatalogDto( + [.. CapabilityCatalog.Groups.Select(g => new CapabilityGroupDto(g, [.. CapabilityCatalog.All.Where(c => c.Group == g).Select(c => new CapabilityDto(c.Id, c.Trust, c.Reserved, c.Implies))]))], + [.. CapabilityCatalog.Presets.Select(p => new CapabilityPresetDto(p.Id, p.Capabilities))]), + ApiJsonContext.Default.CapabilityCatalogDto); + private static async Task ListTokensAsync(AuthService auth, CancellationToken ct) => TypedResults.Json(new PageDto([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto); private static async Task CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request) { - Result result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted); + Result result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted); if (!result.TryGet(out CreatedApiToken? created, out FleetError? error)) { return Problems.From(http, error); diff --git a/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs index f6fc4a1..c5b4cc2 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/TaskEndpoints.cs @@ -16,26 +16,34 @@ public static class TaskEndpoints public static void Map(RouteGroupBuilder group) { group.MapGet("/tasks", ListAsync) - .Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns>().ProducesProblems(401, 403); + .Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.TasksRead); group.MapPost("/tasks", CreateAsync) .Api("tasks_create", "Create a task; params are validated against the kind", TasksTag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404); + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404) + .Requires(Capability.TasksWrite); group.MapGet("/tasks/{id}", GetAsync) - .Api("tasks_get", "One task", TasksTag).Returns().ProducesProblems(401, 403, 404); + .Api("tasks_get", "One task", TasksTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.TasksRead); group.MapPatch("/tasks/{id}", PatchAsync) .Accepts("application/merge-patch+json", "application/json") .Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag) - .Returns().ProducesProblems(400, 401, 403, 404, 412); + .Returns().ProducesProblems(400, 401, 403, 404, 412) + .Requires(Capability.TasksWrite); group.MapDelete("/tasks/{id}", DeleteAsync) - .Api("tasks_delete", "Delete a task", TasksTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403); + .Api("tasks_delete", "Delete a task", TasksTag).Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 403) + .Requires(Capability.TasksWrite); group.MapPost("/lines/{line_id}/tests", StartTestAsync) .Api("tests_start", "Start an on-demand test on a line", TestsTag) - .Returns(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409); + .Returns(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409) + .Requires(Capability.TasksWrite); group.MapGet("/lines/{line_id}/tests", ListTestsAsync) - .Api("tests_list", "Recent tests of a line", TestsTag).Returns>().ProducesProblems(401, 403); + .Api("tests_list", "Recent tests of a line", TestsTag).Returns>().ProducesProblems(401, 403) + .Requires(Capability.TasksRead); group.MapPost("/tests/{id}/cancel", CancelAsync) - .Api("tests_cancel", "Cancel a running test", TestsTag).Returns().ProducesProblems(401, 403, 404); + .Api("tests_cancel", "Cancel a running test", TestsTag).Returns().ProducesProblems(401, 403, 404) + .Requires(Capability.TasksWrite); } private static async Task ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index 6cb4d31..74300f3 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -103,9 +103,9 @@ public static class ApiModule RouteGroupBuilder admin = app.MapGroup("/api/admin"); AuthEndpoints.MapLogin(admin); - BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(ReleaseGuard.InvokeAsync)); + BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync)); - RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync); + RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync); AuthEndpoints.MapSession(guarded); AgentEndpoints.Map(guarded); TaskEndpoints.Map(guarded); diff --git a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs index f749fd0..aec509b 100644 --- a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs +++ b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs @@ -9,6 +9,19 @@ public static class EndpointConventions public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) => builder.WithName(operationId).WithSummary(summary).WithTags(tag); + /// 声明端点需要的能力(授权的唯一依据);OpenAPI 里带上 x-moshiro-capability。 + public static RouteHandlerBuilder Requires(this RouteHandlerBuilder builder, string capability) => + builder.WithMetadata(new CapabilityRequirement(capability)).AddOpenApiOperationTransformer((operation, _, _) => + { + operation.Extensions ??= new Dictionary(); + operation.Extensions["x-moshiro-capability"] = new Microsoft.OpenApi.JsonNodeExtension(System.Text.Json.Nodes.JsonValue.Create(capability)); + return Task.CompletedTask; + }); + + /// 匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。 + public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) => + builder.WithMetadata(new CapabilityRequirement(null)); + public static RouteHandlerBuilder Returns(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) => builder.Produces(status); diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 0be49b8..431c4d3 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -53,17 +53,29 @@ public static class PrincipalResolver } } -/// 管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 X-CSRF-Token。 -public static class AdminGuard +/// +/// 端点声明自己需要的能力()。Capability == null 表示匿名可访问(登录)。 +/// 没有声明的 /api/admin 端点一律拒绝(并有对账测试保证不存在)。 +/// +public sealed record CapabilityRequirement(string? Capability); + +/// 管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 X-CSRF-Token。 +public static class CapabilityGuard { public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { HttpContext http = context.HttpContext; - Principal principal = http.GetPrincipal(); - if (!principal.IsAdmin) + CapabilityRequirement? requirement = http.GetEndpoint()?.Metadata.GetMetadata(); + if (requirement is null) { - return principal.Scope != ApiScope.None - ? Problems.Create(http, MoShiroErrors.Forbidden, "the token scope does not allow this operation") + return Problems.Create(http, MoShiroErrors.Forbidden, "this endpoint has no capability declaration"); + } + + Principal principal = http.GetPrincipal(); + if (requirement.Capability is { } capability && !principal.Has(capability)) + { + return principal.Kind != PrincipalKind.Anonymous + ? Problems.Create(http, MoShiroErrors.Forbidden, "the credential lacks the capability: " + capability) : Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required"); } @@ -80,20 +92,13 @@ public static class AdminGuard } } -/// 登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。 -public static class ReleaseGuard -{ - public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) => - context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next); -} - /// 展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。 public static class PublicGuard { public static async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { HttpContext http = context.HttpContext; - if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService().IsSitePublicAsync(http.RequestAborted)) + if (!http.GetPrincipal().Has(Capability.PublicRead) && !await http.RequestServices.GetRequiredService().IsSitePublicAsync(http.RequestAborted)) { return Problems.Create(http, MoShiroErrors.SitePrivate, "this site is private; sign in or use a read-only token"); } @@ -102,5 +107,5 @@ public static class PublicGuard } /// 被隐藏(public_visible = false)的线路只对已认证的访问者可见。 - public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead; + public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().Has(Capability.PublicRead); } diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index 0a7f60b..b540d8a 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -156,7 +156,7 @@ public static class Mappers public static JoinTokenDto ToDto(JoinTokenInfo t) => new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind)); - public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs)); + public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs)); public static RawChunkMetaDto ToDto(RawChunkMeta m) => new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index fa639aa..7985292 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -7,7 +7,7 @@ public sealed record LoginRequest(string Username, string Password, bool? Return public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken); -public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken); +public sealed record MeDto(string Kind, IReadOnlyList Capabilities, string? UserId, string? Username, string? CsrfToken); public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList AllowedTargetCidrs, int MaxConcurrentTests); @@ -117,9 +117,19 @@ public sealed record RawChunkMetaDto( bool Partial, int Bytes); -public sealed record ApiTokenDto(string Id, string Name, string Scope, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? RevokedAt); +public sealed record ApiTokenDto(string Id, string Name, IReadOnlyList Capabilities, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? ExpiresAt, DateTimeOffset? RevokedAt); -public sealed record CreateApiTokenRequest(string Name, string Scope); +/// 能力列表(见 /api/admin/capabilities);写蕴含读,只能是创建者自己拥有能力的子集。 +/// 有效天数;缺省不过期。 +public sealed record CreateApiTokenRequest(string Name, IReadOnlyList Capabilities, int? ExpiresInDays = null); + +public sealed record CapabilityDto(string Id, bool Trust, bool Reserved, IReadOnlyList Implies); + +public sealed record CapabilityGroupDto(string Id, IReadOnlyList Capabilities); + +public sealed record CapabilityPresetDto(string Id, IReadOnlyList Capabilities); + +public sealed record CapabilityCatalogDto(IReadOnlyList Groups, IReadOnlyList Presets); public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 53f9803..30892fe 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -73,6 +73,7 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(ApiTokenDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreateApiTokenRequest))] +[JsonSerializable(typeof(CapabilityCatalogDto))] [JsonSerializable(typeof(CreatedApiTokenDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "SettingsPatch")] diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index 9c8d766..f098c99 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -17,7 +17,7 @@ public static class OpsEndpoints .Api("health_get", "SQLite and the raw data directory are both writable", Tag) .Returns().ProducesProblems(503); app.MapGet("/metrics", MetricsAsync) - .Api("metrics_get", "Prometheus metrics (admin token)", Tag) + .Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403); // 一行安装:curl -fsSL 'https://panel/install?code=…' | bash(macOS 用 zsh),irm 'https://panel/install.ps1?code=…' | iex(Windows)。 // Agent 和 Probe 是同一份脚本,角色、网关和镜像由一次性代码决定;代码不能用就是 404。 @@ -57,11 +57,11 @@ public static class OpsEndpoints private static async Task MetricsAsync(HttpContext http, OpsService ops) { Principal principal = http.GetPrincipal(); - if (!principal.IsAdmin) + if (!principal.Has(Capability.OpsMetrics)) { - return principal.CanRead - ? Problems.Create(http, MoShiroErrors.Forbidden, "an admin token is required") - : Problems.Create(http, MoShiroErrors.Unauthorized, "an admin token is required"); + return principal.Kind != PrincipalKind.Anonymous + ? Problems.Create(http, MoShiroErrors.Forbidden, "the ops.metrics capability is required") + : Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required"); } return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4"); diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs index 9f438c5..6d8cf92 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs @@ -7,24 +7,16 @@ public enum PrincipalKind Token, } -/// 访问级别:作用域为 read 的令牌只读,admin 可以管理,release 只能登记发布清单(给 CI 用,什么都读不了)。 -public enum ApiScope +/// +/// 已认证的主体:会话或 API key,带着它拥有的能力集合。授权只看 。 +/// +public sealed record Principal(PrincipalKind Kind, IReadOnlySet Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId) { - None = 0, - Release = 1, - Read = 2, - Admin = 3, -} + public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet(), null, null, null, null); -public sealed record Principal(PrincipalKind Kind, ApiScope Scope, string? UserId, string? Username, string? CsrfToken, string? TokenId) -{ - public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, ApiScope.None, null, null, null, null); + public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability); - public bool CanRead => Scope >= ApiScope.Read; - - public bool IsAdmin => Scope >= ApiScope.Admin; - - public bool CanPublishRelease => Scope is ApiScope.Release or ApiScope.Admin; + public static IReadOnlySet SetOf(IEnumerable capabilities) => new HashSet(capabilities, StringComparer.Ordinal); } public sealed record LoginResult(string SessionToken, string CsrfToken, long ExpiresAtMs, string UserId, string Username); diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs index cfc1ea2..7dd0bf8 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs @@ -60,25 +60,24 @@ public sealed partial class AuthService } UserRecord? user = await _store.GetUserAsync(session.UserId, ct); - return user is null ? null : new Principal(PrincipalKind.Session, ApiScope.Admin, user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null); + return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null); } public async ValueTask AuthenticateTokenAsync(string bearer, CancellationToken ct) { ApiTokenRecord? token = await _store.FindApiTokenAsync(TokenHash.Sha256(bearer), ct); - if (token is null || token.RevokedAtMs is not null) + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + if (token is null || token.RevokedAtMs is not null || (token.ExpiresAtMs is { } expires && expires <= now)) { return null; } - long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); if (token.LastUsedAtMs is not { } last || now - last >= TokenTouchIntervalMs) { await _store.TouchApiTokenAsync(token.Id, now, ct); } - ApiScope scope = token.Scope switch { "admin" => ApiScope.Admin, "release" => ApiScope.Release, _ => ApiScope.Read }; - return new Principal(PrincipalKind.Token, scope, null, token.Name, null, token.Id); + return new Principal(PrincipalKind.Token, Principal.SetOf(token.Capabilities), null, token.Name, null, token.Id); } public async ValueTask LogoutAsync(string sessionToken, CancellationToken ct) @@ -128,20 +127,48 @@ public sealed partial class AuthService public ValueTask> ListUsersAsync(CancellationToken ct) => _store.ListUsersAsync(ct); - public async ValueTask> CreateApiTokenAsync(string name, string scope, CancellationToken ct) + /// + /// 创建 API key:能力必须都在目录里,并且是创建者自己拥有能力的子集(不能越权);写蕴含读,结果稳定排序。 + /// 为空表示不过期。 + /// + public async ValueTask> CreateApiTokenAsync(string name, IReadOnlyList capabilities, int? expiresInDays, Principal creator, CancellationToken ct) { + var errors = new Dictionary(); if (name.Length is 0 or > 64) { - return ValidationError.Fail("name", "must be 1-64 characters"); + errors["name"] = "must be 1-64 characters"; } - if (scope is not ("read" or "admin" or "release")) + string[] unknown = [.. capabilities.Where(c => !CapabilityCatalog.IsKnown(c))]; + if (capabilities.Count == 0) { - return ValidationError.Fail("scope", "must be read, admin or release"); + errors["capabilities"] = "choose at least one capability"; + } + else if (unknown.Length > 0) + { + errors["capabilities"] = "unknown capability: " + string.Join(", ", unknown); + } + + if (expiresInDays is < 1 or > 3650) + { + errors["expires_in_days"] = "must be between 1 and 3650"; + } + + IReadOnlyList effective = CapabilityCatalog.Expand(capabilities.Where(CapabilityCatalog.IsKnown)); + string[] beyond = [.. effective.Where(c => !creator.Has(c))]; + if (beyond.Length > 0) + { + errors["capabilities"] = "you cannot grant what you do not have: " + string.Join(", ", beyond); + } + + if (errors.Count > 0) + { + return ValidationError.Fail(errors); } string plaintext = TokenHash.NewToken("mos"); - var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, scope, TokenHash.Sha256(plaintext), _time.GetUtcNow().ToUnixTimeMilliseconds(), null, null); + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + var record = new ApiTokenRecord(Ulid.NewPrefixed("tok", _time), name, effective, TokenHash.Sha256(plaintext), now, null, expiresInDays is { } days ? now + (days * 86_400_000L) : null, null); await _store.CreateApiTokenAsync(record, ct); return Result.Ok(new CreatedApiToken(record, plaintext)); } diff --git a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs new file mode 100644 index 0000000..ec8590c --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs @@ -0,0 +1,135 @@ +namespace MoShiro.Backend.Application; + +/// +/// 能力(capability):一项具体的权限。端点声明自己需要哪一项, 是唯一的判断入口, +/// 会话、API key 都走它。设计见 docs/access.md 第 2 节。 +/// +public static class Capability +{ + /// 任何已认证的主体(会话或 API key);只有登出、查看自己这类端点用它。 + public const string Authenticated = "authenticated"; + + public const string PublicRead = "public.read"; + + public const string FleetRead = "fleet.read"; + public const string FleetWrite = "fleet.write"; + public const string FleetEnroll = "fleet.enroll"; + public const string FleetRevoke = "fleet.revoke"; + + public const string TasksRead = "tasks.read"; + public const string TasksWrite = "tasks.write"; + + public const string DataRead = "data.read"; + public const string DataWrite = "data.write"; + + public const string SettingsRead = "settings.read"; + public const string SettingsWrite = "settings.write"; + + public const string UpdateRead = "update.read"; + public const string UpdateConfirm = "update.confirm"; + public const string ReleasePublish = "release.publish"; + + public const string KeysRead = "keys.read"; + public const string KeysManage = "keys.manage"; + + public const string AccountsManage = "accounts.manage"; + + public const string AuditRead = "audit.read"; + public const string EventsRead = "events.read"; + public const string OpsMetrics = "ops.metrics"; + + // 预留:外部 webhook 回调和数据拉取(docs/access.md 5.4),目录里先存在,实现放后面 + public const string DataExport = "data.export"; + public const string WebhooksRead = "webhooks.read"; + public const string WebhooksManage = "webhooks.manage"; +} + +/// 一项能力的元数据。Trust:改变信任的能力(加节点、吊销、账户),后续会要求 passkey 签名;Reserved:还没有对应的功能。 +public sealed record CapabilityInfo(string Id, string Group, bool Trust = false, bool Reserved = false, params string[] Implies); + +public sealed record CapabilityPreset(string Id, IReadOnlyList Capabilities); + +/// 能力目录:分组、依赖(写蕴含读)、预设,以及创建 API key 时的校验。 +public static class CapabilityCatalog +{ + public static IReadOnlyList All { get; } = + [ + new(Capability.PublicRead, "public"), + + new(Capability.FleetRead, "fleet"), + new(Capability.FleetWrite, "fleet", false, false, Capability.FleetRead), + new(Capability.FleetEnroll, "fleet", true, false, Capability.FleetRead), + new(Capability.FleetRevoke, "fleet", true, false, Capability.FleetRead), + + new(Capability.TasksRead, "tasks"), + new(Capability.TasksWrite, "tasks", false, false, Capability.TasksRead), + + new(Capability.DataRead, "data"), + new(Capability.DataWrite, "data", false, false, Capability.DataRead), + new(Capability.DataExport, "data", false, true, Capability.DataRead), + + new(Capability.SettingsRead, "settings"), + new(Capability.SettingsWrite, "settings", false, false, Capability.SettingsRead), + + new(Capability.UpdateRead, "update"), + new(Capability.UpdateConfirm, "update", false, false, Capability.UpdateRead), + new(Capability.ReleasePublish, "update"), + + new(Capability.KeysRead, "keys"), + new(Capability.KeysManage, "keys", false, false, Capability.KeysRead), + + new(Capability.AccountsManage, "accounts", true), + + new(Capability.EventsRead, "ops"), + new(Capability.AuditRead, "ops"), + new(Capability.OpsMetrics, "ops"), + + new(Capability.WebhooksRead, "integrations", false, true), + new(Capability.WebhooksManage, "integrations", false, true, Capability.WebhooksRead), + ]; + + private static readonly Dictionary s_byId = All.ToDictionary(c => c.Id, StringComparer.Ordinal); + + public static IReadOnlyList Groups { get; } = [.. All.Select(c => c.Group).Distinct()]; + + public static IReadOnlyList Presets { get; } = + [ + new("read-only", [.. All.Where(c => !c.Reserved && c.Id.EndsWith(".read", StringComparison.Ordinal)).Select(c => c.Id)]), + new("operations", Expand([Capability.PublicRead, Capability.FleetWrite, Capability.TasksWrite, Capability.DataWrite, Capability.SettingsWrite, Capability.UpdateConfirm, Capability.EventsRead, Capability.AuditRead])), + new("enroll", Expand([Capability.FleetEnroll])), + new("ci-release", [Capability.ReleasePublish]), + new("all", [.. All.Select(c => c.Id)]), + ]; + + public static bool IsKnown(string id) => s_byId.ContainsKey(id); + + public static CapabilityInfo? Find(string id) => s_byId.GetValueOrDefault(id); + + /// 补上蕴含的能力(写蕴含读),去重并排序,结果稳定。 + public static IReadOnlyList Expand(IEnumerable ids) + { + var result = new SortedSet(StringComparer.Ordinal); + foreach (string id in ids) + { + Add(result, id); + } + + return [.. result]; + } + + private static void Add(SortedSet set, string id) + { + if (id is Capability.Authenticated || !set.Add(id) || !s_byId.TryGetValue(id, out CapabilityInfo? info)) + { + return; + } + + foreach (string implied in info.Implies) + { + Add(set, implied); + } + } + + /// 登录会话的能力。在 passkey 提权落地之前(docs/access.md 第 12 节步骤 2)会话直接拥有全部能力,包括信任类。 + public static IReadOnlyList ForSession() => [.. All.Select(c => c.Id)]; +} diff --git a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs index 235dcf3..3fc8b97 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs @@ -4,7 +4,7 @@ public sealed record UserRecord(string Id, string Username, byte[] PasswordHash, public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs); -public sealed record ApiTokenRecord(string Id, string Name, string Scope, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? RevokedAtMs); +public sealed record ApiTokenRecord(string Id, string Name, IReadOnlyList Capabilities, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? ExpiresAtMs, long? RevokedAtMs); public interface IAuthStore { diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index eb95d9d..79ec4e1 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 internal static class Schema { - public const int CurrentVersion = 10; + public const int CurrentVersion = 11; public static void Migrate(SqliteConnection connection) { @@ -140,6 +140,27 @@ internal static class Schema connection.Exec("PRAGMA user_version = 10"); transaction.Commit(); } + + if (version < 11) + { + // 权限从三个作用域改成细粒度的能力。破坏性变更,不做兼容:旧的 API 令牌全部作废,需要重新创建 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec("DROP TABLE api_tokens"); + connection.Exec(""" + CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + capabilities TEXT NOT NULL, + token_sha256 BLOB NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER, + revoked_at INTEGER + ) + """); + connection.Exec("PRAGMA user_version = 11"); + transaction.Commit(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs index b9a7092..2498413 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs @@ -6,7 +6,7 @@ namespace MoShiro.Backend.Storage.Sqlite; public sealed class SqliteAuthStore : IAuthStore { private const string UserColumns = "id, username, password_hash, password_salt, iterations, created_at"; - private const string TokenColumns = "id, name, scope, token_sha256, created_at, last_used_at, revoked_at"; + private const string TokenColumns = "id, name, capabilities, token_sha256, created_at, last_used_at, expires_at, revoked_at"; private readonly SqliteDatabase _db; @@ -64,8 +64,8 @@ public sealed class SqliteAuthStore : IAuthStore public ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct) => _db.ExecuteAsync( c => c.Exec( - "INSERT INTO api_tokens (id, name, scope, token_sha256, created_at) VALUES (@id, @n, @s, @h, @at)", - ("@id", token.Id), ("@n", token.Name), ("@s", token.Scope), ("@h", token.TokenSha256), ("@at", token.CreatedAtMs)), + "INSERT INTO api_tokens (id, name, capabilities, token_sha256, created_at, expires_at) VALUES (@id, @n, @caps, @h, @at, @exp)", + ("@id", token.Id), ("@n", token.Name), ("@caps", StorageJson.ToJson(token.Capabilities)), ("@h", token.TokenSha256), ("@at", token.CreatedAtMs), ("@exp", token.ExpiresAtMs)), ct); public ValueTask FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct) => @@ -82,5 +82,5 @@ public sealed class SqliteAuthStore : IAuthStore private static UserRecord ReadUser(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.Bytes(2), r.Bytes(3), r.GetInt32(4), r.GetInt64(5)); - private static ApiTokenRecord ReadToken(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetString(2), r.Bytes(3), r.GetInt64(4), r.NullableLong(5), r.NullableLong(6)); + private static ApiTokenRecord ReadToken(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), StorageJson.Strings(r.GetString(2)), r.Bytes(3), r.GetInt64(4), r.NullableLong(5), r.NullableLong(6), r.NullableLong(7)); } diff --git a/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs b/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs index 708f428..d07c39c 100644 --- a/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/AotSmokeTests.cs @@ -27,7 +27,7 @@ public sealed class AotSmokeTests login.EnsureSuccessStatusCode(); string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0]; string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue(); - using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "aot", ["scope"] = "admin" }) }; + using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "aot", ["capabilities"] = BackendFixture.AllCapabilities() }) }; tokenRequest.Headers.Add("Cookie", cookie); tokenRequest.Headers.Add("X-CSRF-Token", csrf); using HttpResponseMessage tokenResponse = await http.SendAsync(tokenRequest); diff --git a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs index d6bbc84..3f351ae 100644 --- a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs @@ -124,9 +124,9 @@ public sealed class ApiBasicsTests } [Fact] - public async Task ReadScopedToken_CanReadButNotManage() + public async Task PublicReadToken_CanReadThePublicApiButNothingUnderAdmin() { - using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "viewer", ["scope"] = "read" })); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "viewer", ["capabilities"] = BackendFixture.Capabilities("public.read") })); Assert.Equal(HttpStatusCode.Created, created.StatusCode); Assert.Equal("no-store", created.Headers.CacheControl!.ToString()); string token = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index e0c8675..8d9258c 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -173,6 +173,11 @@ public class BackendFixture : IAsyncLifetime return JsonNode.Parse(text)!; } + /// 目录里的全部能力("all" 预设)。 + public static JsonArray AllCapabilities() => Capabilities(CapabilityCatalog.Presets.Single(p => p.Id == "all").Capabilities.ToArray()); + + public static JsonArray Capabilities(params string[] ids) => [.. ids.Select(id => (JsonNode?)JsonValue.Create(id))]; + public static StringContent Json(JsonNode node, string mediaType = "application/json") => new(node.ToJsonString(), Encoding.UTF8, mediaType); @@ -237,7 +242,7 @@ public class BackendFixture : IAsyncLifetime JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!; string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0]; - using var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = Json(new JsonObject { ["name"] = "tests", ["scope"] = "admin" }) }; + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }) }; request.Headers.Add("Cookie", cookie); request.Headers.Add("X-CSRF-Token", body["csrf_token"]!.GetValue()); using HttpResponseMessage created = await session.SendAsync(request); diff --git a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs new file mode 100644 index 0000000..5d0f7f0 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Api; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Tests; + +/// 能力模型(docs/access.md 第 2 节):每个管理端点声明能力;密钥只能拿到自己拥有能力的子集;写蕴含读。 +[Collection("probe-e2e")] +public sealed class CapabilityTests +{ + private readonly BackendFixture _backend; + + public CapabilityTests(BackendFixture backend) + { + _backend = backend; + } + + [Fact] + public void EveryAdminEndpoint_DeclaresACapabilityThatIsInTheCatalog() + { + RouteEndpoint[] endpoints = [.. _backend.Services.GetServices().SelectMany(s => s.Endpoints).OfType() + .Where(e => e.RoutePattern.RawText?.StartsWith("/api/admin", StringComparison.Ordinal) == true)]; + Assert.NotEmpty(endpoints); + foreach (RouteEndpoint endpoint in endpoints) + { + CapabilityRequirement? requirement = endpoint.Metadata.GetMetadata(); + Assert.True(requirement is not null, $"{endpoint.RoutePattern.RawText} does not declare a capability"); + if (requirement.Capability is { } capability) + { + Assert.True(capability == Capability.Authenticated || CapabilityCatalog.IsKnown(capability), $"{endpoint.RoutePattern.RawText} declares the unknown capability {capability}"); + } + } + + // 只有登录是匿名的 + Assert.Equal(["/api/admin/auth/login"], endpoints.Where(e => e.Metadata.GetMetadata()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().ToArray()); + } + + [Fact] + public void Catalog_ExpandsWritesToReads_AndPresetsAreConsistent() + { + Assert.Equal([Capability.FleetRead, Capability.FleetWrite], CapabilityCatalog.Expand([Capability.FleetWrite])); + Assert.Contains(Capability.FleetRead, CapabilityCatalog.Expand([Capability.FleetEnroll])); + foreach (CapabilityPreset preset in CapabilityCatalog.Presets) + { + Assert.All(preset.Capabilities, id => Assert.True(CapabilityCatalog.IsKnown(id), $"{preset.Id}: {id}")); + Assert.Equal(CapabilityCatalog.Expand(preset.Capabilities).ToArray(), preset.Capabilities.Order(StringComparer.Ordinal).ToArray()); // 预设已经包含了蕴含的能力 + } + + Assert.All(CapabilityCatalog.All.Where(c => c.Id.EndsWith(".write", StringComparison.Ordinal)), c => Assert.Contains(c.Id[..^"write".Length] + "read", c.Implies)); + } + + private async Task KeyAsync(params string[] capabilities) + { + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("cap"), ["capabilities"] = BackendFixture.Capabilities(capabilities) })); + string body = await created.Content.ReadAsStringAsync(); + Assert.True(created.StatusCode == HttpStatusCode.Created, body); + HttpClient client = _backend.NewClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(body)!["plaintext"]!.GetValue()); + return client; + } + + [Fact] + public async Task AKey_CanOnlyDoWhatItsCapabilitiesAllow_AndWriteImpliesRead() + { + using HttpClient reader = await KeyAsync(Capability.FleetRead); + using HttpClient writer = await KeyAsync(Capability.FleetWrite); // 写蕴含读 + using HttpClient settings = await KeyAsync(Capability.SettingsRead); + + Assert.Equal(HttpStatusCode.OK, (await reader.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await writer.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await settings.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await reader.GetAsync("/api/admin/settings")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await reader.GetAsync("/api/admin/raw/chunks")).StatusCode); + + // 读不能写:fleet.read 的 key 不能生成加入代码,也不能创建 key + using HttpResponseMessage enroll = await reader.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" })); + Assert.Equal(HttpStatusCode.Forbidden, enroll.StatusCode); + using HttpResponseMessage keys = await reader.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead) })); + Assert.Equal(HttpStatusCode.Forbidden, keys.StatusCode); + + // 没有凭证是 401,凭证不够是 403 + Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.GetAsync("/api/admin/agents")).StatusCode); + // /auth/me 列出实际拥有的能力(包含蕴含的) + JsonNode me = JsonNode.Parse(await (await writer.GetAsync("/api/admin/auth/me")).Content.ReadAsStringAsync())!; + Assert.Equal([Capability.FleetRead, Capability.FleetWrite], me["capabilities"]!.AsArray().Select(c => c!.GetValue()).ToArray()); + } + + [Fact] + public async Task AKey_CannotGrantWhatItDoesNotHave_ButCanGrantASubset() + { + using HttpClient manager = await KeyAsync(Capability.KeysManage, Capability.FleetRead); // keys.manage 蕴含 keys.read + using HttpResponseMessage beyond = await manager.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead, Capability.FleetEnroll) })); + Assert.Equal(HttpStatusCode.BadRequest, beyond.StatusCode); + Assert.Contains("fleet.enroll", await beyond.Content.ReadAsStringAsync(), StringComparison.Ordinal); + + using HttpResponseMessage subset = await manager.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "sub", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead) })); + Assert.Equal(HttpStatusCode.Created, subset.StatusCode); + + using HttpResponseMessage unknown = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities("fleet.nope") })); + Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode); + using HttpResponseMessage empty = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = new JsonArray() })); + Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); + } + + [Fact] + public async Task AKey_HasAnExpiry_AndTheCatalogEndpointDescribesEverything() + { + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "short", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead), ["expires_in_days"] = 1 })); + JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!; + Assert.NotNull(body["token"]!["expires_at"]); + using HttpResponseMessage bad = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "x", ["capabilities"] = BackendFixture.Capabilities(Capability.FleetRead), ["expires_in_days"] = 0 })); + Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); + + JsonNode catalog = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/capabilities"); + string[] ids = [.. catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()).Select(c => c!["id"]!.GetValue())]; + Assert.Equal(CapabilityCatalog.All.Select(c => c.Id).Order(StringComparer.Ordinal).ToArray(), ids.Order(StringComparer.Ordinal).ToArray()); + Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue() == "all"); + Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue() == Capability.FleetEnroll && c["trust"]!.GetValue()); + } + + [Fact] + public async Task ReleaseKey_CanOnlyPublishReleases() + { + using HttpClient ci = await KeyAsync(Capability.ReleasePublish); + Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await ci.GetAsync("/api/admin/backend/update")).StatusCode); + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs b/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs index 5fdd240..fb1e8ed 100644 --- a/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OperationsAndSecurityTests.cs @@ -284,7 +284,7 @@ public sealed class OperationsAndSecurityTests [Fact] public async Task ApiTokens_CanBeRevoked_AndTheirPlaintextIsNeverListed() { - using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "temp", ["scope"] = "read" })); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = "temp", ["capabilities"] = BackendFixture.Capabilities("public.read") })); JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!; string plaintext = body["plaintext"]!.GetValue(); string id = body["token"]!["id"]!.GetValue(); diff --git a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs index 7b41051..113cee2 100644 --- a/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/UpdateTests.cs @@ -75,9 +75,9 @@ public sealed class UpdateTests [Fact] public async Task ReleaseToken_CanOnlyRegisterReleases_AndAReadTokenCannotEvenDoThat() { - async Task NewTokenAsync(string scope) + async Task NewTokenAsync(params string[] capabilities) { - using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["scope"] = scope })); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = BackendFixture.Capabilities(capabilities) })); Assert.Equal(HttpStatusCode.Created, created.StatusCode); return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); } @@ -90,8 +90,8 @@ public sealed class UpdateTests } long seq = NextSeq(); - using HttpClient release = Client(await NewTokenAsync("release")); - using HttpClient read = Client(await NewTokenAsync("read")); + using HttpClient release = Client(await NewTokenAsync("release.publish")); + using HttpClient read = Client(await NewTokenAsync("public.read", "fleet.read")); var body = BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" }); using HttpResponseMessage forbidden = await read.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" })); diff --git a/docs/access.md b/docs/access.md index 250b7b5..8b02422 100644 --- a/docs/access.md +++ b/docs/access.md @@ -272,7 +272,7 @@ ## 12. 实施顺序(每步可独立验证和推送) -1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、现有令牌迁移、API key 创建界面(预设、全选、能力子集校验)、IP 白名单。不改节点。 +1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 v11 重建 `api_tokens`)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。`events.read` 对应现有的 `GET /api/admin/events`,不是预留。 2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。 3. **操作员集合与创世公钥固定**:`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。 4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。 diff --git a/docs/api.md b/docs/api.md index 11e0909..3f0a577 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,7 +15,7 @@ ```text /api/public/* 只读。站点设为公开时匿名可访问,设为私有时需要登录或只读令牌; public_visible = false 的 Agent 对匿名访客隐藏 -/api/admin/* 管理。需要 Cookie 会话(含 CSRF 校验)或 admin 作用域的 API 令牌(`POST /api/admin/releases` 也接受只能登记发布清单的 release 令牌) +/api/admin/* 管理。需要 Cookie 会话(含 CSRF 校验)或带有对应**能力**的 API 令牌;每个端点声明自己需要的一项能力(OpenAPI 里的 `x-moshiro-capability`),完整目录见 `GET /api/admin/capabilities`,设计见 [access.md](access.md) /api/agent/v1/* 节点专用:注册(使用加入令牌)和 RPC(使用签名认证) /healthz /metrics /openapi/v1.json /install /install.ps1 ``` @@ -140,7 +140,8 @@ | GET | `/api/admin/raw/chunks/{chunk_id}` | 下载原始块(`application/vnd.moshiro.rawchunk`);过期返回 410 `raw_expired` | | GET | `/api/admin/events?agent_id=&kind=&from=&to=` | 事件 | | GET | `/api/admin/analysis/recompute` | 指标重算进度 | -| GET/POST/DELETE | `/api/admin/api-tokens` | 作用域为 `read`、`admin` 或 `release`(只能登记发布清单,给 CI 用,什么都读不了);明文只显示一次 | +| GET/POST/DELETE | `/api/admin/api-tokens` | 创建时勾选能力(`capabilities`,只能是创建者自己拥有能力的子集,写蕴含读)和可选的有效期(`expires_in_days`);明文只显示一次 | +| GET | `/api/admin/capabilities` | 能力目录:分组、哪些改变信任、哪些是预留、依赖关系和预设(只读、运维、接入、CI 发布、全部) | | GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期、节点定时重启(`fleet.restart_enabled`、`fleet.restart_hour`、`fleet.restart_minute`、`fleet.restart_utc_offset_minutes`,默认开、04:30、UTC+8;改完立即通知在线节点)等 | | GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) | | GET/POST | `/api/admin/releases` | CI 签名的发布清单:POST 登记(`{manifest, signature}`,面板不验签、原样转给在线的节点和之后连上的节点),GET 看每个组件最新的一份 | @@ -178,7 +179,7 @@ | --- | --- | | 400 | `validation_failed`、`budget_exceeded` | | 401 | `unauthorized`、`csrf_failed` | -| 403 | `forbidden`(令牌作用域不足)、`site_private` | +| 403 | `forbidden`(凭证缺少端点需要的能力)、`site_private` | | 404 | `agent_not_found`、`task_not_found`、`test_not_found`、`chunk_not_found`、`unknown_kind`、`unknown_plan` | | 409 | `name_conflict`、`probe_offline`、`test_busy`、`agent_not_active` | | 410 | `raw_expired`、`join_token_expired` | diff --git a/docs/enrollment.md b/docs/enrollment.md index 614e3d3..512c7d3 100644 --- a/docs/enrollment.md +++ b/docs/enrollment.md @@ -9,7 +9,7 @@ | Agent | Fleet 节点身份(ECDSA P-256)+ 面向 Probe 的 TLS 证书 | `fleet-enroll` | 公钥、SPKI 指纹 | | Probe | Fleet 节点身份(连面板)+ 另一把独立的密钥(连 Agent,放在 `tls.pfx`) | `fleet-enroll`,和 Agent 一样用加入令牌(`role=probe`);可以有多个 Probe,每个独立吊销 | 两把公钥 | | 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 | -| API 令牌 | 随机高熵令牌,作用域为 `read` 或 `admin` | 面板 | SHA-256 | +| API 令牌 | 随机高熵令牌,带一组能力(见 [access.md](access.md)) | 面板 | SHA-256 | | 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在节点宿主机的更新器里配置公钥(容器节点是 `moshiro-update`,见 [deployment.md](deployment.md);纯二进制节点的更新是待办),**面板不保存私钥,也不验签** | ## 2. Agent 接入流程 diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index 64f14c3..f1d5c4a 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -153,7 +153,7 @@ export function App() {
{me.username ?? 'API 令牌'}
- {me.scope} + {me.kind === 'token' ? `API 令牌 · ${me.capabilities.length} 项能力` : '管理员'}
diff --git a/web/admin/src/BackendUpdateNotice.tsx b/web/admin/src/BackendUpdateNotice.tsx index f56006f..4c20f9d 100644 --- a/web/admin/src/BackendUpdateNotice.tsx +++ b/web/admin/src/BackendUpdateNotice.tsx @@ -16,7 +16,7 @@ export function BackendUpdateNotice() { const { busy, run } = useAction() useEffect(() => { - if (me?.scope !== 'admin') return + if (!me?.capabilities.includes('update.read')) return let alive = true const receive = (value: BackendUpdateDto) => { if (!alive) return @@ -27,7 +27,7 @@ export function BackendUpdateNotice() { const source = openLive('/api/admin/backend/update/live') source.addEventListener('backend_update', (event) => receive(JSON.parse(event.data) as BackendUpdateDto)) return () => { alive = false; source.close() } - }, [me?.scope]) + }, [me?.capabilities]) useEffect(() => { if (!requested || !update) return diff --git a/web/admin/src/auth.tsx b/web/admin/src/auth.tsx index 7c00a0e..9dc5252 100644 --- a/web/admin/src/auth.tsx +++ b/web/admin/src/auth.tsx @@ -48,7 +48,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { } const r = await api.post('/api/admin/auth/login', { username, password }) setCsrf(r.csrf_token) - setMe({ kind: 'user', scope: 'admin', user_id: r.user_id, username: r.username, csrf_token: r.csrf_token }) + // 登录响应没有能力列表:登录后取一次当前身份 + setMe(await api.get('/api/admin/auth/me')) }, []) const logout = useCallback(async () => { diff --git a/web/admin/src/pages/CapabilityPicker.tsx b/web/admin/src/pages/CapabilityPicker.tsx new file mode 100644 index 0000000..f159fd7 --- /dev/null +++ b/web/admin/src/pages/CapabilityPicker.tsx @@ -0,0 +1,174 @@ +import { KeyRound } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { CapabilityCatalogDto } from '@shared/api/types' +import { Chip } from '@shared/ui' + +/** 能力的中文名和说明。目录(分组、依赖、预设)来自后端,这里只管显示文字。 */ +const NAMES: Record = { + 'public.read': ['查看展示数据', '看隐藏线路;站点私有时看展示页'], + 'fleet.read': ['查看节点', 'Agent、Probe、线路、加入代码'], + 'fleet.write': ['修改节点', '改名、标签、可见性、地址、线路开关、断开会话'], + 'fleet.enroll': ['添加节点', '生成加入代码、批准新节点'], + 'fleet.revoke': ['吊销或删除节点', '破坏性操作'], + 'tasks.read': ['查看任务', '任务模板、覆盖、测试记录'], + 'tasks.write': ['修改任务', '创建、修改、删除任务;发起或取消测试'], + 'data.read': ['读取原始数据', '原始块、导出'], + 'data.write': ['维护数据', '重算分析、清理'], + 'data.export': ['批量导出数据', '预留:按线路和时间批量拉取,带配额'], + 'settings.read': ['查看设置', '站点设置、保留策略'], + 'settings.write': ['修改设置', '站点设置、保留策略、重启计划'], + 'update.read': ['查看更新', '版本、发布清单、Backend 更新状态'], + 'update.confirm': ['确认更新', '确认 Backend 更新、请求节点更新'], + 'release.publish': ['登记发布清单', '给 CI 用,只能做这一件事'], + 'keys.read': ['查看 API 令牌', ''], + 'keys.manage': ['管理 API 令牌', '创建、吊销;只能授予自己拥有的能力'], + 'accounts.manage': ['管理账户', '面板用户、密码'], + 'events.read': ['查看事件', '节点上下线等事件流'], + 'audit.read': ['查看审计日志', ''], + 'ops.metrics': ['读取 /metrics', 'Prometheus 指标'], + 'webhooks.read': ['查看 webhook', '预留:外部回调订阅'], + 'webhooks.manage': ['管理 webhook', '预留:创建、修改、删除回调订阅,触发测试回调'], +} + +const GROUPS: Record = { + public: '展示', + fleet: '节点', + tasks: '任务', + data: '数据', + settings: '设置', + update: '更新与发布', + keys: 'API 令牌', + accounts: '账户', + ops: '运维与审计', + integrations: '集成(预留)', +} + +const PRESETS: Record = { + 'read-only': '只读', + operations: '运维', + enroll: '接入', + 'ci-release': 'CI 发布', + all: '全部', +} + +/** 复选框:支持半选(indeterminate)。 */ +function Check({ checked, partial, disabled, onChange, label }: { checked: boolean; partial?: boolean; disabled?: boolean; onChange: (on: boolean) => void; label: React.ReactNode }) { + const ref = useRef(null) + useEffect(() => { + if (ref.current) ref.current.indeterminate = !!partial && !checked + }, [partial, checked]) + return ( + + ) +} + +/** + * 能力选择器:分组复选框 + 预设 + 全选/组内全选(带半选)/反选 + 搜索。 + * `mine` 是创建者自己拥有的能力:不在里面的灰色不可选(不能授予自己没有的);写蕴含读,勾选写时读会一起被勾上。 + */ +export function CapabilityPicker({ catalog, mine, value, onChange }: { catalog: CapabilityCatalogDto; mine: ReadonlySet; value: ReadonlySet; onChange: (next: Set) => void }) { + const [query, setQuery] = useState('') + const info = useMemo(() => new Map(catalog.groups.flatMap((g) => g.capabilities.map((c) => [c.id, c] as const))), [catalog]) + const selectable = (id: string) => mine.has(id) + + // 勾选一项:补上它蕴含的;取消一项:同时取消依赖它的(写依赖读) + const closure = (ids: Iterable): Set => { + const out = new Set() + const add = (id: string) => { + if (out.has(id) || !selectable(id)) return + out.add(id) + info.get(id)?.implies.forEach(add) + } + for (const id of ids) add(id) + return out + } + const set = (ids: Iterable) => onChange(closure(ids)) + const toggle = (id: string, on: boolean) => { + if (on) return set([...value, id]) + const next = new Set(value) + next.delete(id) + // 依赖被取消的项一起取消 + let changed = true + while (changed) { + changed = false + for (const x of next) if (info.get(x)?.implies.some((i) => !next.has(i))) { next.delete(x); changed = true } + } + onChange(next) + } + + const needle = query.trim().toLowerCase() + const visible = (id: string) => !needle || id.includes(needle) || (NAMES[id]?.[0] ?? '').includes(needle) || (NAMES[id]?.[1] ?? '').includes(needle) + const all = [...info.keys()].filter(selectable) + const allOn = all.length > 0 && all.every((id) => value.has(id)) + + return ( +
+
+ 预设: + {catalog.presets.map((p) => ( + + ))} + setQuery(e.target.value)} /> +
+
+ 0} onChange={(on) => set(on ? all : [])} label={全选} /> + + 已选 {value.size} 项 +
+ {catalog.groups.map((g) => { + const rows = g.capabilities.filter((c) => visible(c.id)) + if (rows.length === 0) return null + const own = g.capabilities.filter((c) => selectable(c.id)) + const on = own.filter((c) => value.has(c.id)) + return ( +
+ 0 && on.length === own.length} + partial={on.length > 0} + disabled={own.length === 0} + onChange={(next) => (next ? set([...value, ...own.map((c) => c.id)]) : onChange(new Set([...value].filter((id) => !own.some((c) => c.id === id)))))} + label={{GROUPS[g.id] ?? g.id}} + /> +
+ {rows.map((c) => ( +
+ toggle(c.id, next)} + label={ + <> + {NAMES[c.id]?.[0] ?? c.id} + + {c.id} + + + } + /> + {c.trust ? ( + + 信任类 + + ) : null} + {c.reserved ? 预留 : null} + {NAMES[c.id]?.[1] ? {NAMES[c.id]![1]} : null} +
+ ))} +
+
+ ) + })} +
+ ) +} + +export function capabilityName(id: string): string { + return NAMES[id]?.[0] ?? id +} diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index 60a088a..2856c34 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,12 +1,13 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' -import { useEffect, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useState, type ReactNode } from 'react' import { api } from '@shared/api/client' -import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo' import { fmtNum, fmtTime } from '@shared/format' import { translateSetting } from '@shared/i18n' import { useQuery } from '@shared/hooks' import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' +import { CapabilityPicker, capabilityName } from './CapabilityPicker' type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis' @@ -304,47 +305,93 @@ function Versions() { function Tokens() { const tokens = useQuery(() => api.get>('/api/admin/api-tokens'), []) + const catalog = useQuery(() => api.get('/api/admin/capabilities'), []) + const me = useQuery(() => api.get('/api/admin/auth/me'), []) const { run } = useAction() const [creating, setCreating] = useState(false) const [name, setName] = useState('') - const [scope, setScope] = useState<'read' | 'admin' | 'release'>('read') + const [days, setDays] = useState(null) + const [caps, setCaps] = useState>(new Set()) const [created, setCreated] = useState() - const [revoking, setRevoking] = useState() + const [revoking, setRevoking] = useState() + const [picked, setPicked] = useState>(new Set()) + const items = tokens.data?.items ?? [] + const active = items.filter((t) => !t.revoked_at) + const allPicked = active.length > 0 && active.every((t) => picked.has(t.id)) + const mine = useMemo(() => new Set(me.data?.capabilities ?? []), [me.data]) + const trust = useMemo(() => new Set((catalog.data?.groups ?? []).flatMap((g) => g.capabilities.filter((c) => c.trust).map((c) => c.id))), [catalog.data]) + const open = () => { + setName('') + setDays(null) + setCaps(new Set()) + setCreating(true) + } return (
- 给脚本和其他程序用的令牌:read 只能读展示数据,admin 可以调用全部管理接口。 - + 给脚本和其他程序用的令牌:按需勾选能力,只能授予自己拥有的能力。 + + {picked.size > 0 ? ( + + ) : null} + +
- {(tokens.data?.items ?? []).length === 0 ? ( + {items.length === 0 ? ( 没有 API 令牌 ) : ( + - + + - {tokens.data?.items.map((t) => ( + {items.map((t) => ( - + + + + @@ -209,6 +210,7 @@ export function Agents() { return ( + diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index eb31244..d089e0b 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -256,6 +256,7 @@ export function Probes() {

创建于 {fmtTime(p.created_at)} + {p.added_by ? `,由 ${p.added_by} 添加` : ''} {p.revoked_at ? `,吊销于 ${fmtTime(p.revoked_at)}` : ''}

{p.connections.length > 0 ? ( diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 980e4df..dec44f8 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -1153,6 +1153,7 @@ export interface components { public_host_auto: boolean; /** @default true */ ipv6_host_auto: boolean; + added_by?: null | string; }; AgentIpv6Dto: { agent_id: string; @@ -1466,6 +1467,7 @@ export interface components { created_at: unknown; /** @default agent */ role: string; + created_by?: null | string; }; JsonElement: unknown; KindDto: { @@ -1792,6 +1794,7 @@ export interface components { online: boolean; connections: components["schemas"]["ProbeConnectionDto"][]; ipv6?: null | components["schemas"]["Ipv6DiagnosticDto"]; + added_by?: null | string; }; ProblemDto: { type: string; From 1379b8c7cb8cf3bf20a0b5dfd3dd538433fb27df Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 22:17:52 +0800 Subject: [PATCH 05/13] feat: audit log of admin writes, sign-ins and key creation, with an audit tab [CI] --- backend/openapi/v1.json | 168 ++++++++++++++++++ .../Admin/AgentEndpoints.cs | 1 + .../Admin/AuditEndpoints.cs | 44 +++++ .../Admin/AuthEndpoints.cs | 17 +- .../Admin/SystemEndpoints.cs | 1 + backend/src/MoShiro.Backend.Api/ApiModule.cs | 1 + .../src/MoShiro.Backend.Api/Common/Guards.cs | 14 ++ .../src/MoShiro.Backend.Api/Common/Mappers.cs | 2 + .../Contracts/ApiJsonContext.cs | 1 + .../Contracts/PublicDtos.cs | 2 + .../ApplicationServiceCollectionExtensions.cs | 1 + .../Audit/AuditService.cs | 60 +++++++ .../Ports/StorePorts.cs | 9 + .../Retention/RetentionService.cs | 5 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 15 ++ .../SqliteAuditStore.cs | 33 ++++ .../StorageServiceCollectionExtensions.cs | 1 + .../tests/MoShiro.Backend.Tests/AuditTests.cs | 68 +++++++ web/admin/src/pages/System.tsx | 56 +++++- web/shared/api/schema.ts | 86 +++++++++ web/shared/api/types.ts | 1 + 21 files changed, 579 insertions(+), 7 deletions(-) create mode 100644 backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs create mode 100644 backend/src/MoShiro.Backend.Application/Audit/AuditService.cs create mode 100644 backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuditStore.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/AuditTests.cs diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 878072d..5823ca4 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -3598,6 +3598,93 @@ "x-moshiro-capability": "data.write" } }, + "/api/admin/audit": { + "get": { + "tags": [ + "admin-audit" + ], + "summary": "Audit log of admin actions, newest first", + "operationId": "audit_list", + "parameters": [ + { + "name": "actor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "action", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfAuditDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "audit.read" + } + }, "/api/admin/api-tokens": { "get": { "tags": [ @@ -4938,6 +5025,63 @@ } } }, + "AuditDto": { + "required": [ + "id", + "at", + "actor", + "method", + "action", + "target", + "detail", + "remote", + "success" + ], + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "at": { + "format": "date-time" + }, + "actor": { + "type": "string" + }, + "method": { + "type": "string" + }, + "action": { + "type": "string" + }, + "target": { + "type": [ + "null", + "string" + ] + }, + "detail": { + "type": [ + "null", + "string" + ] + }, + "remote": { + "type": [ + "null", + "string" + ] + }, + "success": { + "type": "boolean" + } + } + }, "BackendUpdateDto": { "required": [ "channel", @@ -6982,6 +7126,27 @@ } } }, + "PageDtoOfAuditDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, "PageDtoOfEventDto": { "required": [ "items", @@ -8623,6 +8788,9 @@ { "name": "admin-data" }, + { + "name": "admin-audit" + }, { "name": "admin-api-tokens" }, diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index b0c22b0..f80e267 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -132,6 +132,7 @@ public static class AgentEndpoints private static async Task CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options) { string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; + http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}"); Result result = await tokens.CreateAsync( new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, http.RequestAborted); if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error)) diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs new file mode 100644 index 0000000..fc4180d --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Admin/AuditEndpoints.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using MoShiro.Backend.Application; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Api; + +/// 审计日志:管理接口里每个写操作、登录和提权都会记一行,这里只读。 +public static class AuditEndpoints +{ + private const string Tag = "admin-audit"; + + /// handler 往这里放一句「这次具体做了什么」(例如新建令牌的名字),守卫记审计时一起写进去。 + public const string DetailItem = "moshiro.audit.detail"; + + public static void Map(RouteGroupBuilder guarded) => + guarded.MapGet("/audit", ListAsync) + .Api("audit_list", "Audit log of admin actions, newest first", Tag).Returns>().ProducesProblems(400, 401, 403) + .Requires(Capability.AuditRead); + + public static void SetDetail(this HttpContext http, string detail) => http.Items[DetailItem] = detail; + + private static async Task ListAsync(HttpContext http, AuditService audit, string? actor, string? action, int? limit, string? cursor) + { + long? before = null; + if (cursor is not null) + { + if (!long.TryParse(cursor, out long parsed)) + { + return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor")); + } + + before = parsed; + } + + int size = Math.Clamp(limit ?? 50, 1, 500); + IReadOnlyList rows = await audit.ListAsync(new AuditQuery(before, size + 1, string.IsNullOrEmpty(actor) ? null : actor, string.IsNullOrEmpty(action) ? null : action), http.RequestAborted); + bool more = rows.Count > size; + IReadOnlyList page = [.. rows.Take(size)]; + string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null; + return TypedResults.Json(new PageDto([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoAuditDto); + } +} diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 85cc869..39e6f8a 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -57,7 +57,7 @@ public static class AuthEndpoints .Requires(Capability.Authenticated); } - private static async Task LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options) + private static async Task LoginAsync(HttpContext http, LoginRequest request, AuthService auth, AuditService audit, LoginRateLimiter limiter, BackendOptions options) { string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; if (!limiter.TryAcquire(source, out int retryAfter)) @@ -66,11 +66,14 @@ public static class AuthEndpoints } Result result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted); + string remote = source; if (!result.TryGet(out LoginResult? login, out FleetError? error)) { + await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "auth.login", null, null, remote, false, http.RequestAborted); return Problems.From(http, error); } + await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "auth.login", null, null, remote, true, http.RequestAborted); return IssueSession(http, login, request.ReturnSession == true, options); } @@ -132,7 +135,7 @@ public static class AuthEndpoints return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs), ApiJsonContext.Default.PasskeyRequestOptionsDto); } - private static async Task PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options) + private static async Task PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options) { IResult limit = Limited(http, limiter, out bool ok); if (!ok) @@ -142,7 +145,15 @@ public static class AuthEndpoints Result result = await passkeys.LoginAsync( new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted); - return result.TryGet(out LoginResult? login, out FleetError? error) ? IssueSession(http, login, request.ReturnSession == true, options) : Problems.From(http, error); + string? remote = http.Connection.RemoteIpAddress?.ToString(); + if (!result.TryGet(out LoginResult? login, out FleetError? error)) + { + await audit.RecordAsync(null, "?", AuthMethods.Passkey, "auth.login", null, null, remote, false, http.RequestAborted); + return Problems.From(http, error); + } + + await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Passkey, "auth.login", null, null, remote, true, http.RequestAborted); + return IssueSession(http, login, request.ReturnSession == true, options); } private static async Task ListPasskeysAsync(HttpContext http, PasskeyService passkeys) diff --git a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs index f52f95a..3c3b1dc 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs @@ -64,6 +64,7 @@ public static class SystemEndpoints private static async Task CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request) { + http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}"); Result result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted); if (!result.TryGet(out CreatedApiToken? created, out FleetError? error)) { diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index 74300f3..95d72f0 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -110,6 +110,7 @@ public static class ApiModule AgentEndpoints.Map(guarded); TaskEndpoints.Map(guarded); DataEndpoints.Map(guarded); + AuditEndpoints.Map(guarded); SystemEndpoints.Map(guarded); BuildEndpoints.Map(guarded); LinkEndpoints.Map(guarded); diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 431c4d3..92ec7dc 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using MoShiro.Backend.Application; using MoShiro.Protocol; @@ -88,6 +89,19 @@ public static class CapabilityGuard } } + if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method)) + { + // 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里 + string action = http.GetEndpoint()?.Metadata.GetMetadata()?.EndpointName ?? http.Request.Path.Value ?? ""; + string? target = http.Request.RouteValues.Count == 0 ? null : string.Join(' ', http.Request.RouteValues.Where(v => v.Value is not null).Select(v => v.Key + "=" + v.Value)); + http.Response.OnCompleted(() => + { + http.Items.TryGetValue(AuditEndpoints.DetailItem, out object? detail); + return http.RequestServices.GetRequiredService() + .RecordAsync(principal, action, target, detail as string, http.Connection.RemoteIpAddress?.ToString(), http.Response.StatusCode < 400).AsTask(); + }); + } + return await next(context); } } diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index 131affe..cf56e6c 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -156,6 +156,8 @@ public static class Mappers public static JoinTokenDto ToDto(JoinTokenInfo t) => new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy); + public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success); + public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports); public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs)); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index cbb44f7..ce5b95d 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -70,6 +70,7 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(RawChunkMetaDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(PageDto))] +[JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(ApiTokenDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreateApiTokenRequest))] diff --git a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs index 7698bb5..5033e40 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/PublicDtos.cs @@ -10,6 +10,8 @@ public sealed record EdgePartialDto(bool First, bool Last); public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason); +public sealed record AuditDto(long Id, DateTimeOffset At, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success); + public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail); public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt); diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index d8a1c19..cfb3ad2 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -36,6 +36,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Audit/AuditService.cs b/backend/src/MoShiro.Backend.Application/Audit/AuditService.cs new file mode 100644 index 0000000..36c9b47 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Audit/AuditService.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Logging; + +namespace MoShiro.Backend.Application; + +/// +/// 审计日志里的一行:谁(,用户名或 key:令牌名)、怎么登录的()、做了什么(, +/// 管理接口的 operationId,或 auth.login 这类登录事件)、对谁()、结果如何。 +/// +public sealed record AuditRecord(long Id, long AtMs, string? UserId, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success); + +public sealed record AuditQuery(long? BeforeId, int Limit, string? Actor, string? Action); + +/// 审计日志只追加:没有修改和单条删除,只按保留期整体清理。 +public sealed partial class AuditService +{ + /// 保留一年。审计记录的是谁动过信任相关的东西,比监测数据留得久。 + public const int RetentionDays = 365; + + private readonly IAuditStore _store; + private readonly TimeProvider _time; + private readonly ILogger _logger; + + public AuditService(IAuditStore store, TimeProvider time, ILogger logger) + { + _store = store; + _time = time; + _logger = logger; + } + + /// 记一条。写不进去只记日志,不能因为审计失败而让已经完成的操作报错。 + public async ValueTask RecordAsync(Principal principal, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default) => + await RecordAsync(principal.UserId, principal.Label, MethodOf(principal), action, target, detail, remote, success, ct); + + public async ValueTask RecordAsync(string? userId, string actor, string method, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default) + { + try + { + await _store.AddAsync(new AuditRecord(0, _time.GetUtcNow().ToUnixTimeMilliseconds(), userId, actor, method, action, Clip(target, 200), Clip(detail, 500), remote, success), ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogFailed(ex, action); + } + } + + public ValueTask> ListAsync(AuditQuery query, CancellationToken ct) => _store.ListAsync(query, ct); + + public ValueTask PruneAsync(long nowMs, CancellationToken ct) => _store.DeleteOlderThanAsync(nowMs - (RetentionDays * 86_400_000L), ct); + + /// 登录方式:密码、passkey,或 API key。 + public static string MethodOf(Principal principal) => + principal.Kind == PrincipalKind.Anonymous ? "anonymous" + : principal.Kind == PrincipalKind.Token && principal.UserId is null ? "key" + : principal.AuthMethod.Length > 0 ? principal.AuthMethod : AuthMethods.Password; + + private static string? Clip(string? text, int max) => text is { } t && t.Length > max ? t[..max] : text; + + [LoggerMessage(Level = LogLevel.Warning, Message = "could not write the audit record for {Action}")] + private partial void LogFailed(Exception exception, string action); +} diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index ba1c7ef..15d72ee 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -144,6 +144,15 @@ public interface IEventStore ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); } +public interface IAuditStore +{ + ValueTask AddAsync(AuditRecord record, CancellationToken ct); + + ValueTask> ListAsync(AuditQuery query, CancellationToken ct); + + ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); +} + /// 主机资源占用的历史:每个 Agent 每分钟一个样本。 public interface IHostStore { diff --git a/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs b/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs index 85448fb..81966b3 100644 --- a/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs +++ b/backend/src/MoShiro.Backend.Application/Retention/RetentionService.cs @@ -12,19 +12,21 @@ public sealed partial class RetentionService : BackgroundService private readonly IMetricStore _metrics; private readonly IEventStore _events; private readonly IAuthStore _auth; + private readonly AuditService _audit; private readonly IHostStore _hosts; private readonly SettingsService _settings; private readonly LoginRateLimiter _limiter; private readonly TimeProvider _time; private readonly ILogger _logger; - public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger logger) + public RetentionService(IRawStore raw, IMetricStore metrics, IEventStore events, IHostStore hosts, IAuthStore auth, AuditService audit, SettingsService settings, LoginRateLimiter limiter, TimeProvider time, ILogger logger) { _raw = raw; _metrics = metrics; _events = events; _hosts = hosts; _auth = auth; + _audit = audit; _settings = settings; _limiter = limiter; _time = time; @@ -69,6 +71,7 @@ public sealed partial class RetentionService : BackgroundService await _events.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.EventDays, ct) * Steps.Day), ct); await _hosts.DeleteOlderThanAsync(nowMs - (await _settings.GetIntAsync(SettingsService.HostDays, ct) * Steps.Day), ct); await _auth.DeleteExpiredSessionsAsync(nowMs, ct); + await _audit.PruneAsync(nowMs, ct); _limiter.Prune(); } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index 2745ce7..1f03d07 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -191,6 +191,21 @@ internal static class Schema connection.Exec("ALTER TABLE join_tokens ADD COLUMN created_by TEXT"); connection.Exec("ALTER TABLE agents ADD COLUMN added_by TEXT"); connection.Exec("ALTER TABLE probes ADD COLUMN added_by TEXT"); + connection.Exec(""" + CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + at INTEGER NOT NULL, + user_id TEXT, + actor TEXT NOT NULL, + method TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT, + detail TEXT, + remote TEXT, + success INTEGER NOT NULL + ) + """); + connection.Exec("CREATE INDEX ix_audit_time ON audit_log (at)"); connection.Exec("PRAGMA user_version = 13"); transaction.Commit(); } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuditStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuditStore.cs new file mode 100644 index 0000000..cbeb7d7 --- /dev/null +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuditStore.cs @@ -0,0 +1,33 @@ +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Storage.Sqlite; + +public sealed class SqliteAuditStore : IAuditStore +{ + private readonly SqliteDatabase _db; + + public SqliteAuditStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask AddAsync(AuditRecord record, CancellationToken ct) => + _db.ExecuteAsync( + c => c.Exec( + "INSERT INTO audit_log (at, user_id, actor, method, action, target, detail, remote, success) VALUES (@at, @uid, @actor, @method, @action, @target, @detail, @remote, @ok)", + ("@at", record.AtMs), ("@uid", record.UserId), ("@actor", record.Actor), ("@method", record.Method), ("@action", record.Action), + ("@target", record.Target), ("@detail", record.Detail), ("@remote", record.Remote), ("@ok", record.Success ? 1 : 0)), + ct); + + public ValueTask> ListAsync(AuditQuery query, CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + "SELECT id, at, user_id, actor, method, action, target, detail, remote, success FROM audit_log " + + "WHERE (@before IS NULL OR id < @before) AND (@actor IS NULL OR actor = @actor) AND (@action IS NULL OR action = @action) ORDER BY id DESC LIMIT @limit", + r => new AuditRecord(r.GetInt64(0), r.GetInt64(1), r.NullableString(2), r.GetString(3), r.GetString(4), r.GetString(5), r.NullableString(6), r.NullableString(7), r.NullableString(8), r.GetInt64(9) != 0), + ("@before", query.BeforeId), ("@actor", query.Actor), ("@action", query.Action), ("@limit", query.Limit)), + ct); + + public ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM audit_log WHERE at < @cutoff", ("@cutoff", cutoffMs)), ct); +} diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs index bf05c7f..63b4f1a 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -22,6 +22,7 @@ public static class StorageServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/tests/MoShiro.Backend.Tests/AuditTests.cs b/backend/tests/MoShiro.Backend.Tests/AuditTests.cs new file mode 100644 index 0000000..9d1f5ec --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/AuditTests.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Text.Json.Nodes; + +namespace MoShiro.Backend.Tests; + +/// 审计日志:写操作、登录成败都会记一行;谁做的(用户名或 key:令牌名)、结果、细节。 +[Collection("probe-e2e")] +public sealed class AuditTests +{ + private readonly BackendFixture _backend; + + public AuditTests(BackendFixture backend) + { + _backend = backend; + } + + /// 审计在响应发完之后才写,轮询一下。 + private async Task FindAsync(string query, Func match) + { + for (int i = 0; i < 50; i++) + { + JsonNode page = JsonNode.Parse(await _backend.Admin.GetStringAsync("/api/admin/audit?" + query))!; + if (page["items"]!.AsArray().FirstOrDefault(x => x is not null && match(x)) is { } found) + { + return found; + } + + await Task.Delay(100); + } + + return null; + } + + [Fact] + public async Task WriteActions_AreRecorded_WithWhoAndTheDetail() + { + string note = "audit-" + Guid.NewGuid().ToString("N")[..8]; + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["note"] = note })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + + JsonNode? row = await FindAsync("limit=100", x => x["detail"]?.GetValue().Contains(note, StringComparison.Ordinal) == true); + Assert.NotNull(row); + Assert.StartsWith("key:", row["actor"]!.GetValue(), StringComparison.Ordinal); + Assert.Equal("key", row["method"]!.GetValue()); + Assert.True(row["success"]!.GetValue()); + Assert.False(string.IsNullOrEmpty(row["action"]!.GetValue())); + } + + [Fact] + public async Task FailedSignIn_IsRecorded_WithTheAttemptedName_AndWithoutThePassword() + { + string name = "nobody-" + Guid.NewGuid().ToString("N")[..8]; + using HttpResponseMessage denied = await _backend.Anonymous.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "secret-pw-123" })); + Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode); + + JsonNode? row = await FindAsync("action=auth.login&limit=100", x => x["actor"]!.GetValue() == name); + Assert.NotNull(row); + Assert.False(row["success"]!.GetValue()); + Assert.DoesNotContain("secret-pw-123", row.ToJsonString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Audit_NeedsItsOwnCapability() + { + using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/api/admin/audit"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index ef7651f..1cc6d72 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,7 +1,7 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' import { useEffect, useMemo, useState, type ReactNode } from 'react' import { api } from '@shared/api/client' -import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo' import { fmtNum, fmtTime } from '@shared/format' import { translateSetting } from '@shared/i18n' @@ -10,7 +10,7 @@ import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented import { CapabilityPicker, capabilityName } from './CapabilityPicker' import { PasskeyAddForm } from './PasskeyAdd' -type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis' +type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'audit' | 'events' | 'raw' | 'analysis' const RESTART_KEYS = ['fleet.restart_enabled', 'fleet.restart_hour', 'fleet.restart_minute', 'fleet.restart_utc_offset_minutes'] @@ -636,6 +636,55 @@ function Users() { ) } +/** 审计:谁在什么时候做了什么。写操作、登录成败都在里面,保留一年。 */ +function Audit() { + const [actor, setActor] = useState('') + const [action, setAction] = useState('') + const audit = useQuery(() => api.get>('/api/admin/audit', { actor: actor || undefined, action: action || undefined, limit: 100 }), [actor, action], 10_000) + return ( +
+
+ setActor(e.target.value)} /> + setAction(e.target.value)} /> + {audit.loading && !audit.data ? : null} +
+ + {(audit.data?.items ?? []).length === 0 ? ( + 没有记录 + ) : ( +
+ setPicked(e.target.checked ? new Set(active.map((t) => t.id)) : new Set())} /> + 名称权限能力过期 创建 最后使用
{t.name} - {t.scope} + { + const next = new Set(picked) + if (e.target.checked) next.add(t.id) + else next.delete(t.id) + setPicked(next) + }} + /> {t.name} + trust.has(c)) ? 'warn' : ''}>{t.capabilities.length} 项{' '} + + {t.capabilities.slice(0, 2).map(capabilityName).join('、')} + {t.capabilities.length > 2 ? '…' : ''} + + {t.expires_at ? fmtTime(t.expires_at) : '不过期'} {fmtTime(t.created_at)} {t.revoked_at ? 已吊销 : fmtTime(t.last_used_at)} {!t.revoked_at ? ( - @@ -367,16 +414,15 @@ function Tokens() { }> + setCreated(undefined)} + actions={ + + } + >
令牌明文只显示这一次,请现在就复制。
{created.plaintext}
+

+ {created.token.capabilities.length} 项能力:{created.token.capabilities.map(capabilityName).join('、')}。{created.token.expires_at ? `${fmtTime(created.token.expires_at)} 过期。` : '不会过期。'} +

) : null} {revoking ? ( - setRevoking(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/api-tokens/${revoking.id}`), '已吊销'); tokens.reload() }}> - 使用「{revoking.name}」的程序会立刻失去访问权限。 + setRevoking(undefined)} + onConfirm={async () => { + for (const t of revoking) await run(() => api.del(`/api/admin/api-tokens/${t.id}`), `已吊销 ${t.name}`) + setPicked(new Set()) + tokens.reload() + }} + > + 使用 {revoking.map((t) => `「${t.name}」`).join('、')} 的程序会立刻失去访问权限。 ) : null} diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 5d6add3..6c0246b 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -738,7 +738,7 @@ export interface paths { /** API tokens */ get: operations["api_tokens_list"]; put?: never; - /** Create an API token (scope read or admin); the plaintext is returned only once */ + /** Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once */ post: operations["api_tokens_create"]; delete?: never; options?: never; @@ -763,6 +763,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/capabilities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The capability catalog: groups, which ones change trust, which are reserved, and the presets */ + get: operations["capabilities_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/settings": { parameters: { query?: never; @@ -943,7 +960,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Prometheus metrics (admin token) */ + /** Prometheus metrics (requires ops.metrics) */ get: operations["metrics_get"]; put?: never; post?: never; @@ -1069,12 +1086,14 @@ export interface components { ApiTokenDto: { id: string; name: string; - scope: string; + capabilities: string[]; /** Format: date-time */ created_at: unknown; /** Format: date-time */ last_used_at: unknown; /** Format: date-time */ + expires_at: unknown; + /** Format: date-time */ revoked_at: unknown; }; BackendUpdateDto: { @@ -1104,6 +1123,24 @@ export interface components { nodes: components["schemas"]["NodeBuildDto"][]; backend_update?: null | components["schemas"]["BackendUpdateDto"]; }; + CapabilityCatalogDto: { + groups: components["schemas"]["CapabilityGroupDto"][]; + presets: components["schemas"]["CapabilityPresetDto"][]; + }; + CapabilityDto: { + id: string; + trust: boolean; + reserved: boolean; + implies: string[]; + }; + CapabilityGroupDto: { + id: string; + capabilities: components["schemas"]["CapabilityDto"][]; + }; + CapabilityPresetDto: { + id: string; + capabilities: string[]; + }; ChangePasswordRequest: { password: string; }; @@ -1146,7 +1183,9 @@ export interface components { }; CreateApiTokenRequest: { name: string; - scope: string; + capabilities: string[]; + /** Format: int32 */ + expires_in_days?: null | number | string; }; CreateJoinTokenRequest: { note: null | string; @@ -1407,7 +1446,7 @@ export interface components { }; MeDto: { kind: string; - scope: string; + capabilities: string[]; user_id: null | string; username: null | string; csrf_token: null | string; @@ -4485,6 +4524,44 @@ export interface operations { }; }; }; + capabilities_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CapabilityCatalogDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; settings_list: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index c56c1fb..483df39 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -39,6 +39,8 @@ export type JoinTokenDto = Narrow export type CreatedJoinTokenDto = Narrow export type ApiTokenDto = Narrow export type CreatedApiTokenDto = Narrow +export type CapabilityCatalogDto = Narrow +export type MeDto = Narrow export type SettingDto = Narrow export type UserDto = Narrow export type EventDto = Narrow @@ -46,7 +48,6 @@ export type GapDto = Narrow export type RecomputeDto = Narrow export type RawChunkMetaDto = Narrow export type LoginResponse = Narrow -export type MeDto = Narrow export interface Page { items: T[] From 6385c1b9a361e051b5ffa178b87009cfd498b6a6 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 21:16:00 +0800 Subject: [PATCH 03/13] feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI] --- .../src/Fleet.Identity/WebAuthnAssertion.cs | 85 ++++ backend/openapi/v1.json | 477 ++++++++++++++++++ .../Admin/AuthEndpoints.cs | 114 ++++- .../src/MoShiro.Backend.Api/Common/Mappers.cs | 2 + .../Contracts/AdminDtos.cs | 12 + .../Contracts/ApiJsonContext.cs | 6 + .../ApplicationServiceCollectionExtensions.cs | 1 + .../Auth/AuthModels.cs | 3 + .../Auth/AuthService.cs | 15 +- .../Auth/LoginRateLimiter.cs | 6 +- .../Auth/PasskeyService.cs | 205 ++++++++ .../Auth/WebAuthnRegistration.cs | 177 +++++++ .../BackendOptions.cs | 6 + .../Ports/IAuthStore.cs | 14 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 24 +- .../SqliteAuthStore.cs | 31 +- .../MoShiro.Backend.Tests/CapabilityTests.cs | 4 +- .../OpenApiConformanceTests.cs | 2 +- .../MoShiro.Backend.Tests/PasskeyTests.cs | 254 ++++++++++ docs/access.md | 11 +- docs/api.md | 2 + web/admin/src/App.tsx | 165 +++++- web/admin/src/PasskeyNotice.tsx | 92 ++++ web/admin/src/auth.tsx | 11 +- web/admin/src/pages/PasskeyAdd.tsx | 57 +++ web/admin/src/pages/System.tsx | 59 ++- web/shared/api/schema.ts | 385 ++++++++++++++ web/shared/api/types.ts | 3 + web/shared/api/webauthn.ts | 87 ++++ web/shared/theme/base.css | 9 + web/shared/ui/index.tsx | 19 +- 31 files changed, 2296 insertions(+), 42 deletions(-) create mode 100644 backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs create mode 100644 backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs create mode 100644 backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs create mode 100644 web/admin/src/PasskeyNotice.tsx create mode 100644 web/admin/src/pages/PasskeyAdd.tsx create mode 100644 web/shared/api/webauthn.ts diff --git a/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs b/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs new file mode 100644 index 0000000..60a0d0d --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/WebAuthnAssertion.cs @@ -0,0 +1,85 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Fleet.Identity; + +/// +/// 验证一次 WebAuthn 断言(navigator.credentials.get 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL: +/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。 +/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。 +/// +public static class WebAuthnAssertion +{ + public const byte FlagUserPresent = 0x01; + public const byte FlagUserVerified = 0x04; + + /// 注册时保存的凭证公钥(SPKI)。 + /// 这次请求里服务端或签名人规定的挑战原文(不是 base64url)。 + /// 校验 clientDataJSON.origin。 + /// authenticatorData 里的签名计数器。 + public static bool Verify( + ReadOnlySpan publicKeySpki, ReadOnlySpan authenticatorData, ReadOnlySpan clientDataJson, ReadOnlySpan signature, + string rpId, ReadOnlySpan expectedChallenge, Func originAllowed, bool requireUserVerification, out uint signCount) + { + signCount = 0; + if (authenticatorData.Length < 37 || !CryptographicOperations.FixedTimeEquals(authenticatorData[..32], SHA256.HashData(Encoding.UTF8.GetBytes(rpId)))) + { + return false; + } + + byte flags = authenticatorData[32]; + if ((flags & FlagUserPresent) == 0 || (requireUserVerification && (flags & FlagUserVerified) == 0)) + { + return false; + } + + if (!ClientDataValid(clientDataJson, "webauthn.get", expectedChallenge, originAllowed)) + { + return false; + } + + try + { + using ECDsa key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(publicKeySpki, out _); + byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)]; + if (!key.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)) + { + return false; + } + } + catch (CryptographicException) + { + return false; + } + + signCount = (uint)((authenticatorData[33] << 24) | (authenticatorData[34] << 16) | (authenticatorData[35] << 8) | authenticatorData[36]); + return true; + } + + /// clientDataJSON 的 type、challenge、origin 都要对得上。注册和登录共用。 + public static bool ClientDataValid(ReadOnlySpan clientDataJson, string expectedType, ReadOnlySpan expectedChallenge, Func originAllowed) + { + try + { + using JsonDocument document = JsonDocument.Parse(clientDataJson.ToArray()); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || root.GetProperty("type").GetString() != expectedType + || root.GetProperty("origin").GetString() is not { } origin || !originAllowed(origin) + || root.GetProperty("challenge").GetString() is not { } challenge) + { + return false; + } + + byte[] decoded = Base64Url.DecodeFromChars(challenge); + return CryptographicOperations.FixedTimeEquals(decoded, expectedChallenge); + } + catch (Exception ex) when (ex is JsonException or KeyNotFoundException or FormatException or InvalidOperationException) + { + return false; + } + } +} diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 28a6789..439e5b5 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -1187,6 +1187,98 @@ } } }, + "/api/admin/auth/passkey/options": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "A challenge for signing in with a passkey (discoverable credential, no username needed)", + "operationId": "auth_passkey_options", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRequestOptionsDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/api/admin/auth/passkey/login": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "Sign in with a passkey assertion; same response as the password sign-in", + "operationId": "auth_passkey_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/api/admin/releases": { "post": { "tags": [ @@ -1329,6 +1421,210 @@ "x-moshiro-capability": "authenticated" } }, + "/api/admin/auth/passkeys": { + "get": { + "tags": [ + "admin-auth" + ], + "summary": "The signed-in user's passkeys", + "operationId": "auth_passkeys_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfPasskeyDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + }, + "post": { + "tags": [ + "admin-auth" + ], + "summary": "Register a passkey from a WebAuthn attestation (ES256, user verification required)", + "operationId": "auth_passkeys_register", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterPasskeyRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, + "/api/admin/auth/passkeys/options": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "A challenge for registering a new passkey (the user must be signed in)", + "operationId": "auth_passkeys_options", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyCreationOptionsDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, + "/api/admin/auth/passkeys/{id}": { + "delete": { + "tags": [ + "admin-auth" + ], + "summary": "Remove one of your own passkeys", + "operationId": "auth_passkeys_delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, "/api/admin/auth/me": { "get": { "tags": [ @@ -6821,6 +7117,27 @@ } } }, + "PageDtoOfPasskeyDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PasskeyDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, "PageDtoOfPlanDto": { "required": [ "items", @@ -7070,6 +7387,133 @@ } } }, + "PasskeyCreationOptionsDto": { + "required": [ + "challenge", + "rp_id", + "rp_name", + "user_id", + "user_name", + "exclude_credentials", + "timeout_ms" + ], + "type": "object", + "properties": { + "challenge": { + "type": "string" + }, + "rp_id": { + "type": "string" + }, + "rp_name": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "user_name": { + "type": "string" + }, + "exclude_credentials": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeout_ms": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, + "PasskeyDto": { + "required": [ + "id", + "name", + "created_at", + "last_used_at", + "transports" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "format": "date-time" + }, + "last_used_at": { + "format": "date-time" + }, + "transports": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PasskeyLoginRequest": { + "required": [ + "credential_id", + "authenticator_data", + "client_data_json", + "signature", + "return_session" + ], + "type": "object", + "properties": { + "credential_id": { + "type": "string" + }, + "authenticator_data": { + "type": "string" + }, + "client_data_json": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "return_session": { + "type": [ + "null", + "boolean" + ] + } + } + }, + "PasskeyRequestOptionsDto": { + "required": [ + "challenge", + "rp_id", + "timeout_ms" + ], + "type": "object", + "properties": { + "challenge": { + "type": "string" + }, + "rp_id": { + "type": "string" + }, + "timeout_ms": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, "PatchProbeRequest": { "required": [ "name" @@ -7482,6 +7926,39 @@ } } }, + "RegisterPasskeyRequest": { + "required": [ + "name", + "credential_id", + "attestation_object", + "client_data_json", + "transports" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "credential_id": { + "type": "string" + }, + "attestation_object": { + "type": "string" + }, + "client_data_json": { + "type": "string" + }, + "transports": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + } + } + }, "RejectedDto": { "required": [ "id", diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 6782cff..85cc869 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -19,6 +19,14 @@ public static class AuthEndpoints .AllowsAnonymous() .Api("auth_login", "Sign in with a username and password", Tag) .Returns().ProducesProblems(400, 401, 429); + open.MapPost("/auth/passkey/options", PasskeyLoginOptions) + .AllowsAnonymous() + .Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag) + .Returns().ProducesProblems(429); + open.MapPost("/auth/passkey/login", PasskeyLoginAsync) + .AllowsAnonymous() + .Api("auth_passkey_login", "Sign in with a passkey assertion; same response as the password sign-in", Tag) + .Returns().ProducesProblems(400, 401, 429); } public static void MapSession(RouteGroupBuilder guarded) @@ -27,6 +35,22 @@ public static class AuthEndpoints .Api("auth_logout", "Sign out and clear the session cookie", Tag) .Returns(StatusCodes.Status204NoContent).ProducesProblems(401) .Requires(Capability.Authenticated); + guarded.MapGet("/auth/passkeys", ListPasskeysAsync) + .Api("auth_passkeys_list", "The signed-in user's passkeys", Tag) + .Returns>().ProducesProblems(401) + .Requires(Capability.Authenticated); + guarded.MapPost("/auth/passkeys/options", PasskeyCreationOptionsAsync) + .Api("auth_passkeys_options", "A challenge for registering a new passkey (the user must be signed in)", Tag) + .Returns().ProducesProblems(400, 401, 403) + .Requires(Capability.Authenticated); + guarded.MapPost("/auth/passkeys", RegisterPasskeyAsync) + .Api("auth_passkeys_register", "Register a passkey from a WebAuthn attestation (ES256, user verification required)", Tag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.Authenticated); + guarded.MapDelete("/auth/passkeys/{id}", DeletePasskeyAsync) + .Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag) + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 404) + .Requires(Capability.Authenticated); guarded.MapGet("/auth/me", Me) .Api("auth_me", "The current identity", Tag) .Returns().ProducesProblems(401) @@ -47,8 +71,12 @@ public static class AuthEndpoints return Problems.From(http, error); } - // 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上 - bool returnSession = request.ReturnSession == true; + return IssueSession(http, login, request.ReturnSession == true, options); + } + + /// 发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。 + private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options) + { if (!returnSession) { http.Response.Cookies.Append( @@ -68,6 +96,88 @@ public static class AuthEndpoints return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse); } + /// 这次请求的 WebAuthn 依赖方:配置优先,其次面板对外地址,最后是请求自己的主机。 + private static PasskeyContext PasskeyContextOf(HttpContext http, BackendOptions options) + { + string own = $"{http.Request.Scheme}://{http.Request.Host}"; + Uri? configured = Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out Uri? uri) ? uri : null; + string rpId = !string.IsNullOrWhiteSpace(options.WebAuthnRpId) ? options.WebAuthnRpId.Trim() : configured?.Host ?? http.Request.Host.Host; + IEnumerable origins = !string.IsNullOrWhiteSpace(options.WebAuthnOrigins) + ? options.WebAuthnOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(o => o.TrimEnd('/')) + : [configured is null ? own : configured.GetLeftPart(UriPartial.Authority)]; + return new PasskeyContext(rpId, new HashSet(origins, StringComparer.Ordinal)); + } + + /// 限流。 把「取挑战」和「提交登录」分成两个桶:页面加载取挑战不该占用登录失败的额度。 + private static IResult Limited(HttpContext http, LoginRateLimiter limiter, out bool ok, string scope = "", int? limit = null) + { + string source = scope + (http.Connection.RemoteIpAddress?.ToString() ?? "unknown"); + ok = limiter.TryAcquire(source, out int retryAfter, limit); + return ok ? TypedResults.NoContent() : Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter); + } + + /// 取挑战只是页面加载和点按钮,不是猜密码:额度比登录失败宽松,挑战总量另有上限(淘汰最旧的)。 + private const int PasskeyOptionsPerMinute = 60; + + private static IResult PasskeyLoginOptions(HttpContext http, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options) + { + IResult limit = Limited(http, limiter, out bool ok, "passkey-options:", limit: PasskeyOptionsPerMinute); + if (!ok) + { + return limit; + } + + RequestOptions o = passkeys.BeginLogin(PasskeyContextOf(http, options)); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs), ApiJsonContext.Default.PasskeyRequestOptionsDto); + } + + private static async Task PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options) + { + IResult limit = Limited(http, limiter, out bool ok); + if (!ok) + { + return limit; + } + + Result result = await passkeys.LoginAsync( + new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted); + return result.TryGet(out LoginResult? login, out FleetError? error) ? IssueSession(http, login, request.ReturnSession == true, options) : Problems.From(http, error); + } + + private static async Task ListPasskeysAsync(HttpContext http, PasskeyService passkeys) + { + string userId = http.GetPrincipal().UserId ?? ""; + IReadOnlyList list = userId.Length == 0 ? [] : await passkeys.ListAsync(userId, http.RequestAborted); + return TypedResults.Json(new PageDto([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto); + } + + private static async Task PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options) + { + Result result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted); + if (!result.TryGet(out CreationOptions? o, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(new PasskeyCreationOptionsDto(o.Challenge, o.RpId, o.RpName, o.UserId, o.UserName, o.ExcludeCredentials, o.TimeoutMs), ApiJsonContext.Default.PasskeyCreationOptionsDto); + } + + private static async Task RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options) + { + Result result = await passkeys.RegisterAsync( + http.GetPrincipal(), new PasskeyRegistration(request.Name ?? "", request.CredentialId ?? "", request.AttestationObject ?? "", request.ClientDataJson ?? "", request.Transports), PasskeyContextOf(http, options), http.RequestAborted); + return result.TryGet(out PasskeyRecord? record, out FleetError? error) + ? TypedResults.Json(Mappers.ToDto(record), ApiJsonContext.Default.PasskeyDto, statusCode: StatusCodes.Status201Created) + : Problems.From(http, error); + } + + private static async Task DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id) => + http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted) + ? TypedResults.NoContent() + : Problems.Create(http, MoShiroErrors.NotFound, "no such passkey"); + private static async Task LogoutAsync(HttpContext http, AuthService auth) { string? bearer = PrincipalResolver.BearerOf(http); diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index b540d8a..8826c67 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -156,6 +156,8 @@ public static class Mappers public static JoinTokenDto ToDto(JoinTokenInfo t) => new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind)); + public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports); + public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs)); public static RawChunkMetaDto ToDto(RawChunkMeta m) => diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index 7985292..c5f2980 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -5,6 +5,18 @@ namespace MoShiro.Backend.Api; /// return_session:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 Authorization: Bearer 带上。 public sealed record LoginRequest(string Username, string Password, bool? ReturnSession); +/// 浏览器 navigator.credentials.create 要的参数;二进制字段都是 base64url。 +public sealed record PasskeyCreationOptionsDto(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList ExcludeCredentials, int TimeoutMs); + +/// 浏览器 navigator.credentials.get 要的参数(可发现凭证:不需要先输用户名)。 +public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs); + +public sealed record RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList? Transports); + +public sealed record PasskeyLoginRequest(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature, bool? ReturnSession); + +public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, IReadOnlyList Transports); + public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken); public sealed record MeDto(string Kind, IReadOnlyList Capabilities, string? UserId, string? Username, string? CsrfToken); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 30892fe..cbb44f7 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -74,6 +74,12 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreateApiTokenRequest))] [JsonSerializable(typeof(CapabilityCatalogDto))] +[JsonSerializable(typeof(PasskeyCreationOptionsDto))] +[JsonSerializable(typeof(PasskeyRequestOptionsDto))] +[JsonSerializable(typeof(RegisterPasskeyRequest))] +[JsonSerializable(typeof(PasskeyLoginRequest))] +[JsonSerializable(typeof(PasskeyDto))] +[JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreatedApiTokenDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "SettingsPatch")] diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index df33d2b..d8a1c19 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -35,6 +35,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs index 6d8cf92..60d3f4c 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs @@ -12,6 +12,9 @@ public enum PrincipalKind /// public sealed record Principal(PrincipalKind Kind, IReadOnlySet Capabilities, string? UserId, string? Username, string? CsrfToken, string? TokenId) { + /// 会话是怎么登录的():只有 passkey 登录的会话以后能提权。API key 为空。 + public string AuthMethod { get; init; } = ""; + public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet(), null, null, null, null); public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability); diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs index 7dd0bf8..aa32c1d 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs @@ -37,11 +37,22 @@ public sealed partial class AuthService return Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials"); } + return await CreateSessionAsync(user, AuthMethods.Password, ct); + } + + /// 已经确认了用户身份(例如 passkey 断言通过)之后建立会话。 + public async ValueTask> CreateSessionForAsync(string userId, string method, CancellationToken ct) => + await _store.GetUserAsync(userId, ct) is { } user + ? await CreateSessionAsync(user, method, ct) + : Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials"); + + private async ValueTask> CreateSessionAsync(UserRecord user, string method, CancellationToken ct) + { byte[] token = RandomNumberGenerator.GetBytes(32); byte[] csrf = RandomNumberGenerator.GetBytes(32); long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); long expires = now + (long)_options.SessionLifetime.TotalMilliseconds; - await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires), ct); + await _store.CreateSessionAsync(new SessionRecord(SHA256.HashData(token), user.Id, csrf, now, expires, method), ct); await _store.DeleteExpiredSessionsAsync(now, ct); return Result.Ok(new LoginResult(Base64Url.Encode(token), Base64Url.Encode(csrf), expires, user.Id, user.Username)); } @@ -60,7 +71,7 @@ public sealed partial class AuthService } UserRecord? user = await _store.GetUserAsync(session.UserId, ct); - return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null); + return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod }; } public async ValueTask AuthenticateTokenAsync(string bearer, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs b/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs index ca7fede..7b77e49 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/LoginRateLimiter.cs @@ -15,8 +15,8 @@ public sealed class LoginRateLimiter _time = time; } - /// 登记一次尝试;超限时返回 false 与建议的重试等待秒数。 - public bool TryAcquire(string source, out int retryAfterSeconds) + /// 登记一次尝试;超限时返回 false 与建议的重试等待秒数。 缺省是登录失败的额度(每分钟 次)。 + public bool TryAcquire(string source, out int retryAfterSeconds, int? limit = null) { long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); Queue queue = _attempts.GetOrAdd(source, _ => new Queue()); @@ -27,7 +27,7 @@ public sealed class LoginRateLimiter queue.Dequeue(); } - if (queue.Count >= _options.LoginAttemptsPerMinute) + if (queue.Count >= (limit ?? _options.LoginAttemptsPerMinute)) { retryAfterSeconds = (int)Math.Max(1, (60_000 - (now - queue.Peek()) + 999) / 1000); return false; diff --git a/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs new file mode 100644 index 0000000..b24b6b3 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs @@ -0,0 +1,205 @@ +using System.Security.Cryptography; +using System.Text; +using Fleet.Core; +using Fleet.Identity; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Application; + +public sealed record PasskeyRecord(string Id, string UserId, string Name, byte[] PublicKeySpki, long SignCount, IReadOnlyList Transports, long CreatedAtMs, long? LastUsedAtMs); + +/// 这次请求的 WebAuthn 依赖方:RpId 是域名,Origins 是浏览器会填进 clientDataJSON.origin 的来源。 +public sealed record PasskeyContext(string RpId, IReadOnlySet Origins); + +public sealed record CreationOptions(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList ExcludeCredentials, int TimeoutMs); + +public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs); + +public sealed record PasskeyRegistration(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList? Transports); + +public sealed record PasskeyAssertion(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature); + +/// +/// passkey 的注册与登录(WebAuthn,ES256,attestation 当 none)。挑战只在内存里,一次有效、5 分钟过期; +/// 登录是「可发现凭证」:不需要先输用户名,凭证 id 找到用户。验证断言的纯函数在 (节点以后也用)。 +/// 设计见 docs/access.md 第 3 节。 +/// +public sealed class PasskeyService +{ + private const int ChallengeBytes = 32; + private const int MaxPasskeysPerUser = 10; + private const int MaxPendingChallenges = 4096; + private static readonly TimeSpan s_challengeLifetime = TimeSpan.FromMinutes(5); + + private readonly IAuthStore _store; + private readonly AuthService _auth; + private readonly TimeProvider _time; + private readonly object _gate = new(); + private readonly Dictionary _challenges = []; + + private sealed record PendingChallenge(bool Registration, string? UserId, long ExpiresAtMs); + + public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time) + { + _store = store; + _auth = auth; + _time = time; + } + + public async ValueTask> BeginRegistrationAsync(Principal principal, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId || principal.Username is not { } username) + { + return Result.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token"); + } + + IReadOnlyList existing = await _store.ListPasskeysAsync(userId, ct); + if (existing.Count >= MaxPasskeysPerUser) + { + return ValidationError.Fail("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user"); + } + + string challenge = Issue(registration: true, userId); + return Result.Ok(new CreationOptions(challenge, context.RpId, "MoShiro", Base64Url.Encode(Encoding.UTF8.GetBytes(userId)), username, [.. existing.Select(p => p.Id)], 60_000)); + } + + public async ValueTask> RegisterAsync(Principal principal, PasskeyRegistration request, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId) + { + return Result.Fail(MoShiroErrors.Forbidden, "passkeys belong to a signed-in user, not to an API token"); + } + + string name = request.Name.Trim(); + if (name.Length is 0 or > 64) + { + return ValidationError.Fail("name", "must be 1-64 characters"); + } + + if (!TryDecode(request.AttestationObject, out byte[] attestation) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.CredentialId, out byte[] id)) + { + return ValidationError.Fail("credential", "malformed registration"); + } + + // 挑战在 clientDataJSON 里:先取出来,一次有效 + if (!TryTakeChallenge(clientData, registration: true, userId, out byte[] challenge)) + { + return Result.Fail(MoShiroErrors.ValidationFailed, "the registration challenge is unknown, expired or already used"); + } + + RegisteredCredential? credential = WebAuthnRegistration.Verify(attestation, clientData, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true); + if (credential is null || !credential.CredentialId.AsSpan().SequenceEqual(id)) + { + return Result.Fail(MoShiroErrors.ValidationFailed, "the registration could not be verified (ES256 with user verification is required)"); + } + + string credentialId = Base64Url.Encode(credential.CredentialId); + var record = new PasskeyRecord(credentialId, userId, name, credential.PublicKeySpki, credential.SignCount, request.Transports?.Take(8).ToArray() ?? [], _time.GetUtcNow().ToUnixTimeMilliseconds(), null); + return await _store.AddPasskeyAsync(record, ct) + ? Result.Ok(record) + : Result.Fail(MoShiroErrors.NameConflict, "this passkey is already registered"); + } + + public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(registration: false, null), context.RpId, 60_000); + + /// 验证断言并建立会话。失败一律 unauthorized,不区分原因。 + public async ValueTask> LoginAsync(PasskeyAssertion request, PasskeyContext context, CancellationToken ct) + { + Result denied = Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials"); + if (!TryDecode(request.CredentialId, out _) || !TryDecode(request.AuthenticatorData, out byte[] authData) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.Signature, out byte[] signature)) + { + return denied; + } + + if (!TryTakeChallenge(clientData, registration: false, null, out byte[] challenge)) + { + return denied; + } + + PasskeyRecord? passkey = await _store.FindPasskeyAsync(request.CredentialId, ct); + if (passkey is null || !WebAuthnAssertion.Verify(passkey.PublicKeySpki, authData, clientData, signature, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true, out uint signCount)) + { + return denied; + } + + // 计数器只增不减;很多同步的 passkey 一直报 0,两边都是 0 时不检查 + if ((signCount != 0 || passkey.SignCount != 0) && signCount <= passkey.SignCount) + { + return denied; + } + + await _store.TouchPasskeyAsync(passkey.Id, signCount, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); + return await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct); + } + + public ValueTask> ListAsync(string userId, CancellationToken ct) => _store.ListPasskeysAsync(userId, ct); + + public ValueTask DeleteAsync(string userId, string passkeyId, CancellationToken ct) => _store.DeletePasskeyAsync(userId, passkeyId, ct); + + private string Issue(bool registration, string? userId) + { + byte[] challenge = RandomNumberGenerator.GetBytes(ChallengeBytes); + string key = Base64Url.Encode(challenge); + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + lock (_gate) + { + if (_challenges.Count >= MaxPendingChallenges) + { + foreach (string stale in _challenges.Where(p => p.Value.ExpiresAtMs <= now).Select(p => p.Key).ToList()) + { + _challenges.Remove(stale); + } + + if (_challenges.Count >= MaxPendingChallenges) + { + _challenges.Remove(_challenges.OrderBy(p => p.Value.ExpiresAtMs).First().Key); // 淘汰最旧的 + } + } + + _challenges[key] = new PendingChallenge(registration, userId, now + (long)s_challengeLifetime.TotalMilliseconds); + } + + return key; + } + + /// 从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。 + private bool TryTakeChallenge(byte[] clientDataJson, bool registration, string? userId, out byte[] challenge) + { + challenge = []; + string? text; + try + { + using var document = System.Text.Json.JsonDocument.Parse(clientDataJson); + text = document.RootElement.GetProperty("challenge").GetString(); + } + catch (Exception ex) when (ex is System.Text.Json.JsonException or KeyNotFoundException or InvalidOperationException) + { + return false; + } + + if (text is null || !TryDecode(text, out byte[] raw)) + { + return false; + } + + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + lock (_gate) + { + if (!_challenges.Remove(Base64Url.Encode(raw), out PendingChallenge? pending) || pending.ExpiresAtMs <= now || pending.Registration != registration || pending.UserId != userId) + { + return false; + } + } + + challenge = raw; + return true; + } + + private static bool TryDecode(string text, out byte[] data) => Base64Url.TryDecode(text, out data) && data.Length > 0; +} + +public static class AuthMethods +{ + public const string Password = "password"; + public const string Passkey = "passkey"; +} diff --git a/backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs b/backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs new file mode 100644 index 0000000..a85021b --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/WebAuthnRegistration.cs @@ -0,0 +1,177 @@ +using System.Security.Cryptography; +using System.Text; +using Fleet.Identity; + +namespace MoShiro.Backend.Application; + +/// 注册成功后要保存的凭证。 +public sealed record RegisteredCredential(byte[] CredentialId, byte[] PublicKeySpki, uint SignCount); + +/// +/// 解析并校验 WebAuthn 注册(navigator.credentials.create 的结果)。只接受 ES256;attestation 一律当 none 处理: +/// 不验设备证明,信任浏览器的实现(docs/access.md)。CBOR 解析只在 Backend 里,节点不带。 +/// +public static class WebAuthnRegistration +{ + public static RegisteredCredential? Verify( + ReadOnlySpan attestationObject, ReadOnlySpan clientDataJson, string rpId, ReadOnlySpan expectedChallenge, + Func originAllowed, bool requireUserVerification) + { + if (!WebAuthnAssertion.ClientDataValid(clientDataJson, "webauthn.create", expectedChallenge, originAllowed)) + { + return null; + } + + try + { + if (new MiniCbor(attestationObject.ToArray()).ReadValue() is not Dictionary attestation + || attestation.GetValueOrDefault("authData") is not byte[] authData || authData.Length < 55) + { + return null; + } + + if (!CryptographicOperations.FixedTimeEquals(authData.AsSpan(0, 32), SHA256.HashData(Encoding.UTF8.GetBytes(rpId)))) + { + return null; + } + + byte flags = authData[32]; + const byte attestedData = 0x40; + if ((flags & WebAuthnAssertion.FlagUserPresent) == 0 || (flags & attestedData) == 0 || (requireUserVerification && (flags & WebAuthnAssertion.FlagUserVerified) == 0)) + { + return null; + } + + uint signCount = (uint)((authData[33] << 24) | (authData[34] << 16) | (authData[35] << 8) | authData[36]); + int idLength = (authData[53] << 8) | authData[54]; + if (idLength is 0 or > 1023 || authData.Length < 55 + idLength) + { + return null; + } + + byte[] credentialId = authData[55..(55 + idLength)]; + var reader = new MiniCbor(authData[(55 + idLength)..]); + if (reader.ReadValue() is not Dictionary cose + || cose.GetValueOrDefault(1L) is not long kty || kty != 2 + || cose.GetValueOrDefault(3L) is not long alg || alg != -7 + || cose.GetValueOrDefault(-1L) is not long curve || curve != 1 + || cose.GetValueOrDefault(-2L) is not byte[] { Length: 32 } x + || cose.GetValueOrDefault(-3L) is not byte[] { Length: 32 } y) + { + return null; + } + + using ECDsa key = ECDsa.Create(new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = x, Y = y } }); + return new RegisteredCredential(credentialId, key.ExportSubjectPublicKeyInfo(), signCount); + } + catch (Exception ex) when (ex is InvalidDataException or CryptographicException or ArgumentException or IndexOutOfRangeException) + { + return null; + } + } +} + +/// 够用的 CBOR 读取器(RFC 8949):整数、字节串、文本串、数组、映射、布尔和 null。输入有上限,嵌套有深度限制。 +internal sealed class MiniCbor +{ + private const int MaxDepth = 8; + private const int MaxItems = 64; + + private readonly byte[] _data; + private int _position; + + public MiniCbor(byte[] data) + { + _data = data; + } + + public object? ReadValue(int depth = 0) + { + if (depth > MaxDepth || _position >= _data.Length) + { + throw new InvalidDataException("cbor too deep or truncated"); + } + + byte initial = _data[_position++]; + int major = initial >> 5; + long argument = ReadArgument(initial & 0x1F); + switch (major) + { + case 0: + return argument; + case 1: + return -1 - argument; + case 2: + return Take(argument); + case 3: + return Encoding.UTF8.GetString(Take(argument)); + case 4: + { + CheckCount(argument); + var list = new List(); + for (long i = 0; i < argument; i++) + { + list.Add(ReadValue(depth + 1)); + } + + return list; + } + + case 5: + { + CheckCount(argument); + var map = new Dictionary(); + for (long i = 0; i < argument; i++) + { + object key = ReadValue(depth + 1) ?? throw new InvalidDataException("null map key"); + map[key] = ReadValue(depth + 1)!; + } + + return map; + } + + case 7: + return initial switch { 0xF4 => false, 0xF5 => true, 0xF6 => null, _ => throw new InvalidDataException("unsupported simple value") }; + default: + throw new InvalidDataException("unsupported cbor type"); + } + } + + private long ReadArgument(int info) + { + if (info < 24) + { + return info; + } + + int length = info switch { 24 => 1, 25 => 2, 26 => 4, 27 => 8, _ => throw new InvalidDataException("indefinite lengths are not supported") }; + byte[] bytes = Take(length); + long value = 0; + foreach (byte b in bytes) + { + value = (value << 8) | b; + } + + return value < 0 ? throw new InvalidDataException("argument too large") : value; + } + + private byte[] Take(long length) + { + if (length < 0 || length > _data.Length - _position) + { + throw new InvalidDataException("cbor truncated"); + } + + byte[] result = _data[_position..(_position + (int)length)]; + _position += (int)length; + return result; + } + + private static void CheckCount(long count) + { + if (count > MaxItems) + { + throw new InvalidDataException("cbor collection too large"); + } + } +} diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 27e8cf0..d4c8c29 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -26,6 +26,12 @@ public sealed class BackendOptions public int Pbkdf2Iterations { get; set; } = 600_000; + /// WebAuthn 的依赖方域名;缺省取 的主机,没有就取请求的主机。passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。 + public string? WebAuthnRpId { get; set; } + + /// 允许的来源(逗号分隔,如 https://panel.example.com);缺省是 的来源,没有就取请求自己的来源。 + public string? WebAuthnOrigins { get; set; } + /// 会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。 public bool SecureCookies { get; set; } = true; diff --git a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs index 3fc8b97..4ec3401 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs @@ -2,7 +2,7 @@ namespace MoShiro.Backend.Application; public sealed record UserRecord(string Id, string Username, byte[] PasswordHash, byte[] PasswordSalt, int Iterations, long CreatedAtMs); -public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs); +public sealed record SessionRecord(byte[] IdSha256, string UserId, byte[] CsrfToken, long CreatedAtMs, long ExpiresAtMs, string AuthMethod = AuthMethods.Password); public sealed record ApiTokenRecord(string Id, string Name, IReadOnlyList Capabilities, byte[] TokenSha256, long CreatedAtMs, long? LastUsedAtMs, long? ExpiresAtMs, long? RevokedAtMs); @@ -31,6 +31,18 @@ public interface IAuthStore ValueTask CreateApiTokenAsync(ApiTokenRecord token, CancellationToken ct); + /// 凭证 id 已经存在时返回 false。 + ValueTask AddPasskeyAsync(PasskeyRecord passkey, CancellationToken ct); + + ValueTask FindPasskeyAsync(string credentialId, CancellationToken ct); + + ValueTask> ListPasskeysAsync(string userId, CancellationToken ct); + + /// 只能删自己的。 + ValueTask DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct); + + ValueTask TouchPasskeyAsync(string credentialId, long signCount, long nowMs, CancellationToken ct); + ValueTask FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct); ValueTask> ListApiTokensAsync(CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index 79ec4e1..b8c1d26 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 internal static class Schema { - public const int CurrentVersion = 11; + public const int CurrentVersion = 12; public static void Migrate(SqliteConnection connection) { @@ -161,6 +161,28 @@ internal static class Schema connection.Exec("PRAGMA user_version = 11"); transaction.Commit(); } + + if (version < 12) + { + // passkey 登录:每个用户可以注册若干 passkey(凭证公钥 SPKI、签名计数器);会话记录是怎么登录的,只有 passkey 登录的会话以后能提权 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec(""" + CREATE TABLE passkeys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + last_used_at INTEGER + ) + """); + connection.Exec("CREATE INDEX ix_passkeys_user ON passkeys (user_id)"); + connection.Exec("ALTER TABLE sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'password'"); + connection.Exec("PRAGMA user_version = 12"); + transaction.Commit(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs index 2498413..1fccdb6 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs @@ -46,13 +46,13 @@ public sealed class SqliteAuthStore : IAuthStore public ValueTask CreateSessionAsync(SessionRecord session, CancellationToken ct) => _db.ExecuteAsync( c => c.Exec( - "INSERT INTO sessions (id_sha256, user_id, csrf_token, created_at, expires_at) VALUES (@id, @u, @csrf, @c, @e)", - ("@id", session.IdSha256), ("@u", session.UserId), ("@csrf", session.CsrfToken), ("@c", session.CreatedAtMs), ("@e", session.ExpiresAtMs)), + "INSERT INTO sessions (id_sha256, user_id, csrf_token, created_at, expires_at, auth_method) VALUES (@id, @u, @csrf, @c, @e, @m)", + ("@id", session.IdSha256), ("@u", session.UserId), ("@csrf", session.CsrfToken), ("@c", session.CreatedAtMs), ("@e", session.ExpiresAtMs), ("@m", session.AuthMethod)), ct); public ValueTask GetSessionAsync(byte[] idSha256, CancellationToken ct) => _db.ReadAsync( - c => c.QueryOne("SELECT id_sha256, user_id, csrf_token, created_at, expires_at FROM sessions WHERE id_sha256 = @id", r => new SessionRecord(r.Bytes(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.GetInt64(4)), ("@id", idSha256)), + c => c.QueryOne("SELECT id_sha256, user_id, csrf_token, created_at, expires_at, auth_method FROM sessions WHERE id_sha256 = @id", r => new SessionRecord(r.Bytes(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.GetInt64(4), r.GetString(5)), ("@id", idSha256)), ct); public ValueTask DeleteSessionAsync(byte[] idSha256, CancellationToken ct) => @@ -68,6 +68,27 @@ public sealed class SqliteAuthStore : IAuthStore ("@id", token.Id), ("@n", token.Name), ("@caps", StorageJson.ToJson(token.Capabilities)), ("@h", token.TokenSha256), ("@at", token.CreatedAtMs), ("@exp", token.ExpiresAtMs)), ct); + public ValueTask AddPasskeyAsync(PasskeyRecord passkey, CancellationToken ct) => + _db.WriteAsync( + c => c.Scalar("SELECT COUNT(*) FROM passkeys WHERE id = @id", ("@id", passkey.Id)) > 0 + ? false + : c.Exec( + "INSERT INTO passkeys (id, user_id, name, public_key, sign_count, transports, created_at) VALUES (@id, @u, @n, @k, @s, @t, @at)", + ("@id", passkey.Id), ("@u", passkey.UserId), ("@n", passkey.Name), ("@k", passkey.PublicKeySpki), ("@s", passkey.SignCount), ("@t", StorageJson.ToJson(passkey.Transports)), ("@at", passkey.CreatedAtMs)) == 1, + ct); + + public ValueTask FindPasskeyAsync(string credentialId, CancellationToken ct) => + _db.ReadAsync(c => c.QueryOne($"SELECT {PasskeyColumns} FROM passkeys WHERE id = @id", ReadPasskey, ("@id", credentialId)), ct); + + public ValueTask> ListPasskeysAsync(string userId, CancellationToken ct) => + _db.ReadAsync>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys WHERE user_id = @u ORDER BY created_at, id", ReadPasskey, ("@u", userId)), ct); + + public ValueTask DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct) => + _db.WriteAsync(c => c.Exec("DELETE FROM passkeys WHERE id = @id AND user_id = @u", ("@id", credentialId), ("@u", userId)) == 1, ct); + + public ValueTask TouchPasskeyAsync(string credentialId, long signCount, long nowMs, CancellationToken ct) => + _db.ExecuteAsync(c => c.Exec("UPDATE passkeys SET sign_count = @s, last_used_at = @now WHERE id = @id", ("@id", credentialId), ("@s", signCount), ("@now", nowMs)), ct); + public ValueTask FindApiTokenAsync(byte[] tokenSha256, CancellationToken ct) => _db.ReadAsync(c => c.QueryOne($"SELECT {TokenColumns} FROM api_tokens WHERE token_sha256 = @h", ReadToken, ("@h", tokenSha256)), ct); @@ -80,6 +101,10 @@ public sealed class SqliteAuthStore : IAuthStore public ValueTask TouchApiTokenAsync(string id, long nowMs, CancellationToken ct) => _db.ExecuteAsync(c => c.Exec("UPDATE api_tokens SET last_used_at = @now WHERE id = @id", ("@id", id), ("@now", nowMs)), ct); + private const string PasskeyColumns = "id, user_id, name, public_key, sign_count, transports, created_at, last_used_at"; + + private static PasskeyRecord ReadPasskey(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetString(2), r.Bytes(3), r.GetInt64(4), StorageJson.Strings(r.GetString(5)), r.GetInt64(6), r.NullableLong(7)); + private static UserRecord ReadUser(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.Bytes(2), r.Bytes(3), r.GetInt32(4), r.GetInt64(5)); private static ApiTokenRecord ReadToken(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), StorageJson.Strings(r.GetString(2)), r.Bytes(3), r.GetInt64(4), r.NullableLong(5), r.NullableLong(6), r.NullableLong(7)); diff --git a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs index 5d0f7f0..ec58046 100644 --- a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs @@ -35,8 +35,8 @@ public sealed class CapabilityTests } } - // 只有登录是匿名的 - Assert.Equal(["/api/admin/auth/login"], endpoints.Where(e => e.Metadata.GetMetadata()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().ToArray()); + // 只有登录(密码和 passkey)是匿名的 + Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options"], endpoints.Where(e => e.Metadata.GetMetadata()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray()); } [Fact] diff --git a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs index d976acc..b675b81 100644 --- a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs @@ -122,7 +122,7 @@ public sealed partial class OpenApiConformanceTests public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures() { JsonObject doc = await DocumentAsync(); - foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path != "/api/admin/auth/login")) + foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options"))) { JsonObject responses = operation["responses"]!.AsObject(); Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401"); diff --git a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs new file mode 100644 index 0000000..2225029 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs @@ -0,0 +1,254 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Fleet.Core; + +namespace MoShiro.Backend.Tests; + +/// 软件实现的 WebAuthn 认证器(ES256),用来在测试里走完整的注册和登录。 +internal sealed class SoftwarePasskey : IDisposable +{ + private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + private uint _counter; + + public SoftwarePasskey(string rpId, string origin) + { + RpId = rpId; + Origin = origin; + CredentialId = RandomNumberGenerator.GetBytes(32); + } + + public string RpId { get; } + + public string Origin { get; } + + public byte[] CredentialId { get; } + + public string Id => Base64Url.Encode(CredentialId); + + public JsonObject Register(string name, string challenge, byte flags = 0x45, string? origin = null, string? rpId = null) + { + ECParameters p = _key.ExportParameters(false); + byte[] cose = [.. MapHead(5), .. Int(1), .. Int(2), .. Int(3), .. Int(-7), .. Int(-1), .. Int(1), .. Int(-2), .. Bytes(p.Q.X!), .. Int(-3), .. Bytes(p.Q.Y!)]; + byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(rpId ?? RpId)), flags, .. Counter(_counter), .. new byte[16], (byte)(CredentialId.Length >> 8), (byte)CredentialId.Length, .. CredentialId, .. cose]; + byte[] attestation = [.. MapHead(3), .. Text("fmt"), .. Text("none"), .. Text("attStmt"), .. MapHead(0), .. Text("authData"), .. Bytes(authData)]; + return new JsonObject + { + ["name"] = name, + ["credential_id"] = Id, + ["attestation_object"] = Base64Url.Encode(attestation), + ["client_data_json"] = Base64Url.Encode(ClientData("webauthn.create", challenge, origin ?? Origin)), + ["transports"] = new JsonArray("internal"), + }; + } + + public JsonObject Assert(string challenge, uint? counter = null, byte flags = 0x05, string? origin = null, string? rpId = null, bool tamper = false) + { + _counter = counter ?? _counter + 1; + byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(rpId ?? RpId)), flags, .. Counter(_counter)]; + byte[] clientData = ClientData("webauthn.get", challenge, origin ?? Origin); + byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); + if (tamper) + { + signature[^1] ^= 0x01; + } + + return new JsonObject + { + ["credential_id"] = Id, + ["authenticator_data"] = Base64Url.Encode(authData), + ["client_data_json"] = Base64Url.Encode(clientData), + ["signature"] = Base64Url.Encode(signature), + ["return_session"] = true, + }; + } + + public void Dispose() => _key.Dispose(); + + private static byte[] ClientData(string type, string challenge, string origin) => + Encoding.UTF8.GetBytes($"{{\"type\":\"{type}\",\"challenge\":\"{challenge}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}"); + + private static byte[] Counter(uint value) => [(byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value]; + + // 最小的 CBOR 编码器:只写测试需要的几种 + private static byte[] Head(int major, int length) => + length < 24 ? [(byte)((major << 5) | length)] : length < 256 ? [(byte)((major << 5) | 24), (byte)length] : [(byte)((major << 5) | 25), (byte)(length >> 8), (byte)length]; + + private static byte[] MapHead(int count) => Head(5, count); + + private static byte[] Int(int value) => value >= 0 ? Head(0, value) : Head(1, -1 - value); + + private static byte[] Bytes(byte[] data) => [.. Head(2, data.Length), .. data]; + + private static byte[] Text(string text) => [.. Head(3, Encoding.UTF8.GetByteCount(text)), .. Encoding.UTF8.GetBytes(text)]; +} + +/// passkey 登录:注册、登录、拒绝各种错误的断言、只能管理自己的 passkey。设计见 docs/access.md 第 3 节。 +[Collection("probe-e2e")] +public sealed class PasskeyTests +{ + private readonly BackendFixture _backend; + + public PasskeyTests(BackendFixture backend) + { + _backend = backend; + } + + private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority); + + private string RpId => _backend.BaseAddress.Host; + + private async Task PasswordSessionAsync(string user, string password) + { + HttpClient client = _backend.NewClient(); + using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = user, ["password"] = password, ["return_session"] = true })); + string body = await login.Content.ReadAsStringAsync(); + Assert.True(login.IsSuccessStatusCode, body); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(body)!["session_token"]!.GetValue()); + return client; + } + + private static async Task JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + + private async Task RegistrationChallengeAsync(HttpClient session) + { + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null); + JsonNode body = await JsonOf(options); + Assert.Equal(HttpStatusCode.OK, options.StatusCode); + Assert.Equal(RpId, body["rp_id"]!.GetValue()); + return body["challenge"]!.GetValue(); + } + + private async Task LoginChallengeAsync() + { + using HttpResponseMessage options = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/options", null); + Assert.Equal(HttpStatusCode.OK, options.StatusCode); + return (await JsonOf(options))["challenge"]!.GetValue(); + } + + private async Task RegisterAsync(HttpClient session, string name = "laptop") + { + var passkey = new SoftwarePasskey(RpId, Origin); + using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register(name, await RegistrationChallengeAsync(session)))); + Assert.True(registered.StatusCode == HttpStatusCode.Created, await registered.Content.ReadAsStringAsync()); + return passkey; + } + + private async Task LoginStatusAsync(JsonObject assertion) + { + using HttpResponseMessage response = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/login", BackendFixture.Json(assertion)); + return response.StatusCode; + } + + [Fact] + public async Task APasskey_CanBeRegistered_ThenSignsInWithoutAUsername_AndTheSessionRemembersHowItWasMade() + { + using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using SoftwarePasskey passkey = await RegisterAsync(session); + + JsonNode list = await JsonOf(await session.GetAsync("/api/admin/auth/passkeys")); + Assert.Contains(list["items"]!.AsArray(), p => p!["id"]!.GetValue() == passkey.Id && p["name"]!.GetValue() == "laptop"); + + using HttpResponseMessage login = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/login", BackendFixture.Json(passkey.Assert(await LoginChallengeAsync()))); + JsonNode body = await JsonOf(login); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + Assert.Equal(BackendFixture.AdminUser, body["username"]!.GetValue()); + Assert.NotNull(body["session_token"]); + Assert.Equal("no-store", login.Headers.CacheControl?.ToString()); + + // 拿这个会话令牌就是登录状态,能力和密码登录一样 + using var signedIn = new HttpClient { BaseAddress = _backend.BaseAddress }; + signedIn.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", body["session_token"]!.GetValue()); + JsonNode me = await JsonOf(await signedIn.GetAsync("/api/admin/auth/me")); + Assert.Equal(BackendFixture.AdminUser, me["username"]!.GetValue()); + Assert.Contains(me["capabilities"]!.AsArray(), c => c!.GetValue() == "fleet.read"); + } + + [Fact] + public async Task Assertions_AreRejected_WhenTheChallengeOriginRpOrSignatureIsWrong_OrTheChallengeIsReused() + { + using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using SoftwarePasskey passkey = await RegisterAsync(session); + + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert("bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战 + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), origin: "https://evil.example"))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), rpId: "evil.example"))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), tamper: true))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), flags: 0x01))); // 没有用户验证(UV) + + // 挑战只能用一次:同一份断言重放 + string challenge = await LoginChallengeAsync(); + JsonObject assertion = passkey.Assert(challenge); + Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(assertion)); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(assertion)); + + // 凭证不存在 + using var stranger = new SoftwarePasskey(RpId, Origin); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(stranger.Assert(await LoginChallengeAsync()))); + // 缺字段不会崩 + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(new JsonObject())); + } + + [Fact] + public async Task TheSignCounter_OnlyMovesForward_WhenTheAuthenticatorUsesOne() + { + using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using SoftwarePasskey passkey = await RegisterAsync(session); + + Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5))); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5))); // 克隆或回放 + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 3))); + Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 6))); + } + + [Fact] + public async Task Registration_NeedsASignedInUser_AValidAttestation_AndAFreshChallenge() + { + // API 令牌不是用户:没有 passkey 可言 + using HttpResponseMessage asToken = await _backend.Admin.PostAsync("/api/admin/auth/passkeys/options", null); + Assert.Equal(HttpStatusCode.Forbidden, asToken.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/auth/passkeys/options", null)).StatusCode); + + using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using var passkey = new SoftwarePasskey(RpId, Origin); + async Task TryAsync(JsonObject body) + { + using HttpResponseMessage response = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(body)); + return response.StatusCode; + } + + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", "bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战 + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), origin: "https://evil.example"))); + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), rpId: "evil.example"))); + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), flags: 0x41))); // 没有 UV + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("x", await RegistrationChallengeAsync(session), flags: 0x05))); // 没有凭证数据 + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register(" ", await RegistrationChallengeAsync(session)))); // 名字为空 + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new JsonObject { ["name"] = "x", ["credential_id"] = "AA", ["attestation_object"] = "AAAA", ["client_data_json"] = "AAAA" })); // 垃圾 + + // 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册 + string challenge = await RegistrationChallengeAsync(session); + Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge))); + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("again", challenge))); // 挑战只能用一次 + Assert.Equal(HttpStatusCode.Conflict, await TryAsync(passkey.Register("again", await RegistrationChallengeAsync(session)))); + Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new SoftwarePasskey(RpId, Origin).Register("x", await LoginChallengeAsync()))); + } + + [Fact] + public async Task APasskey_CanBeDeleted_OnlyByItsOwner_AndThenNoLongerSignsIn() + { + using HttpClient admin = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using SoftwarePasskey passkey = await RegisterAsync(admin, "phone"); + + string other = _backend.UniqueName("user"); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = other, ["password"] = "correct horse battery" })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + using HttpClient intruder = await PasswordSessionAsync(other, "correct horse battery"); + Assert.Equal(HttpStatusCode.NotFound, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); + Assert.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue() == passkey.Id); + + Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync()))); + } +} diff --git a/docs/access.md b/docs/access.md index 8b02422..b9f3fcb 100644 --- a/docs/access.md +++ b/docs/access.md @@ -70,6 +70,15 @@ - 建议每个账户至少注册两个 passkey(手机加硬件密钥)。 - **离线恢复密钥**:创建 owner 时同时生成一把离线的 ECDSA 恢复密钥,作为 `type=ecdsa`、`recovery=true` 的 owner 凭证写进操作员集合;私钥打印或放进密码管理器,平时不用。节点强制它**只能签操作员集合更新**,不能签委托、入网券和吊销表,所以即使被偷走,也加不了节点;它的用处是 passkey 全部丢失时签一版新的操作员集合、重新加 passkey,节点不用重装。 +### 3.1 passkey 登录的实现(已完成) + +- 流程:登录页「使用 Passkey 登录」→ `POST /api/admin/auth/passkey/options` 取挑战 → 浏览器 `navigator.credentials.get`(可发现凭证,不用输用户名)→ `POST /api/admin/auth/passkey/login` 提交断言 → 建立会话(Cookie,或跨域前端用 `return_session`)。注册:已登录用户在「系统 → 用户 → 我的 Passkey」添加,`POST /api/admin/auth/passkeys/options` 加 `POST /api/admin/auth/passkeys`;每个用户最多 10 个。 +- 校验:挑战在内存里,一次有效、5 分钟过期,并且区分注册和登录、绑定用户;`rpIdHash`、UP 和 UV 标志、`clientDataJSON` 的 type、challenge、origin、ES256 签名;签名计数器只增不减(两边都是 0 的同步型 passkey 不检查)。登录失败一律 401,不区分原因,和密码登录共用限流。 +- 验证断言的纯函数是 `Fleet.Identity.WebAuthnAssertion`(只用 BCL,节点以后验证 passkey 签出的委托也用它);注册里的 CBOR 和 attestation 解析在 Backend(`WebAuthnRegistration`),attestation 一律当 `none`,节点不带 CBOR。 +- 依赖方(RP):`MoShiro__WebAuthnRpId`(缺省取 `PublicUrl` 的主机,没有就取请求的主机)和 `MoShiro__WebAuthnOrigins`(缺省取 `PublicUrl` 的来源)。**passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。**只有和后端同站的页面能用 passkey 登录,管理端在「添加其他后端」(跨域)时不显示这个入口。 +- 会话记录登录方式(`password` 或 `passkey`,`sessions.auth_method`),只有 passkey 登录的会话以后能提权;API key 不是用户,不能注册 passkey。 +- 测试:`SoftwarePasskey` 在测试里模拟认证器走完整的注册和登录,覆盖错误的挑战、来源、RP、签名、缺少 UV、挑战重放、计数器回退、只能管理自己的 passkey;管理端用 Chromium 的 WebAuthn 虚拟认证器验证过注册和登录。 + ## 4. 提权窗口(信任类能力各管各的) | 窗口 | 覆盖 | 默认时长 | 行为 | @@ -273,7 +282,7 @@ ## 12. 实施顺序(每步可独立验证和推送) 1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 v11 重建 `api_tokens`)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。`events.read` 对应现有的 `GET /api/admin/events`,不是预留。 -2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。 +2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。**passkey 登录已实现**(见第 3.1 节);提权窗口、审计日志、归属列还没做。 3. **操作员集合与创世公钥固定**:`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。 4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。 5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。 diff --git a/docs/api.md b/docs/api.md index 3f0a577..7756dfc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -141,6 +141,8 @@ | GET | `/api/admin/events?agent_id=&kind=&from=&to=` | 事件 | | GET | `/api/admin/analysis/recompute` | 指标重算进度 | | GET/POST/DELETE | `/api/admin/api-tokens` | 创建时勾选能力(`capabilities`,只能是创建者自己拥有能力的子集,写蕴含读)和可选的有效期(`expires_in_days`);明文只显示一次 | +| POST | `/api/admin/auth/passkey/options`、`/api/admin/auth/passkey/login` | 匿名(限流)。passkey 登录:先取挑战,再提交断言;响应同密码登录 | +| GET/POST/DELETE | `/api/admin/auth/passkeys`、`/auth/passkeys/options`、`/auth/passkeys/{id}` | 已登录用户管理自己的 passkey:列出、取注册挑战、注册、删除(API key 不能用) | | GET | `/api/admin/capabilities` | 能力目录:分组、哪些改变信任、哪些是预留、依赖关系和预设(只读、运维、接入、CI 发布、全部) | | GET/PATCH | `/api/admin/settings` | 站点公开或私有、是否对访客显示构建信息(`site.show_build`)、保留期、节点定时重启(`fleet.restart_enabled`、`fleet.restart_hour`、`fleet.restart_minute`、`fleet.restart_utc_offset_minutes`,默认开、04:30、UTC+8;改完立即通知在线节点)等 | | GET | `/api/admin/build-info` | 后端和每个 Agent、Probe 的版本、tag、提交、构建时间、系统,以及节点宿主机更新器报告的更新状态(管理端「系统 → 版本」) | diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index f1d5c4a..a0c1d1c 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -1,10 +1,13 @@ -import { FlaskConical, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react' -import { useState, type FormEvent } from 'react' +import { FlaskConical, KeyRound, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react' +import { useEffect, useRef, useState, type FormEvent } from 'react' import { NavLink, Navigate, Route, Routes } from 'react-router-dom' +import { ApiError } from '@shared/api/client' import { activateSource, getBase, listSources } from '@shared/api/source' -import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui' +import { conditionalSupported, passkeySupported } from '@shared/api/webauthn' +import { describeError, Field, Segmented, SourceButton, Spinner, ThemeToggle } from '@shared/ui' import { useAuth } from './auth' import { BackendUpdateNotice } from './BackendUpdateNotice' +import { PasskeyNotice } from './PasskeyNotice' import { AgentDetail } from './pages/AgentDetail' import { Agents } from './pages/Agents' import { Probes } from './pages/Probes' @@ -12,9 +15,9 @@ import { System } from './pages/System' import { Tasks } from './pages/Tasks' import { Tests } from './pages/Tests' -/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边都是用户名 + 密码。 */ +/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边选登录方式:账号密码,或者 Passkey(只有页面自己所在的后端有)。 */ function Login() { - const { login } = useAuth() + const { login, loginWithPasskey } = useAuth() const active = getBase() const saved = listSources() const [adding, setAdding] = useState(false) @@ -47,6 +50,94 @@ function Login() { } } + // passkey 绑定在后端的域名上:只有页面自己所在的后端能用,别的后端(跨域)不显示 + const canPasskey = passkeySupported() && !adding && !active + // 登录方式:记住上次用的;浏览器不支持或者是别的后端就只有账号密码 + const [method, setMethod] = useState<'password' | 'passkey'>(() => { + try { + return localStorage.getItem('moshiro.login_method') === 'passkey' ? 'passkey' : 'password' + } catch { + return 'password' + } + }) + const pickMethod = (next: 'password' | 'passkey') => { + setMethod(next) + setError('') + try { + localStorage.setItem('moshiro.login_method', next) + } catch { + // 记不住也只是下次回到默认 + } + } + const usingPasskey = canPasskey && method === 'passkey' + // 自动填充只在「账号密码」页有用(它依附在用户名输入框上) + const autofillActive = canPasskey && method === 'password' + // 重新开始自动填充登录(点按钮的弹窗会顶掉它,取消之后要重新挂上) + const [round, setRound] = useState(0) + const retry = () => { + if (retryOnFocus.current) { + retryOnFocus.current = false + setRound((n) => n + 1) + } + } + const conditional = useRef(null) // 正在挂着的自动填充请求:点按钮之前要先取消,浏览器同一时间只允许一个 WebAuthn 请求 + // 自动填充是否可用:unknown 还在检测,yes 已经挂上(点用户名框会列出已保存的 passkey),no 浏览器不支持 + const [autofill, setAutofill] = useState<'unknown' | 'yes' | 'no'>('unknown') + const retryOnFocus = useRef(false) // 服务器拒绝了自动填充选的 passkey:等用户再点输入框时才重新开始,不自己循环 + const usePasskey = async () => { + setBusy(true) + setError('') + conditional.current?.abort() + await new Promise((resolve) => setTimeout(resolve, 0)) + try { + await loginWithPasskey() + } catch (err) { + // 用户取消(NotAllowedError)不当成错误 + if (!(err instanceof DOMException && err.name === 'NotAllowedError')) setError(describeError(err)) + } finally { + setBusy(false) + setRound((n) => n + 1) + } + } + + // passkey 自动填充:点用户名输入框,浏览器会把保存的 passkey(Chrome 里是 Google 密码管理工具)列在下拉里,选一个就直接登录。 + // 挑战 5 分钟过期,所以每 4 分钟换一个 + useEffect(() => { + if (!autofillActive) return undefined + const abort = new AbortController() + conditional.current = abort + let timer: ReturnType | undefined + let expired = false // 只有「挑战到期」才重新开始;组件卸载、依赖变化时的取消不能再触发重来(否则会形成循环) + void conditionalSupported().then(async (ok) => { + if (abort.signal.aborted) return + setAutofill(ok ? 'yes' : 'no') + if (!ok) return + timer = setTimeout(() => { + expired = true + abort.abort() + }, 4 * 60_000) + try { + await loginWithPasskey({ conditional: true, signal: abort.signal }) + } catch (err) { + if (timer) clearTimeout(timer) + if (abort.signal.aborted) { + if (expired) setRound((n) => n + 1) + return + } + // 取消、被弹窗顶掉、浏览器不允许都不是错误;服务端拒绝(含限流)才显示 + if (!(err instanceof DOMException)) { + retryOnFocus.current = true + setError(err instanceof ApiError && err.status === 401 ? '这个 Passkey 在服务器上已经不存在或已失效(可能被删除了),请换一个,或者用密码登录' : describeError(err)) + } + } + }) + return () => { + abort.abort() + if (timer) clearTimeout(timer) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autofillActive, round]) + const rows = [{ base: '', label: location.host }, ...saved.map((x) => ({ base: x.base, label: x.base.replace(/^https?:\/\//i, '') }))] const title = adding ? '添加后端' : active ? active.replace(/^https?:\/\//i, '') : location.origin @@ -91,21 +182,56 @@ function Login() {

{title}

- {adding ? ( - - setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required /> - + {canPasskey ? ( + ) : null} - - setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required /> - - - setPassword(e.target.value)} autoComplete="current-password" required /> - - {error ?
{error}
: null} - + {usingPasskey ? ( + <> +

用指纹、人脸或安全密钥登录,不用输用户名和密码。点下面的按钮,浏览器会让你选一个这个站点的 Passkey。

+ {error ?
{error}
: null} + +

+ 还没有 Passkey?先用「账号密码」登录,到「系统 → 用户 → 我的 Passkey」添加。 +

+ + ) : ( + <> + {adding ? ( + + setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required /> + + ) : null} + + setUsername(e.target.value)} autoComplete={autofillActive ? 'username webauthn' : 'username'} autoFocus={!adding} required onFocus={retry} onClick={retry} /> + + {canPasskey ? ( +

+ {autofill === 'yes' + ? '点一下用户名框:如果浏览器里已经保存了这个站点的 Passkey,会列在下拉里,选一个就直接登录。还没有的话,先用密码登录,到「系统 → 用户」添加。' + : autofill === 'no' + ? '这个浏览器不支持从输入框里选 Passkey,请切到上面的「Passkey」。' + : ''} +

+ ) : null} + + setPassword(e.target.value)} autoComplete="current-password" required /> + + {error ?
{error}
: null} + + + )}
@@ -164,6 +290,7 @@ export function App() {
+ } /> } /> diff --git a/web/admin/src/PasskeyNotice.tsx b/web/admin/src/PasskeyNotice.tsx new file mode 100644 index 0000000..b17c18f --- /dev/null +++ b/web/admin/src/PasskeyNotice.tsx @@ -0,0 +1,92 @@ +import { KeyRound } from 'lucide-react' +import { useEffect, useState } from 'react' +import { api } from '@shared/api/client' +import { getBase } from '@shared/api/source' +import type { Page, PasskeyDto } from '@shared/api/types' +import { passkeySupported } from '@shared/api/webauthn' +import { Dialog } from '@shared/ui' +import { useAuth } from './auth' +import { PasskeyAddForm } from './pages/PasskeyAdd' + +const SNOOZE_KEY = 'moshiro.passkey_nag_until' +const SNOOZE_MS = 7 * 24 * 3600 * 1000 + +function snoozed(): boolean { + try { + return Number(localStorage.getItem(SNOOZE_KEY) ?? 0) > Date.now() + } catch { + return false + } +} + +/** + * 登录之后,如果这个用户还没有 Passkey,提醒他添加:Passkey 登录不用输密码,而且只有 passkey 登录的会话以后能做加节点这类信任操作。 + * 只对页面所在的后端提醒(passkey 绑定在后端的域名上);「稍后提醒」7 天内不再出现。 + */ +export function PasskeyNotice() { + const { me } = useAuth() + const [missing, setMissing] = useState(false) + const [adding, setAdding] = useState(false) + + useEffect(() => { + if (!me?.user_id || getBase() || !passkeySupported() || snoozed()) return + let alive = true + api + .get>('/api/admin/auth/passkeys') + .then((list) => alive && setMissing(list.items.length === 0)) + .catch(() => undefined) + return () => { + alive = false + } + }, [me?.user_id]) + + if (!missing) return null + const later = () => { + try { + localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS)) + } catch { + // 存不了也只是下次还会提醒 + } + setMissing(false) + } + return ( +
+
+ + + 建议添加 Passkey:用指纹、人脸或安全密钥登录,不用输密码;以后加节点这类敏感操作也需要它。 + + + + + +
+ {adding ? ( + setAdding(false)} + actions={ + + } + > +

+ 建议至少注册两个(比如这台电脑和手机),其中一个丢了还能登录。也可以随时在「系统 → 用户 → 我的 Passkey」里管理。 +

+ { + setAdding(false) + setMissing(false) + }} + /> +
+ ) : null} +
+ ) +} diff --git a/web/admin/src/auth.tsx b/web/admin/src/auth.tsx index 9dc5252..ba53ec9 100644 --- a/web/admin/src/auth.tsx +++ b/web/admin/src/auth.tsx @@ -2,12 +2,15 @@ import { createContext, useCallback, useContext, useEffect, useState, type React import { api, ApiError, loginTo, setCsrf } from '@shared/api/client' import { getBase, normalizeBase, setSource, setToken } from '@shared/api/source' import type { LoginResponse, MeDto } from '@shared/api/types' +import { signInWithPasskey } from '@shared/api/webauthn' interface Auth { me: MeDto | null ready: boolean /** 用户名密码登录。`base` 是新添加的后端地址(登录成功才保存);不给就登录当前连着的后端。 */ login: (username: string, password: string, base?: string) => Promise + /** passkey 登录(只对页面所在的后端;passkey 绑定在后端的域名上)。 */ + loginWithPasskey: (opts?: { conditional?: boolean; signal?: AbortSignal }) => Promise logout: () => Promise } @@ -52,6 +55,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { setMe(await api.get('/api/admin/auth/me')) }, []) + const loginWithPasskey = useCallback(async (opts?: { conditional?: boolean; signal?: AbortSignal }) => { + const r = await signInWithPasskey(opts) + setCsrf(r.csrf_token) + setMe(await api.get('/api/admin/auth/me')) + }, []) + const logout = useCallback(async () => { try { await api.post('/api/admin/auth/logout') @@ -64,7 +73,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []) - return {children} + return {children} } export function useAuth(): Auth { diff --git a/web/admin/src/pages/PasskeyAdd.tsx b/web/admin/src/pages/PasskeyAdd.tsx new file mode 100644 index 0000000..bd1cb61 --- /dev/null +++ b/web/admin/src/pages/PasskeyAdd.tsx @@ -0,0 +1,57 @@ +import { Plus } from 'lucide-react' +import { useState } from 'react' +import { passkeySupported, registerPasskey, type PasskeyPlace } from '@shared/api/webauthn' +import { guessPlatform, useAction, useToast } from '@shared/ui' + +/** 默认的 passkey 名称:按浏览器所在的系统给一个能认出来的名字。 */ +function defaultName(): string { + const platform = guessPlatform() + return platform === 'windows' ? 'Windows 设备' : platform === 'macos' ? 'Mac' : 'Linux 设备' +} + +/** + * 添加 passkey 的表单:名称 + 保存位置 + 按钮。「我的 Passkey」和登录后的提醒共用同一份。 + * 保存位置只是给浏览器的提示:`device` 优先用设备或浏览器账户自带的(Chrome 里是 Google 密码管理工具、Windows Hello), + * `external` 是安全密钥或手机扫码。 + */ +export function PasskeyAddForm({ onAdded, compact }: { onAdded: () => void; compact?: boolean }) { + const { busy, run } = useAction() + const toast = useToast() + const [name, setName] = useState(defaultName) + const [place, setPlace] = useState('device') + const supported = passkeySupported() + return ( +
+ setName(e.target.value)} disabled={!supported} /> + + +
+ ) +} diff --git a/web/admin/src/pages/System.tsx b/web/admin/src/pages/System.tsx index 2856c34..ef7651f 100644 --- a/web/admin/src/pages/System.tsx +++ b/web/admin/src/pages/System.tsx @@ -1,13 +1,14 @@ import { Download, KeyRound, Plus, Trash2 } from 'lucide-react' import { useEffect, useMemo, useState, type ReactNode } from 'react' import { api } from '@shared/api/client' -import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' +import type { AgentDto, ApiTokenDto, BuildInfoDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types' import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo' import { fmtNum, fmtTime } from '@shared/format' import { translateSetting } from '@shared/i18n' import { useQuery } from '@shared/hooks' import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' import { CapabilityPicker, capabilityName } from './CapabilityPicker' +import { PasskeyAddForm } from './PasskeyAdd' type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis' @@ -495,6 +496,59 @@ function Tokens() { ) } +function Passkeys() { + const list = useQuery(() => api.get>('/api/admin/auth/passkeys'), []) + const { run } = useAction() + const [removing, setRemoving] = useState() + const items = list.data?.items ?? [] + return ( +
+
+ 我的 Passkey + 用指纹、人脸或安全密钥登录,不用输密码。建议至少注册两个(比如手机和安全密钥)。 +
+ + {items.length === 0 ? ( + 还没有 Passkey + ) : ( + + + + + + + + + + {items.map((p) => ( + + + + + + + ))} + +
名称创建最后使用 +
+ {p.name} {p.transports.includes('internal') ? 本机 : p.transports.length > 0 ? {p.transports.join('、')} : null} + {fmtTime(p.created_at)}{p.last_used_at ? fmtTime(p.last_used_at) : '从未'} + +
+ )} + + {removing ? ( + setRemoving(undefined)} onConfirm={async () => { await run(() => api.del(`/api/admin/auth/passkeys/${removing.id}`), '已删除'); list.reload() }}> + 「{removing.name}」删除之后不能再用来登录。如果这是你唯一的 Passkey,仍然可以用密码登录。 + + ) : null} +
+ ) +} + function Users() { const users = useQuery(() => api.get>('/api/admin/users'), []) const { run } = useAction() @@ -502,6 +556,8 @@ function Users() { const [username, setUsername] = useState('') const [password, setPassword] = useState('') return ( + <> +
能登录管理端的账号。 @@ -576,6 +632,7 @@ function Users() { ) : null}
+ ) } diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 6c0246b..980e4df 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -327,6 +327,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/auth/passkey/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** A challenge for signing in with a passkey (discoverable credential, no username needed) */ + post: operations["auth_passkey_options"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/passkey/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Sign in with a passkey assertion; same response as the password sign-in */ + post: operations["auth_passkey_login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/releases": { parameters: { query?: never; @@ -362,6 +396,58 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/auth/passkeys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The signed-in user's passkeys */ + get: operations["auth_passkeys_list"]; + put?: never; + /** Register a passkey from a WebAuthn attestation (ES256, user verification required) */ + post: operations["auth_passkeys_register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/passkeys/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** A challenge for registering a new passkey (the user must be signed in) */ + post: operations["auth_passkeys_options"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/passkeys/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove one of your own passkeys */ + delete: operations["auth_passkeys_delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/auth/me": { parameters: { query?: never; @@ -1574,6 +1660,10 @@ export interface components { items: components["schemas"]["NodeInfoDto"][]; next_cursor: null | string; }; + PageDtoOfPasskeyDto: { + items: components["schemas"]["PasskeyDto"][]; + next_cursor: null | string; + }; PageDtoOfPlanDto: { items: components["schemas"]["PlanDto"][]; next_cursor: null | string; @@ -1622,6 +1712,38 @@ export interface components { values: null | string[]; description: string; }; + PasskeyCreationOptionsDto: { + challenge: string; + rp_id: string; + rp_name: string; + user_id: string; + user_name: string; + exclude_credentials: string[]; + /** Format: int32 */ + timeout_ms: number | string; + }; + PasskeyDto: { + id: string; + name: string; + /** Format: date-time */ + created_at: unknown; + /** Format: date-time */ + last_used_at: unknown; + transports: string[]; + }; + PasskeyLoginRequest: { + credential_id: string; + authenticator_data: string; + client_data_json: string; + signature: string; + return_session: null | boolean; + }; + PasskeyRequestOptionsDto: { + challenge: string; + rp_id: string; + /** Format: int32 */ + timeout_ms: number | string; + }; PatchProbeRequest: { name: null | string; }; @@ -1718,6 +1840,13 @@ export interface components { /** Format: int32 */ queue_length: number | string; }; + RegisterPasskeyRequest: { + name: string; + credential_id: string; + attestation_object: string; + client_data_json: string; + transports: null | string[]; + }; RejectedDto: { id: string; reason: string; @@ -2744,6 +2873,86 @@ export interface operations { }; }; }; + auth_passkey_options: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasskeyRequestOptionsDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_passkey_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasskeyLoginRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; releases_list: { parameters: { query?: never; @@ -2871,6 +3080,182 @@ export interface operations { }; }; }; + auth_passkeys_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfPasskeyDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_passkeys_register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RegisterPasskeyRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasskeyDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_passkeys_options: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasskeyCreationOptionsDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_passkeys_delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; auth_me: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 483df39..0833115 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -40,6 +40,9 @@ export type CreatedJoinTokenDto = Narrow export type ApiTokenDto = Narrow export type CreatedApiTokenDto = Narrow export type CapabilityCatalogDto = Narrow +export type PasskeyDto = Narrow +export type PasskeyCreationOptionsDto = Narrow +export type PasskeyRequestOptionsDto = Narrow export type MeDto = Narrow export type SettingDto = Narrow export type UserDto = Narrow diff --git a/web/shared/api/webauthn.ts b/web/shared/api/webauthn.ts new file mode 100644 index 0000000..2abf28e --- /dev/null +++ b/web/shared/api/webauthn.ts @@ -0,0 +1,87 @@ +import { api } from './client' +import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } from './types' + +/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */ +export async function conditionalSupported(): Promise { + try { + return passkeySupported() && (await PublicKeyCredential.isConditionalMediationAvailable?.()) === true + } catch { + return false + } +} + +/** + * passkey 保存在哪里:浏览器和系统决定具体用谁的密码管理器,网站只能给提示。 + * `device`:此设备或浏览器账户(Chrome 里是 Google 密码管理工具,也可以是 Windows Hello、iCloud 钥匙串); + * `external`:安全密钥,或者用手机扫码。 + */ +export type PasskeyPlace = 'device' | 'external' + +/** 浏览器是否支持 WebAuthn(passkey)。 */ +export function passkeySupported(): boolean { + return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials +} + +function toBytes(text: string): Uint8Array { + const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=') + const binary = atob(padded) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out +} + +function toText(buffer: ArrayBuffer): string { + let binary = '' + for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * 用 passkey 登录(可发现凭证:不需要用户名)。服务端设了会话 Cookie;返回登录响应。 + * passkey 绑定在后端的域名上:只有和后端同站的页面能用,所以其他后端(跨域)不显示这个入口。 + */ +export async function signInWithPasskey(opts: { conditional?: boolean; signal?: AbortSignal } = {}): Promise { + const options = await api.post('/api/admin/auth/passkey/options') + const credential = (await navigator.credentials.get({ + // conditional:不弹窗,浏览器在用户名输入框的自动填充里列出已保存的 passkey(Chrome 里就是 Google 密码管理工具里的) + mediation: opts.conditional ? 'conditional' : undefined, + signal: opts.signal, + publicKey: { challenge: toBytes(options.challenge), rpId: options.rp_id, timeout: opts.conditional ? undefined : options.timeout_ms, userVerification: 'required' }, + })) as PublicKeyCredential | null + if (!credential) throw new Error('没有选择 passkey') + const response = credential.response as AuthenticatorAssertionResponse + return api.post('/api/admin/auth/passkey/login', { + credential_id: credential.id, + authenticator_data: toText(response.authenticatorData), + client_data_json: toText(response.clientDataJSON), + signature: toText(response.signature), + }) +} + +/** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */ +export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise { + const options = await api.post('/api/admin/auth/passkeys/options') + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: toBytes(options.challenge), + rp: { id: options.rp_id, name: options.rp_name }, + user: { id: toBytes(options.user_id), name: options.user_name, displayName: options.user_name }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + excludeCredentials: options.exclude_credentials.map((id) => ({ type: 'public-key' as const, id: toBytes(id) })), + authenticatorSelection: { authenticatorAttachment: place === 'device' ? 'platform' : 'cross-platform', residentKey: 'required', requireResidentKey: true, userVerification: 'required' }, + attestation: 'none', + timeout: options.timeout_ms, + // 给浏览器的提示(Chrome 128+):优先用设备自带的(含 Google 密码管理工具),或者安全密钥和手机 + hints: place === 'device' ? ['client-device'] : ['security-key', 'hybrid'], + } as PublicKeyCredentialCreationOptions, + })) as PublicKeyCredential | null + if (!credential) throw new Error('没有创建 passkey') + const response = credential.response as AuthenticatorAttestationResponse + return api.post('/api/admin/auth/passkeys', { + name, + credential_id: credential.id, + attestation_object: toText(response.attestationObject), + client_data_json: toText(response.clientDataJSON), + transports: response.getTransports?.() ?? [], + }) +} diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css index 45e7146..1fd3f3c 100644 --- a/web/shared/theme/base.css +++ b/web/shared/theme/base.css @@ -504,6 +504,15 @@ textarea { box-shadow: 0 4px 16px color-mix(in srgb, var(--app-shadow), transparent 75%); } +.toast-title { + font-weight: 700; + margin-bottom: 2px; +} + +.toast { + cursor: pointer; +} + .toast.bad { background: var(--app-bad); color: var(--app-card); diff --git a/web/shared/ui/index.tsx b/web/shared/ui/index.tsx index c6d453e..d28f984 100644 --- a/web/shared/ui/index.tsx +++ b/web/shared/ui/index.tsx @@ -104,24 +104,33 @@ interface ToastItem { id: number text: string bad: boolean + title?: string } -const ToastContext = createContext<(text: string, bad?: boolean) => void>(() => undefined) +/** 提示的可选项:`title` 是加粗的标题行,`ms` 是停留的毫秒数(缺省成功 3 秒、失败 6 秒)。点一下提示可以提前关掉。 */ +export interface ToastOptions { + title?: string + ms?: number +} + +const ToastContext = createContext<(text: string, bad?: boolean, options?: ToastOptions) => void>(() => undefined) export function ToastProvider({ children }: { children: ReactNode }) { const [items, setItems] = useState([]) const nextId = useRef(1) - const push = useCallback((text: string, bad = false) => { + const push = useCallback((text: string, bad = false, options?: ToastOptions) => { const id = nextId.current++ - setItems((list) => [...list, { id, text, bad }]) - window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), bad ? 6000 : 3000) + setItems((list) => [...list, { id, text, bad, title: options?.title }]) + window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), options?.ms ?? (bad ? 6000 : 3000)) }, []) + const dismiss = (id: number) => setItems((list) => list.filter((t) => t.id !== id)) return ( {children}
{items.map((t) => ( -
+
dismiss(t.id)} title="点一下关闭"> + {t.title ?
{t.title}
: null} {t.text}
))} From 359313a57bdd96864e96493f3e2bbffc2d0d12fc Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 22:12:15 +0800 Subject: [PATCH 04/13] feat: record who created join codes and who added each node [CI] --- .../Fleet.Server.Abstractions/NodeRecords.cs | 6 ++++-- .../FleetEnrollmentService.cs | 3 ++- backend/openapi/v1.json | 18 +++++++++++++++++ .../Admin/AgentEndpoints.cs | 2 +- .../src/MoShiro.Backend.Api/Common/Mappers.cs | 6 +++--- .../Contracts/AdminDtos.cs | 7 ++++--- .../Admin/JoinTokenService.cs | 4 ++-- .../Auth/AuthModels.cs | 3 +++ .../Models/AgentModels.cs | 8 +++++--- .../Ports/StorePorts.cs | 2 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 13 +++++++++++- .../SqliteAgentStore.cs | 17 ++++++++-------- .../SqliteConfigStores.cs | 20 +++++++++---------- .../MoShiro.Backend.Tests/InstallTests.cs | 7 +++++++ .../MoShiro.Backend.Tests/StorageTests.cs | 4 ++-- web/admin/src/pages/AgentDetail.tsx | 6 ++++++ web/admin/src/pages/Agents.tsx | 2 ++ web/admin/src/pages/Probes.tsx | 1 + web/shared/api/schema.ts | 3 +++ 19 files changed, 95 insertions(+), 37 deletions(-) diff --git a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs index e792a2f..d5db797 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs @@ -33,7 +33,8 @@ public sealed record NewNode( string? NodeVersion, long NowMs, string Kind = "", - byte[]? TlsPublicKey = null); + byte[]? TlsPublicKey = null, + string? AddedBy = null); public sealed record JoinTokenRecord( string Id, @@ -46,6 +47,7 @@ public sealed record JoinTokenRecord( long ExpiresAtMs, long? RevokedAtMs, long CreatedAtMs, - string Kind = ""); + string Kind = "", + string? CreatedBy = null); public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList Capabilities, string? HelloMetaJson); diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs index 64f03a6..bf50fb0 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs @@ -80,7 +80,8 @@ public sealed partial class FleetEnrollmentService request.NodeVersion, nowMs, token.Kind, - tlsPublicKey); + tlsPublicKey, + token.CreatedBy); Result created = await _store.CreateNodeAsync(node, ct); if (!created.TryGet(out NodeRecord? record, out FleetError? error)) diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 439e5b5..878072d 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -4812,6 +4812,12 @@ "ipv6_host_auto": { "type": "boolean", "default": true + }, + "added_by": { + "type": [ + "null", + "string" + ] } } }, @@ -6204,6 +6210,12 @@ "role": { "type": "string", "default": "agent" + }, + "created_by": { + "type": [ + "null", + "string" + ] } } }, @@ -7721,6 +7733,12 @@ "$ref": "#/components/schemas/Ipv6DiagnosticDto" } ] + }, + "added_by": { + "type": [ + "null", + "string" + ] } } }, diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 669d2ac..b0c22b0 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -133,7 +133,7 @@ public static class AgentEndpoints { string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; Result result = await tokens.CreateAsync( - new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole), baseUrl, http.RequestAborted); + new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, http.RequestAborted); if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error)) { return Problems.From(http, error); diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index 8826c67..131affe 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -41,7 +41,7 @@ public static class Mappers public static ProbeDto ToDto(ProbeView v) => new( v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online, - [.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe)); + [.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe), v.Probe.AddedBy); public static SeriesDto ToDto(SeriesResult r, bool live) => new( @@ -103,7 +103,7 @@ public static class Mappers : null; return new AgentDto( a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible, - a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual); + a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy); } public static Ipv6DiagnosticDto Ipv6Of(AgentView view) @@ -154,7 +154,7 @@ public static class Mappers new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs)); public static JoinTokenDto ToDto(JoinTokenInfo t) => - new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind)); + new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy); public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index c5f2980..a95568e 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -54,7 +54,8 @@ public sealed record AgentDto( bool Ipv6Supported = false, Ipv6DiagnosticDto? Ipv6 = null, bool PublicHostAuto = true, - bool Ipv6HostAuto = true); + bool Ipv6HostAuto = true, + string? AddedBy = null); public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSupported, bool? Listening, bool? HasGlobalAddress, bool? HasDefaultRoute, string State, string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null); @@ -64,7 +65,7 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null); /// agent 或 probe:用这个令牌注册出来的是哪种节点。 -public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent"); +public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null); public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null); @@ -79,7 +80,7 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? /// 某个 Probe 现在连着的一个 Agent。 public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); -public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList Connections, Ipv6DiagnosticDto? Ipv6 = null); +public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList Connections, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null); /// 一对(Probe,Agent)的线路开关:linked 是最终结果,override 是手动设置(null = 跟随自动连接),connected 是现在有没有连着。 public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected); diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index bf60a76..f9c2f34 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -3,7 +3,7 @@ using Fleet.Core; namespace MoShiro.Backend.Application; /// agent(默认)或 probe:用这个令牌注册出来的是哪种节点。 -public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole); +public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null); /// 各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。 public sealed record InstallCommands(string Linux, string Macos, string Windows); @@ -72,7 +72,7 @@ public sealed class JoinTokenService long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); JoinTokenInfo info = await _store.CreateAsync( Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role), - request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct); + request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct); string baseUrl = BaseUrl(requestBaseUrl); string query = $"{baseUrl}/install?code={plaintext}"; diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs index 60d3f4c..c6a0620 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs @@ -15,6 +15,9 @@ public sealed record Principal(PrincipalKind Kind, IReadOnlySet Capabili /// 会话是怎么登录的():只有 passkey 登录的会话以后能提权。API key 为空。 public string AuthMethod { get; init; } = ""; + /// 审计和归属里显示的名字:用户名,或者 key:令牌名。 + public string Label => Kind == PrincipalKind.Token && UserId is null ? "key:" + (Username ?? TokenId) : Username ?? "anonymous"; + public static Principal Anonymous { get; } = new(PrincipalKind.Anonymous, new HashSet(), null, null, null, null); public bool Has(string capability) => capability == Capability.Authenticated ? Kind != PrincipalKind.Anonymous : Capabilities.Contains(capability); diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index 25a38af..33ef79e 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -29,7 +29,8 @@ public sealed record AgentRecord( bool PublicHostManual = false, bool Ipv6HostManual = false, IReadOnlyList? ReportedAddresses = null, - byte[]? TlsPublicKey = null); + byte[]? TlsPublicKey = null, + string? AddedBy = null); /// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 public sealed class AgentPatch @@ -76,10 +77,11 @@ public sealed record JoinTokenInfo( long ExpiresAtMs, long? RevokedAtMs, long CreatedAtMs, - string Kind = ""); + string Kind = "", + string? CreatedBy = null); /// /// 是连 Backend 的身份公钥, 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥; /// Agent 的 peers 里放的是后者。 /// -public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null); +public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null); diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index ce18a42..ba1c7ef 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -54,7 +54,7 @@ public interface IGeoLookup public interface IJoinTokenStore { - ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct); + ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct); ValueTask> ListAsync(CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index b8c1d26..2745ce7 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -5,7 +5,7 @@ namespace MoShiro.Backend.Storage.Sqlite; /// 表结构。版本记录在 PRAGMA user_version;以后的迁移是按版本号追加的内置 SQL。 internal static class Schema { - public const int CurrentVersion = 12; + public const int CurrentVersion = 13; public static void Migrate(SqliteConnection connection) { @@ -183,6 +183,17 @@ internal static class Schema connection.Exec("PRAGMA user_version = 12"); transaction.Commit(); } + + if (version < 13) + { + // 归属:加入码是谁创建的,节点是用哪个加入码(也就是谁)加进来的;审计日志 + using SqliteTransaction transaction = connection.BeginTransaction(); + connection.Exec("ALTER TABLE join_tokens ADD COLUMN created_by TEXT"); + connection.Exec("ALTER TABLE agents ADD COLUMN added_by TEXT"); + connection.Exec("ALTER TABLE probes ADD COLUMN added_by TEXT"); + connection.Exec("PRAGMA user_version = 13"); + transaction.Commit(); + } } /// diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs index 2b4e11f..6f13a7f 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs @@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore { private const string AgentColumns = "id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " + - "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey"; + "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by"; private readonly SqliteDatabase _db; @@ -157,8 +157,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore byte[] hash = tokenSha256.ToArray(); return _db.ReadAsync( c => c.QueryOne( - "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind FROM join_tokens WHERE token_sha256 = @h", - r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10)), + "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11)), ("@h", hash)), ct); } @@ -184,9 +184,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore } c.Exec( - "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now)", + "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by)", ("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki), - ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs)); + ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy)); return Result.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson)); }, ct); @@ -204,8 +204,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore } c.Exec( - "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at) VALUES (@id, @name, @pubkey, @peer, @now)", - ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs)); + "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by) VALUES (@id, @name, @pubkey, @peer, @now, @by)", + ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy)); return Result.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null)); } @@ -267,7 +267,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore r.GetInt64(22) != 0, r.GetInt64(23) != 0, StorageJson.Strings(r.GetString(24)), - r.NullableBytes(25)); + r.NullableBytes(25), + r.NullableString(26)); private static NodeRecord ToNode(AgentRecord a, string? metaJson) => new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs index 40cf6b3..1244c2d 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -15,28 +15,28 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore _db = db; } - public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, CancellationToken ct) => + public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct) => _db.WriteAsync(c => { c.Exec( - "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now)", + "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by)", ("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), ("@kind", kind), - ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs)); - return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind); + ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy)); + return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy); }, ct); public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( c => c.Query( - "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind FROM join_tokens ORDER BY created_at DESC, id", - r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9))), + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens ORDER BY created_at DESC, id", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10))), ct); public ValueTask FindAsync(byte[] tokenSha256, CancellationToken ct) => _db.ReadAsync( c => c.QueryOne( - "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind FROM join_tokens WHERE token_sha256 = @h", - r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9)), + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10)), ("@h", tokenSha256)), ct); @@ -58,12 +58,12 @@ public sealed class SqliteProbeStore : IProbeStore public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( - c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6))), + c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7))), ct); public ValueTask GetAsync(string id, CancellationToken ct) => _db.ReadAsync( - c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6)), ("@id", id)), + c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7)), ("@id", id)), ct); public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) => diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index e7c21c6..ddcb629 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -56,6 +56,13 @@ public sealed class InstallTests Assert.DoesNotContain("@@", powershell, StringComparison.Ordinal); } + [Fact] + public async Task Codes_RecordWhoCreatedThem() + { + JsonNode created = await CreateAsync("agent"); + Assert.StartsWith("key:", created["token"]!["created_by"]!.GetValue(), StringComparison.Ordinal); // Admin 客户端是 API key + } + [Fact] public async Task Script_IsNotServedForUnknownRevokedOrUsedUpCodes() { diff --git a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs index fa02e7f..61ac795 100644 --- a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs @@ -302,7 +302,7 @@ public sealed class StorageTests : IDisposable var tokens = new SqliteJoinTokenStore(_db); var fleet = new SqliteAgentStore(_db); byte[] hash = TokenHash.Sha256("jt_race"); - JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, default); + JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", default); Assert.Equal(0, created.Uses); bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)))); @@ -310,7 +310,7 @@ public sealed class StorageTests : IDisposable Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses); Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)); - JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, default); + JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, default); Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary"); Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default)); Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default)); diff --git a/web/admin/src/pages/AgentDetail.tsx b/web/admin/src/pages/AgentDetail.tsx index 0ebdc4c..aa2d444 100644 --- a/web/admin/src/pages/AgentDetail.tsx +++ b/web/admin/src/pages/AgentDetail.tsx @@ -264,6 +264,12 @@ export function AgentDetail() {
{a.public_visible ? '是' : '否'}
注册时间
{fmtTime(a.created_at)}
+ {a.added_by ? ( + <> +
添加者
+
{a.added_by}
+ + ) : null}
最后在线
{fmtTime(a.last_seen_at)}
标签
diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index b8b7673..7246b45 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -195,6 +195,7 @@ export function Agents() {
备注创建者 使用 注册后 过期
{t.note || —}{t.created_by ?? '—'} {t.uses} / {t.max_uses}
+ + + + + + + + + + + + + {audit.data?.items.map((a) => ( + + + + + + + + + + ))} + +
时间操作者方式操作对象 / 细节来源结果
{fmtTime(a.at)}{a.actor}{a.method === 'key' ? 'API 令牌' : a.method === 'passkey' ? 'Passkey' : '密码'}{a.action}{[a.target, a.detail].filter(Boolean).join(' · ')}{a.remote ?? ''} + {a.success ? '成功' : '失败'} +
+ )} +
+ ) +} + function Events() { const [kind, setKind] = useState('') const events = useQuery(() => api.get>('/api/admin/events', { kind: kind || undefined, limit: 100 }), [kind], 10_000) @@ -783,6 +832,7 @@ export function System() { { value: 'versions' as const, label: '版本' }, { value: 'tokens' as const, label: 'API 令牌' }, { value: 'users' as const, label: '用户' }, + { value: 'audit' as const, label: '审计' }, { value: 'events' as const, label: '事件' }, { value: 'raw' as const, label: '原始数据' }, { value: 'analysis' as const, label: '分析' }, @@ -790,7 +840,7 @@ export function System() { value={tab} onChange={setTab} /> - {tab === 'settings' ? : tab === 'versions' ? : tab === 'tokens' ? : tab === 'users' ? : tab === 'events' ? : tab === 'raw' ? : } + {tab === 'settings' ? : tab === 'versions' ? : tab === 'tokens' ? : tab === 'users' ? : tab === 'audit' ? : tab === 'events' ? : tab === 'raw' ? : } ) diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index dec44f8..f02fce8 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -814,6 +814,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/audit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Audit log of admin actions, newest first */ + get: operations["audit_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/api-tokens": { parameters: { query?: never; @@ -1183,6 +1200,19 @@ export interface components { /** Format: date-time */ revoked_at: unknown; }; + AuditDto: { + /** Format: int64 */ + id: number | string; + /** Format: date-time */ + at: unknown; + actor: string; + method: string; + action: string; + target: null | string; + detail: null | string; + remote: null | string; + success: boolean; + }; BackendUpdateDto: { channel: string; enabled: boolean; @@ -1634,6 +1664,10 @@ export interface components { items: components["schemas"]["ApiTokenDto"][]; next_cursor: null | string; }; + PageDtoOfAuditDto: { + items: components["schemas"]["AuditDto"][]; + next_cursor: null | string; + }; PageDtoOfEventDto: { items: components["schemas"]["EventDto"][]; next_cursor: null | string; @@ -4783,6 +4817,58 @@ export interface operations { }; }; }; + audit_list: { + parameters: { + query?: { + actor?: string; + action?: string; + limit?: number | string; + cursor?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfAuditDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; api_tokens_list: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 0833115..8d1cf71 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -47,6 +47,7 @@ export type MeDto = Narrow export type SettingDto = Narrow export type UserDto = Narrow export type EventDto = Narrow +export type AuditDto = Narrow export type GapDto = Narrow export type RecomputeDto = Narrow export type RawChunkMetaDto = Narrow From ff45ad086e3e0e5d77c323a33355bb73d74cc725 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 22:29:24 +0800 Subject: [PATCH 06/13] feat!: sensitive actions need a fresh passkey touch (elevation windows), plus login page hint cleanup [CI] --- backend/openapi/v1.json | 287 +++++++++++++++++- .../shared/MoShiro.Protocol/MoShiroErrors.cs | 1 + .../Admin/AuthEndpoints.cs | 117 ++++++- .../Admin/SystemEndpoints.cs | 7 +- .../src/MoShiro.Backend.Api/Common/Guards.cs | 29 ++ .../src/MoShiro.Backend.Api/Common/Mappers.cs | 2 + .../MoShiro.Backend.Api/Common/Problems.cs | 2 +- .../Contracts/AdminDtos.cs | 10 +- .../Contracts/ApiJsonContext.cs | 4 + .../ApplicationServiceCollectionExtensions.cs | 1 + .../Auth/AuthModels.cs | 3 + .../Auth/AuthService.cs | 2 +- .../Auth/Capabilities.cs | 6 +- .../Auth/ElevationService.cs | 136 +++++++++ .../Auth/PasskeyService.cs | 82 +++-- .../BackendOptions.cs | 9 + .../MoShiro.Backend.Tests/ApiBasicsTests.cs | 8 +- .../MoShiro.Backend.Tests/BackendFixture.cs | 34 ++- .../MoShiro.Backend.Tests/ElevationTests.cs | 190 ++++++++++++ .../MoShiro.Backend.Tests/PasskeyTests.cs | 43 ++- docs/access.md | 2 +- web/admin/src/App.tsx | 15 +- web/admin/src/ElevationBar.tsx | 77 +++++ web/shared/api/client.ts | 22 ++ web/shared/api/schema.ts | 234 ++++++++++++++ web/shared/api/types.ts | 1 + web/shared/api/webauthn.ts | 39 +++ 27 files changed, 1305 insertions(+), 58 deletions(-) create mode 100644 backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/ElevationTests.cs create mode 100644 web/admin/src/ElevationBar.tsx diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 5823ca4..5b9262f 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -1625,6 +1625,208 @@ "x-moshiro-capability": "authenticated" } }, + "/api/admin/auth/elevation": { + "get": { + "tags": [ + "admin-auth" + ], + "summary": "The elevation windows this session has open (sensitive actions need one)", + "operationId": "auth_elevation_list", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PageDtoOfElevationDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + }, + "post": { + "tags": [ + "admin-auth" + ], + "summary": "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)", + "operationId": "auth_elevate", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ElevationAssertionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ElevationDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + }, + "delete": { + "tags": [ + "admin-auth" + ], + "summary": "Close all elevation windows of this session", + "operationId": "auth_elevation_end", + "responses": { + "204": { + "description": "No Content", + "content": { + "application/json": { + "schema": { } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, + "/api/admin/auth/elevation/options": { + "post": { + "tags": [ + "admin-auth" + ], + "summary": "A challenge for opening an elevation window with a passkey touch", + "operationId": "auth_elevation_options", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ElevationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRequestOptionsDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "authenticated" + } + }, "/api/admin/auth/me": { "get": { "tags": [ @@ -4025,7 +4227,7 @@ } } }, - "x-moshiro-capability": "accounts.manage" + "x-moshiro-capability": "accounts.read" }, "post": { "tags": [ @@ -5803,6 +6005,59 @@ } } }, + "ElevationAssertionRequest": { + "required": [ + "class", + "credential_id", + "authenticator_data", + "client_data_json", + "signature" + ], + "type": "object", + "properties": { + "class": { + "type": "string" + }, + "credential_id": { + "type": "string" + }, + "authenticator_data": { + "type": "string" + }, + "client_data_json": { + "type": "string" + }, + "signature": { + "type": "string" + } + } + }, + "ElevationDto": { + "required": [ + "class", + "expires_at" + ], + "type": "object", + "properties": { + "class": { + "type": "string" + }, + "expires_at": { + "format": "date-time" + } + } + }, + "ElevationRequest": { + "required": [ + "class" + ], + "type": "object", + "properties": { + "class": { + "type": "string" + } + } + }, "EventDto": { "required": [ "id", @@ -7147,6 +7402,27 @@ } } }, + "PageDtoOfElevationDto": { + "required": [ + "items", + "next_cursor" + ], + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ElevationDto" + } + }, + "next_cursor": { + "type": [ + "null", + "string" + ] + } + } + }, "PageDtoOfEventDto": { "required": [ "items", @@ -7688,6 +7964,15 @@ "string" ], "format": "int32" + }, + "allow_credentials": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } } } }, diff --git a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs index 91c7259..0cd40dc 100644 --- a/backend/shared/MoShiro.Protocol/MoShiroErrors.cs +++ b/backend/shared/MoShiro.Protocol/MoShiroErrors.cs @@ -11,6 +11,7 @@ public static class MoShiroErrors public static readonly ErrorCode CsrfFailed = new("csrf_failed"); public static readonly ErrorCode Forbidden = new("forbidden"); public static readonly ErrorCode SitePrivate = new("site_private"); + public static readonly ErrorCode ElevationRequired = new("elevation_required"); public static readonly ErrorCode NotFound = new("not_found"); public static readonly ErrorCode AgentNotFound = new("agent_not_found"); public static readonly ErrorCode ProbeNotFound = new("probe_not_found"); diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 39e6f8a..77920d3 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -51,6 +51,22 @@ public static class AuthEndpoints .Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag) .Returns(StatusCodes.Status204NoContent).ProducesProblems(401, 404) .Requires(Capability.Authenticated); + guarded.MapGet("/auth/elevation", ListElevation) + .Api("auth_elevation_list", "The elevation windows this session has open (sensitive actions need one)", Tag) + .Returns>().ProducesProblems(401) + .Requires(Capability.Authenticated); + guarded.MapPost("/auth/elevation/options", ElevationOptionsAsync) + .Api("auth_elevation_options", "A challenge for opening an elevation window with a passkey touch", Tag) + .Returns().ProducesProblems(400, 401, 403, 429) + .Requires(Capability.Authenticated); + guarded.MapPost("/auth/elevation", ElevateAsync) + .Api("auth_elevate", "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)", Tag) + .Returns().ProducesProblems(400, 401, 403, 429) + .Requires(Capability.Authenticated); + guarded.MapDelete("/auth/elevation", EndElevation) + .Api("auth_elevation_end", "Close all elevation windows of this session", Tag) + .Returns(StatusCodes.Status204NoContent).ProducesProblems(401) + .Requires(Capability.Authenticated); guarded.MapGet("/auth/me", Me) .Api("auth_me", "The current identity", Tag) .Returns().ProducesProblems(401) @@ -163,8 +179,20 @@ public static class AuthEndpoints return TypedResults.Json(new PageDto([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto); } + /// 已经有 passkey 的用户再加一个,要先用已有的触碰一次;第一个 passkey(还没有可触碰的)不要求。 + private static async Task CredentialGateAsync(HttpContext http, PasskeyService passkeys, bool consume) + { + string? userId = http.GetPrincipal().UserId; + return userId is null || (await passkeys.ListAsync(userId, http.RequestAborted)).Count == 0 ? null : ElevationGate.Require(http, ElevationClass.Credential, consume); + } + private static async Task PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options) { + if (await CredentialGateAsync(http, passkeys, consume: false) is { } needed) + { + return needed; + } + Result result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted); if (!result.TryGet(out CreationOptions? o, out FleetError? error)) { @@ -177,6 +205,11 @@ public static class AuthEndpoints private static async Task RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options) { + if (await CredentialGateAsync(http, passkeys, consume: true) is { } needed) + { + return needed; + } + Result result = await passkeys.RegisterAsync( http.GetPrincipal(), new PasskeyRegistration(request.Name ?? "", request.CredentialId ?? "", request.AttestationObject ?? "", request.ClientDataJson ?? "", request.Transports), PasskeyContextOf(http, options), http.RequestAborted); return result.TryGet(out PasskeyRecord? record, out FleetError? error) @@ -184,13 +217,91 @@ public static class AuthEndpoints : Problems.From(http, error); } - private static async Task DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id) => - http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted) + private static async Task DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id) + { + if (ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed) + { + return needed; + } + + return http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted) ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.NotFound, "no such passkey"); + } - private static async Task LogoutAsync(HttpContext http, AuthService auth) + private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult> ListElevation(HttpContext http, ElevationService elevation) { + IReadOnlyList open = http.GetPrincipal().SessionId is { } session ? elevation.Active(session) : []; + return TypedResults.Json(new PageDto([.. open.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoElevationDto); + } + + private static async Task ElevationOptionsAsync(HttpContext http, ElevationRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options) + { + IResult limit = Limited(http, limiter, out bool ok, "elevation-options:", limit: PasskeyOptionsPerMinute); + if (!ok) + { + return limit; + } + + if (!Elevation.TryParse(request.Class, out ElevationClass elevation)) + { + return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential")); + } + + Result result = await passkeys.BeginElevationAsync(http.GetPrincipal(), elevation, PasskeyContextOf(http, options), http.RequestAborted); + if (!result.TryGet(out RequestOptions? o, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs, o.AllowCredentials), ApiJsonContext.Default.PasskeyRequestOptionsDto); + } + + private static async Task ElevateAsync(HttpContext http, ElevationAssertionRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options) + { + IResult limit = Limited(http, limiter, out bool ok, "elevation:"); + if (!ok) + { + return limit; + } + + if (!Elevation.TryParse(request.Class, out ElevationClass elevation)) + { + return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential")); + } + + Principal principal = http.GetPrincipal(); + Result result = await passkeys.ElevateAsync( + principal, elevation, new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted); + if (!result.TryGet(out ElevationWindow? window, out FleetError? error)) + { + await audit.RecordAsync(principal, "auth.elevate", null, Elevation.IdOf(elevation), http.Connection.RemoteIpAddress?.ToString(), false, http.RequestAborted); + return Problems.From(http, error); + } + + http.SetDetail(Elevation.IdOf(elevation)); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(Mappers.ToDto(window), ApiJsonContext.Default.ElevationDto); + } + + private static Microsoft.AspNetCore.Http.HttpResults.NoContent EndElevation(HttpContext http, ElevationService elevation) + { + if (http.GetPrincipal().SessionId is { } session) + { + elevation.End(session); + } + + return TypedResults.NoContent(); + } + + private static async Task LogoutAsync(HttpContext http, AuthService auth, ElevationService elevation) + { + if (http.GetPrincipal().SessionId is { } own) + { + elevation.End(own); + } + string? bearer = PrincipalResolver.BearerOf(http); if (bearer is not null) { diff --git a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs index 3c3b1dc..3597853 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/SystemEndpoints.cs @@ -43,7 +43,7 @@ public static class SystemEndpoints group.MapGet("/users", ListUsersAsync) .Api("users_list", "Panel users", UsersTag).Returns>().ProducesProblems(401, 403) - .Requires(Capability.AccountsManage); + .Requires(Capability.AccountsRead); group.MapPost("/users", CreateUserAsync) .Api("users_create", "Create a panel user", UsersTag).Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) .Requires(Capability.AccountsManage); @@ -64,6 +64,11 @@ public static class SystemEndpoints private static async Task CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request) { + if (CapabilityCatalog.Expand(request.Capabilities ?? []).Any(c => CapabilityCatalog.Find(c)?.Trust == true) && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed) + { + return needed; // 带信任类能力的 API key 等于把信任操作交给一份密钥:创建它要用 passkey 签一次 + } + http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}"); Result result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted); if (!result.TryGet(out CreatedApiToken? created, out FleetError? error)) diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 92ec7dc..14610d7 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -89,10 +89,21 @@ public static class CapabilityGuard } } + // 信任类的操作(加节点、吊销、改凭证):登录用户还要有这一种的提权窗口;API key 自己带着能力,不经过这里 + if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && ElevationGate.Require(http, elevation, consume: true) is { } needed) + { + return needed; + } + if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method)) { // 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里 string action = http.GetEndpoint()?.Metadata.GetMetadata()?.EndpointName ?? http.Request.Path.Value ?? ""; + if (action.EndsWith("_options", StringComparison.Ordinal)) + { + return await next(context); // 取挑战不是操作 + } + string? target = http.Request.RouteValues.Count == 0 ? null : string.Join(' ', http.Request.RouteValues.Where(v => v.Value is not null).Select(v => v.Key + "=" + v.Value)); http.Response.OnCompleted(() => { @@ -106,6 +117,24 @@ public static class CapabilityGuard } } +/// 提权检查:登录用户(含用 return_session 拿到的 Bearer 会话)要有对应种类的有效窗口,API key 不需要。 +public static class ElevationGate +{ + /// 没问题返回 null;否则 403 elevation_required,detail 是缺的窗口种类,前端据此触碰 passkey 后重试。 + public static IResult? Require(HttpContext http, ElevationClass elevation, bool consume) + { + Principal principal = http.GetPrincipal(); + if (principal.UserId is null) + { + return null; + } + + return principal.SessionId is { } session && http.RequestServices.GetRequiredService().Check(session, elevation, consume) + ? null + : Problems.Create(http, MoShiroErrors.ElevationRequired, Elevation.IdOf(elevation)); + } +} + /// 展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。 public static class PublicGuard { diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index cf56e6c..d7e8afb 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -156,6 +156,8 @@ public static class Mappers public static JoinTokenDto ToDto(JoinTokenInfo t) => new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy); + public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs)); + public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success); public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports); diff --git a/backend/src/MoShiro.Backend.Api/Common/Problems.cs b/backend/src/MoShiro.Backend.Api/Common/Problems.cs index 87576e9..790d86d 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Problems.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Problems.cs @@ -46,7 +46,7 @@ public static class ErrorCodeHttpMap Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams); Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized); - Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid); + Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, MoShiroErrors.ElevationRequired, FleetErrors.Forbidden, FleetErrors.ProofInvalid); Add( 404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound, MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index a95568e..80fe143 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -9,7 +9,15 @@ public sealed record LoginRequest(string Username, string Password, bool? Return public sealed record PasskeyCreationOptionsDto(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList ExcludeCredentials, int TimeoutMs); /// 浏览器 navigator.credentials.get 要的参数(可发现凭证:不需要先输用户名)。 -public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs); +/// 只有提权的挑战会带:这个用户自己的 passkey 的凭证 id。 +public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs, IReadOnlyList? AllowCredentials = null); + +/// enroll(加节点)、revoke(吊销和删除)、credential(改凭证,用一次就没有)。 +public sealed record ElevationRequest(string Class); + +public sealed record ElevationAssertionRequest(string Class, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature); + +public sealed record ElevationDto(string Class, DateTimeOffset ExpiresAt); public sealed record RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList? Transports); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index ce5b95d..7238f19 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -79,6 +79,10 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(PasskeyRequestOptionsDto))] [JsonSerializable(typeof(RegisterPasskeyRequest))] [JsonSerializable(typeof(PasskeyLoginRequest))] +[JsonSerializable(typeof(ElevationRequest))] +[JsonSerializable(typeof(ElevationAssertionRequest))] +[JsonSerializable(typeof(ElevationDto))] +[JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(PasskeyDto))] [JsonSerializable(typeof(PageDto))] [JsonSerializable(typeof(CreatedApiTokenDto))] diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index cfb3ad2..2ad27e3 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -37,6 +37,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs index c6a0620..73623a1 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthModels.cs @@ -15,6 +15,9 @@ public sealed record Principal(PrincipalKind Kind, IReadOnlySet Capabili /// 会话是怎么登录的():只有 passkey 登录的会话以后能提权。API key 为空。 public string AuthMethod { get; init; } = ""; + /// 会话的标识(令牌哈希的十六进制):提权窗口按它记。API key 为空。 + public string? SessionId { get; init; } + /// 审计和归属里显示的名字:用户名,或者 key:令牌名。 public string Label => Kind == PrincipalKind.Token && UserId is null ? "key:" + (Username ?? TokenId) : Username ?? "anonymous"; diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs index aa32c1d..458f717 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs @@ -71,7 +71,7 @@ public sealed partial class AuthService } UserRecord? user = await _store.GetUserAsync(session.UserId, ct); - return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod }; + return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) }; } public async ValueTask AuthenticateTokenAsync(string bearer, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs index ec8590c..85784a8 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs @@ -32,6 +32,7 @@ public static class Capability public const string KeysRead = "keys.read"; public const string KeysManage = "keys.manage"; + public const string AccountsRead = "accounts.read"; public const string AccountsManage = "accounts.manage"; public const string AuditRead = "audit.read"; @@ -78,7 +79,8 @@ public static class CapabilityCatalog new(Capability.KeysRead, "keys"), new(Capability.KeysManage, "keys", false, false, Capability.KeysRead), - new(Capability.AccountsManage, "accounts", true), + new(Capability.AccountsRead, "accounts"), + new(Capability.AccountsManage, "accounts", true, false, Capability.AccountsRead), new(Capability.EventsRead, "ops"), new(Capability.AuditRead, "ops"), @@ -130,6 +132,6 @@ public static class CapabilityCatalog } } - /// 登录会话的能力。在 passkey 提权落地之前(docs/access.md 第 12 节步骤 2)会话直接拥有全部能力,包括信任类。 + /// 登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口()。 public static IReadOnlyList ForSession() => [.. All.Select(c => c.Id)]; } diff --git a/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs b/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs new file mode 100644 index 0000000..41a75f0 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs @@ -0,0 +1,136 @@ +namespace MoShiro.Backend.Application; + +/// +/// 提权窗口的种类(docs/access.md 第 4 节):加节点(15 分钟)、吊销和删除(5 分钟)、改凭证(60 秒,用一次就没有)。 +/// 窗口属于会话,不属于用户:换一台设备、一个浏览器就要重新触碰 passkey。 +/// +public enum ElevationClass +{ + Enroll, + Revoke, + Credential, +} + +public sealed record ElevationWindow(ElevationClass Class, long ExpiresAtMs); + +public static class Elevation +{ + public static string IdOf(ElevationClass elevation) => elevation switch + { + ElevationClass.Enroll => "enroll", + ElevationClass.Revoke => "revoke", + _ => "credential", + }; + + public static bool TryParse(string? id, out ElevationClass elevation) + { + (bool ok, elevation) = id switch + { + "enroll" => (true, ElevationClass.Enroll), + "revoke" => (true, ElevationClass.Revoke), + "credential" => (true, ElevationClass.Credential), + _ => (false, default), + }; + return ok; + } + + /// 端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。 + public static ElevationClass? For(string capability) => capability switch + { + Capability.FleetEnroll => ElevationClass.Enroll, + Capability.FleetRevoke => ElevationClass.Revoke, + Capability.AccountsManage => ElevationClass.Credential, + _ => null, + }; +} + +/// +/// 会话的提权窗口,只在内存里:重启之后需要重新触碰 passkey,这正是想要的。API key 不经过这里(它自己带着能力,创建时已经签过)。 +/// +public sealed class ElevationService +{ + private readonly BackendOptions _options; + private readonly TimeProvider _time; + private readonly object _gate = new(); + private readonly Dictionary> _windows = []; + + public ElevationService(BackendOptions options, TimeProvider time) + { + _options = options; + _time = time; + } + + public TimeSpan LifetimeOf(ElevationClass elevation) => elevation switch + { + ElevationClass.Enroll => TimeSpan.FromMinutes(_options.ElevationEnrollMinutes), + ElevationClass.Revoke => TimeSpan.FromMinutes(_options.ElevationRevokeMinutes), + _ => TimeSpan.FromSeconds(_options.ElevationCredentialSeconds), + }; + + /// 开窗口;已经有的窗口重新计时。返回到期时间。 + public long Grant(string sessionId, ElevationClass elevation) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + long expires = now + (long)LifetimeOf(elevation).TotalMilliseconds; + lock (_gate) + { + Prune(now); + if (!_windows.TryGetValue(sessionId, out Dictionary? own)) + { + _windows[sessionId] = own = []; + } + + own[elevation] = expires; + } + + return expires; + } + + /// 窗口是否有效。 且是凭证类:同时用掉(一次有效)。 + public bool Check(string sessionId, ElevationClass elevation, bool consume) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + lock (_gate) + { + if (!_windows.TryGetValue(sessionId, out Dictionary? own) || !own.TryGetValue(elevation, out long expires) || expires <= now) + { + return false; + } + + if (consume && elevation == ElevationClass.Credential) + { + own.Remove(elevation); + } + + return true; + } + } + + public IReadOnlyList Active(string sessionId) + { + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + lock (_gate) + { + return _windows.TryGetValue(sessionId, out Dictionary? own) + ? [.. own.Where(w => w.Value > now).OrderBy(w => w.Key).Select(w => new ElevationWindow(w.Key, w.Value))] + : []; + } + } + + /// 主动结束全部窗口(点「结束」或退出登录)。 + public void End(string sessionId) + { + lock (_gate) + { + _windows.Remove(sessionId); + } + } + + private void Prune(long now) + { + foreach (string session in _windows.Where(p => p.Value.All(w => w.Value <= now)).Select(p => p.Key).ToList()) + { + _windows.Remove(session); + } + } +} diff --git a/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs index b24b6b3..9dce41a 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/PasskeyService.cs @@ -13,7 +13,7 @@ public sealed record PasskeyContext(string RpId, IReadOnlySet Origins); public sealed record CreationOptions(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList ExcludeCredentials, int TimeoutMs); -public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs); +public sealed record RequestOptions(string Challenge, string RpId, int TimeoutMs, IReadOnlyList? AllowCredentials = null); public sealed record PasskeyRegistration(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList? Transports); @@ -33,16 +33,23 @@ public sealed class PasskeyService private readonly IAuthStore _store; private readonly AuthService _auth; + private readonly ElevationService _elevation; private readonly TimeProvider _time; private readonly object _gate = new(); private readonly Dictionary _challenges = []; - private sealed record PendingChallenge(bool Registration, string? UserId, long ExpiresAtMs); + private sealed record PendingChallenge(string Purpose, string? UserId, long ExpiresAtMs); - public PasskeyService(IAuthStore store, AuthService auth, TimeProvider time) + private const string RegisterPurpose = "register"; + private const string LoginPurpose = "login"; + + private static string ElevatePurpose(ElevationClass elevation) => "elevate:" + Elevation.IdOf(elevation); + + public PasskeyService(IAuthStore store, AuthService auth, ElevationService elevation, TimeProvider time) { _store = store; _auth = auth; + _elevation = elevation; _time = time; } @@ -59,7 +66,7 @@ public sealed class PasskeyService return ValidationError.Fail("passkeys", $"at most {MaxPasskeysPerUser} passkeys per user"); } - string challenge = Issue(registration: true, userId); + string challenge = Issue(RegisterPurpose, userId); return Result.Ok(new CreationOptions(challenge, context.RpId, "MoShiro", Base64Url.Encode(Encoding.UTF8.GetBytes(userId)), username, [.. existing.Select(p => p.Id)], 60_000)); } @@ -82,7 +89,7 @@ public sealed class PasskeyService } // 挑战在 clientDataJSON 里:先取出来,一次有效 - if (!TryTakeChallenge(clientData, registration: true, userId, out byte[] challenge)) + if (!TryTakeChallenge(clientData, RegisterPurpose, userId, out byte[] challenge)) { return Result.Fail(MoShiroErrors.ValidationFailed, "the registration challenge is unknown, expired or already used"); } @@ -100,43 +107,82 @@ public sealed class PasskeyService : Result.Fail(MoShiroErrors.NameConflict, "this passkey is already registered"); } - public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(registration: false, null), context.RpId, 60_000); + public RequestOptions BeginLogin(PasskeyContext context) => new(Issue(LoginPurpose, null), context.RpId, 60_000); /// 验证断言并建立会话。失败一律 unauthorized,不区分原因。 public async ValueTask> LoginAsync(PasskeyAssertion request, PasskeyContext context, CancellationToken ct) { - Result denied = Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials"); - if (!TryDecode(request.CredentialId, out _) || !TryDecode(request.AuthenticatorData, out byte[] authData) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.Signature, out byte[] signature)) + PasskeyRecord? passkey = await VerifyAsync(request, context, LoginPurpose, null, ct); + return passkey is null + ? Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials") + : await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct); + } + + /// 提权的挑战:绑定用户和窗口种类,并列出这个用户的 passkey 让浏览器只选它们。没有 passkey 的用户没法提权。 + public async ValueTask> BeginElevationAsync(Principal principal, ElevationClass elevation, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId || principal.SessionId is null) { - return denied; + return Result.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already"); } - if (!TryTakeChallenge(clientData, registration: false, null, out byte[] challenge)) + IReadOnlyList own = await _store.ListPasskeysAsync(userId, ct); + if (own.Count == 0) { - return denied; + return Result.Fail(MoShiroErrors.ValidationFailed, "register a passkey first (Users, My passkeys)"); + } + + return Result.Ok(new RequestOptions(Issue(ElevatePurpose(elevation), userId), context.RpId, 60_000, [.. own.Select(p => p.Id)])); + } + + /// 验证提权的断言(必须是这个用户自己的 passkey)并给这个会话开窗口。失败一律 unauthorized。 + public async ValueTask> ElevateAsync(Principal principal, ElevationClass elevation, PasskeyAssertion request, PasskeyContext context, CancellationToken ct) + { + if (principal.UserId is not { } userId || principal.SessionId is not { } sessionId) + { + return Result.Fail(MoShiroErrors.Forbidden, "only a signed-in user can elevate; API keys carry their capabilities already"); + } + + PasskeyRecord? passkey = await VerifyAsync(request, context, ElevatePurpose(elevation), userId, ct); + return passkey is null || passkey.UserId != userId + ? Result.Fail(MoShiroErrors.Unauthorized, "invalid credentials") + : Result.Ok(new ElevationWindow(elevation, _elevation.Grant(sessionId, elevation))); + } + + /// 验证一份断言:挑战(一次有效、种类和用户对得上)、签名、计数器;通过则记下使用并返回那个 passkey。 + private async ValueTask VerifyAsync(PasskeyAssertion request, PasskeyContext context, string purpose, string? userId, CancellationToken ct) + { + if (!TryDecode(request.CredentialId, out _) || !TryDecode(request.AuthenticatorData, out byte[] authData) || !TryDecode(request.ClientDataJson, out byte[] clientData) || !TryDecode(request.Signature, out byte[] signature)) + { + return null; + } + + if (!TryTakeChallenge(clientData, purpose, userId, out byte[] challenge)) + { + return null; } PasskeyRecord? passkey = await _store.FindPasskeyAsync(request.CredentialId, ct); if (passkey is null || !WebAuthnAssertion.Verify(passkey.PublicKeySpki, authData, clientData, signature, context.RpId, challenge, context.Origins.Contains, requireUserVerification: true, out uint signCount)) { - return denied; + return null; } // 计数器只增不减;很多同步的 passkey 一直报 0,两边都是 0 时不检查 if ((signCount != 0 || passkey.SignCount != 0) && signCount <= passkey.SignCount) { - return denied; + return null; } await _store.TouchPasskeyAsync(passkey.Id, signCount, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); - return await _auth.CreateSessionForAsync(passkey.UserId, AuthMethods.Passkey, ct); + return passkey; } public ValueTask> ListAsync(string userId, CancellationToken ct) => _store.ListPasskeysAsync(userId, ct); public ValueTask DeleteAsync(string userId, string passkeyId, CancellationToken ct) => _store.DeletePasskeyAsync(userId, passkeyId, ct); - private string Issue(bool registration, string? userId) + private string Issue(string purpose, string? userId) { byte[] challenge = RandomNumberGenerator.GetBytes(ChallengeBytes); string key = Base64Url.Encode(challenge); @@ -156,14 +202,14 @@ public sealed class PasskeyService } } - _challenges[key] = new PendingChallenge(registration, userId, now + (long)s_challengeLifetime.TotalMilliseconds); + _challenges[key] = new PendingChallenge(purpose, userId, now + (long)s_challengeLifetime.TotalMilliseconds); } return key; } /// 从 clientDataJSON 取出 challenge,按它找到并移除(一次有效)。 - private bool TryTakeChallenge(byte[] clientDataJson, bool registration, string? userId, out byte[] challenge) + private bool TryTakeChallenge(byte[] clientDataJson, string purpose, string? userId, out byte[] challenge) { challenge = []; string? text; @@ -185,7 +231,7 @@ public sealed class PasskeyService long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); lock (_gate) { - if (!_challenges.Remove(Base64Url.Encode(raw), out PendingChallenge? pending) || pending.ExpiresAtMs <= now || pending.Registration != registration || pending.UserId != userId) + if (!_challenges.Remove(Base64Url.Encode(raw), out PendingChallenge? pending) || pending.ExpiresAtMs <= now || pending.Purpose != purpose || pending.UserId != userId) { return false; } diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index d4c8c29..9dd255f 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -32,6 +32,15 @@ public sealed class BackendOptions /// 允许的来源(逗号分隔,如 https://panel.example.com);缺省是 的来源,没有就取请求自己的来源。 public string? WebAuthnOrigins { get; set; } + /// 提权窗口的长度(passkey 触碰一次之后):加节点类的分钟数。 + public int ElevationEnrollMinutes { get; set; } = 15; + + /// 吊销和删除类的分钟数。 + public int ElevationRevokeMinutes { get; set; } = 5; + + /// 改凭证类(用一次就没有)的秒数。 + public int ElevationCredentialSeconds { get; set; } = 60; + /// 会话 Cookie 带 Secure 标记(只提供 HTTPS)。本机用 HTTP 调试时才关闭。 public bool SecureCookies { get; set; } = true; diff --git a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs index 3f351ae..3f114fb 100644 --- a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs @@ -75,7 +75,7 @@ public sealed class ApiBasicsTests Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue()); - using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) }; create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); using HttpResponseMessage created = await client.SendAsync(create); Assert.Equal(HttpStatusCode.Created, created.StatusCode); @@ -104,19 +104,19 @@ public sealed class ApiBasicsTests using HttpResponseMessage me = await client.SendAsync(get); Assert.Equal(HttpStatusCode.OK, me.StatusCode); - using var withoutCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + using var withoutCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) }; withoutCsrf.Headers.Add("Cookie", cookie); using HttpResponseMessage rejected = await client.SendAsync(withoutCsrf); Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode); Assert.Equal("csrf_failed", JsonNode.Parse(await rejected.Content.ReadAsStringAsync())!["code"]!.GetValue()); - using var wrongCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + using var wrongCsrf = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) }; wrongCsrf.Headers.Add("Cookie", cookie); wrongCsrf.Headers.Add("X-CSRF-Token", csrf + "x"); using HttpResponseMessage wrong = await client.SendAsync(wrongCsrf); Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode); - using var good = new HttpRequestMessage(HttpMethod.Post, "/api/admin/join-tokens") { Content = BackendFixture.Json(new JsonObject()) }; + using var good = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) }; good.Headers.Add("Cookie", cookie); good.Headers.Add("X-CSRF-Token", csrf); using HttpResponseMessage created = await client.SendAsync(good); diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index 8d9258c..f0d57e1 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -242,10 +242,36 @@ public class BackendFixture : IAsyncLifetime JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!; string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0]; - using var request = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }) }; - request.Headers.Add("Cookie", cookie); - request.Headers.Add("X-CSRF-Token", body["csrf_token"]!.GetValue()); - using HttpResponseMessage created = await session.SendAsync(request); + // 带信任类能力的 API key 要用 passkey 签一次才能创建(和线上一样):给管理员注册一个 passkey,触碰一次再创建 + string csrf = body["csrf_token"]!.GetValue(); + async Task SendAsync(string path, JsonObject? content) + { + using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content is null ? null : Json(content) }; + request.Headers.Add("Cookie", cookie); + request.Headers.Add("X-CSRF-Token", csrf); + return await session.SendAsync(request); + } + + string origin = BaseAddress.GetLeftPart(UriPartial.Authority); + using var passkey = new SoftwarePasskey(BaseAddress.Host, origin); + using (HttpResponseMessage options = await SendAsync("/api/admin/auth/passkeys/options", null)) + { + options.EnsureSuccessStatusCode(); + string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); + using HttpResponseMessage registered = await SendAsync("/api/admin/auth/passkeys", passkey.Register("fixture", challenge)); + registered.EnsureSuccessStatusCode(); + } + + using (HttpResponseMessage options = await SendAsync("/api/admin/auth/elevation/options", new JsonObject { ["class"] = "credential" })) + { + options.EnsureSuccessStatusCode(); + JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue()); + assertion["class"] = "credential"; + using HttpResponseMessage elevated = await SendAsync("/api/admin/auth/elevation", assertion); + elevated.EnsureSuccessStatusCode(); + } + + using HttpResponseMessage created = await SendAsync("/api/admin/api-tokens", new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }); created.EnsureSuccessStatusCode(); return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); } diff --git a/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs b/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs new file mode 100644 index 0000000..4f47c72 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs @@ -0,0 +1,190 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json.Nodes; + +namespace MoShiro.Backend.Tests; + +/// 提权窗口:信任类操作(加节点、吊销、改凭证)要在登录之外再触碰一次 passkey;API key 自带能力不受影响。设计见 docs/access.md 第 4 节。 +[Collection("probe-e2e")] +public sealed class ElevationTests +{ + private const string Password = "correct horse battery"; + + private readonly BackendFixture _backend; + + public ElevationTests(BackendFixture backend) + { + _backend = backend; + } + + private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority); + + private string RpId => _backend.BaseAddress.Host; + + /// 新建一个用户并用密码登录(会话令牌走 Bearer)。每个测试用自己的用户,互不影响。 + private async Task NewSessionAsync() + { + string name = _backend.UniqueName("elev"); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + HttpClient client = _backend.NewClient(); + using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true })); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); + return client; + } + + private async Task RegisterAsync(HttpClient session, string name = "key") + { + var passkey = new SoftwarePasskey(RpId, Origin); + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null); + Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync()); + string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); + using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register(name, challenge))); + Assert.True(registered.StatusCode == HttpStatusCode.Created, await registered.Content.ReadAsStringAsync()); + return passkey; + } + + private async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey, string cls, string? optionsFor = null) + { + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = optionsFor ?? cls })); + Assert.True(options.IsSuccessStatusCode, await options.Content.ReadAsStringAsync()); + JsonNode body = JsonNode.Parse(await options.Content.ReadAsStringAsync())!; + Assert.Contains(passkey.Id, body["allow_credentials"]!.AsArray().Select(x => x!.GetValue())); // 浏览器只会让用户选自己的 passkey + JsonObject assertion = passkey.Assert(body["challenge"]!.GetValue()); + assertion["class"] = cls; + return await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion)); + } + + private static async Task ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue(); + + private static StringContent NewJoinCode() => BackendFixture.Json(new JsonObject { ["role"] = "agent" }); + + [Fact] + public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens() + { + using HttpClient session = await NewSessionAsync(); + using SoftwarePasskey passkey = await RegisterAsync(session); + + using HttpResponseMessage denied = await session.PostAsync("/api/admin/join-tokens", NewJoinCode()); + Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode); + Assert.Equal("elevation_required", await ProblemCodeAsync(denied)); + Assert.Contains("enroll", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal); + + using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "enroll"); + Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); + Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); + Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); // 窗口内不用再触碰 + + // 窗口只管它那一种 + using HttpResponseMessage revoke = await session.DeleteAsync("/api/admin/agents/agt_nobody"); + Assert.Equal("elevation_required", await ProblemCodeAsync(revoke)); + } + + [Fact] + public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow() + { + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", NewJoinCode()); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + } + + [Fact] + public async Task TheWindowCanBeListed_AndEndedEarly() + { + using HttpClient session = await NewSessionAsync(); + using SoftwarePasskey passkey = await RegisterAsync(session); + Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray()); + + (await ElevateAsync(session, passkey, "enroll")).Dispose(); + JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray(); + Assert.Equal("enroll", Assert.Single(open)!["class"]!.GetValue()); + + Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/elevation")).StatusCode); + Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray()); + Assert.Equal(HttpStatusCode.Forbidden, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); + } + + [Fact] + public async Task ACredentialWindow_IsSingleUse() + { + using HttpClient session = await NewSessionAsync(); + using SoftwarePasskey passkey = await RegisterAsync(session); + + (await ElevateAsync(session, passkey, "credential")).Dispose(); + using HttpResponseMessage first = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password })); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + using HttpResponseMessage second = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password })); + Assert.Equal("elevation_required", await ProblemCodeAsync(second)); + } + + [Fact] + public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow() + { + using HttpClient mine = await NewSessionAsync(); + using SoftwarePasskey myPasskey = await RegisterAsync(mine); + using HttpClient theirs = await NewSessionAsync(); + using SoftwarePasskey theirPasskey = await RegisterAsync(theirs); + + // 别人的 passkey 签了我的挑战:不行(也不在 allow_credentials 里) + using HttpResponseMessage options = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" })); + string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); + JsonObject foreign = theirPasskey.Assert(challenge); + foreign["class"] = "enroll"; + Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(foreign))).StatusCode); + + // 挑战是给 revoke 要的,提交成 enroll:不行 + using HttpResponseMessage wrongKind = await ElevateAsync(mine, myPasskey, "enroll", optionsFor: "revoke"); + Assert.Equal(HttpStatusCode.Unauthorized, wrongKind.StatusCode); + + // 挑战只能用一次 + using HttpResponseMessage options2 = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" })); + string challenge2 = JsonNode.Parse(await options2.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); + JsonObject once = myPasskey.Assert(challenge2); + once["class"] = "enroll"; + Assert.Equal(HttpStatusCode.OK, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode); + + // 另一个用户的会话没有因此提权 + Assert.Equal(HttpStatusCode.Forbidden, (await theirs.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); + } + + [Fact] + public async Task WithoutAPasskey_ThereIsNothingToTouch_AndTheFirstPasskeyNeedsNoWindow() + { + using HttpClient session = await NewSessionAsync(); + using HttpResponseMessage none = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" })); + Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode); + + using SoftwarePasskey first = await RegisterAsync(session, "first"); // 第一个:不要求提权 + using HttpResponseMessage second = await session.PostAsync("/api/admin/auth/passkeys/options", null); + Assert.Equal("elevation_required", await ProblemCodeAsync(second)); // 第二个起要先用已有的触碰 + (await ElevateAsync(session, first, "credential")).Dispose(); + using SoftwarePasskey added = await RegisterAsync(session, "second"); + + // 删除也要窗口 + (await ElevateAsync(session, first, "credential")).Dispose(); + Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/passkeys/" + added.Id)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await session.DeleteAsync("/api/admin/auth/passkeys/" + first.Id)).StatusCode); + } + + [Fact] + public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate() + { + using HttpClient session = await NewSessionAsync(); + using SoftwarePasskey passkey = await RegisterAsync(session); + var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.enroll") }; + var harmless = new JsonObject { ["name"] = _backend.UniqueName("ro"), ["capabilities"] = new JsonArray("fleet.read") }; + + Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(harmless))).StatusCode); + using HttpResponseMessage denied = await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted)); + Assert.Equal("elevation_required", await ProblemCodeAsync(denied)); + (await ElevateAsync(session, passkey, "credential")).Dispose(); + Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(trusted))).StatusCode); + } + + [Fact] + public async Task ListingUsers_NeedsOnlyTheReadCapability() + { + using HttpClient session = await NewSessionAsync(); + Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权 + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs index 2225029..5d20934 100644 --- a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs @@ -110,6 +110,27 @@ public sealed class PasskeyTests return client; } + private string _user = ""; + + /// 每个测试用自己新建的用户:管理员已经有 fixture 的 passkey,再加就要提权,而这里测的是第一个 passkey 的流程。 + private async Task FreshSessionAsync() + { + _user = _backend.UniqueName("pk"); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _user, ["password"] = "correct horse battery" })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + return await PasswordSessionAsync(_user, "correct horse battery"); + } + + /// 用已有的 passkey 触碰一次,开凭证窗口(删除 passkey 需要)。 + private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey) + { + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" })); + JsonObject assertion = passkey.Assert((await JsonOf(options))["challenge"]!.GetValue()); + assertion["class"] = "credential"; + using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion)); + Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); + } + private static async Task JsonOf(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())!; private async Task RegistrationChallengeAsync(HttpClient session) @@ -145,7 +166,7 @@ public sealed class PasskeyTests [Fact] public async Task APasskey_CanBeRegistered_ThenSignsInWithoutAUsername_AndTheSessionRemembersHowItWasMade() { - using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using HttpClient session = await FreshSessionAsync(); using SoftwarePasskey passkey = await RegisterAsync(session); JsonNode list = await JsonOf(await session.GetAsync("/api/admin/auth/passkeys")); @@ -154,7 +175,7 @@ public sealed class PasskeyTests using HttpResponseMessage login = await _backend.Anonymous.PostAsync("/api/admin/auth/passkey/login", BackendFixture.Json(passkey.Assert(await LoginChallengeAsync()))); JsonNode body = await JsonOf(login); Assert.Equal(HttpStatusCode.OK, login.StatusCode); - Assert.Equal(BackendFixture.AdminUser, body["username"]!.GetValue()); + Assert.Equal(_user, body["username"]!.GetValue()); Assert.NotNull(body["session_token"]); Assert.Equal("no-store", login.Headers.CacheControl?.ToString()); @@ -162,14 +183,14 @@ public sealed class PasskeyTests using var signedIn = new HttpClient { BaseAddress = _backend.BaseAddress }; signedIn.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", body["session_token"]!.GetValue()); JsonNode me = await JsonOf(await signedIn.GetAsync("/api/admin/auth/me")); - Assert.Equal(BackendFixture.AdminUser, me["username"]!.GetValue()); + Assert.Equal(_user, me["username"]!.GetValue()); Assert.Contains(me["capabilities"]!.AsArray(), c => c!.GetValue() == "fleet.read"); } [Fact] public async Task Assertions_AreRejected_WhenTheChallengeOriginRpOrSignatureIsWrong_OrTheChallengeIsReused() { - using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using HttpClient session = await FreshSessionAsync(); using SoftwarePasskey passkey = await RegisterAsync(session); Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert("bm90LWEtY2hhbGxlbmdl"))); // 没发过的挑战 @@ -194,7 +215,7 @@ public sealed class PasskeyTests [Fact] public async Task TheSignCounter_OnlyMovesForward_WhenTheAuthenticatorUsesOne() { - using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using HttpClient session = await FreshSessionAsync(); using SoftwarePasskey passkey = await RegisterAsync(session); Assert.Equal(HttpStatusCode.OK, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync(), counter: 5))); @@ -211,7 +232,7 @@ public sealed class PasskeyTests Assert.Equal(HttpStatusCode.Forbidden, asToken.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/auth/passkeys/options", null)).StatusCode); - using HttpClient session = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using HttpClient session = await FreshSessionAsync(); using var passkey = new SoftwarePasskey(RpId, Origin); async Task TryAsync(JsonObject body) { @@ -230,24 +251,30 @@ public sealed class PasskeyTests // 正常注册,同一把凭证不能注册两次,也不能用登录用的挑战注册 string challenge = await RegistrationChallengeAsync(session); Assert.Equal(HttpStatusCode.Created, await TryAsync(passkey.Register("ok", challenge))); + // 已经有 passkey 了,再注册要先触碰一次(凭证窗口,用一次就没有) + await ElevateAsync(session, passkey); Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(passkey.Register("again", challenge))); // 挑战只能用一次 + await ElevateAsync(session, passkey); Assert.Equal(HttpStatusCode.Conflict, await TryAsync(passkey.Register("again", await RegistrationChallengeAsync(session)))); + await ElevateAsync(session, passkey); Assert.Equal(HttpStatusCode.BadRequest, await TryAsync(new SoftwarePasskey(RpId, Origin).Register("x", await LoginChallengeAsync()))); } [Fact] public async Task APasskey_CanBeDeleted_OnlyByItsOwner_AndThenNoLongerSignsIn() { - using HttpClient admin = await PasswordSessionAsync(BackendFixture.AdminUser, BackendFixture.AdminPassword); + using HttpClient admin = await FreshSessionAsync(); using SoftwarePasskey passkey = await RegisterAsync(admin, "phone"); string other = _backend.UniqueName("user"); using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = other, ["password"] = "correct horse battery" })); Assert.Equal(HttpStatusCode.Created, created.StatusCode); using HttpClient intruder = await PasswordSessionAsync(other, "correct horse battery"); - Assert.Equal(HttpStatusCode.NotFound, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await intruder.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 没有窗口,连试都不行 Assert.DoesNotContain((await JsonOf(await intruder.GetAsync("/api/admin/auth/passkeys")))["items"]!.AsArray(), p => p!["id"]!.GetValue() == passkey.Id); + Assert.Equal(HttpStatusCode.Forbidden, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); // 删除要先触碰 + await ElevateAsync(admin, passkey); Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync("/api/admin/auth/passkeys/" + passkey.Id)).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, await LoginStatusAsync(passkey.Assert(await LoginChallengeAsync()))); } diff --git a/docs/access.md b/docs/access.md index b9f3fcb..33e660a 100644 --- a/docs/access.md +++ b/docs/access.md @@ -282,7 +282,7 @@ ## 12. 实施顺序(每步可独立验证和推送) 1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 v11 重建 `api_tokens`)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。`events.read` 对应现有的 `GET /api/admin/events`,不是预留。 -2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。**passkey 登录已实现**(见第 3.1 节);提权窗口、审计日志、归属列还没做。 +2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。**已实现**:passkey 登录(第 3.1 节)、提权窗口(接入 15 分钟、吊销 5 分钟、凭证类 60 秒用一次;窗口属于会话,在内存里,API key 不受影响;创建带信任类能力的 key 和增删 passkey 要凭证窗口,第一个 passkey 例外)、审计日志(`GET /api/admin/audit`,写操作和登录自动记录,保留 365 天)、归属(加入代码的创建者、节点的添加者)。 3. **操作员集合与创世公钥固定**:`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。 4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。 5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。 diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index a0c1d1c..7bf430a 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -7,6 +7,7 @@ import { conditionalSupported, passkeySupported } from '@shared/api/webauthn' import { describeError, Field, Segmented, SourceButton, Spinner, ThemeToggle } from '@shared/ui' import { useAuth } from './auth' import { BackendUpdateNotice } from './BackendUpdateNotice' +import { ElevationBar } from './ElevationBar' import { PasskeyNotice } from './PasskeyNotice' import { AgentDetail } from './pages/AgentDetail' import { Agents } from './pages/Agents' @@ -81,8 +82,6 @@ function Login() { } } const conditional = useRef(null) // 正在挂着的自动填充请求:点按钮之前要先取消,浏览器同一时间只允许一个 WebAuthn 请求 - // 自动填充是否可用:unknown 还在检测,yes 已经挂上(点用户名框会列出已保存的 passkey),no 浏览器不支持 - const [autofill, setAutofill] = useState<'unknown' | 'yes' | 'no'>('unknown') const retryOnFocus = useRef(false) // 服务器拒绝了自动填充选的 passkey:等用户再点输入框时才重新开始,不自己循环 const usePasskey = async () => { setBusy(true) @@ -110,7 +109,6 @@ function Login() { let expired = false // 只有「挑战到期」才重新开始;组件卸载、依赖变化时的取消不能再触发重来(否则会形成循环) void conditionalSupported().then(async (ok) => { if (abort.signal.aborted) return - setAutofill(ok ? 'yes' : 'no') if (!ok) return timer = setTimeout(() => { expired = true @@ -194,7 +192,6 @@ function Login() { ) : null} {usingPasskey ? ( <> -

用指纹、人脸或安全密钥登录,不用输用户名和密码。点下面的按钮,浏览器会让你选一个这个站点的 Passkey。

{error ?
{error}
: null} + + {open.map((w) => ( +
+ {NAMES[w.class] ?? w.class}:还剩 {remaining(w.expires_at, now)} +
+ ))} + + ) +} diff --git a/web/shared/api/client.ts b/web/shared/api/client.ts index 04d459d..7d416a6 100644 --- a/web/shared/api/client.ts +++ b/web/shared/api/client.ts @@ -36,7 +36,29 @@ export function withQuery(path: string, query?: Query): string { return text ? `${path}?${text}` : path } +/** + * 敏感操作(加节点、吊销、改凭证)需要先触碰 passkey 开一个提权窗口:后端返回 403 `elevation_required`(detail 是窗口种类)时, + * 调用已注册的处理函数(弹出 passkey 触碰),成功后把原请求重试一次。 + */ +let elevationHandler: ((elevation: string) => Promise) | null = null + +export function setElevationHandler(handler: ((elevation: string) => Promise) | null) { + elevationHandler = handler +} + async function request(method: string, path: string, body?: unknown, init?: RequestInit): Promise { + try { + return await send(method, path, body, init) + } catch (e) { + if (e instanceof ApiError && e.code === 'elevation_required' && elevationHandler) { + if (await elevationHandler(e.detail)) return send(method, path, body, init) + throw new ApiError(403, e.code, '这个操作需要先用 Passkey 确认,已取消') + } + throw e + } +} + +async function send(method: string, path: string, body?: unknown, init?: RequestInit): Promise { const headers: Record = { Accept: 'application/json', ...authHeaders() } if (body !== undefined) headers['Content-Type'] = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json' if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index f02fce8..4d0fcf7 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -448,6 +448,42 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/auth/elevation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The elevation windows this session has open (sensitive actions need one) */ + get: operations["auth_elevation_list"]; + put?: never; + /** Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s) */ + post: operations["auth_elevate"]; + /** Close all elevation windows of this session */ + delete: operations["auth_elevation_end"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/auth/elevation/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** A challenge for opening an elevation window with a passkey touch */ + post: operations["auth_elevation_options"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/auth/me": { parameters: { query?: never; @@ -1370,6 +1406,21 @@ export interface components { first: boolean; last: boolean; }; + ElevationAssertionRequest: { + class: string; + credential_id: string; + authenticator_data: string; + client_data_json: string; + signature: string; + }; + ElevationDto: { + class: string; + /** Format: date-time */ + expires_at: unknown; + }; + ElevationRequest: { + class: string; + }; EventDto: { /** Format: int64 */ id: number | string; @@ -1668,6 +1719,10 @@ export interface components { items: components["schemas"]["AuditDto"][]; next_cursor: null | string; }; + PageDtoOfElevationDto: { + items: components["schemas"]["ElevationDto"][]; + next_cursor: null | string; + }; PageDtoOfEventDto: { items: components["schemas"]["EventDto"][]; next_cursor: null | string; @@ -1779,6 +1834,7 @@ export interface components { rp_id: string; /** Format: int32 */ timeout_ms: number | string; + allow_credentials?: null | string[]; }; PatchProbeRequest: { name: null | string; @@ -3293,6 +3349,184 @@ export interface operations { }; }; }; + auth_elevation_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PageDtoOfElevationDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_elevate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ElevationAssertionRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ElevationDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_elevation_end: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + auth_elevation_options: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ElevationRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasskeyRequestOptionsDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; auth_me: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 8d1cf71..d1059b3 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -41,6 +41,7 @@ export type ApiTokenDto = Narrow export type CreatedApiTokenDto = Narrow export type CapabilityCatalogDto = Narrow export type PasskeyDto = Narrow +export type ElevationDto = Narrow export type PasskeyCreationOptionsDto = Narrow export type PasskeyRequestOptionsDto = Narrow export type MeDto = Narrow diff --git a/web/shared/api/webauthn.ts b/web/shared/api/webauthn.ts index 2abf28e..551403e 100644 --- a/web/shared/api/webauthn.ts +++ b/web/shared/api/webauthn.ts @@ -58,6 +58,45 @@ export async function signInWithPasskey(opts: { conditional?: boolean; signal?: }) } +/** 提权窗口的种类:加节点、吊销和删除、改凭证(用一次就没有)。 */ +export type ElevationClass = 'enroll' | 'revoke' | 'credential' + +export const ELEVATED_EVENT = 'moshiro:elevated' + +/** + * 用已有的 passkey 触碰一次,给当前会话开一个提权窗口。用户取消返回 false;没有 passkey 等服务端拒绝的情况抛 ApiError。 + * 浏览器只会让用户选自己的 passkey(服务端给了 allow_credentials)。 + */ +export async function elevate(elevation: ElevationClass): Promise { + const options = await api.post('/api/admin/auth/elevation/options', { class: elevation }) + let credential: PublicKeyCredential | null + try { + credential = (await navigator.credentials.get({ + publicKey: { + challenge: toBytes(options.challenge), + rpId: options.rp_id, + timeout: options.timeout_ms, + userVerification: 'required', + allowCredentials: (options.allow_credentials ?? []).map((id) => ({ type: 'public-key' as const, id: toBytes(id) })), + }, + })) as PublicKeyCredential | null + } catch (e) { + if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) return false + throw e + } + if (!credential) return false + const response = credential.response as AuthenticatorAssertionResponse + await api.post('/api/admin/auth/elevation', { + class: elevation, + credential_id: credential.id, + authenticator_data: toText(response.authenticatorData), + client_data_json: toText(response.clientDataJSON), + signature: toText(response.signature), + }) + window.dispatchEvent(new Event(ELEVATED_EVENT)) + return true +} + /** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */ export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise { const options = await api.post('/api/admin/auth/passkeys/options') From a19e6548d318ae59d50215643ed806ec13d7947c Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 22:51:44 +0800 Subject: [PATCH 07/13] feat: trust forwarded client addresses only from configured proxies (rate limit, audit, SSE) [CI] Docs: access.md covers the missing node-side Backend identity and the hash-first install flow. --- .../Admin/AuthEndpoints.cs | 8 +- backend/src/MoShiro.Backend.Api/ApiModule.cs | 1 + .../Common/ClientAddress.cs | 80 +++++++++++++++++++ .../src/MoShiro.Backend.Api/Common/Guards.cs | 2 +- .../Public/LiveEndpoints.cs | 2 +- .../BackendOptions.cs | 9 +++ .../ClientAddressTests.cs | 51 ++++++++++++ docs/access.md | 58 ++++++++++++-- docs/deployment.md | 2 + 9 files changed, 201 insertions(+), 12 deletions(-) create mode 100644 backend/src/MoShiro.Backend.Api/Common/ClientAddress.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/ClientAddressTests.cs diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 77920d3..622d760 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -75,7 +75,7 @@ public static class AuthEndpoints private static async Task LoginAsync(HttpContext http, LoginRequest request, AuthService auth, AuditService audit, LoginRateLimiter limiter, BackendOptions options) { - string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + string source = http.ClientAddress(); if (!limiter.TryAcquire(source, out int retryAfter)) { return Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter); @@ -130,7 +130,7 @@ public static class AuthEndpoints /// 限流。 把「取挑战」和「提交登录」分成两个桶:页面加载取挑战不该占用登录失败的额度。 private static IResult Limited(HttpContext http, LoginRateLimiter limiter, out bool ok, string scope = "", int? limit = null) { - string source = scope + (http.Connection.RemoteIpAddress?.ToString() ?? "unknown"); + string source = scope + (http.ClientAddress()); ok = limiter.TryAcquire(source, out int retryAfter, limit); return ok ? TypedResults.NoContent() : Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter); } @@ -161,7 +161,7 @@ public static class AuthEndpoints Result result = await passkeys.LoginAsync( new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted); - string? remote = http.Connection.RemoteIpAddress?.ToString(); + string? remote = http.ClientAddress(); if (!result.TryGet(out LoginResult? login, out FleetError? error)) { await audit.RecordAsync(null, "?", AuthMethods.Passkey, "auth.login", null, null, remote, false, http.RequestAborted); @@ -276,7 +276,7 @@ public static class AuthEndpoints principal, elevation, new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted); if (!result.TryGet(out ElevationWindow? window, out FleetError? error)) { - await audit.RecordAsync(principal, "auth.elevate", null, Elevation.IdOf(elevation), http.Connection.RemoteIpAddress?.ToString(), false, http.RequestAborted); + await audit.RecordAsync(principal, "auth.elevate", null, Elevation.IdOf(elevation), http.ClientAddress(), false, http.RequestAborted); return Problems.From(http, error); } diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index 95d72f0..8a9da2d 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -18,6 +18,7 @@ public static class ApiModule public static IServiceCollection AddMoShiroApi(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.Configure(options => { JsonSerializerOptions json = options.SerializerOptions; diff --git a/backend/src/MoShiro.Backend.Api/Common/ClientAddress.cs b/backend/src/MoShiro.Backend.Api/Common/ClientAddress.cs new file mode 100644 index 0000000..bf61a94 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Common/ClientAddress.cs @@ -0,0 +1,80 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Api; + +/// +/// 请求的真实来源地址。后端前面通常有反向代理(Cloudflare、frp、Caddy),套接字上看到的是最近一跳; +/// 只有配置了 ,并且连进来的正是其中之一,才相信转发头, +/// 且从右往左跳过所有可信代理,取第一个不可信的地址(客户端自己伪造的在最左边,不会被采信)。 +/// 没配置就一律用套接字地址:宁可限流和审计里是代理的地址,也不让客户端自己报地址。 +/// +public sealed class ClientAddressResolver +{ + private readonly IPNetwork[] _trusted; + private readonly string _header; + + public ClientAddressResolver(BackendOptions options) + { + _header = string.IsNullOrWhiteSpace(options.TrustedProxyHeader) ? "X-Forwarded-For" : options.TrustedProxyHeader.Trim(); + _trusted = [.. (options.TrustedProxies ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(ParseNetwork)]; + } + + private static IPNetwork ParseNetwork(string text) + { + if (IPNetwork.TryParse(text, out IPNetwork network)) + { + return network; + } + + if (IPAddress.TryParse(text, out IPAddress? single)) + { + return new IPNetwork(single, single.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128); + } + + throw new InvalidOperationException($"TrustedProxies: '{text}' is not an IP address or CIDR range"); + } + + private bool IsTrusted(IPAddress address) + { + IPAddress normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; + return _trusted.Any(n => n.Contains(normalized)); + } + + public string Resolve(HttpContext http) + { + IPAddress? remote = http.Connection.RemoteIpAddress; + if (remote is null) + { + return "unknown"; + } + + if (_trusted.Length == 0 || !IsTrusted(remote)) + { + return remote.ToString(); + } + + string[] chain = [.. http.Request.Headers[_header].SelectMany(v => (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))]; + for (int i = chain.Length - 1; i >= 0; i--) + { + if (!IPAddress.TryParse(chain[i], out IPAddress? hop)) + { + break; // 不是地址就不信这条头 + } + + if (!IsTrusted(hop)) + { + return hop.ToString(); + } + } + + return remote.ToString(); + } +} + +public static class ClientAddressExtensions +{ + public static string ClientAddress(this HttpContext http) => http.RequestServices.GetRequiredService().Resolve(http); +} diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 14610d7..221c1cf 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -109,7 +109,7 @@ public static class CapabilityGuard { http.Items.TryGetValue(AuditEndpoints.DetailItem, out object? detail); return http.RequestServices.GetRequiredService() - .RecordAsync(principal, action, target, detail as string, http.Connection.RemoteIpAddress?.ToString(), http.Response.StatusCode < 400).AsTask(); + .RecordAsync(principal, action, target, detail as string, http.ClientAddress(), http.Response.StatusCode < 400).AsTask(); }); } diff --git a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs index 49ccad4..476656a 100644 --- a/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Public/LiveEndpoints.cs @@ -70,7 +70,7 @@ public static class LiveEndpoints } } - string client = http.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + string client = http.ClientAddress(); if (!limiter.TryAcquire(client)) { return Problems.Create(http, MoShiroErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client"); diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 9dd255f..1b1c1d6 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -50,6 +50,15 @@ public sealed class BackendOptions /// public string? CorsOrigins { get; set; } + /// + /// 可信的反向代理(逗号分隔的 IP 或 CIDR,如 127.0.0.1,172.16.0.0/12)。只有从这些地址连进来的请求,才采信 + /// 里的客户端地址(用于登录限流、审计和 SSE 限制);空 = 不信任任何转发头,用套接字地址。 + /// + public string? TrustedProxies { get; set; } + + /// 携带客户端地址的头,缺省 X-Forwarded-For(Cloudflare 单独的 CF-Connecting-IP 也可以填这里)。 + public string? TrustedProxyHeader { get; set; } + public TimeSpan SessionLifetime { get; set; } = TimeSpan.FromHours(12); public int LoginAttemptsPerMinute { get; set; } = 10; diff --git a/backend/tests/MoShiro.Backend.Tests/ClientAddressTests.cs b/backend/tests/MoShiro.Backend.Tests/ClientAddressTests.cs new file mode 100644 index 0000000..6e785d8 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/ClientAddressTests.cs @@ -0,0 +1,51 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using MoShiro.Backend.Api; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Tests; + +/// 真实来源地址:只有从可信代理连进来的请求才采信转发头,而且取最右边第一个不可信的地址。 +public sealed class ClientAddressTests +{ + private static string Resolve(string? trusted, string remote, string? forwarded, string? header = null) + { + var resolver = new ClientAddressResolver(new BackendOptions { TrustedProxies = trusted, TrustedProxyHeader = header }); + var http = new DefaultHttpContext(); + http.Connection.RemoteIpAddress = IPAddress.Parse(remote); + if (forwarded is not null) + { + http.Request.Headers[header ?? "X-Forwarded-For"] = forwarded; + } + + return resolver.Resolve(http); + } + + [Fact] + public void WithoutTrustedProxies_TheSocketAddressIsUsed_AndTheHeaderIsIgnored() => + Assert.Equal("203.0.113.9", Resolve(null, "203.0.113.9", "1.2.3.4")); + + [Fact] + public void FromATrustedProxy_TheForwardedClientIsUsed() => + Assert.Equal("198.51.100.7", Resolve("10.0.0.0/8", "10.1.2.3", "198.51.100.7")); + + [Fact] + public void FromAnUntrustedPeer_TheHeaderIsIgnored() => + Assert.Equal("203.0.113.9", Resolve("10.0.0.0/8", "203.0.113.9", "1.2.3.4")); + + [Fact] + public void TheChainIsWalkedFromTheRight_SoAClientCannotForgeItsAddress() => + Assert.Equal("198.51.100.7", Resolve("10.0.0.0/8,127.0.0.1", "10.1.2.3", "6.6.6.6, 198.51.100.7, 10.9.9.9")); + + [Fact] + public void AGarbageHeader_FallsBackToTheSocketAddress() => + Assert.Equal("10.1.2.3", Resolve("10.0.0.0/8", "10.1.2.3", "not-an-ip")); + + [Fact] + public void ASingleAddressAndACustomHeaderWork() => + Assert.Equal("198.51.100.7", Resolve("127.0.0.1", "127.0.0.1", "198.51.100.7", "CF-Connecting-IP")); + + [Fact] + public void AMalformedTrustedProxyEntry_FailsAtStartup() => + Assert.Throws(() => new ClientAddressResolver(new BackendOptions { TrustedProxies = "banana" })); +} diff --git a/docs/access.md b/docs/access.md index 33e660a..fede523 100644 --- a/docs/access.md +++ b/docs/access.md @@ -191,6 +191,36 @@ - 节点能强制:谁是成员(链)、时间(Grant 窗口、Ticket 过期、单券 TTL 上限)、角色、吊销。 - 节点强制不了:数量上限(它不知道别的节点)。由签名人执行(浏览器或 Backend 的 key 路径)。 +### 7.4 现状的缺口:节点没有任何东西可以用来识别 Backend + +今天的链路(已核对代码): + +```text + 安装 curl https://<面板>/install?code=jt_… | bash 信任 HTTPS 证书,第一次联系即信任(TOFU) + 注册 Agent 生成 node.key 和 tls.pfx,用 jt_ 码把公钥交给 Backend + 连接 Backend 发挑战,Agent 用 node.key 签名 只有「Agent 向 Backend 证明自己」 + 反方向 Agent 验证 Backend?没有。只靠 wss 的 WebPKI 证书;节点本地只存 node_id、面板地址、自己的证书指纹 +``` + +后果:Backend 的身份就是「能让 Agent 连上的、证书有效的服务器」。DNS 或前置代理被攻破、被签发了该域名的证书、或 Backend 机器被拿下,攻击者都等于 Backend,可以改 Agent 的 `peers` 名单(把自己的 Probe 公钥放进去)、下发任务、读全部数据。「换一个 Backend 公钥配自己的私钥」在这里甚至不需要,因为节点从来没有存过 Backend 的公钥。 + +要求:信任根必须是操作员的 passkey 公钥,在节点**第一次安装时固定**,之后 Backend 无法替换: + +- 节点收到的所有「改信任」的内容(成员名单、授权、吊销表)都必须带操作员的签名,节点自己用固定下来的创世公钥验证。Backend 只能转发,签不出新的。 +- Backend 被冒充也只能转发操作员签过的东西:已有的授权可以重放(受有效期、吊销表约束),新授权签不出来。 +- 弱点:创世公钥指纹放在安装命令里,命令本身要从可信渠道拿到(管理端复制)。改了命令的人可以换指纹;缓解见第 15 节(静态签名脚本、按 digest 拉镜像),并让管理端展示指纹供核对。 + +### 7.5 第一个落地切片(重启、改配置的票和 webssh 先不做,只留接口) + +| 步 | 内容 | 验证 | +|---|---|---| +| a | 创世:第一次用 passkey 登记操作员公钥(OpSet v1,自证);安装命令带上指纹 | 命令里的指纹和管理端展示一致 | +| b | Grant:生成加入码时由 passkey 签(`challenge = SHA256(Grant)`);Agent 和 Probe 注册时验签,通过才保存操作员公钥 | 没有签名或签名不对的码,节点拒绝注册 | +| c | 成员名单带签名:Agent 的 `peers`、Probe 的 `probe_agents` 每项带自己的链,节点在状态同步时验,不过就丢弃并计数 | 冒充的 Backend 往名单里加 Probe 公钥,节点不接纳 | +| d | 数据加密(PRF)、`enroll` key、Ticket 作为后续,接口先留在 Grant 里(`controllers`、数据公钥字段) | | + +不在这个切片里:重启和改配置不需要签名的票;webssh 等 extension 见第 8 节。 + ## 8. 节点属主与 extension 授权(预留) 后面会做 extension(web ssh、文件、抓包,见 [security.md](security.md) 第 11 节)。这类能力**连管理员都不信任**:管理员能删,不能读,也不能用。所以入网链现在就要给每个节点留出「属主」这个概念,即使 extension 还没做,字段也先固定下来,之后不用重装节点。 @@ -283,7 +313,7 @@ 1. **能力模型与守卫**:`Capability`、`Principal.Has`、端点声明、对账测试、API key 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 v11 重建 `api_tokens`)。登录会话在步骤 2 之前拥有全部能力(包括信任类),信任类的 passkey 要求从步骤 2 起强制。IP 白名单推迟:本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置,才做得对。`events.read` 对应现有的 `GET /api/admin/events`,不是预留。 2. **账户、passkey、会话分级、审计**:WebAuthn 注册与登录、提权窗口(接入、吊销、凭证三类)、审计日志、归属列和详情页显示。**已实现**:passkey 登录(第 3.1 节)、提权窗口(接入 15 分钟、吊销 5 分钟、凭证类 60 秒用一次;窗口属于会话,在内存里,API key 不受影响;创建带信任类能力的 key 和增删 passkey 要凭证窗口,第一个 passkey 例外)、审计日志(`GET /api/admin/audit`,写操作和登录自动记录,保留 365 天)、归属(加入代码的创建者、节点的添加者)。 -3. **操作员集合与创世公钥固定**:`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。 +3. **操作员集合与创世公钥固定**(落地切片见第 7.5 节):`fleet-enroll` 和安装命令改造、弱信任开关、离线恢复密钥的创建与导出。 4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。 5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。 @@ -306,13 +336,29 @@ Backend 被篡改时,你需要一个**不经过 Backend 账户系统**的信 ## 15. 安装脚本的供应链 -一行安装要执行 Backend 吐出的脚本、拉仓库里的镜像。Backend 或仓库被攻破,就能在新节点上执行任意命令。收束办法: +一行安装要执行 Backend 吐出的脚本、拉仓库里的镜像。Backend 或仓库被攻破,就能在新节点上执行任意命令。收束办法,**优先级最高的是第一跳的哈希核对**: -- **脚本是静态产物**:不在服务端做字符串替换(替换会破坏签名)。CI 用发布密钥给 `install.sh`、`install.ps1` 签名,随 Backend 一起发布;参数(代码、面板地址)通过命令行参数传入,代码也因此不再出现在 URL 和反向代理的访问日志里:`curl -fsSL https://panel/install | sh -s -- ''`。 -- **镜像按 digest 拉取**:脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用脚本里内置的发布公钥验签,再按清单里的 digest 拉镜像。Backend 改不了 digest,最多让节点装到一个旧的合法版本。 +**① 脚本是静态产物,哈希固定(优先做)** +- 不在服务端做字符串替换。CI 构建时算出 `install.sh`、`install.ps1` 的 SHA-256,写进发布清单(发布密钥签名),同时公布在 Gitea 发布页——**这条渠道独立于 Backend**,这是哈希核对有意义的前提:哈希若和脚本从同一处来,攻击者两个一起换就行。 +- 参数(代码、面板地址)通过命令行传入,代码也因此不再出现在 URL 和反向代理日志里。 +- 管理端给的命令把「下载、核对、执行」合成一行,哈希核对不通过就不执行: + +```text +curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo " /tmp/moshiro-install.sh" | sha256sum -c - && sh /tmp/moshiro-install.sh '' +``` + + macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就 `throw`。命令里的 `` 由 Backend 填入,所以管理端同时标出「与 Gitea 发布页的 SHA-256 一致」并提供链接;严格的人对照一次即可(脚本是静态的,哈希在一个版本内不变,只需要核对一次)。 +- 进一步:操作员确认这个版本的发布时(`update.confirm`,要 passkey),把脚本哈希一起签进去,Backend 被攻破也改不了命令里的哈希;节点和安装命令里的创世公钥指纹(第 7.5 节)来自同一次签名。 + +**② 镜像按 digest 拉取,二进制按 SHA-256 校验** +- 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用**脚本里内置的发布公钥**验签,再按清单里的 digest 拉镜像(`repo@sha256:…`);原生二进制则核对清单里的 SHA-256 再安装。Backend 改不了 digest,最多让节点装到一个旧的合法版本。 +- 发布公钥因此既在脚本里,也编进 Agent 和 Probe 二进制(一个常量,来自 `packaging/update/release-key.pub.pem`),节点之后的更新由节点自己验,不依赖宿主机更新器和 Backend 转发的内容。发布密钥轮换时,新公钥必须由旧密钥签名的清单带下来,否则节点不认。 +- 更新建议要求**双签**:CI 的发布签名,加操作员 passkey 对清单摘要的批准签名。只攻破 CI 或只攻破 Backend 都不够。 + +**③ 其余** - 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。 -- 剩下的第一跳信任(脚本本身):想要严格的人可以先下载脚本,对照 Gitea 发布页公布的 SHA-256 再执行;管理端同时给出「先下载再核对」的写法。Windows 版同理,另外可以加 Authenticode 签名。 -- 放在阶段五(Backend 应用层身份固定)一起做。 +- Windows 版同理,另外可以加 Authenticode 签名。 +- 今天的现状:一行安装不装更新器,也不带发布公钥,节点自己不验任何签名;这一节是要补的缺口。 ## 16. 效率(运行时开销与日常摩擦) diff --git a/docs/deployment.md b/docs/deployment.md index 0347125..6726c11 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -232,6 +232,8 @@ sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe sudo sh deploy-node.sh backend --channel dev --admin-password '<密码>' --public-url https://<面板域名> # 其他环境变量用 --env 原样写入(只在首次创建 backend.env 时生效):本地开发前端要连这个面板时加 # --env MoShiro__CorsOrigins=http://localhost:5173,http://localhost:5174 --env MoShiro__SecureCookies=false +# 面板前面有反向代理(Caddy、frp、Cloudflare)时,登录限流和审计看到的是代理的地址:把代理的地址写进可信代理,才会采信转发头里的客户端地址 +# --env MoShiro__TrustedProxies=127.0.0.1,172.16.0.0/12 (头默认 X-Forwarded-For,可用 MoShiro__TrustedProxyHeader 改成 CF-Connecting-IP) # 之后更新:不带参数(旧版 Probe 的 probe.json 没有 fleet 段,加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old) sudo sh deploy-node.sh agent ``` From 310b2b5b1996fa67ab88de518a38f7dedbdf7093 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 23:01:45 +0800 Subject: [PATCH 08/13] feat: the one-line installer verifies the CI-signed release manifest and pulls the image by digest [CI] --- backend/openapi/v1.json | 66 +++++++++++++++++++ .../src/MoShiro.Backend.Api/InstallScripts.cs | 10 +++ .../MoShiro.Backend.Api.csproj | 1 + .../src/MoShiro.Backend.Api/OpsEndpoints.cs | 21 ++++++ .../MoShiro.Backend.Tests/InstallTests.cs | 31 +++++++++ docs/deployment.md | 2 + packaging/install/install.ps1 | 56 ++++++++++++++++ packaging/install/install.sh | 48 +++++++++++--- 8 files changed, 227 insertions(+), 8 deletions(-) diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 5b9262f..524b140 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -4847,6 +4847,72 @@ } } }, + "/install/manifest": { + "get": { + "tags": [ + "ops" + ], + "summary": "The signed release manifest of the component an install code is for (exact signed bytes)", + "operationId": "install_manifest", + "parameters": [ + { + "name": "code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, + "/install/manifest.sig": { + "get": { + "tags": [ + "ops" + ], + "summary": "Base64 DER ECDSA P-256 signature of the manifest", + "operationId": "install_manifest_signature", + "parameters": [ + { + "name": "code", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/install.ps1": { "get": { "tags": [ diff --git a/backend/src/MoShiro.Backend.Api/InstallScripts.cs b/backend/src/MoShiro.Backend.Api/InstallScripts.cs index 35d427f..1969b20 100644 --- a/backend/src/MoShiro.Backend.Api/InstallScripts.cs +++ b/backend/src/MoShiro.Backend.Api/InstallScripts.cs @@ -9,12 +9,22 @@ namespace MoShiro.Backend.Api; /// public static class InstallScripts { + /// CI 的发布公钥(packaging/update/release-key.pub.pem):脚本用它验发布清单的签名。 + public static string ReleaseKey() + { + using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream("release-key.pub.pem") + ?? throw new InvalidOperationException("the embedded release key is missing"); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + public static string Render(string resource, InstallTarget target) { using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream(resource) ?? throw new InvalidOperationException($"the embedded resource {resource} is missing"); using var reader = new StreamReader(stream); return reader.ReadToEnd() + .Replace("@@RELEASE_KEY@@", ReleaseKey().Trim(), StringComparison.Ordinal) .Replace("@@ROLE@@", target.Role, StringComparison.Ordinal) .Replace("@@SERVER@@", target.Server, StringComparison.Ordinal) .Replace("@@CODE@@", target.Code, StringComparison.Ordinal) diff --git a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj index fdf3d8b..3ef7001 100644 --- a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj +++ b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj @@ -9,5 +9,6 @@ + diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index f098c99..801f51a 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -26,11 +26,32 @@ public static class OpsEndpoints .Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript").ProducesProblems(404); app.MapGet("/install.sh", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript")) .ExcludeFromDescription(); + // 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。 + app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false)) + .Api("install_manifest", "The signed release manifest of the component an install code is for (exact signed bytes)", Tag) + .Produces(StatusCodes.Status200OK, contentType: "application/json").ProducesProblems(404); + app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true)) + .Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag) + .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); app.MapGet("/install.ps1", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.ps1", "text/plain")) .Api("install_ps1", "One-line installer for Windows nodes (agent or probe, decided by the code)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); } + private static async Task ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature) + { + string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; + InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted); + ReleaseOffer? offer = target is null ? null : await releases.GetAsync(target.Role, http.RequestAborted); + if (offer is null) + { + return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code"); + } + + http.Response.Headers.CacheControl = "no-store"; + return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8"); + } + private static async Task InstallAsync(HttpContext http, JoinTokenService tokens, BackendOptions options, string? code, string resource, string contentType) { string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index ddcb629..8d38ada 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -56,6 +56,37 @@ public sealed class InstallTests Assert.DoesNotContain("@@", powershell, StringComparison.Ordinal); } + [Fact] + public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte_AndNothingWithoutAValidCode() + { + string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}"""; + using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUCIQDc" })); + Assert.True(published.StatusCode is HttpStatusCode.Created or HttpStatusCode.Conflict, await published.Content.ReadAsStringAsync()); + + string code = (await CreateAsync("probe"))["plaintext"]!.GetValue(); + using HttpResponseMessage served = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code); + Assert.Equal(HttpStatusCode.OK, served.StatusCode); + Assert.Equal("no-store", served.Headers.CacheControl?.ToString()); + string body = await served.Content.ReadAsStringAsync(); + Assert.Contains("\"component\":\"probe\"", body, StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改 + using HttpResponseMessage signature = await _backend.Anonymous.GetAsync("/install/manifest.sig?code=" + code); + Assert.Equal(HttpStatusCode.OK, signature.StatusCode); + Assert.False(string.IsNullOrWhiteSpace(await signature.Content.ReadAsStringAsync())); + + Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest?code=jt_wrong")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest.sig")).StatusCode); + } + + [Fact] + public async Task TheScript_CarriesTheReleaseKey_ToVerifyTheManifest() + { + string code = (await CreateAsync("agent"))["plaintext"]!.GetValue(); + string script = await _backend.Anonymous.GetStringAsync("/install?code=" + code); + Assert.Contains("-----BEGIN PUBLIC KEY-----", script, StringComparison.Ordinal); + Assert.DoesNotContain("@@", script, StringComparison.Ordinal); + Assert.Contains("install/manifest?code=", script, StringComparison.Ordinal); + } + [Fact] public async Task Codes_RecordWhoCreatedThem() { diff --git a/docs/deployment.md b/docs/deployment.md index 6726c11..1693e8c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -307,6 +307,8 @@ sh packaging/update/install-updater.sh agent --user \ 发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。 +一行安装会验证镜像:脚本向面板取 CI 签名的发布清单(`/install/manifest`、`/install/manifest.sig`),用脚本里内置的发布公钥(`packaging/update/release-key.pub.pem`)验签,再按清单里的 digest 拉镜像(`镜像仓库@sha256:…`),签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 `openssl`(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 `MOSHIRO_ALLOW_UNVERIFIED=1` 才会按标签安装。脚本本身的完整性(哈希核对)见 [access.md](access.md) 第 15 节。 + ## Backend 签名更新 Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 `/healthz`;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。 diff --git a/packaging/install/install.ps1 b/packaging/install/install.ps1 index 5c43dea..b3a662c 100644 --- a/packaging/install/install.ps1 +++ b/packaging/install/install.ps1 @@ -1,13 +1,41 @@ # MoShiro 节点一键安装(Windows,Agent 和 Probe 共用同一份)。面板在 /install.ps1?code=… 生成这份脚本,把下面四个值填好: # irm 'https://panel/install.ps1?code=jt_…' | iex +# 镜像:向面板取 CI 签名的发布清单,用内置的发布公钥验签,再按清单里的 digest 拉取。面板还没有签名的清单时,设 $env:MOSHIRO_ALLOW_UNVERIFIED='1' 才会按标签安装。 # 做的事:用 Docker Desktop 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取计算机名。 # 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)。 +function Test-MoShiroSignature([byte[]]$data, [byte[]]$derSignature, [string]$pem) { + # 发布公钥是 PEM 的 SPKI(P-256):最后 64 字节是 X、Y;签名是 DER,.NET 要 r||s + $b64 = ($pem -split "`n" | Where-Object { $_ -notmatch '^-----' }) -join '' + $spki = [Convert]::FromBase64String($b64.Trim()) + $xy = $spki[($spki.Length - 64)..($spki.Length - 1)] + $blob = [byte[]]@(0x45, 0x43, 0x53, 0x31, 0x20, 0, 0, 0) + $xy + $i = 2 + if ($derSignature[1] -band 0x80) { $i = 2 + ($derSignature[1] -band 0x7f) } + $parts = @() + for ($n = 0; $n -lt 2; $n++) { + $len = $derSignature[$i + 1] + $part = [byte[]]$derSignature[($i + 2)..($i + 1 + $len)] + while ($part.Length -gt 32 -and $part[0] -eq 0) { $part = [byte[]]$part[1..($part.Length - 1)] } + if ($part.Length -gt 32) { return $false } + $parts += , ([byte[]](New-Object byte[] (32 - $part.Length)) + $part) + $i += 2 + $len + } + $key = [Security.Cryptography.CngKey]::Import($blob, [Security.Cryptography.CngKeyBlobFormat]::EccPublicBlob) + try { + $ecdsa = New-Object Security.Cryptography.ECDsaCng($key) + return $ecdsa.VerifyData($data, [byte[]]($parts[0] + $parts[1]), [Security.Cryptography.HashAlgorithmName]::SHA256) + } finally { $key.Dispose() } +} + function Install-MoShiro { $ErrorActionPreference = 'Stop' $role = '@@ROLE@@' $server = '@@SERVER@@' $code = '@@CODE@@' $image = '@@IMAGE@@' + $releaseKey = @' +@@RELEASE_KEY@@ +'@ $name = if ($env:MOSHIRO_NAME) { $env:MOSHIRO_NAME } else { $env:COMPUTERNAME } $name = $name -replace '[^A-Za-z0-9._-]', '-' @@ -34,6 +62,34 @@ function Install-MoShiro { return } + # 验发布清单:签名对不上、组件不对、digest 格式不对都直接拒绝,不装 + $client = New-Object System.Net.WebClient + $manifest = $null + try { + $manifest = $client.DownloadData("$server/install/manifest?code=$code") + $signature = [Convert]::FromBase64String(($client.DownloadString("$server/install/manifest.sig?code=$code")).Trim()) + } catch { + $manifest = $null + } + if ($null -eq $manifest) { + if ($env:MOSHIRO_ALLOW_UNVERIFIED -ne '1') { + Write-Error "the panel has no signed release for $role yet, so the image cannot be verified. Run again with `$env:MOSHIRO_ALLOW_UNVERIFIED='1' to install $image without verification" + return + } + Write-Host "warning: installing $image without signature verification" + } else { + if (-not (Test-MoShiroSignature $manifest $signature $releaseKey)) { + Write-Error 'the release signature does not verify against the built-in release key; refusing to install' + return + } + $release = [Text.Encoding]::UTF8.GetString($manifest) | ConvertFrom-Json + if ($release.component -ne $role) { Write-Error "the signed release is for '$($release.component)', not '$role'"; return } + if ($release.digest -notmatch '^sha256:[0-9a-f]{64}$') { Write-Error 'the signed release has a malformed digest'; return } + if ($release.image -notmatch '^[A-Za-z0-9._/:-]+$') { Write-Error 'the signed release has a malformed image name'; return } + $image = "$($release.image)@$($release.digest)" + Write-Host "verified release $($release.version) ($($release.tag)): $image" + } + Write-Host "pulling $image" docker pull $image | Out-Null if ($LASTEXITCODE -ne 0) { diff --git a/packaging/install/install.sh b/packaging/install/install.sh index 1a012ac..04d3271 100644 --- a/packaging/install/install.sh +++ b/packaging/install/install.sh @@ -2,13 +2,16 @@ # MoShiro 节点一键安装(Agent 和 Probe 共用同一份)。面板在 /install?code=… 生成这份脚本,把下面四个值填好: # curl -fsSL 'https://panel/install?code=jt_…' | bash (macOS 用 zsh) # 做的事:用 Docker 或 Podman 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取主机名。 -# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_ENGINE=docker|podman、MOSHIRO_DRY_RUN=1(只打印命令)。 +# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_ENGINE=docker|podman、MOSHIRO_DRY_RUN=1(只打印命令)、 +# MOSHIRO_ALLOW_UNVERIFIED=1(面板还没有已签名的发布清单时,不验签、按标签安装;默认拒绝)。 +# 镜像:向面板取 CI 签名的发布清单,用下面内置的发布公钥验签,再按清单里的 digest 拉取(镜像按内容寻址,换不了)。 set -eu ROLE='@@ROLE@@' SERVER='@@SERVER@@' CODE='@@CODE@@' IMAGE='@@IMAGE@@' +RELEASE_KEY='@@RELEASE_KEY@@' say() { printf '%s\n' "$*"; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } @@ -39,6 +42,42 @@ volume="moshiro-$ROLE-config" linux=0 [ "$(uname -s)" = Linux ] && linux=1 +if [ "${MOSHIRO_DRY_RUN:-}" != 1 ] && eng inspect "$container" >/dev/null 2>&1 /dev/null /dev/null /dev/null /dev/null 2>&1 || die "openssl is required to verify the release signature" + printf '%s\n' "$RELEASE_KEY" > "$tmp/key.pem" + openssl base64 -d -A -in "$tmp/sig.b64" -out "$tmp/sig.bin" 2>/dev/null || die "the release signature is not valid base64" + openssl dgst -sha256 -verify "$tmp/key.pem" -signature "$tmp/sig.bin" "$tmp/manifest" >/dev/null 2>&1 \ + || die "the release signature does not verify against the built-in release key; refusing to install" + field() { sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p" "$tmp/manifest" | head -n 1; } + [ "$(field component)" = "$ROLE" ] || die "the signed release is for '$(field component)', not '$ROLE'" + repo="$(field image)" + digest="$(field digest)" + case "$digest" in sha256:????????????????????????????????????????????????????????????????) ;; *) die "the signed release has a malformed digest" ;; esac + case "$repo" in ''|*[!A-Za-z0-9._/:-]*) die "the signed release has a malformed image name" ;; esac + IMAGE="$repo@$digest" + say "verified release $(field version) ($(field tag)): $IMAGE" +} +resolve_image + restart=unless-stopped [ "$engine" = podman ] && restart=always set -- run -d --name "$container" --restart "$restart" \ @@ -64,13 +103,6 @@ if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then exit 0 fi -if eng inspect "$container" >/dev/null 2>&1 /dev/null /dev/null Date: Wed, 7 Oct 2026 23:02:50 +0800 Subject: [PATCH 09/13] test: allow the installer manifest paths in the OpenAPI layout check [CI SKIP] --- backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs index b675b81..cc2b24a 100644 --- a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs @@ -53,7 +53,7 @@ public sealed partial class OpenApiConformanceTests foreach ((string path, _) in doc["paths"]!.AsObject()) { string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"]; - string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1"]; + string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig"]; Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers"); foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) { From 321cacac32b869417f1db0e866dcd246c7166f06 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 23:09:09 +0800 Subject: [PATCH 10/13] feat: operator trust chain library (passkey-signed operator set, grants and code-key bindings) [CI SKIP] --- .../fleet/src/Fleet.Identity/OperatorTrust.cs | 322 ++++++++++++++++++ .../tests/Fleet.Tests/OperatorTrustTests.cs | 195 +++++++++++ 2 files changed, 517 insertions(+) create mode 100644 backend/fleet/src/Fleet.Identity/OperatorTrust.cs create mode 100644 backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs diff --git a/backend/fleet/src/Fleet.Identity/OperatorTrust.cs b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs new file mode 100644 index 0000000..980b5a5 --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs @@ -0,0 +1,322 @@ +using System.Buffers.Text; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Fleet.Identity; + +/// passkey(WebAuthn)对一段文本签出的断言:challenge = SHA-256(Statement 的 UTF-8)。四个字段都是 base64url。 +public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature); + +/// 一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。 +public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki); + +/// +/// 操作员集合的一个版本。RpId 和 Origins 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。 +/// 创世版本(1)由它自己的某把凭证签名,指纹()在安装时固定到节点;之后每个版本由上一版里的凭证签名。 +/// +public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList Origins, IReadOnlyList Keys); + +/// 委托:操作员授权了一个加入码。CodeKey 是这个码的公钥(SPKI),码的种子是对应的私钥,只在安装命令里。 +public sealed record Grant(string Role, string AuthHash, byte[] CodeKey, long NotBeforeMs, long NotAfterMs, int Uses); + +/// 节点绑定:用码的私钥签的,把节点的两把公钥和这个码连起来。AtMs 必须落在委托的有效期内。 +public sealed record Binding(string Name, string Role, byte[] NodePublicKey, byte[] PeerPublicKey, string AuthHash, long AtMs); + +/// 一个成员带的证明:委托(passkey 签)加绑定(码的私钥签)。 +public sealed record MemberProof(SignedStatement Grant, string BindingStatement, string BindingSignature); + +public enum TrustFailure +{ + None, + Malformed, + BrokenChain, + GrantNotSigned, + GrantExpired, + BindingInvalid, + KeyMismatch, + RoleMismatch, +} + +/// +/// 信任根:操作员(passkey)→ 操作员集合 → 委托 → 绑定 → 节点公钥。设计见 docs/access.md 第 7 节。 +/// 全是纯函数,只用 BCL,节点和 Backend 共用:节点用它确认「对端是我的操作员批准的」,Backend 用它拒绝没有合法绑定的注册。 +/// 语句是 key=value 的文本,第一行是带版本的标题;签名覆盖整段文本,规范化靠固定的字段顺序和严格的解析。 +/// +public static class OperatorTrust +{ + public const string SetHeader = "moshiro-opset-v1"; + public const string GrantHeader = "moshiro-grant-v1"; + public const string BindingHeader = "moshiro-binding-v1"; + public const string Genesis = "0"; + + private const string AuthLabel = "moshiro-join-auth-v1"; + + // ───────────────────────── 编码 ───────────────────────── + + public static string Encode(OperatorSet set) + { + var text = new StringBuilder(); + text.Append(SetHeader).Append('\n') + .Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n') + .Append("prev=").Append(set.Previous).Append('\n') + .Append("rp=").Append(set.RpId).Append('\n') + .Append("origins=").Append(string.Join(',', set.Origins)); + foreach (OperatorKey key in set.Keys) + { + text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki)); + } + + return text.ToString(); + } + + public static string Encode(Grant grant) => + string.Join( + '\n', GrantHeader, "role=" + grant.Role, "auth=" + grant.AuthHash, "key=" + Convert.ToBase64String(grant.CodeKey), + "nb=" + grant.NotBeforeMs.ToString(CultureInfo.InvariantCulture), "na=" + grant.NotAfterMs.ToString(CultureInfo.InvariantCulture), "uses=" + grant.Uses.ToString(CultureInfo.InvariantCulture)); + + public static string Encode(Binding binding) => + string.Join( + '\n', BindingHeader, "name=" + binding.Name, "role=" + binding.Role, "pub=" + Convert.ToBase64String(binding.NodePublicKey), + "peer=" + Convert.ToBase64String(binding.PeerPublicKey), "auth=" + binding.AuthHash, "at=" + binding.AtMs.ToString(CultureInfo.InvariantCulture)); + + // ───────────────────────── 解析(严格:多一行、少一行、顺序不对都算坏) ───────────────────────── + + public static OperatorSet? ParseSet(string text) + { + string[] lines = text.Split('\n'); + if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1 + || !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0) + { + return null; + } + + var keys = new List(); + foreach (string line in lines.Skip(5)) + { + if (!TryValue(line, "key", out string value) || value.IndexOf(':', StringComparison.Ordinal) is not (> 0 and int split) || !TryBase64(value[(split + 1)..], out byte[] spki)) + { + return null; + } + + keys.Add(new OperatorKey(value[..split], spki)); + } + + return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys); + } + + public static Grant? ParseGrant(string text) + { + string[] lines = text.Split('\n'); + if (lines.Length != 7 || lines[0] != GrantHeader || !TryValue(lines[1], "role", out string role) || !TryValue(lines[2], "auth", out string auth) || !TryValue(lines[3], "key", out string key) || !TryBase64(key, out byte[] codeKey) + || !TryLong(lines[4], "nb", out long notBefore) || !TryLong(lines[5], "na", out long notAfter) || !TryValue(lines[6], "uses", out string uses) || !int.TryParse(uses, NumberStyles.None, CultureInfo.InvariantCulture, out int count) + || role.Length == 0 || auth.Length != 64) + { + return null; + } + + return new Grant(role, auth, codeKey, notBefore, notAfter, count); + } + + public static Binding? ParseBinding(string text) + { + string[] lines = text.Split('\n'); + if (lines.Length != 7 || lines[0] != BindingHeader || !TryValue(lines[1], "name", out string name) || !TryValue(lines[2], "role", out string role) || !TryValue(lines[3], "pub", out string pub) || !TryBase64(pub, out byte[] node) + || !TryValue(lines[4], "peer", out string peer) || !TryBase64(peer, out byte[] peerKey) || !TryValue(lines[5], "auth", out string auth) || !TryLong(lines[6], "at", out long at) || auth.Length != 64) + { + return null; + } + + return new Binding(name, role, node, peerKey, auth, at); + } + + // ───────────────────────── 指纹与验证 ───────────────────────── + + /// 一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。 + public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); + + /// + /// 验证操作员集合的链:第一项是创世版本(指纹等于 ,自签),其后每一项由上一版里的凭证签名、版本加一、prev 指向上一版的指纹、域名不变。 + /// 返回最后一版;不对返回 null。 + /// + public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList chain) + { + if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + OperatorSet? current = null; + string? previousFingerprint = null; + foreach (SignedStatement link in chain) + { + OperatorSet? next = ParseSet(link.Statement); + if (next is null) + { + return null; + } + + if (current is null) + { + if (next.Version != 1 || !VerifyOperator(link, next)) + { + return null; + } + } + else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current)) + { + return null; + } + + current = next; + previousFingerprint = Fingerprint(link.Statement); + } + + return current; + } + + /// 断言是不是这个集合里某把凭证对 Statement 签的(要求用户验证;挑战是语句的 SHA-256)。 + public static bool VerifyOperator(SignedStatement signed, OperatorSet set) + { + OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId); + if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature)) + { + return false; + } + + return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _); + } + + /// + /// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签, + /// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。 + /// + public static TrustFailure VerifyMember(OperatorSet current, MemberProof proof, ReadOnlySpan nodePublicKey, ReadOnlySpan peerPublicKey, string role) + { + Grant? grant = ParseGrant(proof.Grant.Statement); + Binding? binding = ParseBinding(proof.BindingStatement); + if (grant is null || binding is null || !TryDecode(proof.BindingSignature, out byte[] bindingSignature)) + { + return TrustFailure.Malformed; + } + + if (!VerifyOperator(proof.Grant, current)) + { + return TrustFailure.GrantNotSigned; + } + + if (binding.AtMs < grant.NotBeforeMs || binding.AtMs > grant.NotAfterMs) + { + return TrustFailure.GrantExpired; + } + + if (binding.AuthHash != grant.AuthHash || binding.Role != grant.Role) + { + return TrustFailure.KeyMismatch; + } + + if (!binding.NodePublicKey.AsSpan().SequenceEqual(nodePublicKey) || !binding.PeerPublicKey.AsSpan().SequenceEqual(peerPublicKey)) + { + return TrustFailure.KeyMismatch; + } + + if (grant.Role != role) + { + return TrustFailure.RoleMismatch; + } + + try + { + using ECDsa codeKey = ECDsa.Create(); + codeKey.ImportSubjectPublicKeyInfo(grant.CodeKey, out _); + return codeKey.VerifyData(Encoding.UTF8.GetBytes(proof.BindingStatement), bindingSignature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence) + ? TrustFailure.None + : TrustFailure.BindingInvalid; + } + catch (CryptographicException) + { + return TrustFailure.BindingInvalid; + } + } + + // ───────────────────────── 加入码的种子 ───────────────────────── + + /// + /// 种子(32 字节,就是码的 P-256 私钥)派生出交给 Backend 的认证串:Backend 只会看到它的哈希,推不回种子, + /// 所以 Backend 没法替这个码签绑定。HKDF-SHA256,空盐,用途标签在 info 里;浏览器端的 WebCrypto 用同样的参数。 + /// + public static string AuthToken(ReadOnlySpan seed) => + "ja_" + Base64Url.EncodeToString(Derive(seed)); + + private static byte[] Derive(ReadOnlySpan seed) + { + byte[] output = new byte[32]; + HKDF.DeriveKey(HashAlgorithmName.SHA256, seed, output, ReadOnlySpan.Empty, Encoding.UTF8.GetBytes(AuthLabel)); + return output; + } + + public static string AuthHash(string authToken) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(authToken))); + + /// 用种子和委托里的码公钥还原码的签名密钥(公钥从已验证的委托里来,种子里只有私钥标量)。 + public static ECDsa? CodeSigningKey(ReadOnlySpan seed, ReadOnlySpan codeKeySpki) + { + try + { + using ECDsa publicOnly = ECDsa.Create(); + publicOnly.ImportSubjectPublicKeyInfo(codeKeySpki, out _); + ECParameters parameters = publicOnly.ExportParameters(false); + parameters.D = seed.ToArray(); + return ECDsa.Create(parameters); + } + catch (CryptographicException) + { + return null; + } + } + + public static string SignBinding(ECDsa codeKey, Binding binding, out string statement) + { + statement = Encode(binding); + return Base64Url.EncodeToString(codeKey.SignData(Encoding.UTF8.GetBytes(statement), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); + } + + // ───────────────────────── 小工具 ───────────────────────── + + private static bool TryValue(string line, string key, out string value) + { + value = ""; + if (!line.StartsWith(key + "=", StringComparison.Ordinal)) + { + return false; + } + + value = line[(key.Length + 1)..]; + return true; + } + + private static bool TryLong(string line, string key, out long value) + { + value = 0; + return TryValue(line, key, out string text) && long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value); + } + + private static bool TryBase64(string text, out byte[] data) + { + try + { + data = Convert.FromBase64String(text); + return data.Length > 0; + } + catch (FormatException) + { + data = []; + return false; + } + } + + private static bool TryDecode(string text, out byte[] data) + { + data = []; + return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0; + } +} diff --git a/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs b/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs new file mode 100644 index 0000000..ae42f15 --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs @@ -0,0 +1,195 @@ +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; +using Fleet.Identity; + +namespace Fleet.Tests; + +/// 软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。 +internal sealed class TestAuthenticator : IDisposable +{ + public const string RpId = "panel.example.com"; + public const string Origin = "https://panel.example.com"; + + private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + private uint _counter; + + public string CredentialId { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); + + public OperatorKey Key => new(CredentialId, _key.ExportSubjectPublicKeyInfo()); + + public SignedStatement Sign(string statement, byte flags = 0x05, string origin = Origin) + { + byte[] challenge = SHA256.HashData(Encoding.UTF8.GetBytes(statement)); + _counter++; + byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(RpId)), flags, (byte)(_counter >> 24), (byte)(_counter >> 16), (byte)(_counter >> 8), (byte)_counter]; + byte[] clientData = Encoding.UTF8.GetBytes($"{{\"type\":\"webauthn.get\",\"challenge\":\"{Base64Url.EncodeToString(challenge)}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}"); + byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); + return new SignedStatement(statement, CredentialId, Base64Url.EncodeToString(authData), Base64Url.EncodeToString(clientData), Base64Url.EncodeToString(signature)); + } + + public void Dispose() => _key.Dispose(); +} + +public sealed class OperatorTrustTests : IDisposable +{ + private readonly TestAuthenticator _alice = new(); + private readonly TestAuthenticator _bob = new(); + + public void Dispose() + { + _alice.Dispose(); + _bob.Dispose(); + } + + private OperatorSet Genesis(params TestAuthenticator[] keys) => + new(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [.. keys.Select(k => k.Key)]); + + private SignedStatement SignedGenesis(OperatorSet set, TestAuthenticator signer) => signer.Sign(OperatorTrust.Encode(set)); + + [Fact] + public void TheGenesis_IsPinnedByItsFingerprint_AndMustBeSelfSigned() + { + OperatorSet set = Genesis(_alice); + SignedStatement genesis = SignedGenesis(set, _alice); + string pin = OperatorTrust.Fingerprint(genesis.Statement); + + Assert.NotNull(OperatorTrust.VerifyChain(pin, [genesis])); + Assert.Null(OperatorTrust.VerifyChain(new string('0', 64), [genesis])); // 指纹对不上:Backend 换了一份创世 + Assert.Null(OperatorTrust.VerifyChain(pin, [SignedGenesis(set, _bob)])); // 不是集合里的凭证签的(也改不了指纹) + Assert.Null(OperatorTrust.VerifyChain(pin, [])); + } + + [Fact] + public void ABackendThatSwapsTheKeys_CannotMakeAChainTheNodeAccepts() + { + SignedStatement real = SignedGenesis(Genesis(_alice), _alice); + string pin = OperatorTrust.Fingerprint(real.Statement); + + using var attacker = new TestAuthenticator(); + SignedStatement forged = SignedGenesis(Genesis(attacker), attacker); // 自洽的假创世,但指纹不同 + Assert.Null(OperatorTrust.VerifyChain(pin, [forged])); + + // 在真创世后面接一个攻击者签的更新:上一版里没有他的凭证 + var update = new OperatorSet(2, OperatorTrust.Fingerprint(real.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, attacker.Key]); + Assert.Null(OperatorTrust.VerifyChain(pin, [real, attacker.Sign(OperatorTrust.Encode(update))])); + } + + [Fact] + public void AnUpdate_NeedsAKeyOfThePreviousVersion_AndFollowsTheChain() + { + SignedStatement genesis = SignedGenesis(Genesis(_alice), _alice); + string pin = OperatorTrust.Fingerprint(genesis.Statement); + var second = new OperatorSet(2, OperatorTrust.Fingerprint(genesis.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, _bob.Key]); + SignedStatement signedSecond = _alice.Sign(OperatorTrust.Encode(second)); + + OperatorSet? current = OperatorTrust.VerifyChain(pin, [genesis, signedSecond]); + Assert.Equal(2, current?.Version); + Assert.Equal(2, current?.Keys.Count); + + // 新加的 bob 可以签下一版(上一版里有他) + var third = new OperatorSet(3, OperatorTrust.Fingerprint(signedSecond.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_bob.Key]); + Assert.Equal(3, OperatorTrust.VerifyChain(pin, [genesis, signedSecond, _bob.Sign(OperatorTrust.Encode(third))])?.Version); + + // 版本号跳了、prev 不对、域名换了:都不行 + var skipped = second with { Version = 3 }; + Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(skipped))])); + var wrongPrev = second with { Previous = new string('a', 64) }; + Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(wrongPrev))])); + var otherRp = second with { RpId = "evil.example" }; + Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(otherRp))])); + } + + [Fact] + public void AnAssertion_NeedsUserVerification_TheRightOrigin_AndTheExactStatement() + { + OperatorSet set = Genesis(_alice); + Assert.True(OperatorTrust.VerifyOperator(_alice.Sign("hello"), set)); + Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", flags: 0x01), set)); // 没有用户验证 + Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", origin: "https://evil.example"), set)); + Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello") with { Statement = "hellO" }, set)); // 语句被改 + Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里 + } + + private sealed record Enrolled(OperatorSet Set, byte[] Seed, MemberProof Proof, byte[] NodeKey, byte[] PeerKey, string Auth); + + private Enrolled Enroll(string role = "probe", long now = 1_000_000, long notBefore = 0, long notAfter = 2_000_000, TestAuthenticator? signer = null) + { + OperatorSet set = Genesis(_alice); + using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256); + byte[] seed = code.ExportParameters(true).D!; + string auth = OperatorTrust.AuthToken(seed); + var grant = new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), notBefore, notAfter, 1); + SignedStatement signedGrant = (signer ?? _alice).Sign(OperatorTrust.Encode(grant)); + + byte[] nodeKey = RandomNumberGenerator.GetBytes(91); + byte[] peerKey = RandomNumberGenerator.GetBytes(91); + using ECDsa? signing = OperatorTrust.CodeSigningKey(seed, grant.CodeKey); + Assert.NotNull(signing); + string signature = OperatorTrust.SignBinding(signing, new Binding("tokyo", role, nodeKey, peerKey, grant.AuthHash, now), out string statement); + return new Enrolled(set, seed, new MemberProof(signedGrant, statement, signature), nodeKey, peerKey, auth); + } + + [Fact] + public void AMember_VerifiesThroughTheWholeChain() + { + Enrolled e = Enroll(); + Assert.Equal(TrustFailure.None, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "probe")); + } + + [Fact] + public void TheSeed_IsNotRecoverableFromWhatTheBackendSees() + { + Enrolled e = Enroll(); + Assert.StartsWith("ja_", e.Auth, StringComparison.Ordinal); + Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Auth, StringComparison.Ordinal); + Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Proof.Grant.Statement + e.Proof.BindingStatement, StringComparison.Ordinal); + Assert.Equal(e.Auth, OperatorTrust.AuthToken(e.Seed)); // 节点自己能算出同一个 + } + + [Fact] + public void ABindingForOtherKeys_AnotherRole_OrAnExpiredGrant_IsRefused() + { + Enrolled e = Enroll(); + Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, RandomNumberGenerator.GetBytes(91), e.PeerKey, "probe")); // Backend 想把别的节点密钥挂在这个绑定上 + Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, RandomNumberGenerator.GetBytes(91), "probe")); + Assert.Equal(TrustFailure.RoleMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "agent")); // 一个 Probe 的绑定不能冒充 Agent + + Enrolled late = Enroll(now: 3_000_000, notAfter: 2_000_000); // 有效期之后才签的绑定 + Assert.Equal(TrustFailure.GrantExpired, OperatorTrust.VerifyMember(late.Set, late.Proof, late.NodeKey, late.PeerKey, "probe")); + } + + [Fact] + public void AGrantNotSignedByTheOperator_OrABindingNotSignedByTheCodeKey_IsRefused() + { + using var stranger = new TestAuthenticator(); + Enrolled forged = Enroll(signer: stranger); + Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(forged.Set, forged.Proof, forged.NodeKey, forged.PeerKey, "probe")); + + // Backend 拿到了委托但没有种子:用自己的密钥签绑定 + Enrolled e = Enroll(); + using ECDsa backend = ECDsa.Create(ECCurve.NamedCurves.nistP256); + Binding stolen = OperatorTrust.ParseBinding(e.Proof.BindingStatement)!; + string bogus = OperatorTrust.SignBinding(backend, stolen, out string statement); + Assert.Equal(TrustFailure.BindingInvalid, OperatorTrust.VerifyMember(e.Set, new MemberProof(e.Proof.Grant, statement, bogus), e.NodeKey, e.PeerKey, "probe")); + } + + [Fact] + public void RemovingAnOperatorKey_InvalidatesTheGrantsItSigned() + { + Enrolled e = Enroll(); + OperatorSet withoutAlice = Genesis(_bob) with { Version = 2, Previous = new string('a', 64) }; + Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(withoutAlice, e.Proof, e.NodeKey, e.PeerKey, "probe")); + } + + [Fact] + public void Parsing_IsStrict() + { + Assert.Null(OperatorTrust.ParseGrant("moshiro-grant-v1\nrole=probe")); + Assert.Null(OperatorTrust.ParseBinding("moshiro-binding-v1\nname=x")); + Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1")); + OperatorSet set = Genesis(_alice); + Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId); + Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0 + } +} From fc2bbdcc6d9196964dba0674a9b87d143234b225 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 23:19:59 +0800 Subject: [PATCH 11/13] feat: operator trust root: passkey-signed operator set, signed join codes, nodes verify and pin the chain at enrollment [CI SKIP] --- backend/fleet/src/Fleet.Core/TrustDtos.cs | 45 +++ .../EnrollmentClient.cs | 101 +++++- .../fleet/src/Fleet.Hosting/TrustAnchor.cs | 132 +++++++ .../fleet/src/Fleet.Identity/OperatorTrust.cs | 10 + .../fleet/src/Fleet.Identity/TrustMapping.cs | 15 + .../Fleet.Server.Abstractions/NodeRecords.cs | 6 +- .../ServerContracts.cs | 6 + .../EnrollmentModels.cs | 15 + .../FleetEnrollmentEndpoints.cs | 33 ++ .../FleetEnrollmentService.cs | 62 +++- backend/fleet/tools/fleet-enroll/Program.cs | 10 +- backend/openapi/v1.json | 326 ++++++++++++++++++ .../Admin/AuthEndpoints.cs | 2 +- .../Admin/TrustEndpoints.cs | 74 ++++ backend/src/MoShiro.Backend.Api/ApiModule.cs | 1 + .../Common/EndpointConventions.cs | 5 + .../src/MoShiro.Backend.Api/Common/Guards.cs | 5 +- .../src/MoShiro.Backend.Api/Common/Mappers.cs | 4 +- .../Contracts/AdminDtos.cs | 13 +- .../Contracts/ApiJsonContext.cs | 3 + .../Admin/JoinTokenService.cs | 73 +++- .../ApplicationServiceCollectionExtensions.cs | 2 + .../Auth/TrustService.cs | 130 +++++++ .../Models/AgentModels.cs | 8 +- .../Ports/IAuthStore.cs | 3 + .../Ports/StorePorts.cs | 11 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 15 + .../SqliteAgentStore.cs | 17 +- .../SqliteAuthStore.cs | 3 + .../SqliteConfigStores.cs | 20 +- .../SqliteOperatorSetStore.cs | 28 ++ .../StorageJson.cs | 11 + .../StorageServiceCollectionExtensions.cs | 1 + .../MoShiro.Backend.Tests/StorageTests.cs | 4 +- .../tests/MoShiro.Backend.Tests/TrustTests.cs | 220 ++++++++++++ 35 files changed, 1374 insertions(+), 40 deletions(-) create mode 100644 backend/fleet/src/Fleet.Core/TrustDtos.cs create mode 100644 backend/fleet/src/Fleet.Hosting/TrustAnchor.cs create mode 100644 backend/fleet/src/Fleet.Identity/TrustMapping.cs create mode 100644 backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs create mode 100644 backend/src/MoShiro.Backend.Application/Auth/TrustService.cs create mode 100644 backend/src/MoShiro.Backend.Storage.Sqlite/SqliteOperatorSetStore.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/TrustTests.cs diff --git a/backend/fleet/src/Fleet.Core/TrustDtos.cs b/backend/fleet/src/Fleet.Core/TrustDtos.cs new file mode 100644 index 0000000..05ce501 --- /dev/null +++ b/backend/fleet/src/Fleet.Core/TrustDtos.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace Fleet.Core; + +/// passkey 对一段语句签出的断言(字段都是 base64url)。信任链的格式和验证在 Fleet.Identity 的 OperatorTrust。 +public sealed class SignedStatementDto +{ + [JsonPropertyName("statement")] + public string Statement { get; set; } = ""; + + [JsonPropertyName("credential_id")] + public string CredentialId { get; set; } = ""; + + [JsonPropertyName("authenticator_data")] + public string AuthenticatorData { get; set; } = ""; + + [JsonPropertyName("client_data_json")] + public string ClientDataJson { get; set; } = ""; + + [JsonPropertyName("signature")] + public string Signature { get; set; } = ""; +} + +/// 一个成员带的证明:操作员签的委托,加加入码的私钥签的绑定。 +public sealed class MemberProofDto +{ + [JsonPropertyName("grant")] + public SignedStatementDto Grant { get; set; } = new(); + + [JsonPropertyName("binding")] + public string Binding { get; set; } = ""; + + [JsonPropertyName("binding_signature")] + public string BindingSignature { get; set; } = ""; +} + +/// 操作员集合的链(创世在前),可选带一个委托(注册前取的,节点验过之后才继续)。 +public sealed class TrustBundle +{ + [JsonPropertyName("chain")] + public List Chain { get; set; } = []; + + [JsonPropertyName("grant")] + public SignedStatementDto? Grant { get; set; } +} diff --git a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs index 726d27c..bc81783 100644 --- a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs +++ b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs @@ -2,13 +2,17 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Fleet.Core; using Fleet.Hosting; using Fleet.Identity; namespace Fleet.Enrollment.Client; -public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory); +[JsonSerializable(typeof(TrustBundle))] +internal sealed partial class TrustBundleContext : JsonSerializerContext; + +public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory, bool Pinned = false); /// /// 用加入令牌注册(供 fleet-enroll 使用,也可以嵌入产品自己的安装工具): @@ -18,7 +22,8 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident public static class EnrollmentClient { public static async Task> EnrollAsync( - HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct) + HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct, + string? seed = null, string? genesis = null, string role = "agent") { string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName); if (File.Exists(nodeFile)) @@ -36,6 +41,27 @@ public static class EnrollmentClient tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo(); } + // 固定了操作员的创世(--genesis)和码的种子(--seed):先取信任链,确认这个面板的操作员集合和委托确实是固定的创世签出来的, + // 再用码的私钥签绑定。任何一步对不上都不注册,也不把自己的密钥交出去。 + Binding? binding = null; + string? bindingStatement = null; + string? bindingSignature = null; + TrustBundle? bundle = null; + if (seed is not null || genesis is not null) + { + Result<(TrustBundle Bundle, Binding Binding, string Statement, string Signature)> trusted = + await EstablishTrustAsync(http, server, joinToken, name, role, seed, genesis, identity.PublicKeySpki, tlsPublicKey, time, ct); + if (!trusted.TryGet(out (TrustBundle Bundle, Binding Binding, string Statement, string Signature) value, out FleetError? trustError)) + { + return Result.Fail(trustError); + } + + bundle = value.Bundle; + binding = value.Binding; + bindingStatement = value.Statement; + bindingSignature = value.Signature; + } + string pubkey = Convert.ToBase64String(identity.PublicKeySpki); string tokenHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(joinToken))); var body = new JsonObject @@ -48,6 +74,8 @@ public static class EnrollmentClient ["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)), ["node_version"] = nodeVersion, ["meta"] = productMeta, + ["binding"] = bindingStatement, + ["binding_signature"] = bindingSignature, }; using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); @@ -76,10 +104,77 @@ public static class EnrollmentClient var store = new ConfigStore(identityDirectory); store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8()); store.Write(LoadedNodeIdentity.TlsFileName, pfx); + if (bundle is not null && binding is not null) + { + TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, new MemberProofDto { Grant = bundle.Grant!, Binding = bindingStatement!, BindingSignature = bindingSignature! }); + } + // node.json 最后写:它存在就表示注册完整完成 store.Write( LoadedNodeIdentity.NodeFileName, JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile)); - return Result.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory)); + return Result.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory, bundle is not null)); + } + + private static async Task> EstablishTrustAsync( + HttpClient http, Uri server, string joinToken, string name, string role, string? seedText, string? genesis, byte[] nodeKey, byte[] peerKey, TimeProvider time, CancellationToken ct) + { + static Result<(TrustBundle, Binding, string, string)> Refuse(string reason) => Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason); + + if (seedText is null || genesis is null) + { + return Refuse("--seed and --genesis go together (the panel's install command has both)"); + } + + if (!Base64Url.TryDecode(seedText, out byte[] seed) || seed.Length != 32) + { + return Refuse("the code seed is malformed"); + } + + if (OperatorTrust.AuthToken(seed) != joinToken) + { + return Refuse("the join token is not derived from the code seed"); + } + + using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json"); + using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct); + if (!response.IsSuccessStatusCode) + { + return Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain"); + } + + TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle); + if (bundle?.Grant is null || bundle.Chain.Count == 0) + { + return Refuse("the panel has no operator trust set up, or this code carries no grant from the operator"); + } + + OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]); + if (current is null) + { + return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)"); + } + + Grant? grant = OperatorTrust.ParseGrant(bundle.Grant.Statement); + long now = time.GetUtcNow().ToUnixTimeMilliseconds(); + if (grant is null || !OperatorTrust.VerifyOperator(bundle.Grant.ToSigned(), current)) + { + return Refuse("the grant is not signed by your operator passkey"); + } + + if (grant.AuthHash != OperatorTrust.AuthHash(joinToken) || grant.Role != role || now < grant.NotBeforeMs || now > grant.NotAfterMs) + { + return Refuse("the grant is not for this code, this role, or this time"); + } + + using ECDsa? codeKey = OperatorTrust.CodeSigningKey(seed, grant.CodeKey); + if (codeKey is null) + { + return Refuse("the code seed does not match the grant's code key"); + } + + var binding = new Binding(name, role, nodeKey, peerKey, grant.AuthHash, now); + string signature = OperatorTrust.SignBinding(codeKey, binding, out string statement); + return Result<(TrustBundle, Binding, string, string)>.Ok((bundle, binding, statement, signature)); } } diff --git a/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs new file mode 100644 index 0000000..be5051c --- /dev/null +++ b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Fleet.Core; +using Fleet.Identity; + +namespace Fleet.Hosting; + +/// trust.json:注册时固定的创世指纹、见过的最新操作员集合链,以及(如果有)本节点自己的证明。 +public sealed class TrustFile +{ + [JsonPropertyName("genesis")] + public string Genesis { get; set; } = ""; + + [JsonPropertyName("chain")] + public List Chain { get; set; } = []; + + [JsonPropertyName("proof")] + public MemberProofDto? Proof { get; set; } +} + +[JsonSerializable(typeof(TrustFile))] +public sealed partial class TrustFileContext : JsonSerializerContext; + +/// +/// 节点的信任根:安装时固定下来的创世指纹,加验证过的操作员集合链。有它就是「强制」模式—— +/// 名册里的对端必须带操作员批准的证明才接纳(Backend 改不了,也签不出新的);没有 trust.json(旧节点、没有指纹的安装)就不强制。 +/// 链随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。 +/// +public sealed class TrustAnchor +{ + public const string FileName = "trust.json"; + + private readonly ConfigStore? _store; + private readonly INodeLog _log; + private readonly object _gate = new(); + private readonly TrustFile? _file; + private OperatorSet? _current; + + private TrustAnchor(ConfigStore? store, TrustFile? file, OperatorSet? current, INodeLog log) + { + _store = store; + _file = file; + _current = current; + _log = log; + } + + /// 不强制(没有固定的创世)。 + public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance); + + public bool Enforcing => _file is not null; + + public string? Genesis => _file?.Genesis; + + public MemberProofDto? OwnProof => _file?.Proof; + + public static TrustAnchor Load(string identityDirectory, INodeLog log) + { + string path = Path.Combine(identityDirectory, FileName); + if (!File.Exists(path)) + { + return new TrustAnchor(new ConfigStore(identityDirectory), null, null, log); + } + + TrustFile file = NodeConfig.Load(path, TrustFileContext.Default.TrustFile); + OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]); + if (current is null) + { + // 固定的创世和存下来的链对不上:不能当成「不强制」放过,直接让节点起不来比悄悄失去保护好 + throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again"); + } + + return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log); + } + + /// 注册时写下:固定的创世、验证过的链、本节点的证明。 + public static void Pin(string identityDirectory, string genesis, IReadOnlyList chain, MemberProofDto proof) => + new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Proof = proof }, TrustFileContext.Default.TrustFile)); + + /// 期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有强制时为 0)。 + public int Update(IReadOnlyList? chain) + { + lock (_gate) + { + if (_file is null || chain is not { Count: > 0 }) + { + return _current?.Version ?? 0; + } + + OperatorSet? candidate = OperatorTrust.VerifyChain(_file.Genesis, [.. chain.Select(c => c.ToSigned())]); + if (candidate is null) + { + _log.Warn("the operator set from the panel does not verify against the pinned genesis; ignoring it"); + return _current!.Version; + } + + if (candidate.Version > _current!.Version) + { + _file.Chain = [.. chain]; + _current = candidate; + try + { + _store?.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(_file, TrustFileContext.Default.TrustFile)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log.Warn("could not save trust.json", ("err", ex.Message)); + } + + _log.Info("operator set updated", ("version", candidate.Version)); + } + + return _current.Version; + } + } + + /// 不强制就放行;强制时对端必须带证明,且验证通过(密钥是用来认证它的那把)。 + public TrustFailure VerifyPeer(MemberProofDto? proof, ReadOnlySpan peerPublicKey, string role) + { + OperatorSet? current; + lock (_gate) + { + if (_file is null) + { + return TrustFailure.None; + } + + current = _current; + } + + return proof is null || current is null ? TrustFailure.Malformed : OperatorTrust.VerifyPeer(current, proof.ToProof(), peerPublicKey, role); + } +} diff --git a/backend/fleet/src/Fleet.Identity/OperatorTrust.cs b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs index 980b5a5..320196a 100644 --- a/backend/fleet/src/Fleet.Identity/OperatorTrust.cs +++ b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs @@ -187,6 +187,16 @@ public static class OperatorTrust return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _); } + /// + /// 验证一个对端:和 一样,只是调用方手里只有它用来认证这个对端的那把密钥(对等面公钥), + /// 所以只核对绑定里的对等面公钥;节点身份公钥由 Backend 在注册时核对。 + /// + public static TrustFailure VerifyPeer(OperatorSet current, MemberProof proof, ReadOnlySpan peerPublicKey, string role) + { + Binding? binding = ParseBinding(proof.BindingStatement); + return binding is null ? TrustFailure.Malformed : VerifyMember(current, proof, binding.NodePublicKey, peerPublicKey, role); + } + /// /// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签, /// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。 diff --git a/backend/fleet/src/Fleet.Identity/TrustMapping.cs b/backend/fleet/src/Fleet.Identity/TrustMapping.cs new file mode 100644 index 0000000..59a4843 --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/TrustMapping.cs @@ -0,0 +1,15 @@ +using Fleet.Core; + +namespace Fleet.Identity; + +public static class TrustMapping +{ + public static SignedStatement ToSigned(this SignedStatementDto dto) => new(dto.Statement, dto.CredentialId, dto.AuthenticatorData, dto.ClientDataJson, dto.Signature); + + public static SignedStatementDto ToDto(this SignedStatement signed) => + new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature }; + + public static MemberProof ToProof(this MemberProofDto dto) => new(dto.Grant.ToSigned(), dto.Binding, dto.BindingSignature); + + public static MemberProofDto ToDto(this MemberProof proof) => new() { Grant = proof.Grant.ToDto(), Binding = proof.BindingStatement, BindingSignature = proof.BindingSignature }; +} diff --git a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs index d5db797..7f88641 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs @@ -34,7 +34,8 @@ public sealed record NewNode( long NowMs, string Kind = "", byte[]? TlsPublicKey = null, - string? AddedBy = null); + string? AddedBy = null, + MemberProofDto? Proof = null); public sealed record JoinTokenRecord( string Id, @@ -48,6 +49,7 @@ public sealed record JoinTokenRecord( long? RevokedAtMs, long CreatedAtMs, string Kind = "", - string? CreatedBy = null); + string? CreatedBy = null, + SignedStatementDto? Grant = null); public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList Capabilities, string? HelloMetaJson); diff --git a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs index 72c567a..66a50b0 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs @@ -6,6 +6,12 @@ namespace Fleet.Server; public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta); +/// 操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。 +public interface IOperatorTrustSource +{ + ValueTask> ChainAsync(CancellationToken ct); +} + /// 产品挂钩:节点注册成功之后调用。 public interface IEnrollmentHook { diff --git a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs index de87ec2..bfa2dec 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs @@ -33,10 +33,23 @@ public sealed class EnrollRequest [JsonPropertyName("node_version")] public string NodeVersion { get; set; } = ""; + /// 带操作员委托的加入码要附上:用码的私钥签的绑定语句(节点的两把公钥和名字)和它的签名。 + [JsonPropertyName("binding")] + public string? Binding { get; set; } + + [JsonPropertyName("binding_signature")] + public string? BindingSignature { get; set; } + [JsonPropertyName("meta")] public JsonElement? Meta { get; set; } } +public sealed class TrustRequest +{ + [JsonPropertyName("join_token")] + public string JoinToken { get; set; } = ""; +} + public sealed class EnrollResponse { [JsonPropertyName("node_id")] @@ -69,5 +82,7 @@ public sealed class FleetProblem [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(EnrollRequest))] [JsonSerializable(typeof(EnrollResponse))] +[JsonSerializable(typeof(TrustRequest))] +[JsonSerializable(typeof(Fleet.Core.TrustBundle))] [JsonSerializable(typeof(FleetProblem))] internal sealed partial class EnrollmentJsonContext : JsonSerializerContext; diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs index 730cf82..ccd3567 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentEndpoints.cs @@ -20,9 +20,42 @@ public static class FleetEnrollmentEndpoints .WithTags("agent") .WithSummary("Enroll a node with a join token") .DisableAntiforgery(); + // 注册之前取信任链:节点先确认这个面板的操作员集合和委托确实是它固定的创世签出来的,再把自己的密钥交出去 + app.MapPost(path + "/trust", HandleTrustAsync) + .WithName("fleet_enroll_trust") + .WithTags("agent") + .WithSummary("The operator set chain and the grant of a join token, for the node to verify before it enrolls") + .DisableAntiforgery(); return app; } + private static async Task HandleTrustAsync(HttpContext http, FleetEnrollmentService service) + { + TrustRequest? request; + try + { + request = await JsonSerializer.DeserializeAsync(http.Request.Body, EnrollmentJsonContext.Default.TrustRequest, http.RequestAborted); + } + catch (JsonException) + { + request = null; + } + + if (request is null || string.IsNullOrEmpty(request.JoinToken)) + { + return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "join_token is required"); + } + + Result result = await service.GetTrustAsync(request.JoinToken, http.RequestAborted); + if (!result.TryGet(out TrustBundle? bundle, out FleetError? error)) + { + return Problem(error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone : StatusCodes.Status401Unauthorized, error.Code, error.Message); + } + + http.Response.Headers.CacheControl = "no-store"; + return Results.Json(bundle, EnrollmentJsonContext.Default.TrustBundle); + } + private static async Task HandleAsync(HttpContext http, FleetEnrollmentService service) { EnrollRequest? request; diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs index bf50fb0..346cbe1 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs @@ -9,13 +9,15 @@ public sealed partial class FleetEnrollmentService { private readonly IFleetStore _store; private readonly IEnumerable _hooks; + private readonly IEnumerable _trust; private readonly FleetServerOptions _options; private readonly TimeProvider _time; - public FleetEnrollmentService(IFleetStore store, IEnumerable hooks, FleetServerOptions options, TimeProvider time) + public FleetEnrollmentService(IFleetStore store, IEnumerable hooks, IEnumerable trust, FleetServerOptions options, TimeProvider time) { _store = store; _hooks = hooks; + _trust = trust; _options = options; _time = time; } @@ -59,6 +61,17 @@ public sealed partial class FleetEnrollmentService return Result.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify"); } + // 带操作员委托的码:节点必须附上用码的私钥签的绑定,委托、绑定和这次注册的密钥、名字、角色都对得上才收 + MemberProofDto? memberProof = null; + if (token.Grant is not null) + { + Result verified = await VerifyBindingAsync(token, request, publicKey, tlsPublicKey, ct); + if (!verified.TryGet(out memberProof, out FleetError? bindingError)) + { + return Result.Fail(bindingError); + } + } + if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct)) { return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid"); @@ -81,7 +94,8 @@ public sealed partial class FleetEnrollmentService nowMs, token.Kind, tlsPublicKey, - token.CreatedBy); + token.CreatedBy, + memberProof); Result created = await _store.CreateNodeAsync(node, ct); if (!created.TryGet(out NodeRecord? record, out FleetError? error)) @@ -102,6 +116,50 @@ public sealed partial class FleetEnrollmentService }); } + /// 节点注册之前取:操作员集合的链,和这个码的委托(如果有)。码要是有效的;不能用的码不给。 + public async ValueTask> GetTrustAsync(string joinToken, CancellationToken ct) + { + JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct); + long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds(); + if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses) + { + return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid"); + } + + if (token.ExpiresAtMs <= nowMs) + { + return Result.Fail(FleetErrors.JoinTokenExpired, "join token has expired"); + } + + var bundle = new TrustBundle { Grant = token.Grant }; + foreach (IOperatorTrustSource source in _trust) + { + bundle.Chain.AddRange(await source.ChainAsync(ct)); + } + + return Result.Ok(bundle); + } + + private async ValueTask> VerifyBindingAsync(JoinTokenRecord token, EnrollRequest request, byte[] publicKey, byte[] tlsPublicKey, CancellationToken ct) + { + IReadOnlyList chain = _trust.Any() ? await _trust.First().ChainAsync(ct) : []; + OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); + if (current is null || string.IsNullOrEmpty(request.Binding) || string.IsNullOrEmpty(request.BindingSignature)) + { + return Result.Fail(FleetErrors.ProofInvalid, "this join code carries an operator grant: the enrollment needs a binding signed with the code key"); + } + + string role = token.Kind.Length == 0 ? "agent" : token.Kind; + var proof = new MemberProofDto { Grant = token.Grant!, Binding = request.Binding, BindingSignature = request.BindingSignature }; + TrustFailure failure = OperatorTrust.VerifyMember(current, proof.ToProof(), publicKey, tlsPublicKey, role); + if (failure != TrustFailure.None || OperatorTrust.ParseBinding(request.Binding)?.Name != request.Name) + { + return Result.Fail(FleetErrors.ProofInvalid, "the binding does not verify against the operator's grant (" + failure + ")"); + } + + return Result.Ok(proof); + } + private static bool TryDecode(string text, out byte[] data) { try diff --git a/backend/fleet/tools/fleet-enroll/Program.cs b/backend/fleet/tools/fleet-enroll/Program.cs index 3c31989..c2610d2 100644 --- a/backend/fleet/tools/fleet-enroll/Program.cs +++ b/backend/fleet/tools/fleet-enroll/Program.cs @@ -12,7 +12,7 @@ string? name = Arg("--name"); string? directory = Arg("--identity-dir"); if (server is null || join is null || name is null || directory is null) { - Console.Error.WriteLine("usage: fleet-enroll --server --join --name --identity-dir [--meta ] [--version ]"); + Console.Error.WriteLine("usage: fleet-enroll --server --join --name --identity-dir [--meta ] [--version ] [--role agent|probe --seed --genesis ]"); return 2; } @@ -30,14 +30,18 @@ if (Arg("--meta") is { } metaText) } } +string? seed = Arg("--seed"); +string? genesis = Arg("--genesis"); +string role = Arg("--role") ?? "agent"; + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; Result result = await EnrollmentClient.EnrollAsync( - http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None); + http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, seed, genesis, role); if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error)) { Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}"); return 1; } -Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}"); +Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}" + (enrolled.Pinned ? "; operator trust pinned" : "; operator trust NOT pinned (no --genesis)")); return 0; diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 524b140..c766cf4 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -3887,6 +3887,190 @@ "x-moshiro-capability": "audit.read" } }, + "/api/admin/trust": { + "get": { + "tags": [ + "admin-trust" + ], + "summary": "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign", + "operationId": "trust_get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrustStateDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "fleet.read" + }, + "post": { + "tags": [ + "admin-trust" + ], + "summary": "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)", + "operationId": "trust_commit", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignedStatementDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrustStateDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "accounts.manage" + } + }, + "/api/admin/join-tokens/signed": { + "post": { + "tags": [ + "admin-trust" + ], + "summary": "Create a join code from a grant the operator signed with a passkey; the browser derives the code from a seed the panel never sees", + "operationId": "join_tokens_create_signed", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSignedJoinTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JoinTokenDto" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + }, + "x-moshiro-capability": "fleet.enroll" + } + }, "/api/admin/api-tokens": { "get": { "tags": [ @@ -4959,6 +5143,20 @@ } } } + }, + "/api/agent/v1/enroll/trust": { + "post": { + "tags": [ + "agent" + ], + "summary": "The operator set chain and the grant of a join token, for the node to verify before it enrolls", + "operationId": "fleet_enroll_trust", + "responses": { + "200": { + "description": "OK" + } + } + } } }, "components": { @@ -5820,6 +6018,41 @@ } } }, + "CreateSignedJoinTokenRequest": { + "required": [ + "grant", + "note", + "labels", + "auto_approve" + ], + "type": "object", + "properties": { + "grant": { + "$ref": "#/components/schemas/SignedStatementDto" + }, + "note": { + "type": [ + "null", + "string" + ] + }, + "labels": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "auto_approve": { + "type": [ + "null", + "boolean" + ] + } + } + }, "CreateTaskRequest": { "required": [ "scope", @@ -6681,6 +6914,10 @@ "null", "string" ] + }, + "signed": { + "type": "boolean", + "default": false } } }, @@ -8733,6 +8970,26 @@ } } }, + "SignedStatementDto": { + "type": "object", + "properties": { + "statement": { + "type": "string" + }, + "credential_id": { + "type": "string" + }, + "authenticator_data": { + "type": "string" + }, + "client_data_json": { + "type": "string" + }, + "signature": { + "type": "string" + } + } + }, "StartTestRequestDto": { "required": [ "plan", @@ -9084,6 +9341,72 @@ } } }, + "TrustProposalDto": { + "required": [ + "statement", + "version", + "challenge" + ], + "type": "object", + "properties": { + "statement": { + "type": "string" + }, + "version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "challenge": { + "type": "string" + } + } + }, + "TrustStateDto": { + "required": [ + "initialized", + "genesis", + "version", + "in_sync", + "proposal" + ], + "type": "object", + "properties": { + "initialized": { + "type": "boolean" + }, + "genesis": { + "type": [ + "null", + "string" + ] + }, + "version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "in_sync": { + "type": "boolean" + }, + "proposal": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TrustProposalDto" + } + ] + } + } + }, "UserDto": { "required": [ "id", @@ -9142,6 +9465,9 @@ { "name": "admin-audit" }, + { + "name": "admin-trust" + }, { "name": "admin-api-tokens" }, diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index 622d760..bbe039a 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -116,7 +116,7 @@ public static class AuthEndpoints } /// 这次请求的 WebAuthn 依赖方:配置优先,其次面板对外地址,最后是请求自己的主机。 - private static PasskeyContext PasskeyContextOf(HttpContext http, BackendOptions options) + internal static PasskeyContext PasskeyContextOf(HttpContext http, BackendOptions options) { string own = $"{http.Request.Scheme}://{http.Request.Host}"; Uri? configured = Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out Uri? uri) ? uri : null; diff --git a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs new file mode 100644 index 0000000..7c1d855 --- /dev/null +++ b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs @@ -0,0 +1,74 @@ +using Fleet.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using MoShiro.Backend.Application; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Api; + +/// +/// 信任根(docs/access.md 第 7 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它; +/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。 +/// +public static class TrustEndpoints +{ + private const string Tag = "admin-trust"; + + public static void Map(RouteGroupBuilder guarded) + { + guarded.MapGet("/trust", StateAsync) + .Api("trust_get", "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign", Tag) + .Returns().ProducesProblems(401, 403) + .Requires(Capability.FleetRead); + guarded.MapPost("/trust", CommitAsync) + .Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window)", Tag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.AccountsManage) + .ElevationExempt(); + guarded.MapPost("/join-tokens/signed", CreateSignedTokenAsync) + .Api("join_tokens_create_signed", "Create a join code from a grant the operator signed with a passkey; the browser derives the code from a seed the panel never sees", Tag) + .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) + .Requires(Capability.FleetEnroll) + .ElevationExempt(); + } + + private static async Task StateAsync(HttpContext http, TrustService trust, BackendOptions options) + { + TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto); + } + + private static async Task CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, BackendOptions options) + { + PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options); + // 第一次(创世)是整个信任根的起点:除了 passkey 签名,还要凭证窗口,避免只拿到一个密码会话的人抢先立根 + if ((await trust.StateAsync(context, http.RequestAborted)).Initialized is false && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed) + { + return needed; + } + + Result result = await trust.CommitAsync(signed, context, http.RequestAborted); + if (!result.TryGet(out TrustState? state, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.SetDetail($"operator set v{state.Version}"); + return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created); + } + + private static async Task CreateSignedTokenAsync(HttpContext http, CreateSignedJoinTokenRequest request, JoinTokenService tokens) + { + Result result = await tokens.CreateSignedAsync(request.Grant, request.Note, request.Labels, request.AutoApprove ?? true, http.GetPrincipal().Label, http.RequestAborted); + if (!result.TryGet(out JoinTokenInfo? info, out FleetError? error)) + { + return Problems.From(http, error); + } + + http.SetDetail($"{JoinTokenService.RoleOf(info.Kind)}: {request.Note}"); + http.Response.Headers.CacheControl = "no-store"; + return TypedResults.Json(Mappers.ToDto(info), ApiJsonContext.Default.JoinTokenDto, statusCode: StatusCodes.Status201Created); + } +} diff --git a/backend/src/MoShiro.Backend.Api/ApiModule.cs b/backend/src/MoShiro.Backend.Api/ApiModule.cs index 8a9da2d..f064f0e 100644 --- a/backend/src/MoShiro.Backend.Api/ApiModule.cs +++ b/backend/src/MoShiro.Backend.Api/ApiModule.cs @@ -112,6 +112,7 @@ public static class ApiModule TaskEndpoints.Map(guarded); DataEndpoints.Map(guarded); AuditEndpoints.Map(guarded); + TrustEndpoints.Map(guarded); SystemEndpoints.Map(guarded); BuildEndpoints.Map(guarded); LinkEndpoints.Map(guarded); diff --git a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs index aec509b..8def48a 100644 --- a/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs +++ b/backend/src/MoShiro.Backend.Api/Common/EndpointConventions.cs @@ -18,6 +18,11 @@ public static class EndpointConventions return Task.CompletedTask; }); + /// + /// 这个端点自己要求 passkey 签名(请求里带着断言),比提权窗口更强,所以不再要求窗口:守卫跳过信任类能力的提权检查。 + /// + public static RouteHandlerBuilder ElevationExempt(this RouteHandlerBuilder builder) => builder.WithMetadata(new ElevationExemptMarker()); + /// 匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。 public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) => builder.WithMetadata(new CapabilityRequirement(null)); diff --git a/backend/src/MoShiro.Backend.Api/Common/Guards.cs b/backend/src/MoShiro.Backend.Api/Common/Guards.cs index 221c1cf..3af9919 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Guards.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Guards.cs @@ -60,6 +60,9 @@ public static class PrincipalResolver /// public sealed record CapabilityRequirement(string? Capability); +/// 端点自己验 passkey 签名(见 )。 +public sealed record ElevationExemptMarker; + /// 管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 X-CSRF-Token。 public static class CapabilityGuard { @@ -90,7 +93,7 @@ public static class CapabilityGuard } // 信任类的操作(加节点、吊销、改凭证):登录用户还要有这一种的提权窗口;API key 自己带着能力,不经过这里 - if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && ElevationGate.Require(http, elevation, consume: true) is { } needed) + if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && http.GetEndpoint()?.Metadata.GetMetadata() is null && ElevationGate.Require(http, elevation, consume: true) is { } needed) { return needed; } diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index d7e8afb..c776cd9 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -154,7 +154,9 @@ public static class Mappers new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs)); public static JoinTokenDto ToDto(JoinTokenInfo t) => - new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy); + new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy, t.Grant is not null); + + public static TrustStateDto ToDto(TrustState s) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge)); public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs)); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index 80fe143..cf66d62 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -1,3 +1,4 @@ +using Fleet.Core; using System.Text.Json; namespace MoShiro.Backend.Api; @@ -73,13 +74,23 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null); /// agent 或 probe:用这个令牌注册出来的是哪种节点。 -public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null); +public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null, bool Signed = false); public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null); /// 令牌明文只在创建的响应里出现一次。 public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); +/// 操作员用 passkey 对这个码签的委托(语句加断言)。 +public sealed record CreateSignedJoinTokenRequest(SignedStatementDto Grant, string? Note, IReadOnlyList? Labels, bool? AutoApprove); + +public sealed record TrustProposalDto(string Statement, int Version, string Challenge); + +/// 创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。 +/// 当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。 +/// 下一个要签的版本(challenge 就是 WebAuthn 的挑战)。 +public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal); + public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands); /// Agent 眼中连着的一个 Probe(Agent 上报)。 diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 7238f19..536dd9a 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -79,6 +79,9 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(PasskeyRequestOptionsDto))] [JsonSerializable(typeof(RegisterPasskeyRequest))] [JsonSerializable(typeof(PasskeyLoginRequest))] +[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")] +[JsonSerializable(typeof(CreateSignedJoinTokenRequest))] +[JsonSerializable(typeof(TrustStateDto))] [JsonSerializable(typeof(ElevationRequest))] [JsonSerializable(typeof(ElevationAssertionRequest))] [JsonSerializable(typeof(ElevationDto))] diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index f9c2f34..ee17437 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -1,4 +1,6 @@ using Fleet.Core; +using Fleet.Identity; +using MoShiro.Protocol; namespace MoShiro.Backend.Application; @@ -25,12 +27,14 @@ public sealed class JoinTokenService public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind; private readonly IJoinTokenStore _store; + private readonly TrustService _trust; private readonly BackendOptions _options; private readonly TimeProvider _time; - public JoinTokenService(IJoinTokenStore store, BackendOptions options, TimeProvider time) + public JoinTokenService(IJoinTokenStore store, TrustService trust, BackendOptions options, TimeProvider time) { _store = store; + _trust = trust; _options = options; _time = time; } @@ -72,7 +76,7 @@ public sealed class JoinTokenService long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); JoinTokenInfo info = await _store.CreateAsync( Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role), - request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct); + request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, null, ct); string baseUrl = BaseUrl(requestBaseUrl); string query = $"{baseUrl}/install?code={plaintext}"; @@ -82,6 +86,71 @@ public sealed class JoinTokenService $"irm '{baseUrl}/install.ps1?code={plaintext}' | iex"))); } + /// + /// 用操作员 passkey 签的委托创建加入码:码本身(认证串)由浏览器用种子派生,面板只拿到它的哈希,推不出种子, + /// 所以面板自己没法为这个码签绑定。有效期、角色、次数都以签名的语句为准。 + /// + public async ValueTask> CreateSignedAsync(SignedStatementDto signedGrant, string? note, IReadOnlyList? labels, bool autoApprove, string? createdBy, CancellationToken ct) + { + OperatorSet? set = await _trust.CurrentAsync(ct); + if (set is null) + { + return Result.Fail(MoShiroErrors.ValidationFailed, "set up the operator trust root first (System, Trust)"); + } + + Grant? grant = OperatorTrust.ParseGrant(signedGrant.Statement); + if (grant is null || !OperatorTrust.VerifyOperator(signedGrant.ToSigned(), set)) + { + return Result.Fail(MoShiroErrors.Forbidden, "the grant is not signed by an operator passkey"); + } + + long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); + var errors = new Dictionary(); + if (grant.Role is not (AgentRole or ProbeRole)) + { + errors["role"] = "must be agent or probe"; + } + + if (grant.Uses is < 1 or > 1000) + { + errors["uses"] = "must be between 1 and 1000"; + } + + if (grant.NotAfterMs <= now || grant.NotAfterMs - now > 24 * 365 * 3_600_000L || grant.NotBeforeMs > now + 300_000) + { + errors["validity"] = "the grant must be valid now and for at most one year"; + } + + if (!System.Text.RegularExpressions.Regex.IsMatch(grant.AuthHash, "^[0-9a-f]{64}$")) + { + errors["auth"] = "must be a lowercase sha-256 hex"; + } + + if (labels is { } l && (l.Count > 16 || l.Any(x => x.Length is 0 or > 32))) + { + errors["labels"] = "at most 16 labels of 1-32 characters"; + } + + if (note is { Length: > 200 }) + { + errors["note"] = "must be at most 200 characters"; + } + + if (errors.Count > 0) + { + return ValidationError.Fail(errors); + } + + byte[] hash = Convert.FromHexString(grant.AuthHash); + if (await _store.FindAsync(hash, ct) is not null) + { + return Result.Fail(MoShiroErrors.NameConflict, "this code already exists"); + } + + return Result.Ok(await _store.CreateAsync( + Ulid.NewPrefixed("jtk", _time), hash, note, labels ?? [], autoApprove, KindOf(grant.Role), grant.Uses, grant.NotAfterMs, now, createdBy, signedGrant, ct)); + } + private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/'); /// diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index 2ad27e3..ba8fbe9 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -37,6 +37,8 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs new file mode 100644 index 0000000..0fc0e89 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs @@ -0,0 +1,130 @@ +using System.Security.Cryptography; +using System.Text; +using Fleet.Core; +using Fleet.Identity; +using Fleet.Server; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Application; + +/// 要操作员用 passkey 签的下一版操作员集合:Challenge 是语句的 SHA-256(base64url),也就是 WebAuthn 的挑战。 +public sealed record TrustProposal(string Statement, int Version, string Challenge); + +/// InSync:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。 +public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList Chain, TrustProposal? Proposal); + +/// +/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。 +/// Backend 不持有任何签名的能力:每一版都要操作员的 passkey 触碰;节点从安装命令里固定的创世指纹出发自己验链, +/// 所以 Backend 在这里的验证只是不收垃圾,不是信任的来源。设计见 docs/access.md 第 7 节。 +/// +public sealed class TrustService : IOperatorTrustSource, IDisposable +{ + private readonly IOperatorSetStore _store; + private readonly IAuthStore _auth; + private readonly TimeProvider _time; + private readonly SemaphoreSlim _gate = new(1, 1); + + public TrustService(IOperatorSetStore store, IAuthStore auth, TimeProvider time) + { + _store = store; + _auth = auth; + _time = time; + } + + public void Dispose() => _gate.Dispose(); + + public ValueTask> ChainAsync(CancellationToken ct) => _store.ListAsync(ct); + + /// 当前生效的操作员集合(从创世验过的链);还没有建立信任根返回 null。 + public async ValueTask CurrentAsync(CancellationToken ct) + { + IReadOnlyList chain = await _store.ListAsync(ct); + return chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); + } + + public async ValueTask StateAsync(PasskeyContext context, CancellationToken ct) + { + IReadOnlyList chain = await _store.ListAsync(ct); + OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); + TrustProposal? proposal = await ProposeAsync(chain, current, context, ct); + OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement); + bool inSync = current is not null && proposed is not null && Same(current, proposed); + return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal); + } + + private async ValueTask ProposeAsync(IReadOnlyList chain, OperatorSet? current, PasskeyContext context, CancellationToken ct) + { + IReadOnlyList passkeys = await _auth.ListAllPasskeysAsync(ct); + if (passkeys.Count == 0) + { + return null; + } + + var next = new OperatorSet( + (current?.Version ?? 0) + 1, + chain.Count == 0 ? OperatorTrust.Genesis : OperatorTrust.Fingerprint(chain[^1].Statement), + context.RpId, + [.. context.Origins.Order(StringComparer.Ordinal)], + [.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))]); + string statement = OperatorTrust.Encode(next); + return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement)))); + } + + private static bool Same(OperatorSet a, OperatorSet b) => + a.RpId == b.RpId && a.Origins.Order(StringComparer.Ordinal).SequenceEqual(b.Origins.Order(StringComparer.Ordinal)) + && a.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal).SequenceEqual(b.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal)); + + /// + /// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。 + /// 谁能调用由端点的能力和提权窗口把关;这里不看调用者。 + /// + public async ValueTask> CommitAsync(SignedStatementDto signed, PasskeyContext context, CancellationToken ct) + { + await _gate.WaitAsync(ct); + try + { + IReadOnlyList chain = await _store.ListAsync(ct); + OperatorSet? set = OperatorTrust.ParseSet(signed.Statement); + if (set is null) + { + return ValidationError.Fail("statement", "is not an operator set"); + } + + OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); + if (set.Version != (current?.Version ?? 0) + 1) + { + return Result.Fail(MoShiroErrors.NameConflict, $"the operator set is at version {current?.Version ?? 0}; sign version {(current?.Version ?? 0) + 1}"); + } + + if (set.RpId != context.RpId || set.Origins.Any(o => !context.Origins.Contains(o))) + { + return ValidationError.Fail("statement", "the relying party or origins do not match this panel"); + } + + IReadOnlyList registered = await _auth.ListAllPasskeysAsync(ct); + if (set.Keys.Any(k => !registered.Any(p => p.Id == k.CredentialId && p.PublicKeySpki.AsSpan().SequenceEqual(k.PublicKeySpki)))) + { + return ValidationError.Fail("statement", "lists a passkey that is not registered on this panel"); + } + + List extended = [.. chain.Select(c => c.ToSigned()), signed.ToSigned()]; + string genesis = OperatorTrust.Fingerprint(extended[0].Statement); + if (OperatorTrust.VerifyChain(genesis, extended) is not { } verified || verified.Version != set.Version) + { + return Result.Fail(MoShiroErrors.Forbidden, "the operator set is not signed by a passkey of the previous version"); + } + + if (!await _store.AddAsync(set.Version, signed, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct)) + { + return Result.Fail(MoShiroErrors.NameConflict, "this operator set version already exists"); + } + } + finally + { + _gate.Release(); + } + + return Result.Ok(await StateAsync(context, ct)); + } +} diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index 33ef79e..62871d1 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -30,7 +30,8 @@ public sealed record AgentRecord( bool Ipv6HostManual = false, IReadOnlyList? ReportedAddresses = null, byte[]? TlsPublicKey = null, - string? AddedBy = null); + string? AddedBy = null, + MemberProofDto? Proof = null); /// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 public sealed class AgentPatch @@ -78,10 +79,11 @@ public sealed record JoinTokenInfo( long? RevokedAtMs, long CreatedAtMs, string Kind = "", - string? CreatedBy = null); + string? CreatedBy = null, + SignedStatementDto? Grant = null); /// /// 是连 Backend 的身份公钥, 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥; /// Agent 的 peers 里放的是后者。 /// -public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null); +public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null, MemberProofDto? Proof = null); diff --git a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs index 4ec3401..5212f36 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/IAuthStore.cs @@ -38,6 +38,9 @@ public interface IAuthStore ValueTask> ListPasskeysAsync(string userId, CancellationToken ct); + /// 所有用户的 passkey(操作员集合从这里取)。 + ValueTask> ListAllPasskeysAsync(CancellationToken ct); + /// 只能删自己的。 ValueTask DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index 15d72ee..c9605a2 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -54,7 +54,7 @@ public interface IGeoLookup public interface IJoinTokenStore { - ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct); + ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct); ValueTask> ListAsync(CancellationToken ct); @@ -144,6 +144,15 @@ public interface IEventStore ValueTask DeleteOlderThanAsync(long cutoffMs, CancellationToken ct); } +/// 操作员集合的链(passkey 签的版本,按版本号递增)。 +public interface IOperatorSetStore +{ + ValueTask> ListAsync(CancellationToken ct); + + /// 追加一个版本;版本号已经有了返回 false。 + ValueTask AddAsync(int version, SignedStatementDto signed, long nowMs, CancellationToken ct); +} + public interface IAuditStore { ValueTask AddAsync(AuditRecord record, CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index 1f03d07..ad26ebd 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -206,6 +206,21 @@ internal static class Schema ) """); connection.Exec("CREATE INDEX ix_audit_time ON audit_log (at)"); + connection.Exec( + """ + CREATE TABLE operator_sets ( + version INTEGER PRIMARY KEY, + statement TEXT NOT NULL, + credential_id TEXT NOT NULL, + authenticator_data TEXT NOT NULL, + client_data_json TEXT NOT NULL, + signature TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """); + connection.Exec("ALTER TABLE join_tokens ADD COLUMN op_grant TEXT"); + connection.Exec("ALTER TABLE agents ADD COLUMN trust TEXT"); + connection.Exec("ALTER TABLE probes ADD COLUMN trust TEXT"); connection.Exec("PRAGMA user_version = 13"); transaction.Commit(); } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs index 6f13a7f..a3b3f31 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs @@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore { private const string AgentColumns = "id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " + - "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by"; + "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by, trust"; private readonly SqliteDatabase _db; @@ -157,8 +157,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore byte[] hash = tokenSha256.ToArray(); return _db.ReadAsync( c => c.QueryOne( - "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h", - r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11)), + "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11), StorageJson.Grant(r.NullableString(12))), ("@h", hash)), ct); } @@ -184,9 +184,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore } c.Exec( - "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by)", + "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by, trust) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by, @trust)", ("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki), - ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy)); + ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof))); return Result.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson)); }, ct); @@ -204,8 +204,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore } c.Exec( - "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by) VALUES (@id, @name, @pubkey, @peer, @now, @by)", - ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy)); + "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by, trust) VALUES (@id, @name, @pubkey, @peer, @now, @by, @trust)", + ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof))); return Result.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null)); } @@ -268,7 +268,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore r.GetInt64(23) != 0, StorageJson.Strings(r.GetString(24)), r.NullableBytes(25), - r.NullableString(26)); + r.NullableString(26), + StorageJson.Proof(r.NullableString(27))); private static NodeRecord ToNode(AgentRecord a, string? metaJson) => new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs index 1fccdb6..7b1a46f 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAuthStore.cs @@ -83,6 +83,9 @@ public sealed class SqliteAuthStore : IAuthStore public ValueTask> ListPasskeysAsync(string userId, CancellationToken ct) => _db.ReadAsync>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys WHERE user_id = @u ORDER BY created_at, id", ReadPasskey, ("@u", userId)), ct); + public ValueTask> ListAllPasskeysAsync(CancellationToken ct) => + _db.ReadAsync>(c => c.Query($"SELECT {PasskeyColumns} FROM passkeys ORDER BY created_at, id", ReadPasskey), ct); + public ValueTask DeletePasskeyAsync(string userId, string credentialId, CancellationToken ct) => _db.WriteAsync(c => c.Exec("DELETE FROM passkeys WHERE id = @id AND user_id = @u", ("@id", credentialId), ("@u", userId)) == 1, ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs index 1244c2d..a18356a 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -15,28 +15,28 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore _db = db; } - public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct) => + public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct) => _db.WriteAsync(c => { c.Exec( - "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by)", + "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by, op_grant) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by, @grant)", ("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), ("@kind", kind), - ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy)); - return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy); + ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy), ("@grant", StorageJson.ToJson(grant))); + return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy, grant); }, ct); public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( c => c.Query( - "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens ORDER BY created_at DESC, id", - r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10))), + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens ORDER BY created_at DESC, id", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11)))), ct); public ValueTask FindAsync(byte[] tokenSha256, CancellationToken ct) => _db.ReadAsync( c => c.QueryOne( - "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h", - r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10)), + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11))), ("@h", tokenSha256)), ct); @@ -58,12 +58,12 @@ public sealed class SqliteProbeStore : IProbeStore public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( - c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7))), + c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8)))), ct); public ValueTask GetAsync(string id, CancellationToken ct) => _db.ReadAsync( - c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7)), ("@id", id)), + c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8))), ("@id", id)), ct); public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) => diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteOperatorSetStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteOperatorSetStore.cs new file mode 100644 index 0000000..5aa0dbd --- /dev/null +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteOperatorSetStore.cs @@ -0,0 +1,28 @@ +using Fleet.Core; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Storage.Sqlite; + +public sealed class SqliteOperatorSetStore : IOperatorSetStore +{ + private readonly SqliteDatabase _db; + + public SqliteOperatorSetStore(SqliteDatabase db) + { + _db = db; + } + + public ValueTask> ListAsync(CancellationToken ct) => + _db.ReadAsync>( + c => c.Query( + "SELECT statement, credential_id, authenticator_data, client_data_json, signature FROM operator_sets ORDER BY version", + r => new SignedStatementDto { Statement = r.GetString(0), CredentialId = r.GetString(1), AuthenticatorData = r.GetString(2), ClientDataJson = r.GetString(3), Signature = r.GetString(4) }), + ct); + + public ValueTask AddAsync(int version, SignedStatementDto signed, long nowMs, CancellationToken ct) => + _db.WriteAsync( + c => c.Exec( + "INSERT OR IGNORE INTO operator_sets (version, statement, credential_id, authenticator_data, client_data_json, signature, created_at) VALUES (@v, @s, @c, @a, @d, @g, @at)", + ("@v", version), ("@s", signed.Statement), ("@c", signed.CredentialId), ("@a", signed.AuthenticatorData), ("@d", signed.ClientDataJson), ("@g", signed.Signature), ("@at", nowMs)) == 1, + ct); +} diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs index 51b9490..996c734 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs @@ -1,9 +1,12 @@ using System.Text.Json; +using Fleet.Core; using System.Text.Json.Serialization; namespace MoShiro.Backend.Storage.Sqlite; [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SignedStatementDto))] +[JsonSerializable(typeof(MemberProofDto))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "ParamMap")] internal sealed partial class StorageJson : JsonSerializerContext @@ -15,6 +18,14 @@ internal sealed partial class StorageJson : JsonSerializerContext public static List Strings(string? json) => string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListString) ?? []; + public static string? ToJson(SignedStatementDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.SignedStatementDto); + + public static string? ToJson(MemberProofDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.MemberProofDto); + + public static SignedStatementDto? Grant(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.SignedStatementDto); + + public static MemberProofDto? Proof(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.MemberProofDto); + public static List Ints(string? json) => string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListInt32) ?? []; } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs index 63b4f1a..5370a80 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ public static class StorageServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs index 61ac795..d8ef4b8 100644 --- a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs @@ -302,7 +302,7 @@ public sealed class StorageTests : IDisposable var tokens = new SqliteJoinTokenStore(_db); var fleet = new SqliteAgentStore(_db); byte[] hash = TokenHash.Sha256("jt_race"); - JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", default); + JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", grant: null, default); Assert.Equal(0, created.Uses); bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)))); @@ -310,7 +310,7 @@ public sealed class StorageTests : IDisposable Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses); Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)); - JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, default); + JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, grant: null, default); Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary"); Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default)); Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default)); diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs new file mode 100644 index 0000000..36c9cb1 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -0,0 +1,220 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Fleet.Core; +using Fleet.Enrollment.Client; +using Fleet.Hosting; +using Fleet.Identity; + +namespace MoShiro.Backend.Tests; + +/// +/// 信任根:操作员(passkey)签操作员集合和加入码的委托,节点从固定的创世指纹出发验证;码的种子面板看不到。 +/// 设计见 docs/access.md 第 7 节。 +/// +[Collection("probe-e2e")] +public sealed class TrustTests +{ + private const string Password = "correct horse battery"; + private static readonly ConditionalWeakTable s_operators = []; + private static readonly SemaphoreSlim s_gate = new(1, 1); + + private readonly BackendFixture _backend; + + public TrustTests(BackendFixture backend) + { + _backend = backend; + } + + /// 一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。 + private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis); + + private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority); + + private async Task OperatorAsync() + { + await s_gate.WaitAsync(); + try + { + if (s_operators.TryGetValue(_backend, out Operator? existing)) + { + return existing; + } + + string name = _backend.UniqueName("operator"); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var session = _backend.NewClient(); + using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true })); + session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); + + var passkey = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); + using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null)) + { + string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); + using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register("operator", challenge))); + Assert.Equal(HttpStatusCode.Created, registered.StatusCode); + } + + // 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身 + await ElevateAsync(session, passkey); + JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!; + Assert.False(state["initialized"]!.GetValue()); + string statement = state["proposal"]!["statement"]!.GetValue(); + Assert.Equal(Base64UrlOf(statement), state["proposal"]!["challenge"]!.GetValue()); + using HttpResponseMessage genesis = await session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(passkey, statement))); + Assert.True(genesis.StatusCode == HttpStatusCode.Created, await genesis.Content.ReadAsStringAsync()); + string fingerprint = JsonNode.Parse(await genesis.Content.ReadAsStringAsync())!["genesis"]!.GetValue(); + Assert.Equal(OperatorTrust.Fingerprint(statement), fingerprint); + + var made = new Operator(session, passkey, fingerprint); + s_operators.Add(_backend, made); + return made; + } + finally + { + s_gate.Release(); + } + } + + private static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); + + /// passkey 对语句签名(挑战是语句的 SHA-256),拼成接口要的断言对象。 + private static JsonObject Sign(SoftwarePasskey passkey, string statement) + { + JsonObject assertion = passkey.Assert(Base64UrlOf(statement)); + return new JsonObject + { + ["statement"] = statement, + ["credential_id"] = assertion["credential_id"]!.DeepClone(), + ["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(), + ["client_data_json"] = assertion["client_data_json"]!.DeepClone(), + ["signature"] = assertion["signature"]!.DeepClone(), + }; + } + + private static async Task ElevateAsync(HttpClient session, SoftwarePasskey passkey) + { + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "credential" })); + JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue()); + assertion["class"] = "credential"; + using HttpResponseMessage elevated = await session.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(assertion)); + Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); + } + + /// 浏览器做的事:生成码的密钥,种子派生认证串,passkey 对委托签名,创建码。返回种子和认证串。 + private async Task<(string Seed, string Auth, HttpStatusCode Status)> CreateSignedCodeAsync(Operator op, string role = "probe", SoftwarePasskey? signer = null, long? notAfter = null) + { + using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256); + byte[] seed = code.ExportParameters(true).D!; + string auth = OperatorTrust.AuthToken(seed); + long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); + string statement = OperatorTrust.Encode(new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), now - 1000, notAfter ?? now + 3_600_000, 1)); + using HttpResponseMessage response = await op.Session.PostAsync("/api/admin/join-tokens/signed", BackendFixture.Json(new JsonObject { ["grant"] = Sign(signer ?? op.Passkey, statement), ["note"] = "trust test" })); + return (Fleet.Core.Base64Url.Encode(seed), auth, response.StatusCode); + } + + private async Task> EnrollAsync(string auth, string? seed, string? genesis, string role = "probe", string? directory = null) + { + directory ??= Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); + using var http = new HttpClient(); + return await EnrollmentClient.EnrollAsync(http, _backend.BaseAddress, auth, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, seed, genesis, role); + } + + [Fact] + public async Task ASignedCode_EnrollsANode_ThatPinsTheOperatorAndCarriesTheProof() + { + Operator op = await OperatorAsync(); + (string seed, string auth, HttpStatusCode status) = await CreateSignedCodeAsync(op); + Assert.Equal(HttpStatusCode.Created, status); + + string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); + Result enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory); + Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); + Assert.True(enrolled.Value.Pinned); + + // 节点本地:固定的创世、验证过的链、自己的证明;强制模式下对端必须带证明 + TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance); + Assert.True(anchor.Enforcing); + Assert.Equal(op.Genesis, anchor.Genesis); + using LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory); + Assert.Equal(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, identity.PeerIdentity.PublicKeySpki, "probe")); + Assert.Equal(TrustFailure.Malformed, anchor.VerifyPeer(null, identity.PeerIdentity.PublicKeySpki, "probe")); + Assert.NotEqual(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, RandomNumberGenerator.GetBytes(91), "probe")); + + // 面板上这个码标记为已签名 + JsonNode tokens = JsonNode.Parse(await _backend.Admin.GetStringAsync("/api/admin/join-tokens"))!; + Assert.Contains(tokens["items"]!.AsArray(), t => t!["signed"]!.GetValue() && t["note"]?.GetValue() == "trust test"); + } + + [Fact] + public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable() + { + Operator op = await OperatorAsync(); + (string seed, string auth, _) = await CreateSignedCodeAsync(op); + + Result wrong = await EnrollAsync(auth, seed, new string('0', 64)); + Assert.False(wrong.IsSuccess); + Assert.Contains("genesis", wrong.Error.Message, StringComparison.Ordinal); + + Result right = await EnrollAsync(auth, seed, op.Genesis); // 码没被消耗 + Assert.True(right.IsSuccess, right.IsSuccess ? "" : right.Error.ToString()); + } + + [Fact] + public async Task TheSeedIsNeededToEnroll_ACodeAloneNeverGetsANodeIn() + { + Operator op = await OperatorAsync(); + (string seed, string auth, _) = await CreateSignedCodeAsync(op); + + // 只有认证串(面板看得到的那部分):不带种子,旧客户端的流程,面板要求绑定 + Result withoutSeed = await EnrollAsync(auth, null, null); + Assert.False(withoutSeed.IsSuccess); + + // 种子不是这个码的:节点自己就不干 + (string otherSeed, _, _) = await CreateSignedCodeAsync(op); + Result mismatched = await EnrollAsync(auth, otherSeed, op.Genesis); + Assert.False(mismatched.IsSuccess); + + Assert.True((await EnrollAsync(auth, seed, op.Genesis)).IsSuccess); + } + + [Fact] + public async Task ARoleMismatch_OrAnExpiredGrant_OrAForeignSigner_IsRefused() + { + Operator op = await OperatorAsync(); + + (string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "agent"); + Result asProbe = await EnrollAsync(auth, seed, op.Genesis, role: "probe"); // 节点以为自己是 probe,委托是给 agent 的 + Assert.False(asProbe.IsSuccess); + + (_, _, HttpStatusCode expired) = await CreateSignedCodeAsync(op, notAfter: TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 1000); + Assert.Equal(HttpStatusCode.BadRequest, expired); + + using var stranger = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); // 没注册的 passkey 签的委托 + (_, _, HttpStatusCode foreign) = await CreateSignedCodeAsync(op, signer: stranger); + Assert.Equal(HttpStatusCode.Forbidden, foreign); + } + + [Fact] + public async Task TheGenesis_IsSetOnce_AndNeedsTheOperatorsSignature() + { + Operator op = await OperatorAsync(); + JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; + Assert.True(state["initialized"]!.GetValue()); + Assert.Equal(op.Genesis, state["genesis"]!.GetValue()); + + // 没有操作员签名的语句:不收 + JsonObject unsigned = Sign(op.Passkey, "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=x\norigins=x\nkey=a:AAAA"); + unsigned["statement"] = "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=" + _backend.BaseAddress.Host + "\norigins=" + Origin + "\nkey=a:AAAA"; // 语句被换掉,签名对不上 + using HttpResponseMessage tampered = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned)); + Assert.True(tampered.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Conflict); + + // 匿名和只有 API key 的调用也没有可能立根 + using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned)); + Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); + } +} From 9bb6841dce3109a2bb897e88dce9579e34f0fb8d Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 7 Oct 2026 23:24:33 +0800 Subject: [PATCH 12/13] feat: pinned agents and probes only accept peers carrying the operator's proof; the panel ships the signed operator chain with the rosters [CI SKIP] --- backend/agent/src/MoShiro.Agent/AgentApp.cs | 2 +- .../src/MoShiro.Agent/Tasks/TaskManager.cs | 18 ++++++- .../src/MoShiro.Probe/Link/AgentDirectory.cs | 27 +++++++++- backend/probe/src/MoShiro.Probe/ProbeApp.cs | 2 +- .../shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs | 7 +++ .../shared/MoShiro.Protocol/Tasks/TaskSet.cs | 8 +++ .../Admin/TrustEndpoints.cs | 3 +- .../Agents/AgentService.cs | 8 ++- .../Tasks/TaskPlanner.cs | 9 +++- .../MoShiro.Backend.Tests/AgentHarness.cs | 8 +-- .../tests/MoShiro.Backend.Tests/TrustTests.cs | 49 +++++++++++++++++++ 11 files changed, 129 insertions(+), 12 deletions(-) diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 8aba5c9..6a5cbe2 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -81,7 +81,7 @@ public sealed class AgentApp : IDisposable }; var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log); var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log); - _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts); + _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, TrustAnchor.Load(config.Fleet.IdentityDir, log)); _hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log); _tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log); diff --git a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs index e61f699..72f9503 100644 --- a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs +++ b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using Fleet.Hosting; +using Fleet.Identity; using Fleet.Core; using Fleet.Rpc; using MoShiro.Measurement; @@ -25,14 +27,16 @@ public sealed class TaskManager private readonly TargetRunners _targets; private readonly Func> _udpPorts; private readonly Func> _tcpPorts; + private readonly TrustAnchor _trust; private readonly Dictionary> _sessionLines = []; private volatile Dictionary _peers = new(StringComparer.Ordinal); private Dictionary _lineSpecs = []; private long _version; private BudgetGuard? _guard; - public TaskManager(Func guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func> udpPorts, Func> tcpPorts) + public TaskManager(Func guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func> udpPorts, Func> tcpPorts, TrustAnchor? trust = null) { + _trust = trust ?? TrustAnchor.None; _guardFactory = guardFactory; _streams = streams; _env = env; @@ -98,12 +102,22 @@ public sealed class TaskManager _ = ct; var result = new StateSyncResult { AppliedVersion = version }; + // 固定了操作员创世的节点:先验面板带来的操作员集合链(从固定的创世出发),再要求每个 Probe 带着操作员批准的证明。 + // 面板自己改名册、塞进自己的 Probe 公钥是过不了这一关的:它签不出操作员的委托。 + _trust.Update(set.TrustChain); var peers = new Dictionary(StringComparer.Ordinal); foreach (PeerSpec peer in set.Peers) { try { - peers[peer.ProbeId.Value] = Convert.FromBase64String(peer.PublicKey); + byte[] key = Convert.FromBase64String(peer.PublicKey); + if (_trust.VerifyPeer(peer.Proof, key, "probe") != TrustFailure.None) + { + result.Rejected.Add(new RejectedItem { Id = peer.ProbeId.Value, Reason = RejectReasons.PolicyDenied }); + continue; + } + + peers[peer.ProbeId.Value] = key; } catch (FormatException) { diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs index 69beae9..6872ccc 100644 --- a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs +++ b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Fleet.Core; +using Fleet.Identity; using Fleet.Hosting; using MoShiro.Protocol; @@ -16,11 +17,13 @@ public sealed class AgentDirectory private readonly object _gate = new(); private readonly ConfigStore _store; private readonly INodeLog _log; + private readonly TrustAnchor _trust; private List _agents = []; private long _version; - public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log) + public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null) { + _trust = trust ?? TrustAnchor.None; _store = store; _log = log; string cache = Path.Combine(configDirectory, FileName); @@ -64,12 +67,34 @@ public sealed class AgentDirectory } } + private static bool TryKey(string text, out byte[] key) + { + try + { + key = Convert.FromBase64String(text); + return key.Length > 0; + } + catch (FormatException) + { + key = []; + return false; + } + } + /// 应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。 public void Apply(ProbeAgentList result) { var next = new List(); + _trust.Update(result.TrustChain); foreach (AgentEndpoint agent in result.Agents) { + // 固定了操作员创世的 Probe 只连操作员批准过的 Agent(证明验不过的,面板塞进来的也不连) + if (!TryKey(agent.PublicKey, out byte[] key) || _trust.VerifyPeer(agent.Proof, key, "agent") != TrustFailure.None) + { + _log.Warn("an agent from the panel carries no valid operator proof; not connecting", ("agent", agent.AgentId.Value)); + continue; + } + if (!string.IsNullOrWhiteSpace(agent.Host)) { next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey }); diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index 1dc5fca..00ba2b3 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -27,7 +27,7 @@ public sealed class ProbeApp : IDisposable public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null) { _log = log; - var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log); + var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, TrustAnchor.Load(file.Fleet.IdentityDir, log)); _env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log); directory.Changed += () => _changed.Release(); diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs index 9703560..b4c66c4 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs @@ -152,6 +152,10 @@ public sealed class AgentEndpoint /// Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。 [JsonPropertyName("public_key")] public string PublicKey { get; set; } = ""; + + /// 操作员批准这个 Agent 的证明;固定了创世的 Probe 没有它就不连。 + [JsonPropertyName("proof")] + public MemberProofDto? Proof { get; set; } } /// 期望状态文档(Fleet state type = probe_agents):Backend 直接下发给每个 Probe 的 Agent 列表。 @@ -164,6 +168,9 @@ public sealed class ProbeAgentList [JsonPropertyName("agents")] public List Agents { get; set; } = []; + + [JsonPropertyName("trust_chain")] + public List? TrustChain { get; set; } } /// 注册元数据(MoShiro 定义,经 Fleet 的 meta 字段带上来)。 diff --git a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs index 88771f4..9a41582 100644 --- a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs +++ b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs @@ -40,6 +40,10 @@ public sealed class PeerSpec /// Probe 的身份公钥(SPKI,base64)。 [JsonPropertyName("pubkey")] public string PublicKey { get; set; } = ""; + + /// 操作员批准这个 Probe 的证明(passkey 签的委托加码的私钥签的绑定)。固定了创世的 Agent 没有它就不接纳。 + [JsonPropertyName("proof")] + public MemberProofDto? Proof { get; set; } } /// 期望状态文档(Fleet state type = task_set):Backend 为每个 Agent 计算的有效任务集。 @@ -56,6 +60,10 @@ public sealed class TaskSet /// 允许连接到这个 Agent 的 Probe 公钥。 [JsonPropertyName("peers")] public List Peers { get; set; } = []; + + /// 操作员集合的链(passkey 签的,创世在前):节点从固定的创世指纹验过才采用。 + [JsonPropertyName("trust_chain")] + public List? TrustChain { get; set; } } public static class TaskSchedule diff --git a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs index 7c1d855..a87dd03 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs @@ -40,7 +40,7 @@ public static class TrustEndpoints return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto); } - private static async Task CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, BackendOptions options) + private static async Task CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options) { PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options); // 第一次(创世)是整个信任根的起点:除了 passkey 签名,还要凭证窗口,避免只拿到一个密码会话的人抢先立根 @@ -55,6 +55,7 @@ public static class TrustEndpoints return Problems.From(http, error); } + await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 新的操作员集合要随名册下发给节点 http.SetDetail($"operator set v{state.Version}"); return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created); } diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index 2f1e150..eeddc83 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -24,9 +24,11 @@ public sealed partial class AgentService private readonly NodeInfoService _nodes; private readonly LinkService _links; + private readonly TrustService _trust; - public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links) + public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust) { + _trust = trust; _links = links; _nodes = nodes; _agents = agents; @@ -169,9 +171,13 @@ public sealed partial class AgentService TcpPort = agent.ProbeTcpPort, UdpPorts = [.. agent.ProbeUdpPorts], PublicKey = Convert.ToBase64String(agent.TlsPublicKey!), + Proof = agent.Proof, }); } + IReadOnlyList chain = await _trust.ChainAsync(ct); + result.TrustChain = chain.Count == 0 ? null : [.. chain]; + return result; } diff --git a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs index 58cdf6c..f8852db 100644 --- a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs +++ b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs @@ -21,10 +21,12 @@ public sealed class TaskPlanner : IDisposable private readonly IProbeStore _probes; private readonly ISettingsStore _settings; private readonly LinkService _links; + private readonly TrustService _trust; private readonly SemaphoreSlim _gate = new(1, 1); - public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links) + public TaskPlanner(ITaskStore tasks, IAgentStore agents, IProbeStore probes, ISettingsStore settings, LinkService links, TrustService trust) { + _trust = trust; _links = links; _tasks = tasks; _agents = agents; @@ -62,9 +64,12 @@ public sealed class TaskPlanner : IDisposable foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && p.PeerPublicKey is { Length: > 0 } && links.IsLinked(p.Id, agentId.Value))) { - document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) }); + document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!), Proof = probe.Proof }); } + IReadOnlyList chain = await _trust.ChainAsync(ct); + document.TrustChain = chain.Count == 0 ? null : [.. chain]; + string hash = Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(document, MoShiroJsonContext.Default.TaskSet))); await _gate.WaitAsync(ct); diff --git a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs index cd63e15..a3c4ceb 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs @@ -41,12 +41,14 @@ public sealed class AgentHarness : IAsyncDisposable public byte[] TlsPublicKey => _identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo(); - public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = null) + /// 操作员签过委托的加入码(认证串、种子、创世指纹):节点按固定了创世的方式注册,名册里的 Probe 必须带操作员批准的证明。 + public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = null, (string Auth, string Seed, string Genesis)? signed = null) { string directory = Path.Combine(Path.GetTempPath(), "moshiro-agent-" + Guid.NewGuid().ToString("N")); - string token = await backend.CreateJoinTokenAsync(); + string token = signed?.Auth ?? await backend.CreateJoinTokenAsync(); using var http = new HttpClient(); - Result enrolled = await EnrollmentClient.EnrollAsync(http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None); + Result enrolled = await EnrollmentClient.EnrollAsync( + http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None, signed?.Seed, signed?.Genesis, "agent"); Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); // 测试里的 Agent 只在回环上监听:手填 public_host(手动的优先于自动识别,Agent 上报本机真实地址也不会覆盖) diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs index 36c9cb1..8ceeca8 100644 --- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -217,4 +217,53 @@ public sealed class TrustTests using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned)); Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); } + + [Fact] + public async Task APinnedAgent_OnlyAcceptsProbesTheOperatorApproved_EvenIfThePanelListsOthers() + { + Operator op = await OperatorAsync(); + (string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent"); + await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis)); + + // 面板里有一个没有操作员证明的 Probe(相当于面板自己塞进名册的),再有一个操作员批准的 + using EnrolledProbe plain = await _backend.CreateProbeAsync(); + (string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "probe"); + string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); + Result enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory); + Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); + using LoadedNodeIdentity approved = LoadedNodeIdentity.Load(directory); + + await BackendFixture.EventuallyAsync( + () => Task.FromResult(agent.App.IsPeerKnown(enrolled.Value.NodeId, Convert.ToBase64String(approved.PeerIdentity.PublicKeySpki))), + because: "the agent should accept the probe the operator approved"); + Assert.False(agent.App.IsPeerKnown(plain.ProbeId, Convert.ToBase64String(plain.Identity.PeerIdentity.PublicKeySpki)), "a probe without the operator's proof must not be accepted"); + } + + [Fact] + public async Task APinnedProbe_OnlyConnectsToAgentsTheOperatorApproved() + { + Operator op = await OperatorAsync(); + (string probeSeed, string probeAuth, _) = await CreateSignedCodeAsync(op, role: "probe"); + string probeDirectory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); + Assert.True((await EnrollAsync(probeAuth, probeSeed, op.Genesis, directory: probeDirectory)).IsSuccess); + + (string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent"); + await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis)); + MemberProofDto? proof = TrustAnchor.Load(agent.DataDirectory, NullNodeLog.Instance).OwnProof; + string key = Convert.ToBase64String(agent.TlsPublicKey); + + string configDirectory = Path.Combine(Path.GetTempPath(), "moshiro-probe-cfg-" + Guid.NewGuid().ToString("N")); + var directory = new MoShiro.Probe.AgentDirectory(new ConfigStore(configDirectory), configDirectory, NullNodeLog.Instance, TrustAnchor.Load(probeDirectory, NullNodeLog.Instance)); + directory.Apply(new MoShiro.Protocol.ProbeAgentList + { + Version = 1, + Agents = + [ + new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_approved"), Host = "127.0.0.1", TcpPort = 1, PublicKey = key, Proof = proof }, + new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_planted"), Host = "127.0.0.1", TcpPort = 2, PublicKey = key }, // 面板塞进来的:没有证明 + new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_swapped"), Host = "127.0.0.1", TcpPort = 3, PublicKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(91)), Proof = proof }, // 拿着别人的证明、换了自己的公钥 + ], + }); + Assert.Equal(["agt_approved"], directory.Agents.Select(a => a.AgentId)); + } } From a2f88a344b5b2aa65e72170a702f258344799f97 Mon Sep 17 00:00:00 2001 From: JustMe Date: Thu, 8 Oct 2026 03:21:44 +0800 Subject: [PATCH 13/13] feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity Join codes need no passkey touch and carry only public values (genesis fingerprint, owner credentials, installer hashes). Nodes pin the genesis and owners at enrollment and only follow passkey-signed operator set versions. The panel's server key is listed in the set; pinned nodes require its signature and run the control link under AES-256-GCM. Password sessions carry no capabilities. The updater adopts a next_key from a signed manifest. Bump version to 0.2.0. [CI SKIP] --- .gitattributes | 1 + backend/Directory.Build.props | 2 +- backend/agent/src/MoShiro.Agent/AgentApp.cs | 8 +- .../src/MoShiro.Agent/Tasks/TaskManager.cs | 13 +- backend/fleet/src/Fleet.Core/TrustDtos.cs | 20 +- .../EnrollmentClient.cs | 74 +---- .../fleet/src/Fleet.Hosting/TrustAnchor.cs | 102 +++++-- .../fleet/src/Fleet.Identity/NodeIdentity.cs | 3 + .../fleet/src/Fleet.Identity/OperatorTrust.cs | 209 ++----------- .../fleet/src/Fleet.Identity/SecureChannel.cs | 173 +++++++++++ .../fleet/src/Fleet.Identity/SessionKeys.cs | 8 + .../fleet/src/Fleet.Identity/TrustMapping.cs | 4 - backend/fleet/src/Fleet.Link/FleetLink.cs | 78 ++++- .../fleet/src/Fleet.Link/SecureTransport.cs | 57 ++++ backend/fleet/src/Fleet.Rpc/FleetMethods.cs | 12 + .../Fleet.Server.Abstractions/NodeRecords.cs | 6 +- .../ServerContracts.cs | 12 + .../EnrollmentModels.cs | 7 - .../FleetEnrollmentService.cs | 38 +-- .../src/Fleet.Server.Link/FleetLinkHost.cs | 7 +- .../Fleet.Server.Link/FleetServerBuilder.cs | 3 +- .../Fleet.Server.Link/NodeConnection.Auth.cs | 29 +- .../Fleet.Server.Link/NodeConnection.Send.cs | 10 +- .../Fleet.Server.Link/NodeConnection.Wire.cs | 13 + .../src/Fleet.Server.Link/NodeConnection.cs | 3 + .../tests/Fleet.Tests/OperatorTrustTests.cs | 74 +---- .../tests/Fleet.Tests/SecureChannelTests.cs | 114 +++++++ backend/fleet/tools/fleet-enroll/Program.cs | 7 +- backend/openapi/v1.json | 187 +++--------- .../src/MoShiro.Probe/Link/AgentDirectory.cs | 22 -- backend/probe/src/MoShiro.Probe/ProbeApp.cs | 4 +- .../shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs | 4 - .../shared/MoShiro.Protocol/Tasks/TaskSet.cs | 4 - .../Admin/AgentEndpoints.cs | 16 +- .../Admin/AuthEndpoints.cs | 2 +- .../Admin/TrustEndpoints.cs | 44 +-- .../src/MoShiro.Backend.Api/Common/Mappers.cs | 4 +- .../Contracts/AdminDtos.cs | 16 +- .../Contracts/ApiJsonContext.cs | 1 - .../src/MoShiro.Backend.Api/InstallScripts.cs | 35 ++- .../src/MoShiro.Backend.Api/OpsEndpoints.cs | 30 +- .../Admin/JoinTokenService.cs | 96 ++---- .../Agents/AgentService.cs | 1 - .../ApplicationServiceCollectionExtensions.cs | 2 + .../Auth/AuthService.cs | 2 +- .../Auth/Capabilities.cs | 8 +- .../Auth/ElevationService.cs | 1 - .../Auth/ServerIdentityService.cs | 82 +++++ .../Auth/TrustService.cs | 21 +- .../Models/AgentModels.cs | 8 +- .../Ports/StorePorts.cs | 2 +- .../StartupTasks.cs | 5 +- .../Tasks/TaskPlanner.cs | 2 +- .../MoShiro.Backend.Storage.Sqlite/Schema.cs | 3 - .../SqliteAgentStore.cs | 17 +- .../SqliteConfigStores.cs | 20 +- .../StorageJson.cs | 11 - .../MoShiro.Backend.Tests/AgentHarness.cs | 8 +- .../MoShiro.Backend.Tests/ApiBasicsTests.cs | 20 +- .../MoShiro.Backend.Tests/BackendFixture.cs | 80 +++-- .../MoShiro.Backend.Tests/CapabilityTests.cs | 2 +- .../MoShiro.Backend.Tests/ElevationTests.cs | 67 +++-- .../MoShiro.Backend.Tests/InstallTests.cs | 131 ++++---- .../MoShiro.Backend.Tests/PasskeyTests.cs | 2 +- .../MoShiro.Backend.Tests/StorageTests.cs | 4 +- .../tests/MoShiro.Backend.Tests/TrustTests.cs | 263 +++++++++------- ci/publish-gitea-release.sh | 5 +- ci/release-manifest.sh | 15 +- docs/access.md | 121 ++++++-- docs/deployment.md | 9 +- docs/security.md | 2 +- packaging/container/entrypoint.sh | 8 +- packaging/install/install.ps1 | 40 ++- packaging/install/install.sh | 39 ++- packaging/update/moshiro-update.sh | 24 +- web/admin/src/App.tsx | 3 + web/admin/src/PasskeyOnly.tsx | 61 ++++ web/admin/src/pages/Agents.tsx | 12 +- web/admin/src/pages/CapabilityPicker.tsx | 111 ++++--- web/admin/src/pages/JoinTokenCreated.tsx | 12 +- web/admin/src/pages/OwnerPicker.tsx | 60 ++++ web/admin/src/pages/Probes.tsx | 8 + web/admin/src/pages/System.tsx | 7 +- web/admin/src/pages/Trust.tsx | 110 +++++++ web/shared/api/schema.ts | 284 +++++++++++++++++- web/shared/api/types.ts | 2 + web/shared/api/webauthn.ts | 26 +- web/shared/theme/base.css | 61 ++++ web/shared/ui/index.tsx | 6 +- 89 files changed, 2145 insertions(+), 1195 deletions(-) create mode 100644 backend/fleet/src/Fleet.Identity/SecureChannel.cs create mode 100644 backend/fleet/src/Fleet.Link/SecureTransport.cs create mode 100644 backend/fleet/tests/Fleet.Tests/SecureChannelTests.cs create mode 100644 backend/src/MoShiro.Backend.Application/Auth/ServerIdentityService.cs create mode 100644 web/admin/src/PasskeyOnly.tsx create mode 100644 web/admin/src/pages/OwnerPicker.tsx create mode 100644 web/admin/src/pages/Trust.tsx diff --git a/.gitattributes b/.gitattributes index a074163..52bd65d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,4 @@ packaging/update/*.sh text eol=lf packaging/container/*.sh text eol=lf ci/release-node-images.sh text eol=lf packaging/install/*.sh text eol=lf +packaging/install/*.ps1 text eol=lf diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index 65e3c69..c120fbd 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -13,7 +13,7 @@ true $(MSBuildThisFileDirectory)artifacts true - 0.1.1 + 0.2.0 false diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 6a5cbe2..2c23bd6 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -18,6 +18,7 @@ public sealed class AgentApp : IDisposable private readonly AgentConfig _config; private readonly bool _ipv6Supported; private readonly KnockGate _knock; + private readonly TrustAnchor _trust; private readonly RestartController _restart; private readonly RestartController? _ownedRestart; private readonly LoadedNodeIdentity _identity; @@ -63,6 +64,7 @@ public sealed class AgentApp : IDisposable _admission = new AgentAdmission(_streams); _udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts); // 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers + _trust = TrustAnchor.Load(config.Fleet.IdentityDir, log); _knock = new KnockGate(identity.PeerIdentity, RosterKeys, time); _tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock); @@ -81,7 +83,7 @@ public sealed class AgentApp : IDisposable }; var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log); var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log); - _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, TrustAnchor.Load(config.Fleet.IdentityDir, log)); + _tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust); _hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log); _tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log); @@ -123,6 +125,9 @@ public sealed class AgentApp : IDisposable public int ControlPort => _peers.Port; + /// 节点当前采用的操作员集合版本(没有固定信任根时是 0)。 + public int TrustVersion => _trust.Version; + public AgentCounters Counters => _counters; public FleetLink Link => _link; @@ -270,6 +275,7 @@ public sealed class AgentApp : IDisposable { Server = new Uri(_config.Fleet.Server), Identity = _identity.Identity, + ServerVerifier = _trust, NodeId = _identity.NodeId, NodeVersion = Version, Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)], diff --git a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs index 72f9503..d63792c 100644 --- a/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs +++ b/backend/agent/src/MoShiro.Agent/Tasks/TaskManager.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Fleet.Hosting; -using Fleet.Identity; using Fleet.Core; using Fleet.Rpc; using MoShiro.Measurement; @@ -102,22 +101,14 @@ public sealed class TaskManager _ = ct; var result = new StateSyncResult { AppliedVersion = version }; - // 固定了操作员创世的节点:先验面板带来的操作员集合链(从固定的创世出发),再要求每个 Probe 带着操作员批准的证明。 - // 面板自己改名册、塞进自己的 Probe 公钥是过不了这一关的:它签不出操作员的委托。 + // 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定) _trust.Update(set.TrustChain); var peers = new Dictionary(StringComparer.Ordinal); foreach (PeerSpec peer in set.Peers) { try { - byte[] key = Convert.FromBase64String(peer.PublicKey); - if (_trust.VerifyPeer(peer.Proof, key, "probe") != TrustFailure.None) - { - result.Rejected.Add(new RejectedItem { Id = peer.ProbeId.Value, Reason = RejectReasons.PolicyDenied }); - continue; - } - - peers[peer.ProbeId.Value] = key; + peers[peer.ProbeId.Value] = Convert.FromBase64String(peer.PublicKey); } catch (FormatException) { diff --git a/backend/fleet/src/Fleet.Core/TrustDtos.cs b/backend/fleet/src/Fleet.Core/TrustDtos.cs index 05ce501..22d16e3 100644 --- a/backend/fleet/src/Fleet.Core/TrustDtos.cs +++ b/backend/fleet/src/Fleet.Core/TrustDtos.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; namespace Fleet.Core; -/// passkey 对一段语句签出的断言(字段都是 base64url)。信任链的格式和验证在 Fleet.Identity 的 OperatorTrust。 +/// passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 OperatorTrust。 public sealed class SignedStatementDto { [JsonPropertyName("statement")] @@ -21,25 +21,9 @@ public sealed class SignedStatementDto public string Signature { get; set; } = ""; } -/// 一个成员带的证明:操作员签的委托,加加入码的私钥签的绑定。 -public sealed class MemberProofDto -{ - [JsonPropertyName("grant")] - public SignedStatementDto Grant { get; set; } = new(); - - [JsonPropertyName("binding")] - public string Binding { get; set; } = ""; - - [JsonPropertyName("binding_signature")] - public string BindingSignature { get; set; } = ""; -} - -/// 操作员集合的链(创世在前),可选带一个委托(注册前取的,节点验过之后才继续)。 +/// 操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。 public sealed class TrustBundle { [JsonPropertyName("chain")] public List Chain { get; set; } = []; - - [JsonPropertyName("grant")] - public SignedStatementDto? Grant { get; set; } } diff --git a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs index bc81783..35cb796 100644 --- a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs +++ b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs @@ -23,7 +23,7 @@ public static class EnrollmentClient { public static async Task> EnrollAsync( HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct, - string? seed = null, string? genesis = null, string role = "agent") + string? genesis = null, IReadOnlyList? controllers = null) { string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName); if (File.Exists(nodeFile)) @@ -41,25 +41,16 @@ public static class EnrollmentClient tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo(); } - // 固定了操作员的创世(--genesis)和码的种子(--seed):先取信任链,确认这个面板的操作员集合和委托确实是固定的创世签出来的, - // 再用码的私钥签绑定。任何一步对不上都不注册,也不把自己的密钥交出去。 - Binding? binding = null; - string? bindingStatement = null; - string? bindingSignature = null; + // 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起, + // 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。 TrustBundle? bundle = null; - if (seed is not null || genesis is not null) + if (genesis is not null) { - Result<(TrustBundle Bundle, Binding Binding, string Statement, string Signature)> trusted = - await EstablishTrustAsync(http, server, joinToken, name, role, seed, genesis, identity.PublicKeySpki, tlsPublicKey, time, ct); - if (!trusted.TryGet(out (TrustBundle Bundle, Binding Binding, string Statement, string Signature) value, out FleetError? trustError)) + Result trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct); + if (!trusted.TryGet(out bundle, out FleetError? trustError)) { return Result.Fail(trustError); } - - bundle = value.Bundle; - binding = value.Binding; - bindingStatement = value.Statement; - bindingSignature = value.Signature; } string pubkey = Convert.ToBase64String(identity.PublicKeySpki); @@ -74,8 +65,6 @@ public static class EnrollmentClient ["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)), ["node_version"] = nodeVersion, ["meta"] = productMeta, - ["binding"] = bindingStatement, - ["binding_signature"] = bindingSignature, }; using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); @@ -104,9 +93,9 @@ public static class EnrollmentClient var store = new ConfigStore(identityDirectory); store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8()); store.Write(LoadedNodeIdentity.TlsFileName, pfx); - if (bundle is not null && binding is not null) + if (bundle is not null) { - TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, new MemberProofDto { Grant = bundle.Grant!, Binding = bindingStatement!, BindingSignature = bindingSignature! }); + TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []); } // node.json 最后写:它存在就表示注册完整完成 @@ -116,37 +105,21 @@ public static class EnrollmentClient return Result.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue(), identityDirectory, bundle is not null)); } - private static async Task> EstablishTrustAsync( - HttpClient http, Uri server, string joinToken, string name, string role, string? seedText, string? genesis, byte[] nodeKey, byte[] peerKey, TimeProvider time, CancellationToken ct) + private static async Task> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList controllers, CancellationToken ct) { - static Result<(TrustBundle, Binding, string, string)> Refuse(string reason) => Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason); - - if (seedText is null || genesis is null) - { - return Refuse("--seed and --genesis go together (the panel's install command has both)"); - } - - if (!Base64Url.TryDecode(seedText, out byte[] seed) || seed.Length != 32) - { - return Refuse("the code seed is malformed"); - } - - if (OperatorTrust.AuthToken(seed) != joinToken) - { - return Refuse("the join token is not derived from the code seed"); - } + static Result Refuse(string reason) => Result.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason); using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json"); using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct); if (!response.IsSuccessStatusCode) { - return Result<(TrustBundle, Binding, string, string)>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain"); + return Result.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain"); } TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle); - if (bundle?.Grant is null || bundle.Chain.Count == 0) + if (bundle is null || bundle.Chain.Count == 0) { - return Refuse("the panel has no operator trust set up, or this code carries no grant from the operator"); + return Refuse("the panel has no operator trust root set up"); } OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]); @@ -155,26 +128,11 @@ public static class EnrollmentClient return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)"); } - Grant? grant = OperatorTrust.ParseGrant(bundle.Grant.Statement); - long now = time.GetUtcNow().ToUnixTimeMilliseconds(); - if (grant is null || !OperatorTrust.VerifyOperator(bundle.Grant.ToSigned(), current)) + if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c))) { - return Refuse("the grant is not signed by your operator passkey"); + return Refuse("an owner passkey in the install command is not in the operator set"); } - if (grant.AuthHash != OperatorTrust.AuthHash(joinToken) || grant.Role != role || now < grant.NotBeforeMs || now > grant.NotAfterMs) - { - return Refuse("the grant is not for this code, this role, or this time"); - } - - using ECDsa? codeKey = OperatorTrust.CodeSigningKey(seed, grant.CodeKey); - if (codeKey is null) - { - return Refuse("the code seed does not match the grant's code key"); - } - - var binding = new Binding(name, role, nodeKey, peerKey, grant.AuthHash, now); - string signature = OperatorTrust.SignBinding(codeKey, binding, out string statement); - return Result<(TrustBundle, Binding, string, string)>.Ok((bundle, binding, statement, signature)); + return Result.Ok(bundle); } } diff --git a/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs index be5051c..b4cf216 100644 --- a/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs +++ b/backend/fleet/src/Fleet.Hosting/TrustAnchor.cs @@ -5,7 +5,7 @@ using Fleet.Identity; namespace Fleet.Hosting; -/// trust.json:注册时固定的创世指纹、见过的最新操作员集合链,以及(如果有)本节点自己的证明。 +/// trust.json:安装时固定的创世指纹和属主,加见过的最新操作员集合链。 public sealed class TrustFile { [JsonPropertyName("genesis")] @@ -14,19 +14,20 @@ public sealed class TrustFile [JsonPropertyName("chain")] public List Chain { get; set; } = []; - [JsonPropertyName("proof")] - public MemberProofDto? Proof { get; set; } + /// 属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。 + [JsonPropertyName("controllers")] + public List Controllers { get; set; } = []; } [JsonSerializable(typeof(TrustFile))] public sealed partial class TrustFileContext : JsonSerializerContext; /// -/// 节点的信任根:安装时固定下来的创世指纹,加验证过的操作员集合链。有它就是「强制」模式—— -/// 名册里的对端必须带操作员批准的证明才接纳(Backend 改不了,也签不出新的);没有 trust.json(旧节点、没有指纹的安装)就不强制。 -/// 链随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。 +/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。 +/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。 +/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。 /// -public sealed class TrustAnchor +public sealed class TrustAnchor : IServerVerifier { public const string FileName = "trust.json"; @@ -44,14 +45,68 @@ public sealed class TrustAnchor _log = log; } - /// 不强制(没有固定的创世)。 + /// 没有固定的创世(旧节点,或没有指纹的安装)。 public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance); - public bool Enforcing => _file is not null; + public bool Pinned => _file is not null; public string? Genesis => _file?.Genesis; - public MemberProofDto? OwnProof => _file?.Proof; + /// 当前生效的操作员集合版本;没有固定时为 0。 + public int Version + { + get + { + lock (_gate) + { + return _current?.Version ?? 0; + } + } + } + + /// 当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。 + public bool Required + { + get + { + lock (_gate) + { + return _current?.Servers is { Count: > 0 }; + } + } + } + + /// 服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。 + public bool Verify(string nonce, ReadOnlySpan serverEphemeralSpki, ReadOnlySpan signature) + { + IReadOnlyList servers; + lock (_gate) + { + servers = _current?.Servers ?? []; + } + + foreach (byte[] server in servers) + { + if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature)) + { + return true; + } + } + + return false; + } + + /// 安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。 + public IReadOnlyList Controllers + { + get + { + lock (_gate) + { + return _file is null || _current is null ? [] : [.. _file.Controllers.Where(c => _current.Keys.Any(k => k.CredentialId == c))]; + } + } + } public static TrustAnchor Load(string identityDirectory, INodeLog log) { @@ -65,18 +120,18 @@ public sealed class TrustAnchor OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]); if (current is null) { - // 固定的创世和存下来的链对不上:不能当成「不强制」放过,直接让节点起不来比悄悄失去保护好 + // 固定的创世和存下来的链对不上:不能当成「没有固定」放过,直接让节点起不来比悄悄失去保护好 throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again"); } return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log); } - /// 注册时写下:固定的创世、验证过的链、本节点的证明。 - public static void Pin(string identityDirectory, string genesis, IReadOnlyList chain, MemberProofDto proof) => - new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Proof = proof }, TrustFileContext.Default.TrustFile)); + /// 注册时写下:固定的创世、验证过的链、属主。 + public static void Pin(string identityDirectory, string genesis, IReadOnlyList chain, IReadOnlyList controllers) => + new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile)); - /// 期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有强制时为 0)。 + /// 期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。 public int Update(IReadOnlyList? chain) { lock (_gate) @@ -112,21 +167,4 @@ public sealed class TrustAnchor return _current.Version; } } - - /// 不强制就放行;强制时对端必须带证明,且验证通过(密钥是用来认证它的那把)。 - public TrustFailure VerifyPeer(MemberProofDto? proof, ReadOnlySpan peerPublicKey, string role) - { - OperatorSet? current; - lock (_gate) - { - if (_file is null) - { - return TrustFailure.None; - } - - current = _current; - } - - return proof is null || current is null ? TrustFailure.Malformed : OperatorTrust.VerifyPeer(current, proof.ToProof(), peerPublicKey, role); - } } diff --git a/backend/fleet/src/Fleet.Identity/NodeIdentity.cs b/backend/fleet/src/Fleet.Identity/NodeIdentity.cs index 04d8095..3c23b0d 100644 --- a/backend/fleet/src/Fleet.Identity/NodeIdentity.cs +++ b/backend/fleet/src/Fleet.Identity/NodeIdentity.cs @@ -7,6 +7,9 @@ namespace Fleet.Identity; public static class SignaturePurpose { public const string Auth = "auth"; + + /// 安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。 + public const string AuthSecure = "auth2"; public const string Peer = "peer"; public const string Enroll = "enroll"; } diff --git a/backend/fleet/src/Fleet.Identity/OperatorTrust.cs b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs index 320196a..fa25752 100644 --- a/backend/fleet/src/Fleet.Identity/OperatorTrust.cs +++ b/backend/fleet/src/Fleet.Identity/OperatorTrust.cs @@ -15,45 +15,21 @@ public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki); /// 操作员集合的一个版本。RpId 和 Origins 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。 /// 创世版本(1)由它自己的某把凭证签名,指纹()在安装时固定到节点;之后每个版本由上一版里的凭证签名。 /// -public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList Origins, IReadOnlyList Keys); - -/// 委托:操作员授权了一个加入码。CodeKey 是这个码的公钥(SPKI),码的种子是对应的私钥,只在安装命令里。 -public sealed record Grant(string Role, string AuthHash, byte[] CodeKey, long NotBeforeMs, long NotAfterMs, int Uses); - -/// 节点绑定:用码的私钥签的,把节点的两把公钥和这个码连起来。AtMs 必须落在委托的有效期内。 -public sealed record Binding(string Name, string Role, byte[] NodePublicKey, byte[] PeerPublicKey, string AuthHash, long AtMs); - -/// 一个成员带的证明:委托(passkey 签)加绑定(码的私钥签)。 -public sealed record MemberProof(SignedStatement Grant, string BindingStatement, string BindingSignature); - -public enum TrustFailure -{ - None, - Malformed, - BrokenChain, - GrantNotSigned, - GrantExpired, - BindingInvalid, - KeyMismatch, - RoleMismatch, -} +/// 服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名, +/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。 +public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList Origins, IReadOnlyList Keys, IReadOnlyList? Servers = null); /// -/// 信任根:操作员(passkey)→ 操作员集合 → 委托 → 绑定 → 节点公钥。设计见 docs/access.md 第 7 节。 -/// 全是纯函数,只用 BCL,节点和 Backend 共用:节点用它确认「对端是我的操作员批准的」,Backend 用它拒绝没有合法绑定的注册。 -/// 语句是 key=value 的文本,第一行是带版本的标题;签名覆盖整段文本,规范化靠固定的字段顺序和严格的解析。 +/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。 +/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本, +/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。 +/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 key=value 的文本,第一行是带版本的标题;签名覆盖整段文本。 /// public static class OperatorTrust { public const string SetHeader = "moshiro-opset-v1"; - public const string GrantHeader = "moshiro-grant-v1"; - public const string BindingHeader = "moshiro-binding-v1"; public const string Genesis = "0"; - private const string AuthLabel = "moshiro-join-auth-v1"; - - // ───────────────────────── 编码 ───────────────────────── - public static string Encode(OperatorSet set) { var text = new StringBuilder(); @@ -67,21 +43,15 @@ public static class OperatorTrust text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki)); } + foreach (byte[] server in set.Servers ?? []) + { + text.Append("\nserver=").Append(Convert.ToBase64String(server)); + } + return text.ToString(); } - public static string Encode(Grant grant) => - string.Join( - '\n', GrantHeader, "role=" + grant.Role, "auth=" + grant.AuthHash, "key=" + Convert.ToBase64String(grant.CodeKey), - "nb=" + grant.NotBeforeMs.ToString(CultureInfo.InvariantCulture), "na=" + grant.NotAfterMs.ToString(CultureInfo.InvariantCulture), "uses=" + grant.Uses.ToString(CultureInfo.InvariantCulture)); - - public static string Encode(Binding binding) => - string.Join( - '\n', BindingHeader, "name=" + binding.Name, "role=" + binding.Role, "pub=" + Convert.ToBase64String(binding.NodePublicKey), - "peer=" + Convert.ToBase64String(binding.PeerPublicKey), "auth=" + binding.AuthHash, "at=" + binding.AtMs.ToString(CultureInfo.InvariantCulture)); - - // ───────────────────────── 解析(严格:多一行、少一行、顺序不对都算坏) ───────────────────────── - + /// 严格解析:多一行、少一行、顺序不对都算坏。 public static OperatorSet? ParseSet(string text) { string[] lines = text.Split('\n'); @@ -92,46 +62,26 @@ public static class OperatorTrust } var keys = new List(); + var servers = new List(); foreach (string line in lines.Skip(5)) { - if (!TryValue(line, "key", out string value) || value.IndexOf(':', StringComparison.Ordinal) is not (> 0 and int split) || !TryBase64(value[(split + 1)..], out byte[] spki)) + if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki)) + { + keys.Add(new OperatorKey(value[..split], spki)); + } + else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey)) + { + servers.Add(serverKey); + } + else { return null; } - - keys.Add(new OperatorKey(value[..split], spki)); } - return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys); + return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers); } - public static Grant? ParseGrant(string text) - { - string[] lines = text.Split('\n'); - if (lines.Length != 7 || lines[0] != GrantHeader || !TryValue(lines[1], "role", out string role) || !TryValue(lines[2], "auth", out string auth) || !TryValue(lines[3], "key", out string key) || !TryBase64(key, out byte[] codeKey) - || !TryLong(lines[4], "nb", out long notBefore) || !TryLong(lines[5], "na", out long notAfter) || !TryValue(lines[6], "uses", out string uses) || !int.TryParse(uses, NumberStyles.None, CultureInfo.InvariantCulture, out int count) - || role.Length == 0 || auth.Length != 64) - { - return null; - } - - return new Grant(role, auth, codeKey, notBefore, notAfter, count); - } - - public static Binding? ParseBinding(string text) - { - string[] lines = text.Split('\n'); - if (lines.Length != 7 || lines[0] != BindingHeader || !TryValue(lines[1], "name", out string name) || !TryValue(lines[2], "role", out string role) || !TryValue(lines[3], "pub", out string pub) || !TryBase64(pub, out byte[] node) - || !TryValue(lines[4], "peer", out string peer) || !TryBase64(peer, out byte[] peerKey) || !TryValue(lines[5], "auth", out string auth) || !TryLong(lines[6], "at", out long at) || auth.Length != 64) - { - return null; - } - - return new Binding(name, role, node, peerKey, auth, at); - } - - // ───────────────────────── 指纹与验证 ───────────────────────── - /// 一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。 public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); @@ -187,111 +137,6 @@ public static class OperatorTrust return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _); } - /// - /// 验证一个对端:和 一样,只是调用方手里只有它用来认证这个对端的那把密钥(对等面公钥), - /// 所以只核对绑定里的对等面公钥;节点身份公钥由 Backend 在注册时核对。 - /// - public static TrustFailure VerifyPeer(OperatorSet current, MemberProof proof, ReadOnlySpan peerPublicKey, string role) - { - Binding? binding = ParseBinding(proof.BindingStatement); - return binding is null ? TrustFailure.Malformed : VerifyMember(current, proof, binding.NodePublicKey, peerPublicKey, role); - } - - /// - /// 验证一个成员:委托由当前操作员集合里的凭证签(并且在有效期内),绑定由委托里的码公钥签, - /// 绑定里的节点公钥、对等面公钥和角色对得上调用方手里的这个成员。 - /// - public static TrustFailure VerifyMember(OperatorSet current, MemberProof proof, ReadOnlySpan nodePublicKey, ReadOnlySpan peerPublicKey, string role) - { - Grant? grant = ParseGrant(proof.Grant.Statement); - Binding? binding = ParseBinding(proof.BindingStatement); - if (grant is null || binding is null || !TryDecode(proof.BindingSignature, out byte[] bindingSignature)) - { - return TrustFailure.Malformed; - } - - if (!VerifyOperator(proof.Grant, current)) - { - return TrustFailure.GrantNotSigned; - } - - if (binding.AtMs < grant.NotBeforeMs || binding.AtMs > grant.NotAfterMs) - { - return TrustFailure.GrantExpired; - } - - if (binding.AuthHash != grant.AuthHash || binding.Role != grant.Role) - { - return TrustFailure.KeyMismatch; - } - - if (!binding.NodePublicKey.AsSpan().SequenceEqual(nodePublicKey) || !binding.PeerPublicKey.AsSpan().SequenceEqual(peerPublicKey)) - { - return TrustFailure.KeyMismatch; - } - - if (grant.Role != role) - { - return TrustFailure.RoleMismatch; - } - - try - { - using ECDsa codeKey = ECDsa.Create(); - codeKey.ImportSubjectPublicKeyInfo(grant.CodeKey, out _); - return codeKey.VerifyData(Encoding.UTF8.GetBytes(proof.BindingStatement), bindingSignature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence) - ? TrustFailure.None - : TrustFailure.BindingInvalid; - } - catch (CryptographicException) - { - return TrustFailure.BindingInvalid; - } - } - - // ───────────────────────── 加入码的种子 ───────────────────────── - - /// - /// 种子(32 字节,就是码的 P-256 私钥)派生出交给 Backend 的认证串:Backend 只会看到它的哈希,推不回种子, - /// 所以 Backend 没法替这个码签绑定。HKDF-SHA256,空盐,用途标签在 info 里;浏览器端的 WebCrypto 用同样的参数。 - /// - public static string AuthToken(ReadOnlySpan seed) => - "ja_" + Base64Url.EncodeToString(Derive(seed)); - - private static byte[] Derive(ReadOnlySpan seed) - { - byte[] output = new byte[32]; - HKDF.DeriveKey(HashAlgorithmName.SHA256, seed, output, ReadOnlySpan.Empty, Encoding.UTF8.GetBytes(AuthLabel)); - return output; - } - - public static string AuthHash(string authToken) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(authToken))); - - /// 用种子和委托里的码公钥还原码的签名密钥(公钥从已验证的委托里来,种子里只有私钥标量)。 - public static ECDsa? CodeSigningKey(ReadOnlySpan seed, ReadOnlySpan codeKeySpki) - { - try - { - using ECDsa publicOnly = ECDsa.Create(); - publicOnly.ImportSubjectPublicKeyInfo(codeKeySpki, out _); - ECParameters parameters = publicOnly.ExportParameters(false); - parameters.D = seed.ToArray(); - return ECDsa.Create(parameters); - } - catch (CryptographicException) - { - return null; - } - } - - public static string SignBinding(ECDsa codeKey, Binding binding, out string statement) - { - statement = Encode(binding); - return Base64Url.EncodeToString(codeKey.SignData(Encoding.UTF8.GetBytes(statement), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); - } - - // ───────────────────────── 小工具 ───────────────────────── - private static bool TryValue(string line, string key, out string value) { value = ""; @@ -304,12 +149,6 @@ public static class OperatorTrust return true; } - private static bool TryLong(string line, string key, out long value) - { - value = 0; - return TryValue(line, key, out string text) && long.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value); - } - private static bool TryBase64(string text, out byte[] data) { try diff --git a/backend/fleet/src/Fleet.Identity/SecureChannel.cs b/backend/fleet/src/Fleet.Identity/SecureChannel.cs new file mode 100644 index 0000000..61e063b --- /dev/null +++ b/backend/fleet/src/Fleet.Identity/SecureChannel.cs @@ -0,0 +1,173 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace Fleet.Identity; + +/// +/// 节点这一侧用来确认「对面确实是我的操作员授权过的 Backend」的校验器:Required 为真(当前操作员集合里登记了服务器密钥) +/// 时,服务器的挑战必须带着对得上的签名,否则节点不继续认证、也不交出任何东西。 +/// +public interface IServerVerifier +{ + bool Required { get; } + + /// 服务器用它的服务器密钥对「挑战随机数加它这次连接的临时公钥」的签名。 + bool Verify(string nonce, ReadOnlySpan serverEphemeralSpki, ReadOnlySpan signature); +} + +/// 服务器在挑战里给出的身份证明:服务器密钥(操作员签进集合)对「随机数加临时公钥」签名。纯函数。 +public static class ServerHello +{ + /// 被签名的内容;服务器那一侧只拿到这个、用自己的密钥签(服务器密钥不一定是 BCL 的 ECDsa,可以在别处)。 + public static byte[] Payload(string nonce, ReadOnlySpan serverEphemeralSpki) => + Encoding.UTF8.GetBytes("fleet|v1|server|" + nonce + "|" + Convert.ToBase64String(serverEphemeralSpki)); + + public static byte[] Sign(ECDsa serverKey, string nonce, ReadOnlySpan serverEphemeralSpki) => + serverKey.SignData(Payload(nonce, serverEphemeralSpki), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + + public static bool Verify(ReadOnlySpan serverKeySpki, string nonce, ReadOnlySpan serverEphemeralSpki, ReadOnlySpan signature) + { + try + { + using ECDsa key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(serverKeySpki, out _); + return key.VerifyData(Payload(nonce, serverEphemeralSpki), signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + catch (CryptographicException) + { + return false; + } + } +} + +/// +/// 节点和 Backend 之间控制链路的应用层加密:两边的临时 ECDH 密钥协商出会话密钥,每个方向一把,AES-256-GCM,每帧带计数器。 +/// 所以即使 TLS 在中间被终止(Cloudflare、被冒充的证书),中间人没有服务器密钥,就拿不到明文,也改不了任何一帧。 +/// 帧格式:计数器(8,大端) ‖ 密文 ‖ 标签(16);明文是 帧类型(1) ‖ 内容;计数器必须严格递增,重放、乱序、丢帧、篡改都会解密失败。 +/// +public sealed class LinkCipher : IDisposable +{ + public const int Overhead = 8 + 16; + + private const byte NodeToServer = 0; + private const byte ServerToNode = 1; + + private readonly AesGcm _send; + private readonly AesGcm _receive; + private readonly byte _sendDirection; + private readonly byte _receiveDirection; + private readonly object _gate = new(); + private long _sendCounter; + private long _receiveCounter; + + private LinkCipher(byte[] sendKey, byte[] receiveKey, byte sendDirection, byte receiveDirection) + { + _send = new AesGcm(sendKey, 16); + _receive = new AesGcm(receiveKey, 16); + _sendDirection = sendDirection; + _receiveDirection = receiveDirection; + } + + public static LinkCipher ForNode(EphemeralKey own, ReadOnlySpan serverEphemeralSpki, string nonce) + { + (byte[] n2s, byte[] s2n) = Derive(own, serverEphemeralSpki, own.PublicKeySpki, serverEphemeralSpki, nonce); + return new LinkCipher(n2s, s2n, NodeToServer, ServerToNode); + } + + public static LinkCipher ForServer(EphemeralKey own, ReadOnlySpan nodeEphemeralSpki, string nonce) + { + (byte[] n2s, byte[] s2n) = Derive(own, nodeEphemeralSpki, nodeEphemeralSpki, own.PublicKeySpki, nonce); + return new LinkCipher(s2n, n2s, ServerToNode, NodeToServer); + } + + private static (byte[] NodeToServerKey, byte[] ServerToNodeKey) Derive(EphemeralKey own, ReadOnlySpan remote, ReadOnlySpan nodeEphemeral, ReadOnlySpan serverEphemeral, string nonce) + { + byte[] secret = own.AgreeRaw(remote); + try + { + byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|link|"), .. SHA256.HashData(serverEphemeral), .. SHA256.HashData(nodeEphemeral)]; + byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 64, Encoding.UTF8.GetBytes(nonce), info); + return (material[..32], material[32..]); + } + finally + { + CryptographicOperations.ZeroMemory(secret); + } + } + + /// 加密一帧。 + public byte[] Seal(byte kind, ReadOnlySpan data) + { + long counter; + lock (_gate) + { + counter = _sendCounter++; + } + + byte[] output = new byte[Overhead + 1 + data.Length]; + BinaryPrimitives.WriteInt64BigEndian(output, counter); + byte[] plaintext = new byte[1 + data.Length]; + plaintext[0] = kind; + data.CopyTo(plaintext.AsSpan(1)); + _send.Encrypt(Nonce(counter), plaintext, output.AsSpan(8, plaintext.Length), output.AsSpan(8 + plaintext.Length, 16), Aad(_sendDirection, counter)); + return output; + } + + /// 解密一帧;计数器不是期望的下一个、或者标签不对,都返回 false。 + public bool TryOpen(ReadOnlySpan frame, out byte kind, out byte[] data) + { + kind = 0; + data = []; + if (frame.Length < Overhead + 1) + { + return false; + } + + long counter = BinaryPrimitives.ReadInt64BigEndian(frame); + lock (_gate) + { + if (counter != _receiveCounter) + { + return false; + } + + int length = frame.Length - Overhead; + byte[] plaintext = new byte[length]; + try + { + _receive.Decrypt(Nonce(counter), frame.Slice(8, length), frame.Slice(8 + length, 16), plaintext, Aad(_receiveDirection, counter)); + } + catch (CryptographicException) + { + return false; + } + + _receiveCounter++; + kind = plaintext[0]; + data = plaintext[1..]; + return true; + } + } + + private static byte[] Nonce(long counter) + { + byte[] nonce = new byte[12]; + BinaryPrimitives.WriteInt64BigEndian(nonce.AsSpan(4), counter); + return nonce; + } + + private static byte[] Aad(byte direction, long counter) + { + byte[] aad = new byte[9]; + aad[0] = direction; + BinaryPrimitives.WriteInt64BigEndian(aad.AsSpan(1), counter); + return aad; + } + + public void Dispose() + { + _send.Dispose(); + _receive.Dispose(); + } +} diff --git a/backend/fleet/src/Fleet.Identity/SessionKeys.cs b/backend/fleet/src/Fleet.Identity/SessionKeys.cs index 2f4eba1..f326b19 100644 --- a/backend/fleet/src/Fleet.Identity/SessionKeys.cs +++ b/backend/fleet/src/Fleet.Identity/SessionKeys.cs @@ -16,6 +16,14 @@ public sealed class EphemeralKey : IDisposable public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo(); + /// 与对端的临时公钥做 ECDH,返回原始共享秘密;必须再经 KDF 才能当密钥用。 + public byte[] AgreeRaw(ReadOnlySpan remotePublicKeySpki) + { + using ECDiffieHellman remote = ECDiffieHellman.Create(); + remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _); + return _key.DeriveRawSecretAgreement(remote.PublicKey); + } + /// /// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。 /// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。 diff --git a/backend/fleet/src/Fleet.Identity/TrustMapping.cs b/backend/fleet/src/Fleet.Identity/TrustMapping.cs index 59a4843..2672fa6 100644 --- a/backend/fleet/src/Fleet.Identity/TrustMapping.cs +++ b/backend/fleet/src/Fleet.Identity/TrustMapping.cs @@ -8,8 +8,4 @@ public static class TrustMapping public static SignedStatementDto ToDto(this SignedStatement signed) => new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature }; - - public static MemberProof ToProof(this MemberProofDto dto) => new(dto.Grant.ToSigned(), dto.Binding, dto.BindingSignature); - - public static MemberProofDto ToDto(this MemberProof proof) => new() { Grant = proof.Grant.ToDto(), Binding = proof.BindingStatement, BindingSignature = proof.BindingSignature }; } diff --git a/backend/fleet/src/Fleet.Link/FleetLink.cs b/backend/fleet/src/Fleet.Link/FleetLink.cs index 04a19d1..e31649d 100644 --- a/backend/fleet/src/Fleet.Link/FleetLink.cs +++ b/backend/fleet/src/Fleet.Link/FleetLink.cs @@ -66,6 +66,12 @@ public sealed class FleetLinkOptions public long MaxInflightBytes { get; init; } = 4 << 20; public Backoff Backoff { get; init; } = new(); + + /// + /// 校验服务器身份: 为真(节点固定的操作员集合里登记了服务器密钥)时, + /// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。 + /// + public IServerVerifier? ServerVerifier { get; init; } } /// @@ -77,6 +83,7 @@ public sealed class FleetLink private const string RpcPath = "/api/agent/v1/rpc"; private readonly FleetLinkOptions _options; + private volatile bool _secure; private readonly RpcRouter _router; private readonly Dictionary _handlers; private readonly TimeProvider _time; @@ -153,6 +160,9 @@ public sealed class FleetLink } } + /// 当前这条链路是不是应用层加密的(服务器证明了身份)。 + public bool IsSecure => _secure && IsConnected; + private async Task RunOnceAsync(CancellationToken ct) { using var socket = new ClientWebSocket(); @@ -160,7 +170,7 @@ public sealed class FleetLink socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死 var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath }; await socket.ConnectAsync(builder.Uri, ct); - await using var transport = new WsTransport(socket, _options.MaxFrameBytes); + await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes)); string? failure = await AuthenticateAsync(transport, ct); if (failure is not null) @@ -168,6 +178,8 @@ public sealed class FleetLink return failure; } + _secure = transport.Secure; + var session = new RpcSession(transport, _router, new LinkContext(this), _time); using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct); Task run = session.RunAsync(sessionCts.Token); @@ -211,7 +223,7 @@ public sealed class FleetLink } /// 挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。 - private async Task AuthenticateAsync(WsTransport transport, CancellationToken ct) + private async Task AuthenticateAsync(SecureTransport transport, CancellationToken ct) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); @@ -222,9 +234,44 @@ public sealed class FleetLink } ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!; - string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)); - RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams); - await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token); + + // 固定了操作员集合、而集合里登记了服务器密钥:服务器必须证明自己,否则不继续,也不交出任何东西 + EphemeralKey? ephemeral = null; + AuthParams authParams; + try + { + if (_options.ServerVerifier is { Required: true } verifier) + { + if (!TryDecodeBase64(nonce.ServerEphemeral, out byte[] serverEphemeral) || !TryDecodeBase64(nonce.ServerSignature, out byte[] serverSignature) + || !verifier.Verify(nonce.Nonce, serverEphemeral, serverSignature)) + { + return "the server could not prove its identity (it is not the server your operator set authorizes)"; + } + + ephemeral = new EphemeralKey(); + string serverEphemeralText = Convert.ToBase64String(serverEphemeral); + string nodeEphemeralText = Convert.ToBase64String(ephemeral.PublicKeySpki); + authParams = new AuthParams + { + NodeId = _options.NodeId.Value, + Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)), + NodeEphemeral = nodeEphemeralText, + }; + RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams); + await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token); + transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce)); + } + else + { + authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) }; + RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams); + await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token); + } + } + finally + { + ephemeral?.Dispose(); + } while (true) { @@ -259,7 +306,26 @@ public sealed class FleetLink } } - private static async Task ReceiveMessageAsync(WsTransport transport, CancellationToken ct) + private static bool TryDecodeBase64(string? text, out byte[] data) + { + data = []; + if (string.IsNullOrEmpty(text)) + { + return false; + } + + try + { + data = Convert.FromBase64String(text); + return true; + } + catch (FormatException) + { + return false; + } + } + + private static async Task ReceiveMessageAsync(SecureTransport transport, CancellationToken ct) { RpcFrame? frame = await transport.ReceiveAsync(ct); return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null; diff --git a/backend/fleet/src/Fleet.Link/SecureTransport.cs b/backend/fleet/src/Fleet.Link/SecureTransport.cs new file mode 100644 index 0000000..6902180 --- /dev/null +++ b/backend/fleet/src/Fleet.Link/SecureTransport.cs @@ -0,0 +1,57 @@ +using Fleet.Identity; +using Fleet.Rpc; + +namespace Fleet.Link; + +/// +/// 认证阶段是明文,认证通过之后换成应用层加密():此后每帧都是加密的二进制帧,明文帧一律拒绝。 +/// 认证阶段服务器的拒绝回复可能是明文(还没有协商好密钥),所以第一帧加密的回复到达之前允许一次明文文本帧。 +/// +internal sealed class SecureTransport : IRpcTransport +{ + private readonly IRpcTransport _inner; + private LinkCipher? _cipher; + private bool _allowPlaintext = true; + + public SecureTransport(IRpcTransport inner) + { + _inner = inner; + } + + public bool Secure => _cipher is not null; + + public void Enable(LinkCipher cipher) => _cipher = cipher; + + public ValueTask SendAsync(RpcFrame frame, CancellationToken ct) => + _cipher is null ? _inner.SendAsync(frame, ct) : _inner.SendAsync(new RpcFrame(FrameKind.Binary, _cipher.Seal((byte)frame.Kind, frame.Data.Span)), ct); + + public async ValueTask ReceiveAsync(CancellationToken ct) + { + RpcFrame? frame = await _inner.ReceiveAsync(ct); + if (frame is not { } received || _cipher is null) + { + return frame; + } + + if (received.Kind == FrameKind.Binary && _cipher.TryOpen(received.Data.Span, out byte kind, out byte[] data)) + { + _allowPlaintext = false; + return new RpcFrame((FrameKind)kind, data); + } + + if (_allowPlaintext && received.Kind == FrameKind.Text) + { + return received; // 认证被拒绝时服务器还没有协商好密钥,回复是明文 + } + + throw new IOException("a frame on the secure link failed to authenticate"); + } + + public ValueTask CloseAsync(string reason, CancellationToken ct) => _inner.CloseAsync(reason, ct); + + public async ValueTask DisposeAsync() + { + _cipher?.Dispose(); + await _inner.DisposeAsync(); + } +} diff --git a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs index d189253..a1c5e61 100644 --- a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs +++ b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs @@ -53,6 +53,14 @@ public sealed class ChallengeParams [JsonPropertyName("expires_at_ms")] public long ExpiresAtMs { get; set; } + + /// 服务器这次连接的临时 ECDH 公钥(SPKI,base64);服务器有身份密钥时才有。 + [JsonPropertyName("server_eph")] + public string? ServerEphemeral { get; set; } + + /// 服务器密钥对「随机数加临时公钥」的签名(base64):节点据此确认对面是操作员授权的服务器。 + [JsonPropertyName("server_sig")] + public string? ServerSignature { get; set; } } public sealed class AuthParams @@ -63,6 +71,10 @@ public sealed class AuthParams /// 对 fleet|v1|auth|{node_id}|{nonce} 的签名(base64)。 [JsonPropertyName("signature")] public string Signature { get; set; } = ""; + + /// 节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。 + [JsonPropertyName("node_eph")] + public string? NodeEphemeral { get; set; } } public sealed class AuthResult diff --git a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs index 7f88641..d5db797 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/NodeRecords.cs @@ -34,8 +34,7 @@ public sealed record NewNode( long NowMs, string Kind = "", byte[]? TlsPublicKey = null, - string? AddedBy = null, - MemberProofDto? Proof = null); + string? AddedBy = null); public sealed record JoinTokenRecord( string Id, @@ -49,7 +48,6 @@ public sealed record JoinTokenRecord( long? RevokedAtMs, long CreatedAtMs, string Kind = "", - string? CreatedBy = null, - SignedStatementDto? Grant = null); + string? CreatedBy = null); public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList Capabilities, string? HelloMetaJson); diff --git a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs index 66a50b0..bb8e4be 100644 --- a/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs +++ b/backend/fleet/src/Fleet.Server.Abstractions/ServerContracts.cs @@ -6,6 +6,18 @@ namespace Fleet.Server; public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta); +/// +/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器, +/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。 +/// +public interface IServerIdentity +{ + byte[] PublicKeySpki { get; } + + /// 对一段内容签名(Fleet.Identity.ServerHello.Payload 给出的)。 + byte[] Sign(ReadOnlySpan payload); +} + /// 操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。 public interface IOperatorTrustSource { diff --git a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs index bfa2dec..124bb39 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/EnrollmentModels.cs @@ -33,13 +33,6 @@ public sealed class EnrollRequest [JsonPropertyName("node_version")] public string NodeVersion { get; set; } = ""; - /// 带操作员委托的加入码要附上:用码的私钥签的绑定语句(节点的两把公钥和名字)和它的签名。 - [JsonPropertyName("binding")] - public string? Binding { get; set; } - - [JsonPropertyName("binding_signature")] - public string? BindingSignature { get; set; } - [JsonPropertyName("meta")] public JsonElement? Meta { get; set; } } diff --git a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs index 346cbe1..8fe2e65 100644 --- a/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs +++ b/backend/fleet/src/Fleet.Server.Enrollment/FleetEnrollmentService.cs @@ -61,17 +61,6 @@ public sealed partial class FleetEnrollmentService return Result.Fail(FleetErrors.ProofInvalid, "proof of possession does not verify"); } - // 带操作员委托的码:节点必须附上用码的私钥签的绑定,委托、绑定和这次注册的密钥、名字、角色都对得上才收 - MemberProofDto? memberProof = null; - if (token.Grant is not null) - { - Result verified = await VerifyBindingAsync(token, request, publicKey, tlsPublicKey, ct); - if (!verified.TryGet(out memberProof, out FleetError? bindingError)) - { - return Result.Fail(bindingError); - } - } - if (!await _store.TryConsumeJoinTokenAsync(token.Id, nowMs, ct)) { return Result.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid"); @@ -94,8 +83,7 @@ public sealed partial class FleetEnrollmentService nowMs, token.Kind, tlsPublicKey, - token.CreatedBy, - memberProof); + token.CreatedBy); Result created = await _store.CreateNodeAsync(node, ct); if (!created.TryGet(out NodeRecord? record, out FleetError? error)) @@ -116,7 +104,7 @@ public sealed partial class FleetEnrollmentService }); } - /// 节点注册之前取:操作员集合的链,和这个码的委托(如果有)。码要是有效的;不能用的码不给。 + /// 节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。 public async ValueTask> GetTrustAsync(string joinToken, CancellationToken ct) { JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct); @@ -131,7 +119,7 @@ public sealed partial class FleetEnrollmentService return Result.Fail(FleetErrors.JoinTokenExpired, "join token has expired"); } - var bundle = new TrustBundle { Grant = token.Grant }; + var bundle = new TrustBundle(); foreach (IOperatorTrustSource source in _trust) { bundle.Chain.AddRange(await source.ChainAsync(ct)); @@ -140,26 +128,6 @@ public sealed partial class FleetEnrollmentService return Result.Ok(bundle); } - private async ValueTask> VerifyBindingAsync(JoinTokenRecord token, EnrollRequest request, byte[] publicKey, byte[] tlsPublicKey, CancellationToken ct) - { - IReadOnlyList chain = _trust.Any() ? await _trust.First().ChainAsync(ct) : []; - OperatorSet? current = chain.Count == 0 ? null : OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(chain[0].Statement), [.. chain.Select(c => c.ToSigned())]); - if (current is null || string.IsNullOrEmpty(request.Binding) || string.IsNullOrEmpty(request.BindingSignature)) - { - return Result.Fail(FleetErrors.ProofInvalid, "this join code carries an operator grant: the enrollment needs a binding signed with the code key"); - } - - string role = token.Kind.Length == 0 ? "agent" : token.Kind; - var proof = new MemberProofDto { Grant = token.Grant!, Binding = request.Binding, BindingSignature = request.BindingSignature }; - TrustFailure failure = OperatorTrust.VerifyMember(current, proof.ToProof(), publicKey, tlsPublicKey, role); - if (failure != TrustFailure.None || OperatorTrust.ParseBinding(request.Binding)?.Name != request.Name) - { - return Result.Fail(FleetErrors.ProofInvalid, "the binding does not verify against the operator's grant (" + failure + ")"); - } - - return Result.Ok(proof); - } - private static bool TryDecode(string text, out byte[] data) { try diff --git a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs index b06fa57..d4d4b82 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetLinkHost.cs @@ -23,8 +23,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier IEnumerable stateProviders, IEnumerable inboxes, IEnumerable sinks, - FleetRouter router) + FleetRouter router, + IServerIdentity? serverIdentity = null) { + ServerIdentity = serverIdentity; Store = store; Options = options; Time = time; @@ -38,6 +40,9 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier internal IFleetStore Store { get; } + /// 服务器的身份密钥;没有时节点连接用旧的明文认证。 + internal IServerIdentity? ServerIdentity { get; } + internal FleetServerOptions Options { get; } internal TimeProvider Time { get; } diff --git a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs index ede5c05..0c99b75 100644 --- a/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs +++ b/backend/fleet/src/Fleet.Server.Link/FleetServerBuilder.cs @@ -109,7 +109,8 @@ public static class FleetServerExtensions provider.GetServices(), provider.GetServices(), provider.GetServices(), - provider.GetRequiredService())); + provider.GetRequiredService(), + provider.GetService())); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(provider => provider.GetRequiredService()); return builder; diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs index 55f33fe..57c87a8 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Auth.cs @@ -14,9 +14,19 @@ internal sealed partial class NodeConnection { string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds(); - await SendAsync( - RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams), - ct); + + // 有服务器身份密钥:挑战里带上这次连接的临时公钥和服务器的签名,节点据此确认服务器、协商加密链路 + using var serverEphemeral = _host.ServerIdentity is null ? null : new EphemeralKey(); + var challenge = new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }; + string? serverEphemeralText = null; + if (_host.ServerIdentity is { } identity && serverEphemeral is not null) + { + serverEphemeralText = Convert.ToBase64String(serverEphemeral.PublicKeySpki); + challenge.ServerEphemeral = serverEphemeralText; + challenge.ServerSignature = Convert.ToBase64String(identity.Sign(ServerHello.Payload(nonce, serverEphemeral.PublicKeySpki))); + } + + await SendAsync(RpcMessage.Notification(FleetMethods.Challenge, challenge, FleetRpcContext.Default.ChallengeParams), ct); using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); @@ -48,10 +58,21 @@ internal sealed partial class NodeConnection } NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct); + + // 安全链路:节点的签名覆盖双方的临时公钥(AuthSecure),验过之后这条连接上的所有帧都是加密的 + byte[] nodeEphemeral = []; + bool secure = auth?.NodeEphemeral is not null && serverEphemeral is not null && serverEphemeralText is not null && TryDecode(auth.NodeEphemeral, out nodeEphemeral); bool signatureOk = node is not null && _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs && TryDecode(auth!.Signature, out byte[] signature) - && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce); + && (secure + ? NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.AuthSecure, auth.NodeId, nonce, serverEphemeralText!, auth.NodeEphemeral!) + : auth.NodeEphemeral is null && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce)); + if (signatureOk && secure) + { + _cipher = LinkCipher.ForServer(serverEphemeral!, nodeEphemeral, nonce); + } + if (!signatureOk) { // 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。 diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs index 467ca87..a05d4ca 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Send.cs @@ -75,7 +75,15 @@ internal sealed partial class NodeConnection return false; } - await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct); + if (_cipher is { } cipher) + { + await _socket.SendAsync(cipher.Seal(0, bytes), WebSocketMessageType.Binary, endOfMessage: true, ct); + } + else + { + await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct); + } + return true; } catch (WebSocketException) diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs index 0c16154..b528943 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.Wire.cs @@ -30,6 +30,19 @@ internal sealed partial class NodeConnection if (result.EndOfMessage) { + if (_cipher is { } cipher) + { + // 加密链路上只接受通过认证的二进制帧;明文、被改过的、重放的、乱序的一律断开 + if (result.MessageType != WebSocketMessageType.Binary || !cipher.TryOpen(writer.WrittenSpan, out byte kind, out byte[] plain)) + { + _closeReason ??= "frame failed authentication"; + await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "frame failed authentication", CancellationToken.None); + return null; + } + + return new Frame(kind == 0, plain); + } + return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray()); } } diff --git a/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs b/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs index 1d32de7..95867d6 100644 --- a/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs +++ b/backend/fleet/src/Fleet.Server.Link/NodeConnection.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Net.WebSockets; using System.Threading.Channels; using Fleet.Core; +using Fleet.Identity; using Fleet.Rpc; namespace Fleet.Server; @@ -24,6 +25,7 @@ internal sealed partial class NodeConnection : IDisposable private readonly Channel _stateSignal = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); private readonly Dictionary _lastPushedVersion = new(StringComparer.Ordinal); private readonly CancellationTokenSource _lifetime = new(); + private LinkCipher? _cipher; private long _nextRequestId; private long _lastPresenceTicks; private NodeRecord? _node; @@ -134,6 +136,7 @@ internal sealed partial class NodeConnection : IDisposable public void Dispose() { + _cipher?.Dispose(); _lifetime.Dispose(); _sendLock.Dispose(); } diff --git a/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs b/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs index ae42f15..2f02e8c 100644 --- a/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs +++ b/backend/fleet/tests/Fleet.Tests/OperatorTrustTests.cs @@ -111,85 +111,13 @@ public sealed class OperatorTrustTests : IDisposable Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里 } - private sealed record Enrolled(OperatorSet Set, byte[] Seed, MemberProof Proof, byte[] NodeKey, byte[] PeerKey, string Auth); - - private Enrolled Enroll(string role = "probe", long now = 1_000_000, long notBefore = 0, long notAfter = 2_000_000, TestAuthenticator? signer = null) - { - OperatorSet set = Genesis(_alice); - using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256); - byte[] seed = code.ExportParameters(true).D!; - string auth = OperatorTrust.AuthToken(seed); - var grant = new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), notBefore, notAfter, 1); - SignedStatement signedGrant = (signer ?? _alice).Sign(OperatorTrust.Encode(grant)); - - byte[] nodeKey = RandomNumberGenerator.GetBytes(91); - byte[] peerKey = RandomNumberGenerator.GetBytes(91); - using ECDsa? signing = OperatorTrust.CodeSigningKey(seed, grant.CodeKey); - Assert.NotNull(signing); - string signature = OperatorTrust.SignBinding(signing, new Binding("tokyo", role, nodeKey, peerKey, grant.AuthHash, now), out string statement); - return new Enrolled(set, seed, new MemberProof(signedGrant, statement, signature), nodeKey, peerKey, auth); - } - - [Fact] - public void AMember_VerifiesThroughTheWholeChain() - { - Enrolled e = Enroll(); - Assert.Equal(TrustFailure.None, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "probe")); - } - - [Fact] - public void TheSeed_IsNotRecoverableFromWhatTheBackendSees() - { - Enrolled e = Enroll(); - Assert.StartsWith("ja_", e.Auth, StringComparison.Ordinal); - Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Auth, StringComparison.Ordinal); - Assert.DoesNotContain(Base64Url.EncodeToString(e.Seed), e.Proof.Grant.Statement + e.Proof.BindingStatement, StringComparison.Ordinal); - Assert.Equal(e.Auth, OperatorTrust.AuthToken(e.Seed)); // 节点自己能算出同一个 - } - - [Fact] - public void ABindingForOtherKeys_AnotherRole_OrAnExpiredGrant_IsRefused() - { - Enrolled e = Enroll(); - Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, RandomNumberGenerator.GetBytes(91), e.PeerKey, "probe")); // Backend 想把别的节点密钥挂在这个绑定上 - Assert.Equal(TrustFailure.KeyMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, RandomNumberGenerator.GetBytes(91), "probe")); - Assert.Equal(TrustFailure.RoleMismatch, OperatorTrust.VerifyMember(e.Set, e.Proof, e.NodeKey, e.PeerKey, "agent")); // 一个 Probe 的绑定不能冒充 Agent - - Enrolled late = Enroll(now: 3_000_000, notAfter: 2_000_000); // 有效期之后才签的绑定 - Assert.Equal(TrustFailure.GrantExpired, OperatorTrust.VerifyMember(late.Set, late.Proof, late.NodeKey, late.PeerKey, "probe")); - } - - [Fact] - public void AGrantNotSignedByTheOperator_OrABindingNotSignedByTheCodeKey_IsRefused() - { - using var stranger = new TestAuthenticator(); - Enrolled forged = Enroll(signer: stranger); - Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(forged.Set, forged.Proof, forged.NodeKey, forged.PeerKey, "probe")); - - // Backend 拿到了委托但没有种子:用自己的密钥签绑定 - Enrolled e = Enroll(); - using ECDsa backend = ECDsa.Create(ECCurve.NamedCurves.nistP256); - Binding stolen = OperatorTrust.ParseBinding(e.Proof.BindingStatement)!; - string bogus = OperatorTrust.SignBinding(backend, stolen, out string statement); - Assert.Equal(TrustFailure.BindingInvalid, OperatorTrust.VerifyMember(e.Set, new MemberProof(e.Proof.Grant, statement, bogus), e.NodeKey, e.PeerKey, "probe")); - } - - [Fact] - public void RemovingAnOperatorKey_InvalidatesTheGrantsItSigned() - { - Enrolled e = Enroll(); - OperatorSet withoutAlice = Genesis(_bob) with { Version = 2, Previous = new string('a', 64) }; - Assert.Equal(TrustFailure.GrantNotSigned, OperatorTrust.VerifyMember(withoutAlice, e.Proof, e.NodeKey, e.PeerKey, "probe")); - } - [Fact] public void Parsing_IsStrict() { - Assert.Null(OperatorTrust.ParseGrant("moshiro-grant-v1\nrole=probe")); - Assert.Null(OperatorTrust.ParseBinding("moshiro-binding-v1\nname=x")); Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1")); OperatorSet set = Genesis(_alice); Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId); Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0 + Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key")); } } diff --git a/backend/fleet/tests/Fleet.Tests/SecureChannelTests.cs b/backend/fleet/tests/Fleet.Tests/SecureChannelTests.cs new file mode 100644 index 0000000..4eb9c5d --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/SecureChannelTests.cs @@ -0,0 +1,114 @@ +using System.Security.Cryptography; +using Fleet.Identity; + +namespace Fleet.Tests; + +public sealed class SecureChannelTests +{ + private const string Nonce = "n0nce-from-the-server"; + + private static (LinkCipher Node, LinkCipher Server) Pair(string nodeNonce = Nonce, string serverNonce = Nonce) + { + using var node = new EphemeralKey(); + using var server = new EphemeralKey(); + return (LinkCipher.ForNode(node, server.PublicKeySpki, nodeNonce), LinkCipher.ForServer(server, node.PublicKeySpki, serverNonce)); + } + + [Fact] + public void TheServerProof_VerifiesOnlyForTheRightKey_Nonce_AndEphemeralKey() + { + using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using ECDsa other = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var eph = new EphemeralKey(); + using var otherEph = new EphemeralKey(); + byte[] spki = serverKey.ExportSubjectPublicKeyInfo(); + byte[] proof = ServerHello.Sign(serverKey, Nonce, eph.PublicKeySpki); + + Assert.True(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof)); + Assert.False(ServerHello.Verify(other.ExportSubjectPublicKeyInfo(), Nonce, eph.PublicKeySpki, proof)); // 别人的服务器密钥 + Assert.False(ServerHello.Verify(spki, "another-nonce", eph.PublicKeySpki, proof)); // 挪到别的挑战上 + Assert.False(ServerHello.Verify(spki, Nonce, otherEph.PublicKeySpki, proof)); // 中间人换了自己的临时公钥 + Assert.False(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof.AsSpan(0, proof.Length - 1))); + Assert.False(ServerHello.Verify([1, 2, 3], Nonce, eph.PublicKeySpki, proof)); // 公钥不合法也不抛异常 + } + + [Fact] + public void BothDirections_RoundTrip_AndKeepTheFrameKind() + { + (LinkCipher node, LinkCipher server) = Pair(); + using LinkCipher n = node; + using LinkCipher s = server; + + byte[] toServer = n.Seal(0, "hello server"u8); + Assert.True(s.TryOpen(toServer, out byte kind, out byte[] data)); + Assert.Equal(0, kind); + Assert.Equal("hello server"u8.ToArray(), data); + + byte[] big = RandomNumberGenerator.GetBytes(300_000); + byte[] toNode = s.Seal(1, big); + Assert.True(n.TryOpen(toNode, out kind, out data)); + Assert.Equal(1, kind); + Assert.Equal(big, data); + Assert.Equal(big.Length + LinkCipher.Overhead + 1, toNode.Length); + } + + [Fact] + public void AReplayedReorderedTamperedOrReflectedFrame_IsRefused() + { + (LinkCipher node, LinkCipher server) = Pair(); + using LinkCipher n = node; + using LinkCipher s = server; + + byte[] first = n.Seal(0, "one"u8); + byte[] second = n.Seal(0, "two"u8); + Assert.False(s.TryOpen(second, out _, out _)); // 乱序(丢了第一帧) + Assert.True(s.TryOpen(first, out _, out _)); + Assert.False(s.TryOpen(first, out _, out _)); // 重放 + Assert.True(s.TryOpen(second, out _, out _)); + + byte[] tampered = n.Seal(0, "three"u8); + tampered[12] ^= 0x01; + Assert.False(s.TryOpen(tampered, out _, out _)); // 改了一位 + + byte[] fromNode = n.Seal(0, "four"u8); + (LinkCipher otherNode, LinkCipher otherServer) = Pair(); + using LinkCipher on = otherNode; + using LinkCipher os = otherServer; + Assert.False(os.TryOpen(fromNode, out _, out _)); // 另一条连接的密钥打不开 + + byte[] fromServer = s.Seal(0, "reflected"u8); + Assert.False(s.TryOpen(fromServer, out _, out _)); // 方向不同:服务器发的东西不能原样塞回给服务器 + Assert.False(n.TryOpen([1, 2, 3], out _, out _)); + } + + [Fact] + public void ADifferentNonce_GivesDifferentKeys() + { + (LinkCipher node, LinkCipher server) = Pair("nonce-a", "nonce-b"); // 两边对不上:没人能解开对方的东西 + using LinkCipher n = node; + using LinkCipher s = server; + Assert.False(s.TryOpen(n.Seal(0, "x"u8), out _, out _)); + } + + [Fact] + public void AnOperatorSet_CarriesTheServerKeys_ThroughTheChain() + { + using var alice = new TestAuthenticator(); + using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + byte[] spki = serverKey.ExportSubjectPublicKeyInfo(); + var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [alice.Key], [spki]); + string text = OperatorTrust.Encode(set); + + OperatorSet parsed = OperatorTrust.ParseSet(text)!; + Assert.Equal(spki, Assert.Single(parsed.Servers!)); + Assert.Equal(text, OperatorTrust.Encode(parsed)); // 编码和解析互逆,指纹稳定 + + SignedStatement genesis = alice.Sign(text); + OperatorSet verified = OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(text), [genesis])!; + Assert.Equal(spki, Assert.Single(verified.Servers!)); + + // 服务器密钥只能写在凭证后面;顺序不对、内容不是 base64 都算坏 + Assert.Null(OperatorTrust.ParseSet(text + "\nkey=late:AAAA")); + Assert.Null(OperatorTrust.ParseSet(text + "\nserver=***")); + } +} diff --git a/backend/fleet/tools/fleet-enroll/Program.cs b/backend/fleet/tools/fleet-enroll/Program.cs index c2610d2..3e7ee3b 100644 --- a/backend/fleet/tools/fleet-enroll/Program.cs +++ b/backend/fleet/tools/fleet-enroll/Program.cs @@ -12,7 +12,7 @@ string? name = Arg("--name"); string? directory = Arg("--identity-dir"); if (server is null || join is null || name is null || directory is null) { - Console.Error.WriteLine("usage: fleet-enroll --server --join --name --identity-dir [--meta ] [--version ] [--role agent|probe --seed --genesis ]"); + Console.Error.WriteLine("usage: fleet-enroll --server --join --name --identity-dir [--meta ] [--version ] [--genesis --controllers ]"); return 2; } @@ -30,13 +30,12 @@ if (Arg("--meta") is { } metaText) } } -string? seed = Arg("--seed"); string? genesis = Arg("--genesis"); -string role = Arg("--role") ?? "agent"; +string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; Result result = await EnrollmentClient.EnrollAsync( - http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, seed, genesis, role); + http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers); if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error)) { Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}"); diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index c766cf4..30ce08f 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -3999,78 +3999,6 @@ "x-moshiro-capability": "accounts.manage" } }, - "/api/admin/join-tokens/signed": { - "post": { - "tags": [ - "admin-trust" - ], - "summary": "Create a join code from a grant the operator signed with a passkey; the browser derives the code from a seed the panel never sees", - "operationId": "join_tokens_create_signed", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateSignedJoinTokenRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JoinTokenDto" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - }, - "409": { - "description": "Conflict", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } - } - }, - "x-moshiro-capability": "fleet.enroll" - } - }, "/api/admin/api-tokens": { "get": { "tags": [ @@ -5003,30 +4931,11 @@ "tags": [ "ops" ], - "summary": "One-line installer for Linux and macOS nodes (agent or probe, decided by the code)", + "summary": "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", "operationId": "install_sh", - "parameters": [ - { - "name": "code", - "in": "query", - "schema": { - "type": "string" - } - } - ], "responses": { "200": { "description": "OK" - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } } } } @@ -5102,30 +5011,11 @@ "tags": [ "ops" ], - "summary": "One-line installer for Windows nodes (agent or probe, decided by the code)", + "summary": "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", "operationId": "install_ps1", - "parameters": [ - { - "name": "code", - "in": "query", - "schema": { - "type": "string" - } - } - ], "responses": { "200": { "description": "OK" - }, - "404": { - "description": "Not Found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDto" - } - } - } } } } @@ -6015,28 +5905,8 @@ "null", "string" ] - } - } - }, - "CreateSignedJoinTokenRequest": { - "required": [ - "grant", - "note", - "labels", - "auto_approve" - ], - "type": "object", - "properties": { - "grant": { - "$ref": "#/components/schemas/SignedStatementDto" }, - "note": { - "type": [ - "null", - "string" - ] - }, - "labels": { + "controllers": { "type": [ "null", "array" @@ -6044,12 +5914,6 @@ "items": { "type": "string" } - }, - "auto_approve": { - "type": [ - "null", - "boolean" - ] } } }, @@ -6914,10 +6778,6 @@ "null", "string" ] - }, - "signed": { - "type": "boolean", - "default": false } } }, @@ -7223,6 +7083,12 @@ "null", "string" ] + }, + "auth_method": { + "type": [ + "null", + "string" + ] } } }, @@ -9404,6 +9270,41 @@ "$ref": "#/components/schemas/TrustProposalDto" } ] + }, + "install_url": { + "type": "string", + "default": "" + }, + "operators": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "install_sh_sha256": { + "type": "string", + "default": "" + }, + "install_ps1_sha256": { + "type": "string", + "default": "" + }, + "install_matches_release": { + "type": [ + "null", + "boolean" + ] + }, + "server_key": { + "type": "string", + "default": "" + }, + "server_key_listed": { + "type": "boolean", + "default": false } } }, diff --git a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs index 6872ccc..69c68cc 100644 --- a/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs +++ b/backend/probe/src/MoShiro.Probe/Link/AgentDirectory.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Fleet.Core; -using Fleet.Identity; using Fleet.Hosting; using MoShiro.Protocol; @@ -67,20 +66,6 @@ public sealed class AgentDirectory } } - private static bool TryKey(string text, out byte[] key) - { - try - { - key = Convert.FromBase64String(text); - return key.Length > 0; - } - catch (FormatException) - { - key = []; - return false; - } - } - /// 应用面板下发的列表。版本不比当前新的忽略(乱序到达的旧推送)。 public void Apply(ProbeAgentList result) { @@ -88,13 +73,6 @@ public sealed class AgentDirectory _trust.Update(result.TrustChain); foreach (AgentEndpoint agent in result.Agents) { - // 固定了操作员创世的 Probe 只连操作员批准过的 Agent(证明验不过的,面板塞进来的也不连) - if (!TryKey(agent.PublicKey, out byte[] key) || _trust.VerifyPeer(agent.Proof, key, "agent") != TrustFailure.None) - { - _log.Warn("an agent from the panel carries no valid operator proof; not connecting", ("agent", agent.AgentId.Value)); - continue; - } - if (!string.IsNullOrWhiteSpace(agent.Host)) { next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey }); diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index 00ba2b3..a5cfe26 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -27,7 +27,8 @@ public sealed class ProbeApp : IDisposable public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null) { _log = log; - var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, TrustAnchor.Load(file.Fleet.IdentityDir, log)); + var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log); + var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust); _env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log); directory.Changed += () => _changed.Release(); @@ -36,6 +37,7 @@ public sealed class ProbeApp : IDisposable { Server = new Uri(file.Fleet.Server), Identity = identity.Identity, + ServerVerifier = trust, NodeId = identity.NodeId, NodeVersion = Version, Capabilities = [MoShiroCapabilities.Base], diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs index b4c66c4..43c7b34 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs @@ -152,10 +152,6 @@ public sealed class AgentEndpoint /// Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。 [JsonPropertyName("public_key")] public string PublicKey { get; set; } = ""; - - /// 操作员批准这个 Agent 的证明;固定了创世的 Probe 没有它就不连。 - [JsonPropertyName("proof")] - public MemberProofDto? Proof { get; set; } } /// 期望状态文档(Fleet state type = probe_agents):Backend 直接下发给每个 Probe 的 Agent 列表。 diff --git a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs index 9a41582..6094e66 100644 --- a/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs +++ b/backend/shared/MoShiro.Protocol/Tasks/TaskSet.cs @@ -40,10 +40,6 @@ public sealed class PeerSpec /// Probe 的身份公钥(SPKI,base64)。 [JsonPropertyName("pubkey")] public string PublicKey { get; set; } = ""; - - /// 操作员批准这个 Probe 的证明(passkey 签的委托加码的私钥签的绑定)。固定了创世的 Agent 没有它就不接纳。 - [JsonPropertyName("proof")] - public MemberProofDto? Proof { get; set; } } /// 期望状态文档(Fleet state type = task_set):Backend 为每个 Agent 计算的有效任务集。 diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index f80e267..8fe3026 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -1,4 +1,5 @@ using Fleet.Core; +using Fleet.Identity; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -129,12 +130,23 @@ public static class AgentEndpoints private static async Task ListTokensAsync(JoinTokenService tokens, CancellationToken ct) => TypedResults.Json(new PageDto([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto); - private static async Task CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options) + private static async Task CreateTokenAsync(HttpContext http, JoinTokenService tokens, TrustService trust, CreateJoinTokenRequest request, BackendOptions options) { string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; + + // 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把 + OperatorSet? set = await trust.CurrentAsync(http.RequestAborted); + string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis; + string[] controllers = set is null ? [] : [.. request.Controllers ?? [.. set.Keys.Select(k => k.CredentialId)]]; + if (set is not null && controllers.Any(c => set.Keys.All(k => k.CredentialId != c))) + { + return Problems.From(http, ValidationError.Single("controllers", "must be passkeys of the operator set")); + } + + var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1)); http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}"); Result result = await tokens.CreateAsync( - new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, http.RequestAborted); + new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted); if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error)) { return Problems.From(http, error); diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index bbe039a..fb13212 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -320,7 +320,7 @@ public static class AuthEndpoints { Principal principal = http.GetPrincipal(); return TypedResults.Json( - new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken), + new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken, principal.AuthMethod.Length > 0 ? principal.AuthMethod : null), ApiJsonContext.Default.MeDto); } } diff --git a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs index a87dd03..1d8cdf1 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/TrustEndpoints.cs @@ -26,18 +26,35 @@ public static class TrustEndpoints .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) .Requires(Capability.AccountsManage) .ElevationExempt(); - guarded.MapPost("/join-tokens/signed", CreateSignedTokenAsync) - .Api("join_tokens_create_signed", "Create a join code from a grant the operator signed with a passkey; the browser derives the code from a seed the panel never sees", Tag) - .Returns(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409) - .Requires(Capability.FleetEnroll) - .ElevationExempt(); } - private static async Task StateAsync(HttpContext http, TrustService trust, BackendOptions options) + private static async Task StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options) { TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted); http.Response.Headers.CacheControl = "no-store"; - return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto); + string sh = InstallScripts.Sha256(InstallScripts.Sh); + string ps1 = InstallScripts.Sha256(InstallScripts.Ps1); + return TypedResults.Json(Mappers.ToDto(state, options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}", sh, ps1, await MatchesReleaseAsync(releases, sh, ps1, http.RequestAborted)), ApiJsonContext.Default.TrustStateDto); + } + + /// 面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。 + private static async Task MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct) + { + foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole }) + { + if (await releases.GetAsync(component, ct) is not { } offer) + { + continue; + } + + using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(offer.Manifest); + if (document.RootElement.TryGetProperty("install_sh", out System.Text.Json.JsonElement recordedSh) && document.RootElement.TryGetProperty("install_ps1", out System.Text.Json.JsonElement recordedPs1)) + { + return string.Equals(recordedSh.GetString(), sh, StringComparison.Ordinal) && string.Equals(recordedPs1.GetString(), ps1, StringComparison.Ordinal); + } + } + + return null; } private static async Task CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options) @@ -59,17 +76,4 @@ public static class TrustEndpoints http.SetDetail($"operator set v{state.Version}"); return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created); } - - private static async Task CreateSignedTokenAsync(HttpContext http, CreateSignedJoinTokenRequest request, JoinTokenService tokens) - { - Result result = await tokens.CreateSignedAsync(request.Grant, request.Note, request.Labels, request.AutoApprove ?? true, http.GetPrincipal().Label, http.RequestAborted); - if (!result.TryGet(out JoinTokenInfo? info, out FleetError? error)) - { - return Problems.From(http, error); - } - - http.SetDetail($"{JoinTokenService.RoleOf(info.Kind)}: {request.Note}"); - http.Response.Headers.CacheControl = "no-store"; - return TypedResults.Json(Mappers.ToDto(info), ApiJsonContext.Default.JoinTokenDto, statusCode: StatusCodes.Status201Created); - } } diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index c776cd9..b751f06 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -154,9 +154,9 @@ public static class Mappers new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs)); public static JoinTokenDto ToDto(JoinTokenInfo t) => - new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy, t.Grant is not null); + new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy); - public static TrustStateDto ToDto(TrustState s) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge)); + public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed); public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs)); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index cf66d62..0ee5bf1 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -28,7 +28,8 @@ public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken); -public sealed record MeDto(string Kind, IReadOnlyList Capabilities, string? UserId, string? Username, string? CsrfToken); +/// 会话是怎么登录的:password(没有任何能力,只能注册 passkey)或 passkey;API 令牌为空。 +public sealed record MeDto(string Kind, IReadOnlyList Capabilities, string? UserId, string? Username, string? CsrfToken, string? AuthMethod = null); public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList AllowedTargetCidrs, int MaxConcurrentTests); @@ -74,22 +75,23 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo public sealed record AgentPatchRequest(string? Name, IReadOnlyList? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null); /// agent 或 probe:用这个令牌注册出来的是哪种节点。 -public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null, bool Signed = false); +public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null); -public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null); +/// 属主:允许授权这个节点 extension 会话的 passkey 凭证 id(必须是操作员集合里的);缺省是集合里的全部。没有建立信任根时忽略。 +public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList? Controllers = null); /// 令牌明文只在创建的响应里出现一次。 public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); -/// 操作员用 passkey 对这个码签的委托(语句加断言)。 -public sealed record CreateSignedJoinTokenRequest(SignedStatementDto Grant, string? Note, IReadOnlyList? Labels, bool? AutoApprove); - public sealed record TrustProposalDto(string Statement, int Version, string Challenge); /// 创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。 /// 当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。 /// 下一个要签的版本(challenge 就是 WebAuthn 的挑战)。 -public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal); +/// 当前操作员集合里的 passkey 凭证 id:加入码的 controllers 只能从这里选。 +/// 面板提供的安装脚本的 SHA-256(命令里核对的就是它)。 +/// 和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。 +public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false); public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 536dd9a..82e8b9b 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -80,7 +80,6 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(RegisterPasskeyRequest))] [JsonSerializable(typeof(PasskeyLoginRequest))] [JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")] -[JsonSerializable(typeof(CreateSignedJoinTokenRequest))] [JsonSerializable(typeof(TrustStateDto))] [JsonSerializable(typeof(ElevationRequest))] [JsonSerializable(typeof(ElevationAssertionRequest))] diff --git a/backend/src/MoShiro.Backend.Api/InstallScripts.cs b/backend/src/MoShiro.Backend.Api/InstallScripts.cs index 1969b20..6417fbe 100644 --- a/backend/src/MoShiro.Backend.Api/InstallScripts.cs +++ b/backend/src/MoShiro.Backend.Api/InstallScripts.cs @@ -1,33 +1,32 @@ using System.Reflection; -using MoShiro.Backend.Application; +using System.Security.Cryptography; +using System.Text; namespace MoShiro.Backend.Api; /// -/// 一行安装脚本:packaging/install/ 下的 install.sh 和 install.ps1 作为嵌入资源打进 Backend, -/// 这里把角色、网关、代码和镜像填进去。Agent 和 Probe 共用同一份,不分开维护。 +/// 一行安装脚本:packaging/install/ 下的 install.sh 和 install.ps1 作为嵌入资源打进 Backend,原样返回,不替换任何值。 +/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/access.md 第 15 节)。 +/// 角色、面板地址、代码都走命令里的环境变量。 /// public static class InstallScripts { - /// CI 的发布公钥(packaging/update/release-key.pub.pem):脚本用它验发布清单的签名。 - public static string ReleaseKey() - { - using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream("release-key.pub.pem") - ?? throw new InvalidOperationException("the embedded release key is missing"); - using var reader = new StreamReader(stream); - return reader.ReadToEnd(); - } + public const string Sh = "install.sh"; + public const string Ps1 = "install.ps1"; - public static string Render(string resource, InstallTarget target) + /// CI 的发布公钥(packaging/update/release-key.pub.pem):脚本里内置的就是这一把,测试核对两边一致。 + public static string ReleaseKey() => Read("release-key.pub.pem").Trim(); + + public static string Script(string resource) => Read(resource); + + /// 脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。 + public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource)))); + + private static string Read(string resource) { using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream(resource) ?? throw new InvalidOperationException($"the embedded resource {resource} is missing"); using var reader = new StreamReader(stream); - return reader.ReadToEnd() - .Replace("@@RELEASE_KEY@@", ReleaseKey().Trim(), StringComparison.Ordinal) - .Replace("@@ROLE@@", target.Role, StringComparison.Ordinal) - .Replace("@@SERVER@@", target.Server, StringComparison.Ordinal) - .Replace("@@CODE@@", target.Code, StringComparison.Ordinal) - .Replace("@@IMAGE@@", target.Image, StringComparison.Ordinal); + return reader.ReadToEnd(); } } diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index 801f51a..5799778 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -19,12 +19,12 @@ public static class OpsEndpoints app.MapGet("/metrics", MetricsAsync) .Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403); - // 一行安装:curl -fsSL 'https://panel/install?code=…' | bash(macOS 用 zsh),irm 'https://panel/install.ps1?code=…' | iex(Windows)。 - // Agent 和 Probe 是同一份脚本,角色、网关和镜像由一次性代码决定;代码不能用就是 404。 - app.MapGet("/install", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript")) - .Api("install_sh", "One-line installer for Linux and macOS nodes (agent or probe, decided by the code)", Tag) - .Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript").ProducesProblems(404); - app.MapGet("/install.sh", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript")) + // 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。 + // 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。 + app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript")) + .Api("install_sh", "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag) + .Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript"); + app.MapGet("/install.sh", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript")) .ExcludeFromDescription(); // 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。 app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false)) @@ -33,9 +33,9 @@ public static class OpsEndpoints app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true)) .Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); - app.MapGet("/install.ps1", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.ps1", "text/plain")) - .Api("install_ps1", "One-line installer for Windows nodes (agent or probe, decided by the code)", Tag) - .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); + app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain")) + .Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag) + .Produces(StatusCodes.Status200OK, contentType: "text/plain"); } private static async Task ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature) @@ -52,18 +52,10 @@ public static class OpsEndpoints return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8"); } - private static async Task InstallAsync(HttpContext http, JoinTokenService tokens, BackendOptions options, string? code, string resource, string contentType) + private static Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult Static(HttpContext http, string resource, string contentType) { - string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; - InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted); - if (target is null) - { - return Problems.Create(http, MoShiroErrors.NotFound, "the install code is invalid, expired or already used"); - } - - string script = InstallScripts.Render(resource, target); http.Response.Headers.CacheControl = "no-store"; - return TypedResults.Text(script, contentType + "; charset=utf-8"); + return TypedResults.Text(InstallScripts.Script(resource), contentType + "; charset=utf-8"); } private static async Task HealthAsync(HttpContext http, OpsService ops) diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index ee17437..05c40b6 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -10,6 +10,12 @@ public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? /// 各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。 public sealed record InstallCommands(string Linux, string Macos, string Windows); +/// +/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、 +/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 Genesis 为空,节点不固定信任根。 +/// +public sealed record InstallPlan(string? Genesis, IReadOnlyList Controllers, string ScriptShSha256, string ScriptPs1Sha256); + public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands); /// 安装脚本里要填的四个值。 @@ -27,19 +33,17 @@ public sealed class JoinTokenService public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind; private readonly IJoinTokenStore _store; - private readonly TrustService _trust; private readonly BackendOptions _options; private readonly TimeProvider _time; - public JoinTokenService(IJoinTokenStore store, TrustService trust, BackendOptions options, TimeProvider time) + public JoinTokenService(IJoinTokenStore store, BackendOptions options, TimeProvider time) { _store = store; - _trust = trust; _options = options; _time = time; } - public async ValueTask> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct) + public async ValueTask> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct) { var errors = new Dictionary(); if (request.Role is not (AgentRole or ProbeRole)) @@ -47,9 +51,9 @@ public sealed class JoinTokenService errors["role"] = "must be agent or probe"; } - if (request.MaxUses is < 1 or > 1000) + if (request.MaxUses is < 1 or > 100_000) { - errors["max_uses"] = "must be between 1 and 1000"; + errors["max_uses"] = "must be between 1 and 100000"; } if (request.ExpiresInHours is < 1 or > 24 * 365) @@ -76,79 +80,25 @@ public sealed class JoinTokenService long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); JoinTokenInfo info = await _store.CreateAsync( Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role), - request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, null, ct); + request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct); - string baseUrl = BaseUrl(requestBaseUrl); - string query = $"{baseUrl}/install?code={plaintext}"; - return Result.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands( - $"curl -fsSL '{query}' | bash", - $"curl -fsSL '{query}' | zsh", - $"irm '{baseUrl}/install.ps1?code={plaintext}' | iex"))); + return Result.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan))); } /// - /// 用操作员 passkey 签的委托创建加入码:码本身(认证串)由浏览器用种子派生,面板只拿到它的哈希,推不出种子, - /// 所以面板自己没法为这个码签绑定。有效期、角色、次数都以签名的语句为准。 + /// 一行安装命令:先下载静态脚本、核对 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹和属主都走环境变量。 + /// 里面没有任何私钥,码泄露最多让别人注册出一个节点。 /// - public async ValueTask> CreateSignedAsync(SignedStatementDto signedGrant, string? note, IReadOnlyList? labels, bool autoApprove, string? createdBy, CancellationToken ct) + public static InstallCommands Commands(string baseUrl, string role, string code, InstallPlan plan) { - OperatorSet? set = await _trust.CurrentAsync(ct); - if (set is null) - { - return Result.Fail(MoShiroErrors.ValidationFailed, "set up the operator trust root first (System, Trust)"); - } - - Grant? grant = OperatorTrust.ParseGrant(signedGrant.Statement); - if (grant is null || !OperatorTrust.VerifyOperator(signedGrant.ToSigned(), set)) - { - return Result.Fail(MoShiroErrors.Forbidden, "the grant is not signed by an operator passkey"); - } - - long now = _time.GetUtcNow().ToUnixTimeMilliseconds(); - var errors = new Dictionary(); - if (grant.Role is not (AgentRole or ProbeRole)) - { - errors["role"] = "must be agent or probe"; - } - - if (grant.Uses is < 1 or > 1000) - { - errors["uses"] = "must be between 1 and 1000"; - } - - if (grant.NotAfterMs <= now || grant.NotAfterMs - now > 24 * 365 * 3_600_000L || grant.NotBeforeMs > now + 300_000) - { - errors["validity"] = "the grant must be valid now and for at most one year"; - } - - if (!System.Text.RegularExpressions.Regex.IsMatch(grant.AuthHash, "^[0-9a-f]{64}$")) - { - errors["auth"] = "must be a lowercase sha-256 hex"; - } - - if (labels is { } l && (l.Count > 16 || l.Any(x => x.Length is 0 or > 32))) - { - errors["labels"] = "at most 16 labels of 1-32 characters"; - } - - if (note is { Length: > 200 }) - { - errors["note"] = "must be at most 200 characters"; - } - - if (errors.Count > 0) - { - return ValidationError.Fail(errors); - } - - byte[] hash = Convert.FromHexString(grant.AuthHash); - if (await _store.FindAsync(hash, ct) is not null) - { - return Result.Fail(MoShiroErrors.NameConflict, "this code already exists"); - } - - return Result.Ok(await _store.CreateAsync( - Ulid.NewPrefixed("jtk", _time), hash, note, labels ?? [], autoApprove, KindOf(grant.Role), grant.Uses, grant.NotAfterMs, now, createdBy, signedGrant, ct)); + string pin = plan.Genesis is null ? "" : $" MOSHIRO_GENESIS='{plan.Genesis}' MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'"; + string env = $"MOSHIRO_SERVER='{baseUrl}' MOSHIRO_ROLE='{role}' MOSHIRO_CODE='{code}'{pin}"; + string psPin = plan.Genesis is null ? "" : $"$env:MOSHIRO_GENESIS='{plan.Genesis}'; $env:MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'; "; + return new InstallCommands( + $"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | sha256sum -c - >/dev/null && {env} sh \"$f\"", + $"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"", + $"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; " + + $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)"); } private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/'); diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index eeddc83..c513461 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -171,7 +171,6 @@ public sealed partial class AgentService TcpPort = agent.ProbeTcpPort, UdpPorts = [.. agent.ProbeUdpPorts], PublicKey = Convert.ToBase64String(agent.TlsPublicKey!), - Proof = agent.Proof, }); } diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index ba8fbe9..0dbb58e 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -37,6 +37,8 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs index 458f717..a03a70e 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/AuthService.cs @@ -71,7 +71,7 @@ public sealed partial class AuthService } UserRecord? user = await _store.GetUserAsync(session.UserId, ct); - return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession()), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) }; + return user is null ? null : new Principal(PrincipalKind.Session, Principal.SetOf(CapabilityCatalog.ForSession(session.AuthMethod)), user.Id, user.Username, Base64Url.Encode(session.CsrfToken), null) { AuthMethod = session.AuthMethod, SessionId = Convert.ToHexString(session.IdSha256) }; } public async ValueTask AuthenticateTokenAsync(string bearer, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs index 85784a8..503a3a2 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs @@ -59,7 +59,7 @@ public static class CapabilityCatalog new(Capability.FleetRead, "fleet"), new(Capability.FleetWrite, "fleet", false, false, Capability.FleetRead), - new(Capability.FleetEnroll, "fleet", true, false, Capability.FleetRead), + new(Capability.FleetEnroll, "fleet", false, false, Capability.FleetRead), new(Capability.FleetRevoke, "fleet", true, false, Capability.FleetRead), new(Capability.TasksRead, "tasks"), @@ -134,4 +134,10 @@ public static class CapabilityCatalog /// 登录会话的能力:全部,包括信任类;信任类的操作还要有效的提权窗口()。 public static IReadOnlyList ForSession() => [.. All.Select(c => c.Id)]; + + /// + /// 按登录方式给会话能力:passkey 登录的拿全部;密码登录的什么能力都没有,只能做「已登录」就能做的事—— + /// 看自己是谁、注册自己的第一个 passkey。管理员要先用密码进来加一个 passkey,之后用 passkey 登录才能管事。 + /// + public static IReadOnlyList ForSession(string authMethod) => authMethod == AuthMethods.Passkey ? ForSession() : []; } diff --git a/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs b/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs index 41a75f0..de6c7c1 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/ElevationService.cs @@ -37,7 +37,6 @@ public static class Elevation /// 端点要求的能力需要哪一种窗口;不是信任类的能力返回 null。 public static ElevationClass? For(string capability) => capability switch { - Capability.FleetEnroll => ElevationClass.Enroll, Capability.FleetRevoke => ElevationClass.Revoke, Capability.AccountsManage => ElevationClass.Credential, _ => null, diff --git a/backend/src/MoShiro.Backend.Application/Auth/ServerIdentityService.cs b/backend/src/MoShiro.Backend.Application/Auth/ServerIdentityService.cs new file mode 100644 index 0000000..5ac65b7 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/ServerIdentityService.cs @@ -0,0 +1,82 @@ +using System.Security.Cryptography; +using Fleet.Identity; +using Fleet.Server; + +namespace MoShiro.Backend.Application; + +/// +/// 服务器的身份密钥(P-256):操作员用 passkey 把公钥签进操作员集合,之后节点只认带这把密钥签名的服务器, +/// 并在这条链路上用应用层加密(TLS 在 Cloudflare 或被冒充的证书那里终止时,中间人拿不出这把密钥)。 +/// 私钥存在 Backend 的设置里:它保护的是「面板被冒充」,不是「面板被攻破」;面板被攻破时它也会泄露,那时要由 passkey 签一个换掉它的新集合版本。 +/// +public sealed class ServerIdentityService : IServerIdentity, IDisposable +{ + private const string Key = "server.identity_key"; + + private readonly ISettingsStore _settings; + private readonly object _gate = new(); + private ECDsa? _key; + private byte[]? _spki; + + public ServerIdentityService(ISettingsStore settings) + { + _settings = settings; + } + + public byte[] PublicKeySpki + { + get + { + EnsureLoaded(); + return _spki!; + } + } + + /// 公钥的指纹(SHA-256,小写十六进制):页面上给人核对。 + public string Fingerprint => Convert.ToHexStringLower(SHA256.HashData(PublicKeySpki)); + + public byte[] Sign(ReadOnlySpan payload) + { + EnsureLoaded(); + return _key!.SignData(payload, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + + /// 启动时调用:读出或生成密钥。节点可能在启动完成之前就连上来,所以 也会按需加载。 + public ValueTask EnsureAsync(CancellationToken ct) + { + EnsureLoaded(ct); + return ValueTask.CompletedTask; + } + + private void EnsureLoaded(CancellationToken ct = default) + { + if (_key is not null) + { + return; + } + + lock (_gate) + { + if (_key is not null) + { + return; + } + + string? stored = _settings.GetAsync(Key, ct).AsTask().GetAwaiter().GetResult(); + var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + if (stored is not null) + { + key.ImportPkcs8PrivateKey(Convert.FromBase64String(stored), out _); + } + else + { + _settings.SetAsync(Key, Convert.ToBase64String(key.ExportPkcs8PrivateKey()), ct).AsTask().GetAwaiter().GetResult(); + } + + _spki = key.ExportSubjectPublicKeyInfo(); + _key = key; + } + } + + public void Dispose() => _key?.Dispose(); +} diff --git a/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs index 0fc0e89..db0ebff 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs @@ -11,7 +11,8 @@ namespace MoShiro.Backend.Application; public sealed record TrustProposal(string Statement, int Version, string Challenge); /// InSync:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。 -public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList Chain, TrustProposal? Proposal); +/// 这个面板的服务器身份密钥的指纹;ServerKeyListed:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。 +public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList Chain, TrustProposal? Proposal, IReadOnlyList? Operators = null, string ServerKey = "", bool ServerKeyListed = false); /// /// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。 @@ -22,11 +23,13 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable { private readonly IOperatorSetStore _store; private readonly IAuthStore _auth; + private readonly ServerIdentityService _server; private readonly TimeProvider _time; private readonly SemaphoreSlim _gate = new(1, 1); - public TrustService(IOperatorSetStore store, IAuthStore auth, TimeProvider time) + public TrustService(IOperatorSetStore store, IAuthStore auth, ServerIdentityService server, TimeProvider time) { + _server = server; _store = store; _auth = auth; _time = time; @@ -50,7 +53,7 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable TrustProposal? proposal = await ProposeAsync(chain, current, context, ct); OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement); bool inSync = current is not null && proposed is not null && Same(current, proposed); - return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal); + return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true); } private async ValueTask ProposeAsync(IReadOnlyList chain, OperatorSet? current, PasskeyContext context, CancellationToken ct) @@ -66,14 +69,16 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable chain.Count == 0 ? OperatorTrust.Genesis : OperatorTrust.Fingerprint(chain[^1].Statement), context.RpId, [.. context.Origins.Order(StringComparer.Ordinal)], - [.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))]); + [.. passkeys.Select(p => new OperatorKey(p.Id, p.PublicKeySpki))], + [_server.PublicKeySpki]); string statement = OperatorTrust.Encode(next); return new TrustProposal(statement, next.Version, Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement)))); } private static bool Same(OperatorSet a, OperatorSet b) => a.RpId == b.RpId && a.Origins.Order(StringComparer.Ordinal).SequenceEqual(b.Origins.Order(StringComparer.Ordinal)) - && a.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal).SequenceEqual(b.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal)); + && a.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal).SequenceEqual(b.Keys.Select(k => k.CredentialId).Order(StringComparer.Ordinal)) + && (a.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal).SequenceEqual((b.Servers ?? []).Select(Convert.ToBase64String).Order(StringComparer.Ordinal)); /// /// 收下操作员签回来的一版(创世,或上一版里的凭证签的更新):语句里的每把凭证都要是已注册的 passkey、域名对得上、链验得过。 @@ -102,6 +107,12 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable return ValidationError.Fail("statement", "the relying party or origins do not match this panel"); } + if (set.Servers is not { Count: > 0 } || !set.Servers.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki))) + { + // 集合里没有这个面板的服务器密钥:签下去之后固定了它的节点就认不出这个面板了 + return ValidationError.Fail("statement", "must list this panel's server key"); + } + IReadOnlyList registered = await _auth.ListAllPasskeysAsync(ct); if (set.Keys.Any(k => !registered.Any(p => p.Id == k.CredentialId && p.PublicKeySpki.AsSpan().SequenceEqual(k.PublicKeySpki)))) { diff --git a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs index 62871d1..33ef79e 100644 --- a/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs +++ b/backend/src/MoShiro.Backend.Application/Models/AgentModels.cs @@ -30,8 +30,7 @@ public sealed record AgentRecord( bool Ipv6HostManual = false, IReadOnlyList? ReportedAddresses = null, byte[]? TlsPublicKey = null, - string? AddedBy = null, - MemberProofDto? Proof = null); + string? AddedBy = null); /// 管理员可以修改的字段(merge-patch 语义:为 null 的字段不改)。 public sealed class AgentPatch @@ -79,11 +78,10 @@ public sealed record JoinTokenInfo( long? RevokedAtMs, long CreatedAtMs, string Kind = "", - string? CreatedBy = null, - SignedStatementDto? Grant = null); + string? CreatedBy = null); /// /// 是连 Backend 的身份公钥, 是对等面(连 Agent)的公钥,两把是节点自己生成的独立密钥; /// Agent 的 peers 里放的是后者。 /// -public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null, MemberProofDto? Proof = null); +public sealed record ProbeRecord(string Id, string Name, byte[] PublicKey, long CreatedAtMs, long? RevokedAtMs, string? Ipv6DiagnosticsJson = null, byte[]? PeerPublicKey = null, string? AddedBy = null); diff --git a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs index c9605a2..7f1e666 100644 --- a/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs +++ b/backend/src/MoShiro.Backend.Application/Ports/StorePorts.cs @@ -54,7 +54,7 @@ public interface IGeoLookup public interface IJoinTokenStore { - ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct); + ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct); ValueTask> ListAsync(CancellationToken ct); diff --git a/backend/src/MoShiro.Backend.Application/StartupTasks.cs b/backend/src/MoShiro.Backend.Application/StartupTasks.cs index 3716e30..55000a7 100644 --- a/backend/src/MoShiro.Backend.Application/StartupTasks.cs +++ b/backend/src/MoShiro.Backend.Application/StartupTasks.cs @@ -11,9 +11,11 @@ public sealed class StartupTasks : IHostedService private readonly IRawStore _raw; private readonly AuthService _auth; private readonly TaskService _tasks; + private readonly ServerIdentityService _identity; - public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks) + public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity) { + _identity = identity; _raw = raw; _auth = auth; _tasks = tasks; @@ -24,6 +26,7 @@ public sealed class StartupTasks : IHostedService await _raw.RecoverAsync(cancellationToken); await _auth.EnsureInitialAdminAsync(cancellationToken); await _tasks.SeedDefaultsAsync(cancellationToken); + await _identity.EnsureAsync(cancellationToken); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs index f8852db..dfe7cc0 100644 --- a/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs +++ b/backend/src/MoShiro.Backend.Application/Tasks/TaskPlanner.cs @@ -64,7 +64,7 @@ public sealed class TaskPlanner : IDisposable foreach (ProbeRecord probe in probes.Where(p => p.RevokedAtMs is null && p.PeerPublicKey is { Length: > 0 } && links.IsLinked(p.Id, agentId.Value))) { - document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!), Proof = probe.Proof }); + document.Peers.Add(new PeerSpec { ProbeId = new ProbeId(probe.Id), PublicKey = Convert.ToBase64String(probe.PeerPublicKey!) }); } IReadOnlyList chain = await _trust.ChainAsync(ct); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs index ad26ebd..ba8a7c5 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/Schema.cs @@ -218,9 +218,6 @@ internal static class Schema created_at INTEGER NOT NULL ) """); - connection.Exec("ALTER TABLE join_tokens ADD COLUMN op_grant TEXT"); - connection.Exec("ALTER TABLE agents ADD COLUMN trust TEXT"); - connection.Exec("ALTER TABLE probes ADD COLUMN trust TEXT"); connection.Exec("PRAGMA user_version = 13"); transaction.Commit(); } diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs index a3b3f31..6f13a7f 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteAgentStore.cs @@ -14,7 +14,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore { private const string AgentColumns = "id, name, status, pubkey, tls_spki, public_host, probe_tcp_port, probe_udp_ports, labels, public_visible, kinds, agent_version, local_limits, " + - "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by, trust"; + "task_version, applied_task_version, rejected, created_at, last_seen_at, row_version, ipv6_host, ipv6_supported, ipv6_diagnostics, public_host_manual, ipv6_host_manual, reported_addresses, tls_pubkey, added_by"; private readonly SqliteDatabase _db; @@ -157,8 +157,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore byte[] hash = tokenSha256.ToArray(); return _db.ReadAsync( c => c.QueryOne( - "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h", - r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11), StorageJson.Grant(r.NullableString(12))), + "SELECT id, token_sha256, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenRecord(r.GetString(0), r.Bytes(1), r.NullableString(2), StorageJson.Strings(r.GetString(3)), r.GetInt64(4) != 0, r.GetInt32(5), r.GetInt32(6), r.GetInt64(7), r.NullableLong(8), r.GetInt64(9), r.GetString(10), r.NullableString(11)), ("@h", hash)), ct); } @@ -184,9 +184,9 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore } c.Exec( - "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by, trust) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by, @trust)", + "INSERT INTO agents (id, name, status, pubkey, tls_spki, labels, meta, agent_version, created_at, added_by) VALUES (@id, @name, @status, @pubkey, @spki, @labels, @meta, @version, @now, @by)", ("@id", node.Id.Value), ("@name", node.Name), ("@status", StatusText(node.Status)), ("@pubkey", node.PublicKey), ("@spki", node.TlsSpki), - ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof))); + ("@labels", StorageJson.ToJson(node.Labels)), ("@meta", node.MetaJson), ("@version", node.NodeVersion), ("@now", node.NowMs), ("@by", node.AddedBy)); return Result.Ok(ToNode(GetInternal(c, node.Id)!, node.MetaJson)); }, ct); @@ -204,8 +204,8 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore } c.Exec( - "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by, trust) VALUES (@id, @name, @pubkey, @peer, @now, @by, @trust)", - ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy), ("@trust", StorageJson.ToJson(node.Proof))); + "INSERT INTO probes (id, name, pubkey, peer_pubkey, created_at, added_by) VALUES (@id, @name, @pubkey, @peer, @now, @by)", + ("@id", node.Id.Value), ("@name", node.Name), ("@pubkey", node.PublicKey), ("@peer", peerKey), ("@now", node.NowMs), ("@by", node.AddedBy)); return Result.Ok(new NodeRecord(node.Id, node.Name, NodeStatus.Active, node.PublicKey, node.TlsSpki, node.Labels, node.MetaJson, node.NodeVersion, node.NowMs, null)); } @@ -268,8 +268,7 @@ public sealed class SqliteAgentStore : IAgentStore, IFleetStore r.GetInt64(23) != 0, StorageJson.Strings(r.GetString(24)), r.NullableBytes(25), - r.NullableString(26), - StorageJson.Proof(r.NullableString(27))); + r.NullableString(26)); private static NodeRecord ToNode(AgentRecord a, string? metaJson) => new(a.Id, a.Name, a.Status, a.PublicKey, a.TlsSpki, a.Labels, metaJson, a.AgentVersion, a.CreatedAtMs, a.LastSeenAtMs); diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs index a18356a..1244c2d 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteConfigStores.cs @@ -15,28 +15,28 @@ public sealed class SqliteJoinTokenStore : IJoinTokenStore _db = db; } - public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, SignedStatementDto? grant, CancellationToken ct) => + public ValueTask CreateAsync(string id, byte[] tokenSha256, string? note, IReadOnlyList labels, bool autoApprove, string kind, int maxUses, long expiresAtMs, long nowMs, string? createdBy, CancellationToken ct) => _db.WriteAsync(c => { c.Exec( - "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by, op_grant) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by, @grant)", + "INSERT INTO join_tokens (id, token_sha256, note, labels, auto_approve, kind, max_uses, expires_at, created_at, created_by) VALUES (@id, @h, @note, @labels, @auto, @kind, @max, @exp, @now, @by)", ("@id", id), ("@h", tokenSha256), ("@note", note), ("@labels", StorageJson.ToJson(labels)), ("@auto", autoApprove ? 1 : 0), ("@kind", kind), - ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy), ("@grant", StorageJson.ToJson(grant))); - return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy, grant); + ("@max", maxUses), ("@exp", expiresAtMs), ("@now", nowMs), ("@by", createdBy)); + return new JoinTokenInfo(id, note, labels, autoApprove, maxUses, 0, expiresAtMs, null, nowMs, kind, createdBy); }, ct); public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( c => c.Query( - "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens ORDER BY created_at DESC, id", - r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11)))), + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens ORDER BY created_at DESC, id", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10))), ct); public ValueTask FindAsync(byte[] tokenSha256, CancellationToken ct) => _db.ReadAsync( c => c.QueryOne( - "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by, op_grant FROM join_tokens WHERE token_sha256 = @h", - r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10), StorageJson.Grant(r.NullableString(11))), + "SELECT id, note, labels, auto_approve, max_uses, uses, expires_at, revoked_at, created_at, kind, created_by FROM join_tokens WHERE token_sha256 = @h", + r => new JoinTokenInfo(r.GetString(0), r.NullableString(1), StorageJson.Strings(r.GetString(2)), r.GetInt64(3) != 0, r.GetInt32(4), r.GetInt32(5), r.GetInt64(6), r.NullableLong(7), r.GetInt64(8), r.GetString(9), r.NullableString(10)), ("@h", tokenSha256)), ct); @@ -58,12 +58,12 @@ public sealed class SqliteProbeStore : IProbeStore public ValueTask> ListAsync(CancellationToken ct) => _db.ReadAsync>( - c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8)))), + c => c.Query("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes ORDER BY created_at, id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7))), ct); public ValueTask GetAsync(string id, CancellationToken ct) => _db.ReadAsync( - c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by, trust FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7), StorageJson.Proof(r.NullableString(8))), ("@id", id)), + c => c.QueryOne("SELECT id, name, pubkey, created_at, revoked_at, ipv6_diagnostics, peer_pubkey, added_by FROM probes WHERE id = @id", r => new ProbeRecord(r.GetString(0), r.GetString(1), r.Bytes(2), r.GetInt64(3), r.NullableLong(4), r.NullableString(5), r.NullableBytes(6), r.NullableString(7)), ("@id", id)), ct); public ValueTask SetIpv6DiagnosticsAsync(string id, string diagnosticsJson, CancellationToken ct) => diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs index 996c734..51b9490 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageJson.cs @@ -1,12 +1,9 @@ using System.Text.Json; -using Fleet.Core; using System.Text.Json.Serialization; namespace MoShiro.Backend.Storage.Sqlite; [JsonSerializable(typeof(List))] -[JsonSerializable(typeof(SignedStatementDto))] -[JsonSerializable(typeof(MemberProofDto))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "ParamMap")] internal sealed partial class StorageJson : JsonSerializerContext @@ -18,14 +15,6 @@ internal sealed partial class StorageJson : JsonSerializerContext public static List Strings(string? json) => string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListString) ?? []; - public static string? ToJson(SignedStatementDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.SignedStatementDto); - - public static string? ToJson(MemberProofDto? value) => value is null ? null : JsonSerializer.Serialize(value, Default.MemberProofDto); - - public static SignedStatementDto? Grant(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.SignedStatementDto); - - public static MemberProofDto? Proof(string? json) => string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json, Default.MemberProofDto); - public static List Ints(string? json) => string.IsNullOrEmpty(json) ? [] : JsonSerializer.Deserialize(json, Default.ListInt32) ?? []; } diff --git a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs index a3c4ceb..2a4854b 100644 --- a/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs +++ b/backend/tests/MoShiro.Backend.Tests/AgentHarness.cs @@ -41,14 +41,14 @@ public sealed class AgentHarness : IAsyncDisposable public byte[] TlsPublicKey => _identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo(); - /// 操作员签过委托的加入码(认证串、种子、创世指纹):节点按固定了创世的方式注册,名册里的 Probe 必须带操作员批准的证明。 - public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = null, (string Auth, string Seed, string Genesis)? signed = null) + /// 带信任根固定的加入码(码、创世指纹、属主):节点按固定了创世和属主的方式注册。 + public static async Task StartAsync(BackendFixture backend, Action? configure = null, string? name = null, (string Code, string Genesis, string[] Controllers)? pin = null) { string directory = Path.Combine(Path.GetTempPath(), "moshiro-agent-" + Guid.NewGuid().ToString("N")); - string token = signed?.Auth ?? await backend.CreateJoinTokenAsync(); + string token = pin?.Code ?? await backend.CreateJoinTokenAsync(); using var http = new HttpClient(); Result enrolled = await EnrollmentClient.EnrollAsync( - http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None, signed?.Seed, signed?.Genesis, "agent"); + http, backend.BaseAddress, token, name ?? backend.UniqueName("agent"), directory, AgentApp.Version, null, TimeProvider.System, CancellationToken.None, pin?.Genesis, pin?.Controllers); Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); // 测试里的 Agent 只在回环上监听:手填 public_host(手动的优先于自动识别,Agent 上报本机真实地址也不会覆盖) diff --git a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs index 3f114fb..b7a7625 100644 --- a/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ApiBasicsTests.cs @@ -61,8 +61,11 @@ public sealed class ApiBasicsTests [Fact] public async Task Login_WithReturnSession_GivesABearerSession_WithoutACookie_AndNoCsrfNeeded() { + // 密码登录的会话没有任何能力(只能注册 passkey);有能力的会话来自 passkey 登录 + (string username, SoftwarePasskey passkey) = await _backend.CreateUserWithPasskeyAsync(); + using SoftwarePasskey passkeyScope = passkey; using HttpClient client = _backend.NewClient(); - using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword, ["return_session"] = true })); + using HttpResponseMessage login = await _backend.PasskeyLoginAsync(client, passkey); Assert.Equal(HttpStatusCode.OK, login.StatusCode); Assert.False(login.Headers.Contains("Set-Cookie")); string session = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue(); @@ -73,7 +76,7 @@ public sealed class ApiBasicsTests me.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); using HttpResponseMessage meResponse = await client.SendAsync(me); Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode); - Assert.Equal(BackendFixture.AdminUser, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue()); + Assert.Equal(username, JsonNode.Parse(await meResponse.Content.ReadAsStringAsync())!["username"]!.GetValue()); using var create = new HttpRequestMessage(HttpMethod.Post, "/api/admin/api-tokens") { Content = BackendFixture.Json(new JsonObject { ["name"] = "session-write", ["capabilities"] = new JsonArray("fleet.read") }) }; create.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session); @@ -94,8 +97,10 @@ public sealed class ApiBasicsTests [Fact] public async Task SessionCookie_RequiresCsrfHeaderOnUnsafeMethods() { + (_, SoftwarePasskey passkey) = await _backend.CreateUserWithPasskeyAsync(); + using SoftwarePasskey passkeyScope = passkey; using HttpClient client = _backend.NewClient(); - using HttpResponseMessage login = await client.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = BackendFixture.AdminUser, ["password"] = BackendFixture.AdminPassword })); + using HttpResponseMessage login = await _backend.PasskeyLoginAsync(client, passkey, returnSession: false); string csrf = JsonNode.Parse(await login.Content.ReadAsStringAsync())!["csrf_token"]!.GetValue(); string cookie = login.Headers.GetValues("Set-Cookie").Single().Split(';')[0]; @@ -220,15 +225,6 @@ public sealed class ApiBasicsTests Assert.Contains("moshiro_agents_online", text); Assert.Contains("moshiro_analysis_queue_length", text); } - - [Fact] - public async Task InstallScripts_RequireAValidCode() - { - // 一行安装脚本凭一次性代码获取;没有代码或代码不能用是 404(完整行为见 InstallTests) - using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install.sh"); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Equal("not_found", JsonNode.Parse(await response.Content.ReadAsStringAsync())!["code"]!.GetValue()); - } } [CollectionDefinition("backend")] diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index f0d57e1..d7f4a9f 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -135,6 +135,46 @@ public class BackendFixture : IAsyncLifetime Admin.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _adminToken); } + /// 新建一个用户(管理员用 API key 建),用密码登录一次注册好 passkey。之后用 登录才有能力。 + public async Task<(string Username, SoftwarePasskey Passkey)> CreateUserWithPasskeyAsync(string prefix = "user") + { + string name = UniqueName(prefix); + using HttpResponseMessage created = await Admin.PostAsync("/api/admin/users", Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" })); + created.EnsureSuccessStatusCode(); + using HttpClient session = NewClient(); + using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true })); + login.EnsureSuccessStatusCode(); + session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); + var passkey = new SoftwarePasskey(BaseAddress.Host, BaseAddress.GetLeftPart(UriPartial.Authority)); + using HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null); + options.EnsureSuccessStatusCode(); + string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); + using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", Json(passkey.Register("key", challenge))); + registered.EnsureSuccessStatusCode(); + return (name, passkey); + } + + /// 用 passkey 登录(可发现凭证,不用用户名); 为真时不设 Cookie、把会话令牌放在响应里。 + public async Task PasskeyLoginAsync(HttpClient client, SoftwarePasskey passkey, bool returnSession = true) + { + using HttpResponseMessage options = await client.PostAsync("/api/admin/auth/passkey/options", null); + options.EnsureSuccessStatusCode(); + JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue()); + assertion["return_session"] = returnSession; + return await client.PostAsync("/api/admin/auth/passkey/login", Json(assertion)); + } + + /// 有能力的会话:新用户加 passkey,用 passkey 登录,会话令牌走 Bearer。 + public async Task<(HttpClient Client, SoftwarePasskey Passkey, string Username)> CreateStrongSessionAsync(string prefix = "strong") + { + (string name, SoftwarePasskey passkey) = await CreateUserWithPasskeyAsync(prefix); + HttpClient client = NewClient(); + using HttpResponseMessage login = await PasskeyLoginAsync(client, passkey); + login.EnsureSuccessStatusCode(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); + return (client, passkey, name); + } + public HttpClient NewClient() => new() { BaseAddress = BaseAddress }; public string UniqueName(string prefix) => $"{prefix}-{Interlocked.Increment(ref _counter)}"; @@ -233,45 +273,49 @@ public class BackendFixture : IAsyncLifetime await App.DisposeAsync(); } + /// + /// 管理员的全能力 API key:和线上一样的路径——密码登录的会话没有任何能力,只能注册 passkey; + /// 注册之后用 passkey 登录才有能力,再触碰一次开凭证窗口,才能创建带信任类能力的 key。 + /// private async Task CreateAdminTokenAsync() { using HttpClient session = NewClient(); using HttpResponseMessage login = await session.PostAsync( - "/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword })); + "/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true })); login.EnsureSuccessStatusCode(); - JsonNode body = JsonNode.Parse(await login.Content.ReadAsStringAsync())!; - string cookie = login.Headers.GetValues("Set-Cookie").First().Split(';')[0]; - - // 带信任类能力的 API key 要用 passkey 签一次才能创建(和线上一样):给管理员注册一个 passkey,触碰一次再创建 - string csrf = body["csrf_token"]!.GetValue(); - async Task SendAsync(string path, JsonObject? content) - { - using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content is null ? null : Json(content) }; - request.Headers.Add("Cookie", cookie); - request.Headers.Add("X-CSRF-Token", csrf); - return await session.SendAsync(request); - } + session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); string origin = BaseAddress.GetLeftPart(UriPartial.Authority); using var passkey = new SoftwarePasskey(BaseAddress.Host, origin); - using (HttpResponseMessage options = await SendAsync("/api/admin/auth/passkeys/options", null)) + using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null)) { options.EnsureSuccessStatusCode(); string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); - using HttpResponseMessage registered = await SendAsync("/api/admin/auth/passkeys", passkey.Register("fixture", challenge)); + using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", Json(passkey.Register("fixture", challenge))); registered.EnsureSuccessStatusCode(); } - using (HttpResponseMessage options = await SendAsync("/api/admin/auth/elevation/options", new JsonObject { ["class"] = "credential" })) + // passkey 登录:拿到有能力的会话(令牌走 Bearer) + using HttpClient strong = NewClient(); + using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/passkey/options", null)) + { + options.EnsureSuccessStatusCode(); + JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue()); + using HttpResponseMessage signedIn = await strong.PostAsync("/api/admin/auth/passkey/login", Json(assertion)); + signedIn.EnsureSuccessStatusCode(); + strong.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await signedIn.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); + } + + using (HttpResponseMessage options = await strong.PostAsync("/api/admin/auth/elevation/options", Json(new JsonObject { ["class"] = "credential" }))) { options.EnsureSuccessStatusCode(); JsonObject assertion = passkey.Assert(JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue()); assertion["class"] = "credential"; - using HttpResponseMessage elevated = await SendAsync("/api/admin/auth/elevation", assertion); + using HttpResponseMessage elevated = await strong.PostAsync("/api/admin/auth/elevation", Json(assertion)); elevated.EnsureSuccessStatusCode(); } - using HttpResponseMessage created = await SendAsync("/api/admin/api-tokens", new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() }); + using HttpResponseMessage created = await strong.PostAsync("/api/admin/api-tokens", Json(new JsonObject { ["name"] = "tests", ["capabilities"] = AllCapabilities() })); created.EnsureSuccessStatusCode(); return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue(); } diff --git a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs index ec58046..118d1f7 100644 --- a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs @@ -119,7 +119,7 @@ public sealed class CapabilityTests string[] ids = [.. catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()).Select(c => c!["id"]!.GetValue())]; Assert.Equal(CapabilityCatalog.All.Select(c => c.Id).Order(StringComparer.Ordinal).ToArray(), ids.Order(StringComparer.Ordinal).ToArray()); Assert.Contains(catalog["presets"]!.AsArray(), p => p!["id"]!.GetValue() == "all"); - Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue() == Capability.FleetEnroll && c["trust"]!.GetValue()); + Assert.Contains(catalog["groups"]!.AsArray().SelectMany(g => g!["capabilities"]!.AsArray()), c => c!["id"]!.GetValue() == Capability.FleetRevoke && c["trust"]!.GetValue()); } [Fact] diff --git a/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs b/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs index 4f47c72..d34c93b 100644 --- a/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ElevationTests.cs @@ -33,6 +33,12 @@ public sealed class ElevationTests return client; } + private async Task<(HttpClient, SoftwarePasskey)> StrongAsync() + { + (HttpClient client, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("elev"); + return (client, passkey); + } + private async Task RegisterAsync(HttpClient session, string name = "key") { var passkey = new SoftwarePasskey(RpId, Origin); @@ -57,57 +63,61 @@ public sealed class ElevationTests private static async Task ProblemCodeAsync(HttpResponseMessage response) => JsonNode.Parse(await response.Content.ReadAsStringAsync())?["code"]?.GetValue(); - private static StringContent NewJoinCode() => BackendFixture.Json(new JsonObject { ["role"] = "agent" }); + /// 需要「吊销」窗口的操作(删除节点):有窗口时返回 204(删除是幂等的),没有窗口返回 403 elevation_required。 + private static Task Gated(HttpClient client) => client.DeleteAsync("/api/admin/agents/agt_nobody"); [Fact] public async Task EnrollingNeedsAWindow_ThatOnlyAPasskeyTouchOpens() { - using HttpClient session = await NewSessionAsync(); - using SoftwarePasskey passkey = await RegisterAsync(session); + (HttpClient session, SoftwarePasskey passkey) = await StrongAsync(); + using HttpClient sessionScope = session; + using SoftwarePasskey passkeyScope = passkey; - using HttpResponseMessage denied = await session.PostAsync("/api/admin/join-tokens", NewJoinCode()); + using HttpResponseMessage denied = await Gated(session); Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode); Assert.Equal("elevation_required", await ProblemCodeAsync(denied)); - Assert.Contains("enroll", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal); + Assert.Contains("revoke", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal); - using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "enroll"); + using HttpResponseMessage elevated = await ElevateAsync(session, passkey, "revoke"); Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); - Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); - Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); // 窗口内不用再触碰 + Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 有窗口:到了业务层(删除是幂等的) + Assert.Equal(HttpStatusCode.NoContent, (await Gated(session)).StatusCode); // 窗口内不用再触碰 // 窗口只管它那一种 - using HttpResponseMessage revoke = await session.DeleteAsync("/api/admin/agents/agt_nobody"); - Assert.Equal("elevation_required", await ProblemCodeAsync(revoke)); + using HttpResponseMessage credential = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("x"), ["password"] = Password })); + Assert.Equal("elevation_required", await ProblemCodeAsync(credential)); } [Fact] public async Task ApiKeys_CarryTheirCapabilities_AndSkipTheWindow() { - using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/join-tokens", NewJoinCode()); - Assert.Equal(HttpStatusCode.Created, created.StatusCode); + using HttpResponseMessage allowed = await Gated(_backend.Admin); + Assert.Equal(HttpStatusCode.NoContent, allowed.StatusCode); // API key 自带能力,不经过提权窗口 } [Fact] public async Task TheWindowCanBeListed_AndEndedEarly() { - using HttpClient session = await NewSessionAsync(); - using SoftwarePasskey passkey = await RegisterAsync(session); + (HttpClient session, SoftwarePasskey passkey) = await StrongAsync(); + using HttpClient sessionScope = session; + using SoftwarePasskey passkeyScope = passkey; Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray()); - (await ElevateAsync(session, passkey, "enroll")).Dispose(); + (await ElevateAsync(session, passkey, "revoke")).Dispose(); JsonArray open = JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray(); - Assert.Equal("enroll", Assert.Single(open)!["class"]!.GetValue()); + Assert.Equal("revoke", Assert.Single(open)!["class"]!.GetValue()); Assert.Equal(HttpStatusCode.NoContent, (await session.DeleteAsync("/api/admin/auth/elevation")).StatusCode); Assert.Empty(JsonNode.Parse(await session.GetStringAsync("/api/admin/auth/elevation"))!["items"]!.AsArray()); - Assert.Equal(HttpStatusCode.Forbidden, (await session.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await Gated(session)).StatusCode); } [Fact] public async Task ACredentialWindow_IsSingleUse() { - using HttpClient session = await NewSessionAsync(); - using SoftwarePasskey passkey = await RegisterAsync(session); + (HttpClient session, SoftwarePasskey passkey) = await StrongAsync(); + using HttpClient sessionScope = session; + using SoftwarePasskey passkeyScope = passkey; (await ElevateAsync(session, passkey, "credential")).Dispose(); using HttpResponseMessage first = await session.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = _backend.UniqueName("made"), ["password"] = Password })); @@ -119,10 +129,8 @@ public sealed class ElevationTests [Fact] public async Task OnlyYourOwnPasskey_WithTheRightChallengeAndKind_OpensAWindow() { - using HttpClient mine = await NewSessionAsync(); - using SoftwarePasskey myPasskey = await RegisterAsync(mine); - using HttpClient theirs = await NewSessionAsync(); - using SoftwarePasskey theirPasskey = await RegisterAsync(theirs); + (HttpClient mine, SoftwarePasskey myPasskey) = await StrongAsync(); + (HttpClient theirs, SoftwarePasskey theirPasskey) = await StrongAsync(); // 别人的 passkey 签了我的挑战:不行(也不在 allow_credentials 里) using HttpResponseMessage options = await mine.PostAsync("/api/admin/auth/elevation/options", BackendFixture.Json(new JsonObject { ["class"] = "enroll" })); @@ -144,7 +152,7 @@ public sealed class ElevationTests Assert.Equal(HttpStatusCode.Unauthorized, (await mine.PostAsync("/api/admin/auth/elevation", BackendFixture.Json(once))).StatusCode); // 另一个用户的会话没有因此提权 - Assert.Equal(HttpStatusCode.Forbidden, (await theirs.PostAsync("/api/admin/join-tokens", NewJoinCode())).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await Gated(theirs)).StatusCode); } [Fact] @@ -169,9 +177,10 @@ public sealed class ElevationTests [Fact] public async Task AKeyThatCarriesTrust_NeedsAPasskeySignatureToCreate() { - using HttpClient session = await NewSessionAsync(); - using SoftwarePasskey passkey = await RegisterAsync(session); - var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.enroll") }; + (HttpClient session, SoftwarePasskey passkey) = await StrongAsync(); + using HttpClient sessionScope = session; + using SoftwarePasskey passkeyScope = passkey; + var trusted = new JsonObject { ["name"] = _backend.UniqueName("ci"), ["capabilities"] = new JsonArray("fleet.revoke") }; var harmless = new JsonObject { ["name"] = _backend.UniqueName("ro"), ["capabilities"] = new JsonArray("fleet.read") }; Assert.Equal(HttpStatusCode.Created, (await session.PostAsync("/api/admin/api-tokens", BackendFixture.Json(harmless))).StatusCode); @@ -184,7 +193,9 @@ public sealed class ElevationTests [Fact] public async Task ListingUsers_NeedsOnlyTheReadCapability() { - using HttpClient session = await NewSessionAsync(); + (HttpClient session, SoftwarePasskey passkey) = await StrongAsync(); + using HttpClient sessionScope = session; + using SoftwarePasskey passkeyScope = passkey; Assert.Equal(HttpStatusCode.OK, (await session.GetAsync("/api/admin/users")).StatusCode); // 看列表不用提权 } } diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index 8d38ada..95529b3 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -3,7 +3,10 @@ using System.Text.Json.Nodes; namespace MoShiro.Backend.Tests; -/// 一行安装:Agent 和 Probe 共用同一份脚本,角色、网关、镜像由一次性代码决定,节点名字默认取主机名。 +/// +/// 一行安装:Agent 和 Probe 共用同一份静态脚本(所有人拿到的字节都一样,所以哈希固定、可以先核对再执行), +/// 面板地址、角色、一次性代码都走命令里的环境变量;脚本再向面板取 CI 签名的发布清单来验证镜像。 +/// [Collection("probe-e2e")] public sealed class InstallTests { @@ -23,7 +26,7 @@ public sealed class InstallTests [Theory] [InlineData("agent")] [InlineData("probe")] - public async Task Commands_AreOneLiners_WithoutAPlaceholder_AndTheScriptIsFilledInForTheRole(string role) + public async Task Commands_VerifyTheScriptHash_ThenRunItWithThePublicValues(string role) { JsonNode created = await CreateAsync(role); string code = created["plaintext"]!.GetValue(); @@ -31,33 +34,55 @@ public sealed class InstallTests string linux = commands["linux"]!.GetValue(); string macos = commands["macos"]!.GetValue(); string windows = commands["windows"]!.GetValue(); - Assert.StartsWith("curl -fsSL '", linux, StringComparison.Ordinal); - Assert.EndsWith("/install?code=" + code + "' | bash", linux, StringComparison.Ordinal); - Assert.EndsWith("/install?code=" + code + "' | zsh", macos, StringComparison.Ordinal); - Assert.StartsWith("irm '", windows, StringComparison.Ordinal); - Assert.EndsWith("/install.ps1?code=" + code + "' | iex", windows, StringComparison.Ordinal); + + Assert.StartsWith("f=$(mktemp) && curl -fsSL '", linux, StringComparison.Ordinal); + Assert.Contains("/install' -o \"$f\" && echo \"", linux, StringComparison.Ordinal); + Assert.Contains("sha256sum -c -", linux, StringComparison.Ordinal); + Assert.Contains($"MOSHIRO_ROLE='{role}'", linux, StringComparison.Ordinal); + Assert.Contains($"MOSHIRO_CODE='{code}'", linux, StringComparison.Ordinal); + Assert.EndsWith("sh \"$f\"", linux, StringComparison.Ordinal); + Assert.Contains("shasum -a 256 -c -", macos, StringComparison.Ordinal); + Assert.StartsWith("$f = Join-Path $env:TEMP", windows, StringComparison.Ordinal); + Assert.Contains($"$env:MOSHIRO_ROLE='{role}'", windows, StringComparison.Ordinal); + Assert.EndsWith("iex (Get-Content -Raw $f)", windows, StringComparison.Ordinal); Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符 - - // 脚本不需要登录(拿代码就是授权),不带缓存 - using HttpResponseMessage sh = await _backend.Anonymous.GetAsync("/install?code=" + code); - string script = await sh.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, sh.StatusCode); - Assert.Equal("no-store", sh.Headers.CacheControl?.ToString()); - Assert.Contains($"ROLE='{role}'", script, StringComparison.Ordinal); - Assert.Contains($"CODE='{code}'", script, StringComparison.Ordinal); - Assert.Contains($"/moshiro-{role}:latest'", script, StringComparison.Ordinal); - Assert.Contains("SERVER='http", script, StringComparison.Ordinal); - Assert.DoesNotContain("@@", script, StringComparison.Ordinal); - - using HttpResponseMessage ps = await _backend.Anonymous.GetAsync("/install.ps1?code=" + code); - string powershell = await ps.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, ps.StatusCode); - Assert.Contains($"$role = '{role}'", powershell, StringComparison.Ordinal); - Assert.DoesNotContain("@@", powershell, StringComparison.Ordinal); } [Fact] - public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte_AndNothingWithoutAValidCode() + public async Task TheScripts_AreStatic_TheSameBytesForEveryone_AndNeedNoCode() + { + foreach (string path in new[] { "/install", "/install.sh", "/install.ps1" }) + { + using HttpResponseMessage first = await _backend.Anonymous.GetAsync(path); + using HttpResponseMessage second = await _backend.Anonymous.GetAsync(path + "?code=jt_whatever"); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal("no-store", first.Headers.CacheControl?.ToString()); + string body = await first.Content.ReadAsStringAsync(); + Assert.Equal(body, await second.Content.ReadAsStringAsync()); // 带不带码、带什么码,内容都一样 + Assert.DoesNotContain("@@", body, StringComparison.Ordinal); + Assert.Contains("MOSHIRO_SERVER", body, StringComparison.Ordinal); + Assert.Contains("MOSHIRO_ROLE", body, StringComparison.Ordinal); + Assert.Contains("MOSHIRO_CODE", body, StringComparison.Ordinal); + } + + // 同一份字节:sh 的两个路径一致 + Assert.Equal(await _backend.Anonymous.GetStringAsync("/install"), await _backend.Anonymous.GetStringAsync("/install.sh")); + } + + [Fact] + public async Task TheScripts_CarryTheReleaseKey_ThatTheBackendShips() + { + string key = MoShiro.Backend.Api.InstallScripts.ReleaseKey(); + Assert.StartsWith("-----BEGIN PUBLIC KEY-----", key, StringComparison.Ordinal); + foreach (string path in new[] { "/install", "/install.ps1" }) + { + string script = (await _backend.Anonymous.GetStringAsync(path)).Replace("\r\n", "\n", StringComparison.Ordinal); + Assert.Contains(key.Replace("\r\n", "\n", StringComparison.Ordinal), script, StringComparison.Ordinal); // 脚本里内置的和面板发布的是同一把 + } + } + + [Fact] + public async Task TheScript_GetsTheSignedManifestOfItsRole_ByteForByte() { string manifest = """{"schema":1,"component":"probe","channel":"dev","image":"registry.example/moshiro-dev/moshiro-probe","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000","tag":"dev-1","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":1}"""; using HttpResponseMessage published = await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = "MEUCIQDc" })); @@ -67,24 +92,33 @@ public sealed class InstallTests using HttpResponseMessage served = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code); Assert.Equal(HttpStatusCode.OK, served.StatusCode); Assert.Equal("no-store", served.Headers.CacheControl?.ToString()); - string body = await served.Content.ReadAsStringAsync(); - Assert.Contains("\"component\":\"probe\"", body, StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改 + Assert.Contains("\"component\":\"probe\"", await served.Content.ReadAsStringAsync(), StringComparison.Ordinal); // 签名对象是原始字节,面板一个字节也不改 using HttpResponseMessage signature = await _backend.Anonymous.GetAsync("/install/manifest.sig?code=" + code); Assert.Equal(HttpStatusCode.OK, signature.StatusCode); Assert.False(string.IsNullOrWhiteSpace(await signature.Content.ReadAsStringAsync())); - - Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest?code=jt_wrong")).StatusCode); - Assert.Equal(HttpStatusCode.NotFound, (await _backend.Anonymous.GetAsync("/install/manifest.sig")).StatusCode); } [Fact] - public async Task TheScript_CarriesTheReleaseKey_ToVerifyTheManifest() + public async Task TheManifest_IsNotServedForUnknownRevokedOrUsedUpCodes() { - string code = (await CreateAsync("agent"))["plaintext"]!.GetValue(); - string script = await _backend.Anonymous.GetStringAsync("/install?code=" + code); - Assert.Contains("-----BEGIN PUBLIC KEY-----", script, StringComparison.Ordinal); - Assert.DoesNotContain("@@", script, StringComparison.Ordinal); - Assert.Contains("install/manifest?code=", script, StringComparison.Ordinal); + foreach (string path in new[] { "/install/manifest", "/install/manifest?code=", "/install/manifest?code=jt_nope", "/install/manifest.sig?code=jt_nope" }) + { + using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + JsonNode revoked = await CreateAsync("probe"); + using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue()); + Assert.True(delete.IsSuccessStatusCode); + using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install/manifest?code=" + revoked["plaintext"]!.GetValue()); + Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode); + + // 用完了(一次性令牌已经注册过一个节点):脚本在动手之前就能从这里知道,给出明确的提示 + JsonNode used = await CreateAsync("probe"); + string code = used["plaintext"]!.GetValue(); + await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { } + using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install/manifest?code=" + code); + Assert.Equal(HttpStatusCode.NotFound, after.StatusCode); } [Fact] @@ -93,29 +127,4 @@ public sealed class InstallTests JsonNode created = await CreateAsync("agent"); Assert.StartsWith("key:", created["token"]!["created_by"]!.GetValue(), StringComparison.Ordinal); // Admin 客户端是 API key } - - [Fact] - public async Task Script_IsNotServedForUnknownRevokedOrUsedUpCodes() - { - foreach (string path in new[] { "/install", "/install?code=", "/install?code=jt_nope", "/install.ps1?code=jt_nope", "/install.sh?code=jt_nope" }) - { - using HttpResponseMessage response = await _backend.Anonymous.GetAsync(path); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - JsonNode revoked = await CreateAsync("agent"); - using HttpResponseMessage delete = await _backend.Admin.DeleteAsync("/api/admin/join-tokens/" + revoked["token"]!["id"]!.GetValue()); - Assert.True(delete.IsSuccessStatusCode); - using HttpResponseMessage gone = await _backend.Anonymous.GetAsync("/install?code=" + revoked["plaintext"]!.GetValue()); - Assert.Equal(HttpStatusCode.NotFound, gone.StatusCode); - - // 用完了(一次性令牌已经注册过一个节点) - JsonNode used = await CreateAsync("probe"); - string code = used["plaintext"]!.GetValue(); - using HttpResponseMessage before = await _backend.Anonymous.GetAsync("/install?code=" + code); - Assert.Equal(HttpStatusCode.OK, before.StatusCode); - await using (await MoShiro.TestSupport.AgentSimulator.EnrollAsync(_backend.Anonymous, code, _backend.UniqueName("used"))) { } - using HttpResponseMessage after = await _backend.Anonymous.GetAsync("/install?code=" + code); - Assert.Equal(HttpStatusCode.NotFound, after.StatusCode); - } } diff --git a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs index 5d20934..2d85ec8 100644 --- a/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/PasskeyTests.cs @@ -8,7 +8,7 @@ using Fleet.Core; namespace MoShiro.Backend.Tests; /// 软件实现的 WebAuthn 认证器(ES256),用来在测试里走完整的注册和登录。 -internal sealed class SoftwarePasskey : IDisposable +public sealed class SoftwarePasskey : IDisposable { private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256); private uint _counter; diff --git a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs index d8ef4b8..61ac795 100644 --- a/backend/tests/MoShiro.Backend.Tests/StorageTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/StorageTests.cs @@ -302,7 +302,7 @@ public sealed class StorageTests : IDisposable var tokens = new SqliteJoinTokenStore(_db); var fleet = new SqliteAgentStore(_db); byte[] hash = TokenHash.Sha256("jt_race"); - JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", grant: null, default); + JoinTokenInfo created = await tokens.CreateAsync("jtk_1", hash, "note", ["a"], true, "", maxUses: 3, expiresAtMs: 10_000, nowMs: 1_000, createdBy: "alice", default); Assert.Equal(0, created.Uses); bool[] results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(async () => await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)))); @@ -310,7 +310,7 @@ public sealed class StorageTests : IDisposable Assert.Equal(3, (await fleet.FindJoinTokenAsync(hash, default))!.Uses); Assert.False(await fleet.TryConsumeJoinTokenAsync("jtk_1", 2_000, default)); - JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, grant: null, default); + JoinTokenInfo expiring = await tokens.CreateAsync("jtk_2", TokenHash.Sha256("jt_exp"), null, [], true, "", 5, expiresAtMs: 5_000, nowMs: 1_000, createdBy: null, default); Assert.False(await fleet.TryConsumeJoinTokenAsync(expiring.Id, 5_000, default), "expired at the boundary"); Assert.True(await tokens.RevokeAsync("jtk_2", 3_000, default)); Assert.False(await tokens.RevokeAsync("jtk_2", 3_000, default)); diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs index 8ceeca8..8a240a1 100644 --- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -8,17 +8,18 @@ using Fleet.Core; using Fleet.Enrollment.Client; using Fleet.Hosting; using Fleet.Identity; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; namespace MoShiro.Backend.Tests; /// -/// 信任根:操作员(passkey)签操作员集合和加入码的委托,节点从固定的创世指纹出发验证;码的种子面板看不到。 +/// 信任根:操作员(passkey)签操作员集合;安装命令里带着创世指纹和属主(都是公开值),节点注册时验证面板的集合并固定在本地。 /// 设计见 docs/access.md 第 7 节。 /// [Collection("probe-e2e")] public sealed class TrustTests { - private const string Password = "correct horse battery"; private static readonly ConditionalWeakTable s_operators = []; private static readonly SemaphoreSlim s_gate = new(1, 1); @@ -32,8 +33,6 @@ public sealed class TrustTests /// 一个已经建立了信任根的操作员:自己的用户、passkey、会话,以及创世指纹。 private sealed record Operator(HttpClient Session, SoftwarePasskey Passkey, string Genesis); - private string Origin => _backend.BaseAddress.GetLeftPart(UriPartial.Authority); - private async Task OperatorAsync() { await s_gate.WaitAsync(); @@ -44,20 +43,8 @@ public sealed class TrustTests return existing; } - string name = _backend.UniqueName("operator"); - using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password })); - Assert.Equal(HttpStatusCode.Created, created.StatusCode); - var session = _backend.NewClient(); - using HttpResponseMessage login = await session.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = Password, ["return_session"] = true })); - session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); - - var passkey = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); - using (HttpResponseMessage options = await session.PostAsync("/api/admin/auth/passkeys/options", null)) - { - string challenge = JsonNode.Parse(await options.Content.ReadAsStringAsync())!["challenge"]!.GetValue(); - using HttpResponseMessage registered = await session.PostAsync("/api/admin/auth/passkeys", BackendFixture.Json(passkey.Register("operator", challenge))); - Assert.Equal(HttpStatusCode.Created, registered.StatusCode); - } + // 操作员:有 passkey、用 passkey 登录的会话(密码会话什么能力都没有) + (HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("operator"); // 创世要凭证窗口(立根的人要先证明自己),再用 passkey 签集合本身 await ElevateAsync(session, passkey); @@ -105,98 +92,127 @@ public sealed class TrustTests Assert.Equal(HttpStatusCode.OK, elevated.StatusCode); } - /// 浏览器做的事:生成码的密钥,种子派生认证串,passkey 对委托签名,创建码。返回种子和认证串。 - private async Task<(string Seed, string Auth, HttpStatusCode Status)> CreateSignedCodeAsync(Operator op, string role = "probe", SoftwarePasskey? signer = null, long? notAfter = null) + /// 创建一个加入码(普通 API key 就行,不需要 passkey),返回响应里的码和三条安装命令。 + private async Task<(string Code, JsonNode Commands, HttpStatusCode Status)> CreateCodeAsync(string role = "probe", string[]? controllers = null) { - using ECDsa code = ECDsa.Create(ECCurve.NamedCurves.nistP256); - byte[] seed = code.ExportParameters(true).D!; - string auth = OperatorTrust.AuthToken(seed); - long now = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds(); - string statement = OperatorTrust.Encode(new Grant(role, OperatorTrust.AuthHash(auth), code.ExportSubjectPublicKeyInfo(), now - 1000, notAfter ?? now + 3_600_000, 1)); - using HttpResponseMessage response = await op.Session.PostAsync("/api/admin/join-tokens/signed", BackendFixture.Json(new JsonObject { ["grant"] = Sign(signer ?? op.Passkey, statement), ["note"] = "trust test" })); - return (Fleet.Core.Base64Url.Encode(seed), auth, response.StatusCode); + var body = new JsonObject { ["role"] = role, ["note"] = "trust test" }; + if (controllers is not null) + { + body["controllers"] = new JsonArray([.. controllers.Select(c => (JsonNode?)c)]); + } + + using HttpResponseMessage response = await _backend.Admin.PostAsync("/api/admin/join-tokens", BackendFixture.Json(body)); + JsonNode result = JsonNode.Parse(await response.Content.ReadAsStringAsync())!; + return response.StatusCode == HttpStatusCode.Created ? (result["plaintext"]!.GetValue(), result["install_commands"]!, response.StatusCode) : ("", result, response.StatusCode); } - private async Task> EnrollAsync(string auth, string? seed, string? genesis, string role = "probe", string? directory = null) + private async Task> EnrollAsync(string code, string? genesis, string[]? controllers, string? directory = null) { directory ??= Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); using var http = new HttpClient(); - return await EnrollmentClient.EnrollAsync(http, _backend.BaseAddress, auth, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, seed, genesis, role); + return await EnrollmentClient.EnrollAsync(http, _backend.BaseAddress, code, _backend.UniqueName("node"), directory, "0.0.0-test", null, TimeProvider.System, default, genesis, controllers); } [Fact] - public async Task ASignedCode_EnrollsANode_ThatPinsTheOperatorAndCarriesTheProof() + public async Task TheInstallCommand_CarriesOnlyPublicValues_AndChecksTheScriptHashFirst() { Operator op = await OperatorAsync(); - (string seed, string auth, HttpStatusCode status) = await CreateSignedCodeAsync(op); + (string code, JsonNode commands, HttpStatusCode status) = await CreateCodeAsync(); Assert.Equal(HttpStatusCode.Created, status); + string linux = commands["linux"]!.GetValue(); + string sha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(await _backend.Anonymous.GetStringAsync("/install")))); + Assert.Contains($"echo \"{sha} $f\" | sha256sum -c -", linux, StringComparison.Ordinal); // 先核对哈希,一致才执行 + Assert.Contains($"MOSHIRO_CODE='{code}'", linux, StringComparison.Ordinal); + Assert.Contains($"MOSHIRO_GENESIS='{op.Genesis}'", linux, StringComparison.Ordinal); + Assert.Contains(op.Passkey.Id, linux, StringComparison.Ordinal); // 默认属主:操作员集合里的全部 passkey + Assert.DoesNotContain("SEED", linux + commands["macos"] + commands["windows"], StringComparison.Ordinal); // 没有任何私钥在命令里 + Assert.Contains("shasum -a 256 -c -", commands["macos"]!.GetValue(), StringComparison.Ordinal); + string windowsSha = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(await _backend.Anonymous.GetStringAsync("/install.ps1")))); + Assert.Contains($"-ne '{windowsSha}'", commands["windows"]!.GetValue(), StringComparison.Ordinal); + } + + [Fact] + public async Task ANode_PinsTheGenesisAndItsOwners_AtEnrollment() + { + Operator op = await OperatorAsync(); + (string code, _, _) = await CreateCodeAsync(controllers: [op.Passkey.Id]); + string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); - Result enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory); + Result enrolled = await EnrollAsync(code, op.Genesis, [op.Passkey.Id], directory); Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); Assert.True(enrolled.Value.Pinned); - // 节点本地:固定的创世、验证过的链、自己的证明;强制模式下对端必须带证明 TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance); - Assert.True(anchor.Enforcing); + Assert.True(anchor.Pinned); Assert.Equal(op.Genesis, anchor.Genesis); - using LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory); - Assert.Equal(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, identity.PeerIdentity.PublicKeySpki, "probe")); - Assert.Equal(TrustFailure.Malformed, anchor.VerifyPeer(null, identity.PeerIdentity.PublicKeySpki, "probe")); - Assert.NotEqual(TrustFailure.None, anchor.VerifyPeer(anchor.OwnProof, RandomNumberGenerator.GetBytes(91), "probe")); - - // 面板上这个码标记为已签名 - JsonNode tokens = JsonNode.Parse(await _backend.Admin.GetStringAsync("/api/admin/join-tokens"))!; - Assert.Contains(tokens["items"]!.AsArray(), t => t!["signed"]!.GetValue() && t["note"]?.GetValue() == "trust test"); + Assert.Equal([op.Passkey.Id], anchor.Controllers); + Assert.True(anchor.Version >= 1); } [Fact] public async Task ANodeWithAWrongPin_RefusesBeforeSendingAnything_AndTheCodeStaysUsable() { Operator op = await OperatorAsync(); - (string seed, string auth, _) = await CreateSignedCodeAsync(op); + (string code, _, _) = await CreateCodeAsync(); - Result wrong = await EnrollAsync(auth, seed, new string('0', 64)); + Result wrong = await EnrollAsync(code, new string('0', 64), [op.Passkey.Id]); Assert.False(wrong.IsSuccess); Assert.Contains("genesis", wrong.Error.Message, StringComparison.Ordinal); - Result right = await EnrollAsync(auth, seed, op.Genesis); // 码没被消耗 - Assert.True(right.IsSuccess, right.IsSuccess ? "" : right.Error.ToString()); + Result unknownOwner = await EnrollAsync(code, op.Genesis, ["not-a-credential-of-the-set"]); + Assert.False(unknownOwner.IsSuccess); + Assert.Contains("owner", unknownOwner.Error.Message, StringComparison.Ordinal); + + Assert.True((await EnrollAsync(code, op.Genesis, [op.Passkey.Id])).IsSuccess); // 前两次都在交出密钥之前被拒绝,码没有被消耗 } [Fact] - public async Task TheSeedIsNeededToEnroll_ACodeAloneNeverGetsANodeIn() + public async Task APlainCode_StillWorksWithoutAPin_ButTheNodeIsNotPinned() { - Operator op = await OperatorAsync(); - (string seed, string auth, _) = await CreateSignedCodeAsync(op); - - // 只有认证串(面板看得到的那部分):不带种子,旧客户端的流程,面板要求绑定 - Result withoutSeed = await EnrollAsync(auth, null, null); - Assert.False(withoutSeed.IsSuccess); - - // 种子不是这个码的:节点自己就不干 - (string otherSeed, _, _) = await CreateSignedCodeAsync(op); - Result mismatched = await EnrollAsync(auth, otherSeed, op.Genesis); - Assert.False(mismatched.IsSuccess); - - Assert.True((await EnrollAsync(auth, seed, op.Genesis)).IsSuccess); + (string code, _, _) = await CreateCodeAsync(); + string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); + Result enrolled = await EnrollAsync(code, null, null, directory); + Assert.True(enrolled.IsSuccess); + Assert.False(enrolled.Value.Pinned); + Assert.False(TrustAnchor.Load(directory, NullNodeLog.Instance).Pinned); } [Fact] - public async Task ARoleMismatch_OrAnExpiredGrant_OrAForeignSigner_IsRefused() + public async Task TheOwnersOfACode_MustBePasskeysOfTheOperatorSet() + { + await OperatorAsync(); + (_, JsonNode problem, HttpStatusCode status) = await CreateCodeAsync(controllers: ["not-in-the-set"]); + Assert.Equal(HttpStatusCode.BadRequest, status); + Assert.Contains("controllers", problem.ToJsonString(), StringComparison.Ordinal); + } + + [Fact] + public async Task APinnedAgent_FollowsTheOperatorSet_WhenThePasskeysChange_AndOnlyWhenTheOperatorSigns() { Operator op = await OperatorAsync(); + (string code, _, _) = await CreateCodeAsync(role: "agent", controllers: [op.Passkey.Id]); + await using AgentHarness agent = await AgentHarness.StartAsync(_backend, pin: (code, op.Genesis, [op.Passkey.Id])); + Assert.True(agent.App.Link.IsSecure, "a pinned agent must be on the encrypted link (the server proved itself with the key in the operator set)"); + int before = agent.App.TrustVersion; + Assert.True(before >= 1); - (string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "agent"); - Result asProbe = await EnrollAsync(auth, seed, op.Genesis, role: "probe"); // 节点以为自己是 probe,委托是给 agent 的 - Assert.False(asProbe.IsSuccess); + // 账号下多了一把 passkey:集合和 passkey 不一致,面板给出下一版要签的语句 + (_, SoftwarePasskey added) = await _backend.CreateUserWithPasskeyAsync("colleague"); + using SoftwarePasskey addedScope = added; + JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; + Assert.False(state["in_sync"]!.GetValue()); + string next = state["proposal"]!["statement"]!.GetValue(); - (_, _, HttpStatusCode expired) = await CreateSignedCodeAsync(op, notAfter: TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() - 1000); - Assert.Equal(HttpStatusCode.BadRequest, expired); + // 不是操作员签的(新来的 passkey 还不在上一版里):不收 + using HttpResponseMessage forged = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(added, next))); + Assert.True(forged.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.BadRequest, await forged.Content.ReadAsStringAsync()); + Assert.Equal(before, agent.App.TrustVersion); - using var stranger = new SoftwarePasskey(_backend.BaseAddress.Host, Origin); // 没注册的 passkey 签的委托 - (_, _, HttpStatusCode foreign) = await CreateSignedCodeAsync(op, signer: stranger); - Assert.Equal(HttpStatusCode.Forbidden, foreign); + // 现有操作员签:收下,随名册下发给节点,节点从固定的创世验过之后采用 + using HttpResponseMessage committed = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(op.Passkey, next))); + Assert.Equal(HttpStatusCode.Created, committed.StatusCode); + await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.App.TrustVersion > before), because: "the pinned agent should adopt the operator-signed set"); } [Fact] @@ -207,63 +223,86 @@ public sealed class TrustTests Assert.True(state["initialized"]!.GetValue()); Assert.Equal(op.Genesis, state["genesis"]!.GetValue()); - // 没有操作员签名的语句:不收 - JsonObject unsigned = Sign(op.Passkey, "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=x\norigins=x\nkey=a:AAAA"); - unsigned["statement"] = "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=" + _backend.BaseAddress.Host + "\norigins=" + Origin + "\nkey=a:AAAA"; // 语句被换掉,签名对不上 - using HttpResponseMessage tampered = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned)); - Assert.True(tampered.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Conflict); + // 语句被换掉,签名对不上:不收 + JsonObject tampered = Sign(op.Passkey, "moshiro-opset-v1\nversion=99\nprev=x\nrp=x\norigins=x\nkey=a:AAAA"); + tampered["statement"] = "moshiro-opset-v1\nversion=2\nprev=" + op.Genesis + "\nrp=" + _backend.BaseAddress.Host + "\norigins=" + _backend.BaseAddress.GetLeftPart(UriPartial.Authority) + "\nkey=a:AAAA"; + using HttpResponseMessage rejected = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(tampered)); + Assert.True(rejected.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Conflict); - // 匿名和只有 API key 的调用也没有可能立根 - using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(unsigned)); + // 匿名没有可能立根或改根 + using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/trust", BackendFixture.Json(tampered)); Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); } [Fact] - public async Task APinnedAgent_OnlyAcceptsProbesTheOperatorApproved_EvenIfThePanelListsOthers() + public async Task APasswordSession_CanOnlyRegisterAPasskey_AndCreatingACodeNeedsNoPasskeyTouch() { - Operator op = await OperatorAsync(); - (string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent"); - await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis)); + // 密码登录的会话没有任何能力:连看节点列表都不行,只能注册 passkey + string name = _backend.UniqueName("pwonly"); + using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/users", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery" })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + using HttpClient password = _backend.NewClient(); + using HttpResponseMessage login = await password.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = name, ["password"] = "correct horse battery", ["return_session"] = true })); + password.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue()); + JsonNode me = JsonNode.Parse(await password.GetStringAsync("/api/admin/auth/me"))!; + Assert.Empty(me["capabilities"]!.AsArray()); + Assert.Equal("password", me["auth_method"]!.GetValue()); + Assert.Equal(HttpStatusCode.Forbidden, (await password.GetAsync("/api/admin/agents")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await password.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" }))).StatusCode); + using HttpResponseMessage options = await password.PostAsync("/api/admin/auth/passkeys/options", null); + Assert.Equal(HttpStatusCode.OK, options.StatusCode); - // 面板里有一个没有操作员证明的 Probe(相当于面板自己塞进名册的),再有一个操作员批准的 - using EnrolledProbe plain = await _backend.CreateProbeAsync(); - (string seed, string auth, _) = await CreateSignedCodeAsync(op, role: "probe"); - string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); - Result enrolled = await EnrollAsync(auth, seed, op.Genesis, directory: directory); - Assert.True(enrolled.IsSuccess, enrolled.IsSuccess ? "" : enrolled.Error.ToString()); - using LoadedNodeIdentity approved = LoadedNodeIdentity.Load(directory); - - await BackendFixture.EventuallyAsync( - () => Task.FromResult(agent.App.IsPeerKnown(enrolled.Value.NodeId, Convert.ToBase64String(approved.PeerIdentity.PublicKeySpki))), - because: "the agent should accept the probe the operator approved"); - Assert.False(agent.App.IsPeerKnown(plain.ProbeId, Convert.ToBase64String(plain.Identity.PeerIdentity.PublicKeySpki)), "a probe without the operator's proof must not be accepted"); + // passkey 登录的会话创建码:不需要提权窗口、不需要再触碰(创建码不是信任动作,信任在属主固定里) + (HttpClient strong, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("creator"); + using HttpClient strongScope = strong; + using SoftwarePasskey passkeyScope = passkey; + using HttpResponseMessage code = await strong.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent" })); + Assert.Equal(HttpStatusCode.Created, code.StatusCode); } [Fact] - public async Task APinnedProbe_OnlyConnectsToAgentsTheOperatorApproved() + public async Task TheServerKey_IsSignedIntoTheOperatorSet_AndOnlyThatServerPassesTheNodesCheck() { Operator op = await OperatorAsync(); - (string probeSeed, string probeAuth, _) = await CreateSignedCodeAsync(op, role: "probe"); - string probeDirectory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); - Assert.True((await EnrollAsync(probeAuth, probeSeed, op.Genesis, directory: probeDirectory)).IsSuccess); + JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; + Assert.True(state["server_key_listed"]!.GetValue()); + Assert.Equal(64, state["server_key"]!.GetValue().Length); - (string agentSeed, string agentAuth, _) = await CreateSignedCodeAsync(op, role: "agent"); - await using AgentHarness agent = await AgentHarness.StartAsync(_backend, signed: (agentAuth, agentSeed, op.Genesis)); - MemberProofDto? proof = TrustAnchor.Load(agent.DataDirectory, NullNodeLog.Instance).OwnProof; - string key = Convert.ToBase64String(agent.TlsPublicKey); + (string code, _, _) = await CreateCodeAsync(controllers: [op.Passkey.Id]); + string directory = Path.Combine(Path.GetTempPath(), "moshiro-trust-id-" + Guid.NewGuid().ToString("N")); + Assert.True((await EnrollAsync(code, op.Genesis, [op.Passkey.Id], directory)).IsSuccess); + TrustAnchor anchor = TrustAnchor.Load(directory, NullNodeLog.Instance); + Assert.True(anchor.Required); // 集合里登记了服务器密钥:节点要求服务器证明自己 - string configDirectory = Path.Combine(Path.GetTempPath(), "moshiro-probe-cfg-" + Guid.NewGuid().ToString("N")); - var directory = new MoShiro.Probe.AgentDirectory(new ConfigStore(configDirectory), configDirectory, NullNodeLog.Instance, TrustAnchor.Load(probeDirectory, NullNodeLog.Instance)); - directory.Apply(new MoShiro.Protocol.ProbeAgentList + // 真服务器的证明过得了节点的校验;任何别的密钥(冒充者)、或者换了临时公钥的都过不了 + ServerIdentityService server = _backend.Services.GetRequiredService(); + using var ephemeral = new EphemeralKey(); + byte[] proof = server.Sign(ServerHello.Payload("nonce-1", ephemeral.PublicKeySpki)); + Assert.True(anchor.Verify("nonce-1", ephemeral.PublicKeySpki, proof)); + using ECDsa impostor = ECDsa.Create(ECCurve.NamedCurves.nistP256); + Assert.False(anchor.Verify("nonce-1", ephemeral.PublicKeySpki, ServerHello.Sign(impostor, "nonce-1", ephemeral.PublicKeySpki))); + using var otherEphemeral = new EphemeralKey(); + Assert.False(anchor.Verify("nonce-1", otherEphemeral.PublicKeySpki, proof)); + Assert.False(anchor.Verify("nonce-2", ephemeral.PublicKeySpki, proof)); + } + + [Fact] + public async Task AnOperatorSetWithoutThisPanelsServerKey_IsNotAccepted() + { + Operator op = await OperatorAsync(); + JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; + string current = state["proposal"]?["statement"]?.GetValue() ?? ""; + if (current.Length == 0) { - Version = 1, - Agents = - [ - new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_approved"), Host = "127.0.0.1", TcpPort = 1, PublicKey = key, Proof = proof }, - new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_planted"), Host = "127.0.0.1", TcpPort = 2, PublicKey = key }, // 面板塞进来的:没有证明 - new MoShiro.Protocol.AgentEndpoint { AgentId = new NodeId("agt_swapped"), Host = "127.0.0.1", TcpPort = 3, PublicKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(91)), Proof = proof }, // 拿着别人的证明、换了自己的公钥 - ], - }); - Assert.Equal(["agt_approved"], directory.Agents.Select(a => a.AgentId)); + // 已经同步:用当前集合的内容拼一个没有服务器密钥的下一版 + (_, SoftwarePasskey extra) = await _backend.CreateUserWithPasskeyAsync("bystander"); + using SoftwarePasskey extraScope = extra; + state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!; + current = state["proposal"]!["statement"]!.GetValue(); + } + + string withoutServer = string.Join('\n', current.Split('\n').Where(l => !l.StartsWith("server=", StringComparison.Ordinal))); + using HttpResponseMessage refused = await op.Session.PostAsync("/api/admin/trust", BackendFixture.Json(Sign(op.Passkey, withoutServer))); + Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode); // 签下去会让固定了它的节点认不出这个面板 } } diff --git a/ci/publish-gitea-release.sh b/ci/publish-gitea-release.sh index 55cc612..35dea76 100644 --- a/ci/publish-gitea-release.sh +++ b/ci/publish-gitea-release.sh @@ -26,7 +26,10 @@ printf 'machine %s login justme password %s\n' "$gitea_host" "$GITEA_TOKEN" > "$ chmod 600 "$netrc" trap 'rm -rf "$tmp"' EXIT -release_body="Agent and Probe builds: Linux glibc and musl (x64, arm64), macOS (x64, arm64), and Windows (x64, arm64). Linux glibc Agent and Probe OCI images: registry.oeo.one/moshiro/moshiro-agent:$version and registry.oeo.one/moshiro/moshiro-probe:$version (linux/amd64 and linux/arm64). Backend OCI image: registry.oeo.one/moshiro/moshiro-backend:$version (linux/amd64 and linux/arm64). macOS binaries are not Developer ID signed or notarized." +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +install_sh="$(sha256sum "$root_dir/packaging/install/install.sh" | cut -d" " -f1)" +install_ps1="$(sha256sum "$root_dir/packaging/install/install.ps1" | cut -d" " -f1)" +release_body="Install script SHA-256 (compare with the panel before you run an install command): install.sh $install_sh, install.ps1 $install_ps1. Agent and Probe builds: Linux glibc and musl (x64, arm64), macOS (x64, arm64), and Windows (x64, arm64). Linux glibc Agent and Probe OCI images: registry.oeo.one/moshiro/moshiro-agent:$version and registry.oeo.one/moshiro/moshiro-probe:$version (linux/amd64 and linux/arm64). Backend OCI image: registry.oeo.one/moshiro/moshiro-backend:$version (linux/amd64 and linux/arm64). macOS binaries are not Developer ID signed or notarized." payload="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"MoShiro %s","body":"%s","draft":false,"prerelease":false}' \ "$tag" "${CI_COMMIT_SHA:-}" "$tag" "$release_body")" release_status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \ diff --git a/ci/release-manifest.sh b/ci/release-manifest.sh index 5a73e7e..2594838 100644 --- a/ci/release-manifest.sh +++ b/ci/release-manifest.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash # 生成并签名一个镜像的发布清单:ci/release-manifest.sh <组件> <镜像仓库> <通道> <输出目录> # 签名私钥从环境变量 RELEASE_SIGNING_KEY(PEM 内容)读取,只写进一个 600 权限的临时文件,用完即删;私钥只放在 CI 密钥里,不进仓库、不上传面板。 +# 换发布密钥:把新公钥(PEM)放进环境变量 RELEASE_NEXT_KEY,清单里就会带 "next_key"(SPKI DER 的 base64);这份清单仍由旧密钥签名, +# 宿主机更新器验过之后改认新密钥。下一个版本起把 RELEASE_SIGNING_KEY 换成新私钥、去掉 RELEASE_NEXT_KEY,并把安装脚本和 release-key.pub.pem 里的公钥换成新的。 # 输出:<输出目录>/manifest.json(扁平 JSON,签名对象是它的原始字节)和 manifest.sig(base64 的 DER ECDSA 签名)。 set -euo pipefail @@ -16,10 +18,19 @@ version="$(sed -n 's#.*\(.*\).*#\1#p' "$root/backend/Director commit="$(git -C "$root" rev-parse --short=10 HEAD 2>/dev/null || true)" built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" seq="$(date +%s)" +# 静态安装脚本的 SHA-256:面板给的命令会先核对它再执行;记在签名的清单里,面板和用户都能拿它和这份构建对照 +install_sh="$(sha256sum "$root/packaging/install/install.sh" | cut -d" " -f1)" +install_ps1="$(sha256sum "$root/packaging/install/install.ps1" | cut -d" " -f1)" + +next_key="" +if [[ -n "${RELEASE_NEXT_KEY:-}" ]]; then + next_key="$(printf '%s\n' "$RELEASE_NEXT_KEY" | openssl pkey -pubin -inform PEM -outform DER | base64 -w0 2>/dev/null || true)" + [[ -n "$next_key" ]] || { echo "RELEASE_NEXT_KEY is not a public key" >&2; exit 2; } +fi mkdir -p "$outdir" -printf '{"schema":1,"component":"%s","channel":"%s","image":"%s","digest":"%s","tag":"%s","version":"%s","commit":"%s","built_at":"%s","seq":%s}' \ - "$component" "$channel" "$repository" "$digest" "$tag" "$version" "$commit" "$built_at" "$seq" > "$outdir/manifest.json" +printf '{"schema":1,"component":"%s","channel":"%s","image":"%s","digest":"%s","tag":"%s","version":"%s","commit":"%s","built_at":"%s","seq":%s,"install_sh":"%s","install_ps1":"%s"%s}' \ + "$component" "$channel" "$repository" "$digest" "$tag" "$version" "$commit" "$built_at" "$seq" "$install_sh" "$install_ps1" "${next_key:+,\"next_key\":\"$next_key\"}" > "$outdir/manifest.json" key="$(mktemp)" trap 'rm -f "$key" "$outdir/manifest.sig.bin"' EXIT diff --git a/docs/access.md b/docs/access.md index fede523..b70ae3a 100644 --- a/docs/access.md +++ b/docs/access.md @@ -1,9 +1,38 @@ # 访问控制与入网信任链 -这份文档定义**谁能做什么**(账户、角色、能力、API key)和**谁能让节点入网**(passkey 到节点的签名链)。它是 [security.md](security.md) 第 5、6 节的展开:security.md 讲为什么(缺口和威胁),这里讲怎么做。**状态:设计已定,未实现**(实施顺序见第 12 节)。 +这份文档定义**谁能做什么**(账户、角色、能力、API key)和**谁能让节点入网**(passkey 到节点的签名链)。它是 [security.md](security.md) 第 5、6 节的展开:security.md 讲为什么(缺口和威胁),这里讲怎么做。**状态:部分实现**(当前实现见第 0 节,完整设计的实施顺序见第 12 节)。 一句话:登录和授权分开。登录证明「你是谁」,密码、passkey、OIDC 都可以;改变信任(加节点、吊销、改凭证)只认 passkey 的签名,并且这个签名要一路传到节点,Backend 只搬运,伪造不了。 +## 0. 当前实现(以这一节为准) + +下面的第 1~16 节是完整设计,其中「成员证明」那一整套(Grant、Ticket、Binding、每个节点的入网链,第 7.1~7.3 节)**当前没有采用**:评估之后,被攻破的 Backend 往名册里塞节点拿不到比 Backend 已经有的更多的东西(监测数据本来就是明文给 Backend 的;敏感的 extension 靠节点属主,不靠名册),成本却是每个节点一次触碰、一套证明格式和一套自动化委托。现在实现的是它的精简版: + +```text + 两个根,各管一摊 + 根 A:CI(代码) 发布公钥写在安装脚本和节点二进制里 + └─ CI 签名的发布清单 → 镜像 digest → 节点跑的二进制;安装脚本的 SHA-256 也记在清单里,并公布在发布页 + 假设:CI(Woodpecker、Gitea、镜像仓库、发布密钥)可信;CI 被攻破等于能给所有节点签恶意更新 + 根 B:你的 passkey(运维) + └─ 操作员集合(passkey 签,版本递增;创世指纹在安装时固定到节点) + ├─ 操作员的 passkey 公钥 + ├─ 服务器密钥:Backend 的身份密钥(节点据此认服务器,链路用应用层加密) + └─ 域名(rp、origins) + + 添加节点:创建加入码不需要 passkey、不需要触碰,一个码可以装任意多台。命令里只有公开的值: + 面板地址、角色、一次性码、创世指纹、属主(passkey 凭证 id)、安装脚本的哈希(命令先核对哈希再执行) + 节点注册:向面板取操作员集合链,从命令里的创世指纹验起,属主必须是集合里的凭证;任何一步不对就不注册、不交出密钥; + 通过后写 trust.json(创世指纹、链、属主),之后只认从这个创世出发的新版本 + 节点和 Backend 之间:集合里登记了服务器密钥之后,节点的认证挑战要求服务器用它签名;两边各出一把临时 ECDH 密钥, + 认证签名覆盖双方的临时公钥,之后控制链路每一帧都是 AES-256-GCM(每个方向一把密钥、严格递增的计数器)。 + 所以 TLS 在 Cloudflare 或被冒充的证书那里终止,中间人没有服务器密钥,既冒充不了服务器,也读不了、改不了这条链路 + 密码登录的会话没有任何能力,只能注册 passkey;有能力的会话来自 passkey 登录;信任类操作要提权窗口(passkey 触碰) +``` + +**发布密钥可以换**:见 `docs/deployment.md` 的「换发布密钥」;回滚已经由清单的 `seq` 防住(更新器只收比已安装更大的)。 + +**还没有的**:吊销表;任务和配置内容的操作员签名;节点自证跑的是官方构建;extension 本身和属主的 `controllers`、接收者的实际加密(只是被固定在节点本地,没有行为)。 + ## 1. 名词 | 名词 | 含义 | @@ -105,24 +134,11 @@ - 现有令牌迁移(破坏性):`read` → 全部 `*.read`;`admin` → 全部非信任类能力;`release` → `release.publish`。迁移后,旧的 admin 令牌不能再生成加入代码,需要换成 `enroll` key。 - 每把 key 可设:有效期、可选的 IP 白名单(CIDR,**待确认**)、`enroll` 类的数量上限和单券 TTL 上限;预留:资源范围、速率和数据配额(见 5.4)。 -### 5.2 `enroll` key 怎么工作 +### 5.2 `enroll` key:自动化添加节点 -```text - 创建(浏览器 + passkey 一次触控) - 浏览器生成种子 S,只显示一次(形如 mok_…) - 认证值 = HKDF(S, "auth") → Backend 只存哈希,用来识别这把 key - 签名密钥 = HKDF(S, "sign") → 公钥 P 写进委托;私钥只有拿着 S 的人算得出 - passkey 对委托签名:{P, scope=enroll, not_after, max_ticket_ttl, 数量上限, 签发凭证 id} - Backend 存委托和认证值哈希,不存 S +**当前实现:它就是一把带 `fleet.enroll` 的普通 API key**,用 `POST /api/admin/join-tokens` 创建加入码,响应里直接给出安装命令(带创世指纹、属主和脚本哈希,没有任何私钥)。因为创建码不是信任动作,所以不需要 passkey,也不需要提权窗口;CI 和脚本照常工作。信任在两处:命令里的创世指纹和属主由面板生成,节点注册时固定;以后改属主和集合要操作员 passkey 签名。 - 使用(和普通令牌一样的 HTTP 调用) - POST /api/admin/join-tokens Authorization: Bearer mok_S - Backend 用请求带来的 S 现算签名密钥,签出入网券和 K_code,返回一行安装命令;用完即忘 -``` - -- 数据库被偷走拿不到任何签名密钥。被攻破的 Backend 最多在 key 被使用的那一刻拿到 S,权力受这把 key 的有效期和单券 TTL 限制,key 过期或被撤销就没用了。 -- 数量上限由 Backend 执行,**节点强制不了**;节点只强制时间(见第 7.3 节)。 -- CI 和测试:夹具自己生成 key 种子,把公钥写进测试的操作员集合,不需要真 passkey。 +早先设计过的「委托密钥」(passkey 签一次委托,API key 自己签入网券,节点验证整条链)属于第 7.1~7.3 节的完整设计,当前没有采用,见第 0 节的取舍。 ### 5.3 创建界面 @@ -154,7 +170,9 @@ - 审计日志 `(时间, 账户, 凭证 id, 动作, 对象, 详情)`:创建或撤销 key、加入代码、吊销、删除、手填地址、确认更新、提权、因能力不足被拒绝的请求。保留 365 天(同事件表)。 - **可验证的一层**:委托里写进签名它的 passkey 凭证 id,入网券和节点绑定带着它,节点在 hello 里报告「由哪个凭证准入」。「谁加的」不只是 Backend 的一面之词。 -## 7. 入网信任链 +## 7. 入网信任链(完整设计,当前没有采用;当前实现见第 0 节) + +> 第 7.1~7.3 节描述的 Grant、Ticket、Binding、成员证明和节点验证在代码里**不存在**。要不要叠加回来,取决于你要不要防「被攻破的 Backend 往名册里塞节点」;操作员集合和创世固定已经在,叠加时不用重装节点。 ### 7.1 文档 @@ -221,6 +239,18 @@ 不在这个切片里:重启和改配置不需要签名的票;webssh 等 extension 见第 8 节。 +### 7.6 当前实现要点 + +- 语句是 `key=value` 文本,第一行是带版本的标题,签名覆盖整段文本;WebAuthn 的挑战是语句的 SHA-256。库在 `Fleet.Identity` 的 `OperatorTrust`(操作员集合的编码、解析、链验证)和 `SecureChannel`(服务器证明和链路加密),都是只用 BCL 的纯函数,节点和 Backend 共用。 +- **创世**:第一次建立信任根时,把账号下所有 passkey 和这个面板的服务器密钥登记成一个操作员集合,passkey 对它签名;创世语句的 SHA-256 就是命令里的 `MOSHIRO_GENESIS`。之后加删 passkey 或换服务器密钥要签新版本(上一版里的凭证签),页面会提示;签名更新不会让节点重装。 +- **操作员集合版本**随期望状态(`trust_chain`)下发,节点从固定的创世验起,版本比现有的新才采用并保存。 +- **节点注册**:`fleet-enroll --genesis … --controllers …`:先 `POST /api/agent/v1/enroll/trust` 取链,验过(创世指纹、签名、属主在集合里)才把密钥交出去;之后写 `trust.json`。没有命令里的指纹(`--genesis`)就不固定,节点按旧方式工作。 +- **服务器身份和安全链路**:`ChallengeParams` 带服务器的临时公钥和签名(`server_eph`、`server_sig`),`AuthParams` 带节点的临时公钥(`node_eph`),节点签名的用途是 `auth2`,覆盖双方的临时公钥;通过后两边用 HKDF 派生每个方向一把 AES-256-GCM 密钥,每帧 `计数器(8) ‖ 密文 ‖ 标签(16)`,计数器严格递增,明文帧、改动、重放、乱序一律断开。集合里没有服务器密钥的节点(旧节点)保持明文,不会被降级成放行。 +- **服务器密钥**存在 Backend 的设置里(`server.identity_key`):它防的是面板被冒充,不是面板被攻破;面板被攻破时它也泄露,那时要由 passkey 签一个换掉它的新集合版本。集合里没有这个面板的服务器密钥的版本,Backend 不收(否则固定了它的节点就认不出这个面板了)。 +- **安装脚本是静态的**:不替换任何值,所以 SHA-256 固定;CI 把哈希写进签名的发布清单和发布页,面板的信任页显示它和清单是否一致;命令先下载、核对哈希,一致才执行。 +- **密码登录的会话没有任何能力**,只能注册 passkey;创建加入码需要 `fleet.enroll`,不需要提权窗口。 +- **现状的缺口**:没有吊销表;删除一把操作员 passkey 只是让它不能再签新版本;任务和配置没有操作员签名;节点没有自证构建;服务器密钥换新要靠签一个新集合版本,忘了签会让固定了集合的节点认不出面板(fail closed)。 + ## 8. 节点属主与 extension 授权(预留) 后面会做 extension(web ssh、文件、抓包,见 [security.md](security.md) 第 11 节)。这类能力**连管理员都不信任**:管理员能删,不能读,也不能用。所以入网链现在就要给每个节点留出「属主」这个概念,即使 extension 还没做,字段也先固定下来,之后不用重装节点。 @@ -249,21 +279,46 @@ - **属主是 passkey 凭证,不是账户**:账户只是 Backend 里给人看的标签;节点只认公钥。一个人丢了全部属主 passkey,这个节点上的 extension 就没人能用了,要用就得重装节点(和第 3 节的恢复一致),所以添加时建议绑两把以上。 - 属主字段在**步骤 4(入网链)**就写进 Ticket;extension 本身、ControllerUpdate 和能力令牌放在阶段六。 -### 8.1 添加节点时选属主(接口现在就留) +### 8.0 范围:这一节先固定协议和要求,extension 本身先不做 -- API:`POST /api/admin/join-tokens` 和 `enroll` key 的签发请求都带可选的 `controllers: [credential_id…]`;缺省 = 签发账户的全部 passkey。校验:每个 id 必须是已注册的 passkey(任何账户的都可以,比如把同事的一把加进来),数量不超过上限。 -- 界面:「添加 Agent / Probe」对话框里一个「属主 passkey」多选列表,按账户分组,带**全选**、组内全选和反选;默认勾选自己的全部;至少选一把;提示「只有这些 passkey 能授权这个节点上的 web ssh 等敏感能力,管理员也不能」。 -- 节点详情页显示属主(凭证 id 和所属账户),以及「由谁绑定」;改属主走 `ControllerUpdate`,需要现有属主签名,管理员做不了。 -- 没有 extension 的现阶段,这组字段只是被签名、被节点持久化,不起别的作用。 +- **web ssh、文件、抓包等 extension 现在都不实现**,Agent 和 Probe 一样(Probe 是节点,同样要在本地执行下面的要求)。这里只把「谁来强制」和「怎么证明」定下来,以后实现时不改信任链,也不用重装节点。 +- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。默认是操作员集合里的全部凭证,空 = 不允许任何 extension 会话。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。 -### 8.2 数据用属主的私钥解密:Backend 解不开,也改不了 +### 8.1 添加节点时选属主 + +- API:`POST /api/admin/join-tokens` 带可选的 `controllers: [credential_id…]`;缺省 = 操作员集合里的全部凭证。校验:每个 id 必须是操作员集合里的凭证。 +- 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表;只是选,不用触碰。选定的写进安装命令,节点装上时固定。 +- 以后改属主要现有属主的 passkey 签名(`ControllerUpdate`,没有实现);管理员改不了。 + +### 8.2 谁来强制:节点本地,不是 Backend + +web ssh 这类会话要不要加密、要不要验证,**由 Agent(和 Probe)自己要求**,不是 Backend。Backend 是被防的一方:让它决定,被攻破的 Backend 一句「这次不用加密」或自己充当会话的一端就绕过去了。所以要求落在节点本地,Backend 只能转发: + +| 节点本地强制的要求 | 为什么不能交给 Backend | +|---|---| +| 没有操作员的现场证明就不启动会话 | Backend 说「已经授权过了」不算数 | +| 会话密钥只和证明里的那把密钥协商,不和 Backend 转来的任何公钥协商 | 否则 Backend 换一把自己的公钥就成了中间人 | +| 加密字段缺失就拒绝,没有明文降级 | 防降级 | +| 能开哪些 extension、哪些 passkey 能授权,只看本节点属主(passkey 签的 `controllers`) | Backend 改不了属主,账户角色也不赋予任何权力 | +| 这些要求写在节点本地(编进二进制或本地 `trust.json`),Backend 下发的配置、任务、更新清单都覆盖不了 | 配置是 Backend 能碰的东西 | + +**要求证明的方向是反的:节点向请求者要证明,不信任 Backend 的转述。** 一次会话由节点先出题(nonce 加它的临时公钥,用节点身份密钥签名),浏览器把这个 nonce 和自己的临时公钥一起放进 passkey 断言的挑战里(见 8.3 的流程);节点验:断言来自固定的操作员集合里的凭证、要求用户验证、挑战里的节点 id、extension、过期时间都对得上、这把凭证在本节点的 `controllers` 里、nonce 是它自己刚出的且没用过。任何一条不满足,节点直接拒绝,不看 Backend 的任何说法。 + +**Backend 在这件事里仍然管**:谁有资格发起请求(一项 `extensions.use` 能力)、审计、限流、转发密文。这些只挡「不该问的人」;真正的放行始终是节点本地验过证明之后。 + +**交互式会话和静态数据用不同的密钥**: +- 交互式会话(终端、ssh):现场签名的临时密钥协商,**不依赖 PRF**,passkey 只用来签名授权,不参与解密,所以不支持 PRF 的 passkey 也能用,还有前向保密。 +- 静态数据(抓包文件、会话录像):需要事后解密,才用接收者名单里的公钥加密(`recipients`,来自 WebAuthn PRF 或浏览器里的不可导出密钥,见 8.3)。 + +### 8.3 数据用属主的私钥解密:Backend 解不开,也改不了 目标:交互式会话(web ssh、终端)、以及以后 extension 产生的数据(文件、抓包),**只有属主 passkey 对应的私钥能解开**。Backend 被篡改或被攻破,拿到的只有密文,且改动密文会被发现。 ```text 浏览器(属主) Backend(只搬运) 节点上的 extension + ⓪ 节点先出题:nonce 和它的临时公钥 n,签名后经 Backend 转给浏览器(见 8.2:要求证明的是节点) ① 生成会话临时 ECDH 密钥 e - ② passkey 对「e 的公钥 ‖ 节点 ID ‖ 命令哈希 ‖ nonce ‖ not_after」做 WebAuthn 断言(能力令牌) + ② passkey 对「e 的公钥 ‖ 节点 ID ‖ 命令哈希 ‖ 节点出的 nonce ‖ not_after」做 WebAuthn 断言(能力令牌) ──────────── 能力令牌 + e 公钥 ───────▶ 转发 ─────────────────────────▶ ③ 验:断言来自本节点属主 + 令牌新鲜、nonce 未用 ◀── 节点的会话公钥(节点身份密钥签名)── 转发 ◀────────────────────────── ④ 节点生成临时密钥 n,签名后返回 @@ -281,6 +336,20 @@ 属主记录里没有 `enc_pubkey` 的节点只支持实时会话,不能存加密的静态数据。 - 加密发生在属主这一侧的浏览器和节点上的 extension 之间,不经过 Backend 的任何账户、角色、会话机制,所以改 Backend 的数据库或代码也无从下手。 +### 8.4 数据边界:哪些明文,哪些加密,谁能解 + +| 数据 | 形态 | 谁能读 | +|---|---|---| +| **监测数据**(延迟、丢包、吞吐曲线、事件、主机资源汇总) | **明文**:Backend 要做分析、聚合和展示页 | 有 `data.read` 等能力的人和 key | +| **extension 会话**(web ssh、文件、抓包等,以后)、原始 payload | **端到端加密**:节点加密给接收者名单里的公钥,Backend 只存和转发密文 | 只有接收者私钥的持有者 | + +- **读取权不是能力,是私钥**:Backend 不持有任何接收者私钥,所以 `data.read`、`data.export` 对加密流只返回密文。API key 解不开,除非你登记过读者密钥。 +- **接收者名单由操作员固定**(做加密时和 `controllers` 一样,写进安装命令并固定在节点本地;现在还没有加密功能): + - 默认只有操作员自己的 passkey(经 WebAuthn PRF 派生的公钥)。 + - 接入密钥签出来的码,名单必须和操作员签的接入委托完全一致:它能让节点加入,不能让节点把数据加密给自己。 +- **读者密钥**(预留):自动化想读明文,要操作员用 passkey 显式登记一把读者公钥(范围、有效期),私钥由读者自己保管;吊销只管之后的数据,已经加密给它的历史数据它仍然能解。 +- 管和读互不相通:passkey 或接入密钥能让节点加入和批准,但不能解密;解密靠私钥,Backend 被攻破也拿不到。 + ## 9. 流程 | 流程 | 步骤 | @@ -317,6 +386,8 @@ 4. **入网链**:Grant、Ticket(含属主 `controllers`)、Binding 的签发和节点验证(验证一次、缓存结果);成员条目随期望状态下发、节点在状态同步时验证;Probe 只拨公网地址;`enroll` key;节点信任状态上报和管理页显示(第 14 节)。添加节点的界面可以选属主(默认添加者的全部 passkey)。 5. **吊销表与操作员集合更新**;自动化夹具(测试里的软件 ECDSA 凭证);**安装脚本的供应链**(第 15 节:静态签名脚本、按 digest 拉镜像)。 +实现状态:能力模型、passkey 登录、提权窗口、审计、操作员集合与创世固定、服务器身份和安全链路、静态安装脚本和哈希核对、属主固定都已实现(第 0 节);成员证明(第 7.1~7.3 节)没有采用;吊销表、任务和配置的操作员签名、节点自证构建、extension 属主的实际强制还没有做。 + ## 13. 局限 - 数量上限只能由签名人执行,节点只强制时间;窗口过期后设备被攻破的人,最多还能多签有效期内的入网券(受节点时钟约束)。 diff --git a/docs/deployment.md b/docs/deployment.md index 1693e8c..f534dd0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -307,7 +307,14 @@ sh packaging/update/install-updater.sh agent --user \ 发布私钥只放在 Woodpecker 的 `release_signing_key` secret;发布公钥位于 `packaging/update/release-key.pub.pem`。CI 的 `release_api_token` 只有发布登记权限,`RELEASE_API_URL` 指向面板。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。 -一行安装会验证镜像:脚本向面板取 CI 签名的发布清单(`/install/manifest`、`/install/manifest.sig`),用脚本里内置的发布公钥(`packaging/update/release-key.pub.pem`)验签,再按清单里的 digest 拉镜像(`镜像仓库@sha256:…`),签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 `openssl`(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 `MOSHIRO_ALLOW_UNVERIFIED=1` 才会按标签安装。脚本本身的完整性(哈希核对)见 [access.md](access.md) 第 15 节。 +**换发布密钥**(泄露或定期轮换,不用重装节点): + +1. 生成新密钥对;CI 给发布流水线加 `RELEASE_NEXT_KEY`(新公钥 PEM),仍用旧私钥签名。清单里会带 `next_key`(SPKI DER 的 base64)。 +2. 宿主机更新器验过这份清单后,把新公钥存到自己的状态目录(`<组件>.key.pem`),之后只认新密钥,旧密钥签的清单一律拒绝。清单里的 `next_key` 不是合法公钥就整份拒绝。 +3. 下一个版本起把 `release_signing_key` 换成新私钥、去掉 `RELEASE_NEXT_KEY`,并把 `packaging/update/release-key.pub.pem`、`install.sh`、`install.ps1` 里的公钥换成新的(安装脚本哈希随清单变化)。 +4. 还没收到过渡清单的节点(离线很久)会因为签名对不上而停在旧版本,需要手动换 `release-key.pub.pem`;旧密钥泄露时,这些节点是唯一的暴露面。 + +一行安装:页面给的命令先下载静态安装脚本(`/install`,所有人拿到的字节都一样)并核对它的 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹(`MOSHIRO_GENESIS`)和属主(`MOSHIRO_CONTROLLERS`)走环境变量,都是公开值,没有私钥。脚本向面板取 CI 签名的发布清单(`/install/manifest`、`/install/manifest.sig`),用脚本里内置的发布公钥(`packaging/update/release-key.pub.pem`)验签,再按清单里的 digest 拉镜像(`镜像仓库@sha256:…`);签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 `openssl`(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 `MOSHIRO_ALLOW_UNVERIFIED=1` 和 `MOSHIRO_IMAGE=镜像` 才会安装。CI 把脚本的 SHA-256 写进签名清单和发布页,面板的「系统 → 信任」页显示它和清单是否一致。详见 [access.md](access.md) 第 0 节。 ## Backend 签名更新 diff --git a/docs/security.md b/docs/security.md index b6b755a..19a3a42 100644 --- a/docs/security.md +++ b/docs/security.md @@ -305,7 +305,7 @@ Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec 1. 敲门先做等级一,等级二先记 TODO(已按此实现等级一)。 2. 严格程度:节点没有固定操作员公钥就拒绝一切 Backend 给的对端(默认),弱信任模式是安装时的显式选择(见 access.md 第 8 节)。 3. 节点面和外部面是否拆成两个监听端口(8.1 的目标):拆的话部署文档、Caddy 和隧道的配置都要跟着改。 -4. extension 的交互式会话**已定**:端到端加密,Backend 只转发密文(见 access.md 第 8.2 节);剩下的是静态数据私钥来源和 AEAD 的选择。 +4. extension 的交互式会话**已定**:端到端加密,Backend 只转发密文(见 access.md 第 8.2 和 8.3 节);剩下的是静态数据私钥来源和 AEAD 的选择。 5. extension 的第一个具体实现做不做 web ssh,放在哪个阶段。 6. 块摘要固定 SHA-256,不预留算法字段(不协商、不降级,见第 9.4 节)。 7. Agent 的 `max_total_mbps` 默认值调到多少(第 9.5 节),比如 100。 diff --git a/packaging/container/entrypoint.sh b/packaging/container/entrypoint.sh index e701be1..6fbc417 100644 --- a/packaging/container/entrypoint.sh +++ b/packaging/container/entrypoint.sh @@ -13,7 +13,13 @@ if [ ! -f "$identity/node.json" ]; then echo "first start: set MOSHIRO_SERVER, MOSHIRO_JOIN and MOSHIRO_NAME (the panel shows the complete command)" >&2 exit 1 fi - /usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" + if [ -n "${MOSHIRO_GENESIS:-}" ]; then + # 命令里带着信任根指纹和属主(公开值):先验证面板的操作员集合,并把它们固定在本地 + /usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" \ + --genesis "$MOSHIRO_GENESIS" --controllers "${MOSHIRO_CONTROLLERS:-}" + else + /usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" + fi fi if [ ! -f "$config" ]; then diff --git a/packaging/install/install.ps1 b/packaging/install/install.ps1 index b3a662c..ba945c2 100644 --- a/packaging/install/install.ps1 +++ b/packaging/install/install.ps1 @@ -1,8 +1,10 @@ -# MoShiro 节点一键安装(Windows,Agent 和 Probe 共用同一份)。面板在 /install.ps1?code=… 生成这份脚本,把下面四个值填好: -# irm 'https://panel/install.ps1?code=jt_…' | iex -# 镜像:向面板取 CI 签名的发布清单,用内置的发布公钥验签,再按清单里的 digest 拉取。面板还没有签名的清单时,设 $env:MOSHIRO_ALLOW_UNVERIFIED='1' 才会按标签安装。 +# MoShiro 节点一键安装(Windows,Agent 和 Probe 共用同一份,**静态**文件:不替换任何值,所以它的 SHA-256 在一个版本内固定不变)。 +# 面板给的命令先下载这份文件、核对 SHA-256(CI 构建时算出,写进签名的发布清单,也公布在发布页),一致才执行。 +# 必需的环境变量:MOSHIRO_SERVER(面板地址)、MOSHIRO_ROLE(agent 或 probe)、MOSHIRO_CODE(一次性的加入码)。 # 做的事:用 Docker Desktop 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取计算机名。 -# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)。 +# 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_GENESIS / MOSHIRO_CONTROLLERS(命令里带着:信任根指纹和这个节点的属主 passkey 凭证 id,都是公开值)、 +# MOSHIRO_ALLOW_UNVERIFIED=1 加 MOSHIRO_IMAGE=镜像(面板还没有已签名的发布清单时,不验签、按这个镜像安装;默认拒绝)。 +# 镜像:向面板取 CI 签名的发布清单,用内置的发布公钥验签,再按清单里的 digest 拉取。 function Test-MoShiroSignature([byte[]]$data, [byte[]]$derSignature, [string]$pem) { # 发布公钥是 PEM 的 SPKI(P-256):最后 64 字节是 X、Y;签名是 DER,.NET 要 r||s $b64 = ($pem -split "`n" | Where-Object { $_ -notmatch '^-----' }) -join '' @@ -29,14 +31,22 @@ function Test-MoShiroSignature([byte[]]$data, [byte[]]$derSignature, [string]$pe function Install-MoShiro { $ErrorActionPreference = 'Stop' - $role = '@@ROLE@@' - $server = '@@SERVER@@' - $code = '@@CODE@@' - $image = '@@IMAGE@@' + $role = $env:MOSHIRO_ROLE + $server = $env:MOSHIRO_SERVER + $code = $env:MOSHIRO_CODE + $image = $env:MOSHIRO_IMAGE $releaseKey = @' -@@RELEASE_KEY@@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfw7EkNcl0FZqOETK+Vt55RMZ9K0q +YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA== +-----END PUBLIC KEY----- '@ + # 这些值来自命令行环境:只允许正常的字符,因为它们会进到 URL 和容器参数里 + if ($role -notin @('agent', 'probe')) { Write-Error 'MOSHIRO_ROLE must be agent or probe'; return } + if ($server -notmatch '^https?://[A-Za-z0-9._:/@~%+\[\]-]+$') { Write-Error 'MOSHIRO_SERVER must be the panel address (http:// or https://)'; return } + if ($code -notmatch '^[A-Za-z0-9._~%+-]+$') { Write-Error 'MOSHIRO_CODE is required (copy the whole command from the panel)'; return } + $name = if ($env:MOSHIRO_NAME) { $env:MOSHIRO_NAME } else { $env:COMPUTERNAME } $name = $name -replace '[^A-Za-z0-9._-]', '-' if ($name.Length -gt 64) { $name = $name.Substring(0, 64) } @@ -72,8 +82,8 @@ function Install-MoShiro { $manifest = $null } if ($null -eq $manifest) { - if ($env:MOSHIRO_ALLOW_UNVERIFIED -ne '1') { - Write-Error "the panel has no signed release for $role yet, so the image cannot be verified. Run again with `$env:MOSHIRO_ALLOW_UNVERIFIED='1' to install $image without verification" + if ($env:MOSHIRO_ALLOW_UNVERIFIED -ne '1' -or -not $image) { + Write-Error "the panel has no signed release for $role yet, so the image cannot be verified. To install anyway set `$env:MOSHIRO_ALLOW_UNVERIFIED='1' and `$env:MOSHIRO_IMAGE" return } Write-Host "warning: installing $image without signature verification" @@ -102,7 +112,13 @@ function Install-MoShiro { '-v', "${volume}:/etc/moshiro") # Docker Desktop 没有宿主网络:Agent 把端口发布出来(面板里的地址需要指向这台机器的公网地址) if ($role -eq 'agent') { $arguments += @('-p', '7443:7443/tcp', '-p', '7443:7443/udp', '-p', '7444:7444/tcp') } - $arguments += @('-e', "MOSHIRO_SERVER=$server", '-e', "MOSHIRO_JOIN=$code", '-e', "MOSHIRO_NAME=$name", $image) + $arguments += @('-e', "MOSHIRO_SERVER=$server", '-e', "MOSHIRO_JOIN=$code", '-e', "MOSHIRO_NAME=$name") + # 命令里带着信任根指纹和属主(公开值):节点在注册时验证面板的操作员集合并把它们固定在本地 + if ($env:MOSHIRO_GENESIS) { + if (($env:MOSHIRO_GENESIS + $env:MOSHIRO_CONTROLLERS) -notmatch '^[A-Za-z0-9_,-]+$') { Write-Error 'MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters'; return } + $arguments += @('-e', "MOSHIRO_GENESIS=$($env:MOSHIRO_GENESIS)", '-e', "MOSHIRO_CONTROLLERS=$($env:MOSHIRO_CONTROLLERS)") + } + $arguments += $image Write-Host "starting $container as '$name'" docker @arguments | Out-Null diff --git a/packaging/install/install.sh b/packaging/install/install.sh index 04d3271..2a5659b 100644 --- a/packaging/install/install.sh +++ b/packaging/install/install.sh @@ -1,21 +1,33 @@ #!/bin/sh -# MoShiro 节点一键安装(Agent 和 Probe 共用同一份)。面板在 /install?code=… 生成这份脚本,把下面四个值填好: -# curl -fsSL 'https://panel/install?code=jt_…' | bash (macOS 用 zsh) +# MoShiro 节点一键安装(Agent 和 Probe 共用同一份,**静态**文件:不替换任何值,所以它的 SHA-256 在一个版本内固定不变)。 +# 面板给的命令先下载这份文件、核对 SHA-256(CI 构建时算出,写进签名的发布清单,也公布在发布页),一致才执行: +# f=$(mktemp) && curl -fsSL 'https://panel/install' -o "$f" && echo " $f" | sha256sum -c - && MOSHIRO_SERVER=… MOSHIRO_ROLE=… MOSHIRO_CODE=… sh "$f" +# 必需的环境变量:MOSHIRO_SERVER(面板地址)、MOSHIRO_ROLE(agent 或 probe)、MOSHIRO_CODE(一次性的加入码)。 # 做的事:用 Docker 或 Podman 起一个容器,身份和配置存在命名卷里(重启、换新镜像重建都不用再给参数),节点名字默认取主机名。 # 可选环境变量:MOSHIRO_NAME=节点名字(名字被占用时用)、MOSHIRO_ENGINE=docker|podman、MOSHIRO_DRY_RUN=1(只打印命令)、 -# MOSHIRO_ALLOW_UNVERIFIED=1(面板还没有已签名的发布清单时,不验签、按标签安装;默认拒绝)。 +# MOSHIRO_GENESIS / MOSHIRO_CONTROLLERS(命令里带着:你的信任根指纹和这个节点的属主 passkey 凭证 id,节点在注册时验证面板的操作员集合并把它们固定在本地;都是公开值)、 +# MOSHIRO_ALLOW_UNVERIFIED=1 加 MOSHIRO_IMAGE=镜像(面板还没有已签名的发布清单时,不验签、按这个镜像安装;默认拒绝)。 # 镜像:向面板取 CI 签名的发布清单,用下面内置的发布公钥验签,再按清单里的 digest 拉取(镜像按内容寻址,换不了)。 set -eu -ROLE='@@ROLE@@' -SERVER='@@SERVER@@' -CODE='@@CODE@@' -IMAGE='@@IMAGE@@' -RELEASE_KEY='@@RELEASE_KEY@@' +SERVER="${MOSHIRO_SERVER:-}" +ROLE="${MOSHIRO_ROLE:-}" +CODE="${MOSHIRO_CODE:-}" +IMAGE="${MOSHIRO_IMAGE:-}" +RELEASE_KEY='-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfw7EkNcl0FZqOETK+Vt55RMZ9K0q +YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA== +-----END PUBLIC KEY-----' say() { printf '%s\n' "$*"; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } +# 这些值来自命令行环境:只允许正常的字符,因为它们会进到 URL 和容器参数里 +case "$ROLE" in agent|probe) ;; *) die "MOSHIRO_ROLE must be agent or probe" ;; esac +case "$SERVER" in http://*|https://*) ;; *) die "MOSHIRO_SERVER must be the panel address (http:// or https://)" ;; esac +case "$SERVER$CODE" in *[!A-Za-z0-9._:/@~%+\[\]-]*|'') die "MOSHIRO_SERVER or MOSHIRO_CODE has unexpected characters" ;; esac +[ -n "$CODE" ] || die "MOSHIRO_CODE is required (copy the whole command from the panel)" + name="${MOSHIRO_NAME:-$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo node)}" name="$(printf '%s' "$name" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-64)" [ -n "$name" ] || name=node @@ -51,14 +63,14 @@ fi # 验发布清单:签名对不上、组件不对、digest 格式不对都直接拒绝,不装 resolve_image() { - if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then return 0; fi + if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then IMAGE="${IMAGE:-}"; return 0; fi tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT have_manifest=1 curl -fsS "$SERVER/install/manifest?code=$CODE" -o "$tmp/manifest" 2>/dev/null /dev/null " say "warning: installing $IMAGE without signature verification" return 0 fi @@ -96,7 +108,12 @@ elif [ "$ROLE" = agent ]; then # macOS 上的 Docker 没有宿主网络:把 Agent 的端口发布出来(面板里的地址需要指向这台机器的公网地址) set -- "$@" -p 7443:7443/tcp -p 7443:7443/udp -p 7444:7444/tcp fi -set -- "$@" -e "MOSHIRO_SERVER=$SERVER" -e "MOSHIRO_JOIN=$CODE" -e "MOSHIRO_NAME=$name" "$IMAGE" +set -- "$@" -e "MOSHIRO_SERVER=$SERVER" -e "MOSHIRO_JOIN=$CODE" -e "MOSHIRO_NAME=$name" +if [ -n "${MOSHIRO_GENESIS:-}" ]; then + case "$MOSHIRO_GENESIS${MOSHIRO_CONTROLLERS:-}" in *[!A-Za-z0-9_,-]*) die "MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters" ;; esac + set -- "$@" -e "MOSHIRO_GENESIS=$MOSHIRO_GENESIS" -e "MOSHIRO_CONTROLLERS=${MOSHIRO_CONTROLLERS:-}" +fi +set -- "$@" "$IMAGE" if [ "${MOSHIRO_DRY_RUN:-}" = 1 ]; then say "$engine $*" diff --git a/packaging/update/moshiro-update.sh b/packaging/update/moshiro-update.sh index 3e5940c..218daab 100644 --- a/packaging/update/moshiro-update.sh +++ b/packaging/update/moshiro-update.sh @@ -78,6 +78,7 @@ else chmod 755 "$STATUS_DIR" fi INSTALLED="$STATE_DIR/$COMPONENT.installed" # 两行:seq、digest +ADOPTED="$STATE_DIR/$COMPONENT.key.pem" # 发布密钥换新之后采用的公钥;有它就只认它,配置里的旧公钥不再用 mkdir -p "$STATE_DIR" # Serialize check/apply path events for this component. exec 9>"$STATE_DIR/$COMPONENT.lock" @@ -169,7 +170,18 @@ verify_offer() { [ "$(wc -c < "$OFFER_MANIFEST")" -le 4096 ] || { ERR="manifest too large"; return 1; } sigbin="$WORK/sig.bin" base64 -d "$OFFER_SIG" > "$sigbin" 2>/dev/null || { ERR="bad signature encoding"; return 1; } - openssl dgst -sha256 -verify "$PUBKEY" -signature "$sigbin" "$OFFER_MANIFEST" >/dev/null 2>&1 || { ERR="signature check failed"; return 1; } + key="$PUBKEY" + [ -s "$ADOPTED" ] && key="$ADOPTED" + openssl dgst -sha256 -verify "$key" -signature "$sigbin" "$OFFER_MANIFEST" >/dev/null 2>&1 || { ERR="signature check failed"; return 1; } + # 密钥延续:这份清单由当前密钥签名,里面的 next_key(SPKI DER 的 base64)就是以后要认的密钥;写不成合法的公钥就整份拒绝 + M_NEXT="" + next="$(field next_key)" + if [ -n "$next" ]; then + printf '%s' "$next" | base64 -d > "$WORK/next.der" 2>/dev/null \ + && openssl pkey -pubin -inform DER -in "$WORK/next.der" -outform PEM -out "$WORK/next.pem" >/dev/null 2>&1 \ + && [ -s "$WORK/next.pem" ] || { ERR="next_key is not a public key"; return 1; } + M_NEXT="$WORK/next.pem" + fi [ "$(field component)" = "$COMPONENT" ] || { ERR="manifest is for another component"; return 1; } [ "$(field image)" = "$IMAGE_REPO" ] || { ERR="manifest names another repository"; return 1; } if [ -n "$CHANNEL" ]; then @@ -183,6 +195,14 @@ verify_offer() { [ "$M_SEQ" -gt "$(installed_seq)" ] || { ERR="stale manifest (seq $M_SEQ <= $(installed_seq))"; return 1; } } +# 验过签的清单带了 next_key 就改认它(写临时文件再换名;已经是它就不动) +adopt_next_key() { + [ -n "$M_NEXT" ] || return 0 + if [ -s "$ADOPTED" ] && cmp -s "$M_NEXT" "$ADOPTED"; then return 0; fi + cp "$M_NEXT" "$ADOPTED.new" && mv -f "$ADOPTED.new" "$ADOPTED" + echo "release key replaced by the one in the signed manifest" +} + available_json() { printf '{"version":"%s","tag":"%s","digest":"%s","seq":%s}' "$(printf '%s' "$M_VERSION" | tr -d '\042\134')" "$(printf '%s' "$M_TAG" | tr -d '\042\134')" "$M_DIGEST" "$M_SEQ"; } do_check() { @@ -195,6 +215,7 @@ do_check() { esac return 0 fi + adopt_next_key if [ "$M_DIGEST" = "$(current_digest)" ]; then write_status up_to_date null "" return 0 @@ -215,6 +236,7 @@ do_apply() { fi fetch_offer verify_offer || { write_status invalid null "$ERR"; echo "refusing to apply: $ERR" >&2; return 1; } + adopt_next_key if [ "$COMPONENT" = backend ]; then case "$requested_seq" in ''|*[!0-9]*) write_status failed "$(available_json)" "a confirmed release sequence is required"; return 1 ;; esac [ "$requested_seq" = "$M_SEQ" ] || { write_status available "$(available_json)" "release changed; confirm again"; return 1; } diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index 7bf430a..ffdf817 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -9,6 +9,7 @@ import { useAuth } from './auth' import { BackendUpdateNotice } from './BackendUpdateNotice' import { ElevationBar } from './ElevationBar' import { PasskeyNotice } from './PasskeyNotice' +import { PasskeyOnly } from './PasskeyOnly' import { AgentDetail } from './pages/AgentDetail' import { Agents } from './pages/Agents' import { Probes } from './pages/Probes' @@ -242,6 +243,8 @@ export function App() { ) } if (!me) return + // 密码登录的会话没有任何能力:只给它添加 Passkey 的页面 + if (me.kind === 'session' && me.auth_method === 'password') return return (
diff --git a/web/admin/src/PasskeyOnly.tsx b/web/admin/src/PasskeyOnly.tsx new file mode 100644 index 0000000..d10cb2a --- /dev/null +++ b/web/admin/src/PasskeyOnly.tsx @@ -0,0 +1,61 @@ +import { KeyRound, LogOut } from 'lucide-react' +import { useEffect, useState } from 'react' +import { api } from '@shared/api/client' +import { getBase } from '@shared/api/source' +import type { Page, PasskeyDto } from '@shared/api/types' +import { passkeySupported } from '@shared/api/webauthn' +import { useAuth } from './auth' +import { PasskeyAddForm } from './pages/PasskeyAdd' + +/** + * 用密码登录的会话没有任何管理能力,只能做一件事:添加 Passkey。 + * 之后要用 Passkey 登录才能管理节点、令牌和设置——密码泄露最多只能让人给你的账号加一个 Passkey 的机会(还要你已有的 Passkey 批准第二个)。 + */ +export function PasskeyOnly() { + const { me, logout } = useAuth() + const [count, setCount] = useState(null) + + const load = () => { + api + .get>('/api/admin/auth/passkeys') + .then((list) => setCount(list.items.length)) + .catch(() => setCount(null)) + } + useEffect(load, []) + + const remote = !!getBase() + return ( +
+
+
+

MoShiro

+

+ {me?.username},你是用密码登录的。 +

+
+
用密码登录的会话不能管理任何东西,只能添加 Passkey。添加之后退出,用 Passkey 重新登录。
+ {remote ? ( +
这是另一个后端:Passkey 绑定在后端自己的域名上,请直接打开那个后端的管理端地址再登录。
+ ) : !passkeySupported() ? ( +
这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。
+ ) : count === 0 || count === null ? ( +
+
+ + 添加你的第一个 Passkey +
+ +
+ ) : ( +
你已经有 {count} 个 Passkey。退出后用 Passkey 登录即可。
+ )} +
+ +
+
+
+ ) +} diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index 7246b45..001e880 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -7,6 +7,7 @@ import { fmtAgo, fmtTime } from '@shared/format' import { useQuery } from '@shared/hooks' import { NodeTag, useNodeInfos } from '@shared/nodes' import { Chip, Confirm, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui' +import { OwnerPicker } from './OwnerPicker' import { JoinTokenCreated } from './JoinTokenCreated' export const STATUS: Record = { @@ -33,6 +34,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated const [maxUses, setMaxUses] = useState(1) const [hours, setHours] = useState(24) const [created, setCreated] = useState() + const [controllers, setControllers] = useState(null) if (created) return @@ -57,6 +59,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated auto_approve: autoApprove, max_uses: maxUses, expires_in_hours: hours, + controllers: controllers ?? undefined, }) setCreated(token) onCreated() @@ -69,7 +72,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated } >

- 添加 Agent 就是创建一个一次性的加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。 + 添加 Agent 就是创建一个加入令牌:在目标 VPS 上运行安装命令,Agent 会用它注册并出现在列表里。一个令牌可以装任意多台(可用次数不设上限)。

@@ -83,11 +86,16 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
- setMaxUses(Number(e.target.value))} /> + setMaxUses(Number(e.target.value))} /> setHours(Number(e.target.value))} /> +
+ + + +
) diff --git a/web/admin/src/pages/Trust.tsx b/web/admin/src/pages/Trust.tsx new file mode 100644 index 0000000..ef3401c --- /dev/null +++ b/web/admin/src/pages/Trust.tsx @@ -0,0 +1,110 @@ +import { ShieldCheck } from 'lucide-react' +import { api } from '@shared/api/client' +import type { TrustStateDto } from '@shared/api/types' +import { signStatement } from '@shared/api/webauthn' +import { useQuery } from '@shared/hooks' +import { CopyButton, ErrorNotice, Spinner, useAction, useToast } from '@shared/ui' + +/** + * 信任根:你的 Passkey 是整个系统的根。节点安装时把下面这个指纹和属主(passkey)固定在本地,之后只认从它出发、由你的 Passkey 签过名的 + * 操作员集合,面板自己改不了,也签不出新的版本。 + */ +export function Trust() { + const trust = useQuery(() => api.get('/api/admin/trust'), []) + const { busy, run } = useAction() + const toast = useToast() + const state = trust.data + + const sign = async () => { + if (!state?.proposal) return + const proposal = state.proposal + const ok = await run(async () => { + const signed = await signStatement(proposal.statement) + await api.post('/api/admin/trust', signed) + }) + if (ok) { + toast(state.initialized ? `操作员集合已更新到第 ${proposal.version} 版,会随名册下发给节点。` : '信任根已建立。现在可以添加 Agent 和 Probe 了。', false, { title: '已用 Passkey 签名', ms: 8000 }) + trust.reload() + } + } + + return ( +
+ + {!state ? ( + + ) : !state.initialized ? ( +
+
+ + 还没有建立信任根 +
+

+ 建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。 + 这一步会把账号下已有的 Passkey 登记为操作员,并需要你触碰两次 Passkey(确认身份、给集合签名)。 +

+ {state.proposal ? ( +
+ +
+ ) : ( +
先在「用户 → 我的 Passkey」添加一个 Passkey,才能建立信任根。
+ )} +
+ ) : ( +
+
+ + 信任根已建立 + 操作员集合第 {state.version} 版 +
+
+
+ 创世指纹(安装命令里的 MOSHIRO_GENESIS,节点把它固定下来) +
+
+              {state.genesis}
+            
+ +
+
+
+ 服务器身份密钥(面板用它向节点证明自己;登记进操作员集合之后,节点只认带这把密钥签名的服务器,链路用应用层加密) + {state.server_key_listed ? ':已登记' : ':还没有登记,需要签名更新操作员集合'} +
+
{state.server_key}
+
+
+
+ 安装脚本的 SHA-256(命令会先核对它再执行){state.install_matches_release === true ? ':与 CI 签名的发布清单一致' : state.install_matches_release === false ? ':与 CI 签名的发布清单不一致' : ':发布清单里还没有记录'} +
+ {state.install_matches_release === false ?
这个面板提供的安装脚本和 CI 构建时记下的不一样,先不要用它装节点,去发布页核对。
: null} +
install.sh   {state.install_sh_sha256}
+
install.ps1  {state.install_ps1_sha256}
+
+ {state.in_sync ? ( +

操作员集合和账号下的 Passkey 一致。

+ ) : ( +
+
+ 账号下的 Passkey(或站点域名)和生效的操作员集合不一致,比如新添加或删除了 Passkey。需要用一把现有操作员的 Passkey签一个新版本,节点才会采用。 +
+
+ 注意:从集合里去掉一把 Passkey 之后,由它批准过的节点会被固定了信任根的对端拒绝。 +
+ {state.proposal ? ( +
+ +
+ ) : null} +
+ )} +
+ )} +
+ ) +} diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 4d0fcf7..db73696 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -867,6 +867,24 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/trust": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign */ + get: operations["trust_get"]; + put?: never; + /** Store the next operator set, signed with a passkey (the first one sets the genesis and needs a credential window) */ + post: operations["trust_commit"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/api-tokens": { parameters: { query?: never; @@ -1116,7 +1134,7 @@ export interface paths { path?: never; cookie?: never; }; - /** One-line installer for Linux and macOS nodes (agent or probe, decided by the code) */ + /** The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release) */ get: operations["install_sh"]; put?: never; post?: never; @@ -1126,6 +1144,40 @@ export interface paths { patch?: never; trace?: never; }; + "/install/manifest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The signed release manifest of the component an install code is for (exact signed bytes) */ + get: operations["install_manifest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/install/manifest.sig": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Base64 DER ECDSA P-256 signature of the manifest */ + get: operations["install_manifest_signature"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/install.ps1": { parameters: { query?: never; @@ -1133,7 +1185,7 @@ export interface paths { path?: never; cookie?: never; }; - /** One-line installer for Windows nodes (agent or probe, decided by the code) */ + /** The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release) */ get: operations["install_ps1"]; put?: never; post?: never; @@ -1160,6 +1212,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/agent/v1/enroll/trust": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** The operator set chain and the grant of a join token, for the node to verify before it enrolls */ + post: operations["fleet_enroll_trust"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1349,6 +1418,7 @@ export interface components { /** Format: int32 */ expires_in_hours: null | number | string; role?: null | string; + controllers?: null | string[]; }; CreateTaskRequest: { scope: string; @@ -1619,6 +1689,7 @@ export interface components { user_id: null | string; username: null | string; csrf_token: null | string; + auth_method?: null | string; }; MetricDto: { id: string; @@ -1999,6 +2070,13 @@ export interface components { default: string; description: string; }; + SignedStatementDto: { + statement?: string; + credential_id?: string; + authenticator_data?: string; + client_data_json?: string; + signature?: string; + }; StartTestRequestDto: { plan: string; params: null | Record; @@ -2085,6 +2163,32 @@ export interface components { /** Format: date-time */ finished_at: unknown; }; + TrustProposalDto: { + statement: string; + /** Format: int32 */ + version: number | string; + challenge: string; + }; + TrustStateDto: { + initialized: boolean; + genesis: null | string; + /** Format: int32 */ + version: number | string; + in_sync: boolean; + proposal: null | components["schemas"]["TrustProposalDto"]; + /** @default */ + install_url: string; + operators?: null | string[]; + /** @default */ + install_sh_sha256: string; + /** @default */ + install_ps1_sha256: string; + install_matches_release?: null | boolean; + /** @default */ + server_key: string; + /** @default false */ + server_key_listed: boolean; + }; UserDto: { id: string; username: string; @@ -5103,6 +5207,104 @@ export interface operations { }; }; }; + trust_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrustStateDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + trust_commit: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SignedStatementDto"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TrustStateDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; api_tokens_list: { parameters: { query?: never; @@ -5899,6 +6101,53 @@ export interface operations { }; }; install_sh: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + install_manifest: { + parameters: { + query?: { + code?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDto"]; + }; + }; + }; + }; + install_manifest_signature: { parameters: { query?: { code?: string; @@ -5929,9 +6178,7 @@ export interface operations { }; install_ps1: { parameters: { - query?: { - code?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -5945,15 +6192,6 @@ export interface operations { }; content?: never; }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDto"]; - }; - }; }; }; fleet_enroll: { @@ -5974,4 +6212,22 @@ export interface operations { }; }; }; + fleet_enroll_trust: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index d1059b3..27d44c7 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -42,6 +42,8 @@ export type CreatedApiTokenDto = Narrow export type CapabilityCatalogDto = Narrow export type PasskeyDto = Narrow export type ElevationDto = Narrow +export type TrustStateDto = Narrow +export type SignedStatementDto = Narrow export type PasskeyCreationOptionsDto = Narrow export type PasskeyRequestOptionsDto = Narrow export type MeDto = Narrow diff --git a/web/shared/api/webauthn.ts b/web/shared/api/webauthn.ts index 551403e..1c1e591 100644 --- a/web/shared/api/webauthn.ts +++ b/web/shared/api/webauthn.ts @@ -1,5 +1,5 @@ import { api } from './client' -import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } from './types' +import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto, SignedStatementDto } from './types' /** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */ export async function conditionalSupported(): Promise { @@ -58,6 +58,30 @@ export async function signInWithPasskey(opts: { conditional?: boolean; signal?: }) } +/** + * 用 passkey 对一段文本签名(挑战是文本的 SHA-256):操作员集合、加入码的委托都是这样签出来的,节点自己验。 + * 浏览器会让用户从这个站点的 passkey 里选一个;面板再检查它是不是操作员集合里的凭证。 + */ +export async function signStatement(statement: string): Promise { + const challenge = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(statement))) + let credential: PublicKeyCredential | null + try { + credential = (await navigator.credentials.get({ publicKey: { challenge, userVerification: 'required', timeout: 120_000 } })) as PublicKeyCredential | null + } catch (e) { + if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) throw new Error('已取消') + throw e + } + if (!credential) throw new Error('没有选择 passkey') + const response = credential.response as AuthenticatorAssertionResponse + return { + statement, + credential_id: credential.id, + authenticator_data: toText(response.authenticatorData), + client_data_json: toText(response.clientDataJSON), + signature: toText(response.signature), + } +} + /** 提权窗口的种类:加节点、吊销和删除、改凭证(用一次就没有)。 */ export type ElevationClass = 'enroll' | 'revoke' | 'credential' diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css index 1fd3f3c..1c51d2f 100644 --- a/web/shared/theme/base.css +++ b/web/shared/theme/base.css @@ -478,6 +478,67 @@ textarea { margin-bottom: 16px; } +/* 宽对话框:标题和按钮固定,中间滚动(内容很长的表单,比如选能力) */ +.dialog.wide { + width: min(980px, 100%); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dialog.wide .dialog-body { + flex: 1; + min-height: 0; + overflow: auto; + margin: 0 -8px; + padding: 0 8px; +} + +.dialog.wide .actions { + margin-top: 16px; +} + +/* 能力选择器:工具栏吸顶,分组排成多列,每项两行(名称,代码和说明) */ +.cap-toolbar { + position: sticky; + top: 0; + z-index: 2; + background: var(--app-card); + padding-bottom: 10px; +} + +.cap-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 12px; + align-items: start; +} + +.cap-group { + border: 1px solid var(--app-outline-soft); + border-radius: 16px; + padding: 12px 14px; +} + +.cap-row { + padding: 5px 0 5px 22px; + border-top: 1px solid var(--app-outline-soft); +} + +.cap-row:first-of-type { + border-top: 0; +} + +.cap-meta { + margin: 1px 0 0 26px; + font-size: 12px; + line-height: 1.45; +} + +.cap-meta .mono { + margin-right: 6px; +} + .dialog .actions { display: flex; justify-content: flex-end; diff --git a/web/shared/ui/index.tsx b/web/shared/ui/index.tsx index d28f984..a7ca5e1 100644 --- a/web/shared/ui/index.tsx +++ b/web/shared/ui/index.tsx @@ -57,7 +57,7 @@ export function Field({ label, hint, children }: { label: string; hint?: string; ) } -export function Dialog({ title, onClose, children, actions }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode }) { +export function Dialog({ title, onClose, children, actions, wide }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode; wide?: boolean }) { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() @@ -69,9 +69,9 @@ export function Dialog({ title, onClose, children, actions }: { title: string; o // 它会以那个祖先为定位基准,被挤到顶栏里、一半在屏幕外 return createPortal(
e.target === e.currentTarget && onClose()}> -
+

{title}

- {children} + {wide ?
{children}
: children} {actions ?
{actions}
: null}
,