Files
MoShiro/docs/security/access.md
T
justme c9beeee8e3 docs: describe the unified Agent topology and link controls
Align architecture, protocol, API, storage, security and deployment documentation with default mutual measurements and the Agent link board.
2026-10-21 11:55:55 +08:00

209 lines
24 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) 的展开:security.md 讲缺口和威胁,这里讲现在怎么做。以已实现的为主,没做的在最后一节列出。
概要:登录和授权分开。登录证明「你是谁」,授权只看能力;改变信任(操作员集合)只认 passkey 的签名,并且这个签名由节点自己验证,Backend 只搬运,伪造不了。
## 1. 总览
```text
两个根,各管一摊
根 A:CI(代码) 发布公钥写在安装脚本、宿主机更新器的本机配置和 Backend 里
└─ 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 只用在节点自己会验证的事上(现在是操作员集合的签名,以后是 extension 会话和加密数据)。
Backend 自己拦自己没有意义,所以没有提权窗口
```
没有采用「成员证明」(每个节点一条 Grant、Ticket、Binding 的入网链):被攻破的 Backend 往名册里塞节点,拿不到比 Backend 已经有的更多的东西(监测数据本来就是明文给 Backend 的;敏感的 extension 靠节点属主,不靠名册),成本却是每个节点一次触碰和一套证明格式。操作员集合和创世固定已经在,以后要叠加不用重装节点。
发布密钥可以换:见 [deployment.md](../ops/deployment.md) 的「换发布密钥」;回滚由清单的 `seq` 防住(更新器只收比已安装更大的)。面板信任哪些发布密钥(官方的,加上可选的自有公钥)在管理端「系统 → 信任 → 可信资产来源」。
## 2. 名词
| 名词 | 含义 |
| --- | --- |
| 账户 | 一个人,有用户名和密码,可以注册 passkey |
| 能力(capability) | 一项具体权限,如 `fleet.write`。端点声明自己需要哪项能力 |
| 会话 | 登录得到的状态,拿到全部能力(见第 3 节) |
| 操作员集合 | 节点固定的、带版本的 passkey 公钥列表,加上服务器密钥和域名,由操作员 passkey 签名 |
| API key | 一串随机令牌,只带创建时勾选的能力;明文只在创建时显示一次 |
| 属主(controllers) | 一个节点绑定的一组 passkey:只有它们能授权这个节点上的 extension(web ssh 等)。管理员没有这份权力,见第 9 节 |
## 3. 能力
| 组 | 能力 | 说明 | 信任类 |
| --- | --- | --- | --- |
| 展示 | `public.read` | 看隐藏线路;站点私有时看展示页 | |
| 节点 | `fleet.read` | 看 Agent、线路、加入代码 | |
| | `fleet.write` | 改名、标签、可见性、手填地址、线路开关、断开会话 | |
| | `fleet.enroll` | 生成加入代码 | |
| | `fleet.revoke` | 吊销或删除节点 | 是 |
| 任务 | `tasks.read` · `tasks.write` | 任务模板、覆盖、发起测试 | |
| 数据 | `data.read` · `data.write` | 原始块;清理 | |
| 设置 | `settings.read` · `settings.write` | 站点设置、保留策略、重启计划 | |
| 更新 | `update.read` · `update.confirm` | 看 Backend 更新和发布清单、确认更新、管理可信的发布密钥 | |
| 令牌 | `keys.read` · `keys.manage` | 列出、创建、撤销 API key | |
| 账户 | `accounts.read` · `accounts.manage` | 账户 | 管理是 |
| 运维 | `events.read` · `audit.read` · `ops.metrics` | 事件;审计日志;`/metrics` | |
| 集成(预留) | `data.export` · `webhooks.read` · `webhooks.manage` | 批量拉数据、外部回调订阅,见第 6.4 节 | |
- 一个能力只在一个地方声明:`Capability` 常量(`MoShiro.Backend.Application`),端点用 `.Requires(Capability.X)`;`Principal.Has(capability)` 是唯一的判断入口,会话和 API key 都走它。写蕴含读(`fleet.write` 带 `fleet.read`)。
- 对账测试:所有 `/api/admin` 端点必须声明能力,没声明测试就失败;OpenAPI 的每个操作带 `x-moshiro-capability`。
- 信任类是界面上特别标出的(吊销、账户管理),授权和其他能力一样靠 API key 或会话。passkey 签名保护的是操作员集合,由节点自己验证。
- 不能越权:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集。
- Backend 更新确认:更新清单由 CI 的发布密钥签名(面板登记时验签),`seq` 防重放;确认只是让 Backend 有序退出并备份数据库,拉哪个镜像由 Quadlet 单元里写死的仓库和标签决定,面板决定不了。
## 4. 登录与会话
| 登录方式 | 说明 |
| --- | --- |
| 密码 | PBKDF2;认领向导创建第一个管理员时设置 |
| passkey | WebAuthn,只支持 ES256,attestation 一律当 `none`;可以登录,也能给操作员集合签名(节点验证) |
- 两种方式的会话拿同样的能力。passkey 只用在节点自己会验证的事上,见第 5 节。
- 认领向导(没有默认账户):面板里还没有任何用户时,打开 `/admin/` 进向导——用后端日志里的认领码(或 `MoShiro__ClaimCode`)创建第一个管理员(用户名和密码自己填),注册第一个 passkey,再用它给操作员集合签名建立信任根。谁先打开页面谁就能当管理员,所以要认领码;创建完码作废、入口关闭(`POST /api/admin/auth/setup` 之后返回 409)。后两步是引导不是门槛,可以稍后补;不注册 passkey、不建立信任根,就添加不了 Agent,只能进面板做基础管理,这是架构如此。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认。
- 建议每个账户至少注册两个 passkey(手机加硬件密钥)。
### 4.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,节点验证操作员集合也用它);注册里的 CBOR 和 attestation 解析在 Backend(`WebAuthnRegistration`),节点不带 CBOR。
- 依赖方(RP):`MoShiro__WebAuthnRpId`(缺省取 `PublicUrl` 的主机,没有就取请求的主机)和 `MoShiro__WebAuthnOrigins`(缺省取 `PublicUrl` 的来源)。passkey 绑定在这个域名上,改了会让已注册的 passkey 失效。只有和后端同站的页面能用 passkey 登录,管理端在「添加其他后端」(跨域)时不显示这个入口。
- 会话记录登录方式(`password` 或 `passkey`),只用于审计和显示;API key 不是用户,不能注册 passkey。
- 测试:`SoftwarePasskey` 在测试里模拟认证器走完整的注册和登录,覆盖错误的挑战、来源、RP、签名、缺少 UV、挑战重放、计数器回退、只能管理自己的 passkey。
## 5. passkey 用在哪里(没有提权窗口)
原则:passkey 只用在节点或密钥持有者自己验证、解密的场合。Backend 自己做的拦截在 Backend 被攻破时一起失效,所以不用 passkey 拦操作。
| 事 | 谁验证 | 要 passkey |
| --- | --- | --- |
| 建立、更新操作员集合(信任根) | 节点验链 | 是(现在唯一做了的) |
| extension 会话(web ssh 等) | 节点验 passkey 签名的临时密钥 | 是(还没做) |
| 读加密存储的数据 | 私钥持有者 | 是(还没做) |
| 吊销或删除节点、账号、API key、加入码、设置 | 只有 Backend | 否,只看能力 |
- 这些「只有 Backend」的事靠能力限权:API key 只带需要的能力。密码泄露的后果是这些能力被滥用,改不了信任根,也用不了以后的 extension。
- 唯一的风险是往账号里塞一把 passkey 再等人签名:信任页的提案会列出新增和移除的凭证,签名之前要看一眼是不是自己加的;签了才算授权。
## 6. API key
- 创建时勾选能力,只能是创建者当前拥有能力的子集;可设有效期。明文只在创建时显示一次,列表显示能力数、最后使用时间、过期时间。预设:只读、运维、接入(`enroll`)、全部。
- 创建界面的能力用分组复选框:全选(带半选状态)、组内全选、反选、搜索;预设按钮一键套用。
- 列表(API key、加入代码、节点、审计日志)都支持多选和全选做批量操作。
### 6.1 `enroll` key:自动化添加节点
它就是一把带 `fleet.enroll` 的普通 API key,用 `POST /api/admin/join-tokens` 创建加入码,响应里直接给出安装命令(带创世指纹、属主和脚本哈希,没有任何私钥)。创建码不是信任动作,所以不需要 passkey;CI 和脚本照常工作。信任在两处:命令里的创世指纹和属主由面板生成,节点注册时固定;以后改属主和集合要操作员 passkey 签名。
### 6.2 CI 登记发布清单不需要令牌
CI 把签名清单 POST 给 `POST /api/release/offer`,面板用发布公钥验签,没有令牌,所以清库、重装、还没有 passkey 时照常工作,也没有长期令牌可偷。旧清单重放由 `seq` 递增挡住。
### 6.3 预留:外部 webhook 回调与数据拉取
后面要让外部系统能订阅事件、触发回调、拉数据。接口和权限先留(`data.export`、`webhooks.*` 标预留),实现放后面;全部发生在 Backend 里,节点零开销。订阅只能订阅创建者自己读得到的事件;投递是带 HMAC 签名的 `POST`,至少一次、指数退避;目标 URL 要做 SSRF 限制(只放行公网地址,不跟随重定向到内网,有超时和大小上限)。这些都属于 Backend 的集成配置,不改变节点信任,但每次增删订阅都进审计。
## 7. 归属与审计
- 加入代码记 `created_by`,节点记 `added_by`;管理页的 Agent 详情显示「由 xxx 于 xxx 添加」。
- 审计日志(`GET /api/admin/audit`):所有非 GET 的管理操作和登录自动记录(时间、账户、登录方式、动作、对象、详情)。写不进去只记日志,不让已完成的操作报错。保留 365 天,只追加。
## 8. 入网信任链的实现要点
- 语句是 `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 不收(否则固定了它的节点就认不出这个面板了)。
- 弱点:创世指纹放在安装命令里,命令本身要从可信渠道拿到(管理端复制)。缓解见第 10 节(静态签名脚本、按 digest 拉镜像),并在信任页展示指纹供核对。
## 9. 节点属主与 extension 授权
extension(web ssh、文件、抓包,见 [security.md](security.md))现在都不实现,Agent 一样。这类能力连管理员都不信任:管理员能删,不能读,也不能用。所以入网时就给每个节点固定「属主」,之后不用重装节点。
已经实现的部分
- 属主 `controllers` 是一组 passkey 凭证 id,必须是操作员集合里的凭证。由安装命令带来,节点注册时写进 `trust.json`,Backend 之后改不了。
- 每个 Agent 都必须有属主,至少一把。这由节点自己强制:节点启动时检查本地固定的属主,没有(或者属主的 passkey 已经不在操作员集合里)就不运行(共用 `Fleet.Hosting` 里的 `OwnerGuard`;配置 `fleet.require_owner`,`MOSHIRO_ALLOW_NO_OWNER=1` 放行)。面板被攻破也改不了。
- 添加节点时选属主:`POST /api/admin/join-tokens` 的 `controllers: [credential_id…]`,不给就是操作员集合里的全部凭证;给了就必须非空且都在集合里。还没有信任根或一把属主都没有时,面板提前拒绝创建加入码(400,字段 `controllers`),这只是尽早失败,真正的强制在节点;`MoShiro__RequireOwner=false` 可以关掉这个提前检查(只给没有信任根的老部署)。界面里是必选的「属主(passkey)」多选列表,只是选,不用触碰。
- 安装端也强制:`install.sh`、`install.ps1`、容器入口缺了 `MOSHIRO_GENESIS` 或 `MOSHIRO_CONTROLLERS` 就不装;`fleet-enroll --genesis` 不带 `--controllers` 也拒绝。
- 逆推出绑定关系:节点握手时把本地固定的属主(passkey 凭证 id)报给面板(hello 的 `owners`),面板顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上,显示在「系统 → 版本」的「属主」列(对不上任何已注册 passkey 的显示「未知凭证」)。上报只是自述,真正的授权由节点自己校验 passkey 签名。
预留的设计(还没做)
- 谁来强制:节点本地。web ssh 这类会话要不要加密、要不要验证,由 Agent 自己要求。让 Backend 决定,被攻破的 Backend 一句「这次不用加密」就绕过去了。节点本地强制:没有操作员的现场证明不启动会话;会话密钥只和证明里的那把密钥协商,不和 Backend 转来的公钥协商;加密字段缺失就拒绝,不降级;能开哪些 extension、哪些 passkey 能授权,只看本节点属主。
- 证明的方向是反的:节点先出题(nonce 加它的临时公钥,用节点身份密钥签名),属主的浏览器把 nonce 和自己的临时公钥放进 passkey 断言的挑战里;节点验断言来自本节点属主、要求用户验证、nonce 是自己刚出的且没用过。Backend 仍然管谁有资格发起请求、审计、限流、转发密文。
- 交互式会话(终端、ssh)用现场签名的临时 ECDH 密钥协商,passkey 只签名授权,不参与解密,所以不支持 PRF 的 passkey 也能用,还有前向保密;之后双向是带序号的 AEAD。静态数据(抓包文件、会话录像)需要事后解密,才用属主记录里的接收者公钥加密(来自 WebAuthn PRF 或浏览器里不可导出的密钥)。
- 删除和使用分开:admin 能删节点、卸载 extension,读不到也用不了。改属主要现有属主的 passkey 签名(`ControllerUpdate`),账户被删、角色被改都改不了某个节点的属主。属主是 passkey 凭证,不是账户;一个人丢了全部属主 passkey,这个节点上的 extension 就没人能用了,所以添加时建议绑两把以上。
- 数据边界:监测数据(延迟、丢包、吞吐、事件、主机资源汇总)是明文,Backend 要做分析和展示;extension 会话和原始 payload 端到端加密,Backend 只存转发密文。读取权由私钥决定。
## 10. 安装的供应链
一行安装要执行 Backend 吐出的脚本、拉仓库里的镜像。Backend 或仓库被攻破,就能在新节点上执行任意命令。收束办法,优先级最高的是第一跳的哈希核对:
① 脚本是静态产物,哈希固定
- 不在服务端做字符串替换。CI 构建时算出 `install.sh`、`install.ps1` 的 SHA-256,写进发布清单(发布密钥签名),同时公布在 Gitea 发布页——这条渠道独立于 Backend,这是哈希核对有意义的前提:哈希若和脚本从同一处来,攻击者两个一起换就行。
- 参数(代码、面板地址)通过命令行环境变量传入,代码因此不出现在 URL 和反向代理日志里。
- 管理端给的命令把「下载、核对、执行」合成一行,哈希核对不通过就不执行:
```text
f=$(mktemp) && curl -fsSL https://panel/install -o "$f" && echo "<sha256> $f" | sha256sum -c - && MOSHIRO_SERVER=… MOSHIRO_ROLE=… MOSHIRO_CODE=… sh "$f"
```
macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就 `throw`。命令里的 `<sha256>` 由 Backend 填入,信任页同时标出它与 CI 签名的发布清单是否一致;严格的人对照 Gitea 发布页一次即可(脚本是静态的,哈希在一个版本内不变)。
② 镜像按 digest 拉取
- 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用脚本里内置的发布公钥验签,再按清单里的 digest 拉镜像(`repo@sha256:…`)。Backend 改不了 digest,最多让节点装到一个旧的合法版本。
- 下载压缩包安装的人,用 Release 里签名的 `SHA256SUMS` 核对(见 [deployment.md](../ops/deployment.md))。
③ 容器安装:Podman 系用 Quadlet(默认),Docker 用 compose
- 管理端的容器安装是:建目录,把参数写进本地 `.env`(面板地址、一次性码、创世指纹、属主,都是公开的值),下载面板提供的静态 compose 文件、核对 SHA-256,`compose up -d`。compose 文件是静态的(和安装脚本一样嵌在 Backend 里,也作为 Release 资产发布),镜像是 `:latest` 或 `.env` 里固定的版本,镜像地址由 CI 渠道决定,不由面板决定,面板被攻破也改不了;Backend 第一次部署时没有面板,就从 Release 资产下载。
- 代价:客户端不再验发布签名,更新(`compose pull && compose up -d`)靠 TLS 加「注册表可信」这个假设;想要更严,在 `.env` 里把 `MOSHIRO_VERSION` 固定到 digest,或者用宿主机更新器(清单有发布签名,不健康自动回滚)。详见 [deployment.md](../ops/deployment.md)。
④ 其余
- 角色(Agent 还是 拨号方)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。
- macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要证书,目前没有做。
## 11. 运行中的二进制不做校验
曾经做过「节点二进制绑定」:节点握手时自报正在运行的二进制的 SHA-256,Backend 在第一次握手时绑定,之后对不上就标红或拒绝。已经移除。
原因:哈希和清单都是节点自己报的。一个被换进来、故意撒谎的二进制可以照抄原来的哈希和清单,Backend 分辨不出——上报者就是被怀疑的对象。这一层只能抓到「文件被换了、但程序本身没撒谎」的情形,却让人误以为有保障,还要求运维宿主机更新器、状态卷和每个节点的清单。得不偿失。
「这个资产是我们的」靠安装时的来源证明,那里的哈希来自另一个信道,伪造不了:
- 安装脚本(SHA-256 记在签名的清单里,并公布在发布页),按清单里的 digest 拉镜像(`repo@sha256:…`);
- 下载压缩包的人用签名的 `SHA256SUMS` 核对(验签用 `packaging/update/release-key.pub.pem`);
- 节点的更新由宿主机更新器用本机公钥验签,只认本机配置的镜像仓库。
要让运行中的二进制真的无法伪造,必须有容器之外的东西背书:宿主机更新器持有一把节点进程读不到的密钥,对「当前运行的镜像 digest + Backend 的挑战」签名,由节点转发;容器里的进程被换掉也伪造不了,除非攻破宿主机。这一步没有做(一键容器安装的节点也没有宿主机更新器)。
## 12. 还没有的与局限
- 还没有:吊销表;任务和配置内容的操作员签名;节点向别人证明自己运行的就是官方二进制(见第 11 节);extension 本身、属主的 `ControllerUpdate`、接收者的实际加密(属主只是被固定在节点本地,没有行为);API key 的 IP 白名单(本部署经 Cloudflare、frp 和 Caddy,后端看到的来源地址是代理,需要先有「可信代理」配置才做得对);OIDC 登录。
- WebAuthn 只验 ES256 和 attestation = `none`,不验设备证明。
- 某个节点的属主 passkey 全部丢失,这个节点上的 extension 就没人能授权,需要重装节点并换属主。
- 删除一把操作员 passkey 只是让它不能再签新版本;服务器密钥换新要靠签一个新集合版本,忘了签会让固定了集合的节点认不出面板(fail closed)。
- `enroll` key 是持有者凭证,谁拿到令牌,谁就能在它的有效期内创建加入码,必须按密码对待。