Files
MoShiro/docs/access.md
T
justme a19e6548d3 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.
2026-10-07 22:51:44 +08:00

413 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 访问控制与入网信任链
这份文档定义**谁能做什么**(账户、角色、能力、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,节点不用重装。
### 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. 提权窗口(信任类能力各管各的)
| 窗口 | 覆盖 | 默认时长 | 行为 |
| --- | --- | --- | --- |
| 接入 | `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 路径)。
### 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 还没做,字段也先固定下来,之后不用重装节点。
```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 创建界面(预设、全选、能力子集校验)。不改节点。**已实现**(破坏性:旧令牌作废,数据库迁移 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. **操作员集合与创世公钥固定**(落地切片见第 7.5 节):`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` 的 SHA-256,写进发布清单(发布密钥签名),同时公布在 Gitea 发布页——**这条渠道独立于 Backend**,这是哈希核对有意义的前提:哈希若和脚本从同一处来,攻击者两个一起换就行。
- 参数(代码、面板地址)通过命令行传入,代码也因此不再出现在 URL 和反向代理日志里。
- 管理端给的命令把「下载、核对、执行」合成一行,哈希核对不通过就不执行:
```text
curl -fsSL https://panel/install -o /tmp/moshiro-install.sh && echo "<sha256> /tmp/moshiro-install.sh" | sha256sum -c - && sh /tmp/moshiro-install.sh '<code>'
```
macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就 `throw`。命令里的 `<sha256>` 由 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)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。
- Windows 版同理,另外可以加 Authenticode 签名。
- 今天的现状:一行安装不装更新器,也不带发布公钥,节点自己不验任何签名;这一节是要补的缺口。
## 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 和数据拉取的配额默认值(每分钟请求数、每天行数)和事件清单。