docs: access control and enrollment trust chain design [CI SKIP]

This commit is contained in:
justme committed 2026-10-07 20:45:23 +08:00
1 parent af2962fc58
commit 55b80f0a97
3 files changed
+383 -41

No files matched your search

+357
View File
@@ -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=<cursor>`),适合外部系统轮询。
- `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=<HMAC(secret, 时间戳 ‖ body)>`、时间戳和投递 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 -- '<code>'`。
- **镜像按 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 和数据拉取的配额默认值(每分钟请求数、每天行数)和事件清单。
+24 -40
View File
@@ -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 节)。