docs: reorganize into design, security, api, ops and dev; drop stale designs; refresh the diagrams [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
- Group docs by category with an index (docs/README.md); fix every link, code comment and the protocol conformance test that read docs/protocol.md. - access.md and security.md rewritten to what is implemented: removed the unadopted member-proof chain, roles and recovery key that do not exist, the old implementation order and the budgets; gaps now carry a status. - enrollment, deployment, api: the install flow is the static, hash-checked script with environment variables; no default admin. - README and architecture: new topology and update-flow diagrams (operator passkey, CI signing, registry feed, auth and trust modules).
This commit is contained in:
1 parent
636a784043
commit
e014a19554
45 files changed
+592
-966
No files matched your search
@@ -0,0 +1,203 @@
|
||||
# 访问控制与入网信任链
|
||||
|
||||
这份文档讲**谁能做什么**(账户、能力、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、Probe、线路、加入代码 | |
|
||||
| | `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` 防回滚,真正的门是发布密钥,不是确认这一步。
|
||||
|
||||
## 4. 登录与会话
|
||||
|
||||
| 登录方式 | 说明 |
|
||||
| --- | --- |
|
||||
| 密码 | PBKDF2;认领向导创建第一个管理员时设置 |
|
||||
| passkey | WebAuthn,只支持 ES256,attestation 一律当 `none`;可以登录,也能给操作员集合签名(节点验证) |
|
||||
|
||||
- 两种方式的会话拿**同样的能力**。passkey 不是 Backend 用来拦操作的一道门,只用在节点自己会验证的事上,见第 5 节。
|
||||
- **认领向导(没有默认账户)**:面板里还没有任何用户时,打开 `/admin/` 进向导——用后端日志里的认领码(或 `MoShiro__ClaimCode`)创建第一个管理员(用户名和密码自己填),注册第一个 passkey,再用它给操作员集合签名建立信任根。谁先打开页面谁就能当管理员,所以要认领码;创建完码作废、入口关闭(`POST /api/admin/auth/setup` 之后返回 409)。后两步是引导不是门槛,可以稍后补;不注册 passkey、不建立信任根,就添加不了 Agent 和 Probe,只能进面板做基础管理,这是架构如此。之后新增的 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 |
|
||||
| --- | --- | --- |
|
||||
| 建立、更新操作员集合(信任根) | **节点**验链 | 是(现在唯一做了的) |
|
||||
| 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、Probe 详情显示「由 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 和 Probe 一样。这类能力连管理员都不信任:管理员能删,不能读,也不能用。所以入网时就给每个节点固定「属主」,之后不用重装节点。
|
||||
|
||||
**已经实现的部分**
|
||||
|
||||
- 属主 `controllers` 是一组 passkey 凭证 id,必须是操作员集合里的凭证。由安装命令带来,节点注册时写进 `trust.json`,Backend 之后改不了。
|
||||
- **每个 Agent 和 Probe 都必须有属主,至少一把——而且这是节点自己的要求,不是 Backend 的要求**:节点启动时检查本地固定的属主,没有(或者属主的 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 签名。
|
||||
|
||||
**预留的设计(还没做)**
|
||||
|
||||
- **谁来强制:节点本地,不是 Backend。** web ssh 这类会话要不要加密、要不要验证,由 Agent 和 Probe 自己要求。让 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))。
|
||||
|
||||
**③ 其余**
|
||||
|
||||
- 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。
|
||||
- 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 是持有者凭证,谁拿到令牌,谁就能在它的有效期内创建加入码,必须按密码对待。
|
||||
@@ -0,0 +1,108 @@
|
||||
# MoShiro 的注册与身份
|
||||
|
||||
通用的注册、身份、认证机制由 Fleet 提供,见 [../backend/fleet/README.md](../../backend/fleet/README.md) 的「身份」「注册」两节。本文只描述 **MoShiro 在 Fleet 之上的约定**:Agent 注册后如何自动接入测量、Probe 的身份以及 Agent 列表同步,以及节点本地的硬上限。
|
||||
|
||||
## 1. 身份一览
|
||||
|
||||
| 主体 | 凭据 | 生成方 | Backend 保存 |
|
||||
| --- | --- | --- | --- |
|
||||
| Agent | Fleet 节点身份(ECDSA P-256)+ 面向 Probe 的 TLS 证书 | `fleet-enroll` | 公钥、SPKI 指纹 |
|
||||
| Probe | Fleet 节点身份(连面板)+ 另一把独立的密钥(连 Agent,放在 `tls.pfx`) | `fleet-enroll`,和 Agent 一样用加入令牌(`role=probe`);可以有多个 Probe,每个独立吊销 | 两把公钥 |
|
||||
| 面板用户 | 账号 + 密码(PBKDF2-SHA256) | 面板 | 密码哈希 |
|
||||
| API 令牌 | 随机高熵令牌,带一组能力(见 [access.md](access.md)) | 面板 | SHA-256 |
|
||||
| 发布签名 | ECDSA P-256 发布密钥 | CI(Woodpecker 密钥 `release_signing_key`)或离线环境 | 只在节点宿主机的更新器里配置公钥(容器节点是 `moshiro-update`,见 [deployment.md](../ops/deployment.md);纯二进制节点的更新是待办),**面板不保存私钥**;登记清单时面板用公钥验签,节点宿主机的更新器之后再验一次 |
|
||||
|
||||
## 2. Agent 接入流程
|
||||
|
||||
```text
|
||||
管理员 ── 创建加入令牌 {标签, max_uses, 过期时间, auto_approve, controllers} ──> 面板(Fleet.Server.Enrollment)
|
||||
<── 一行安装命令(Agent 和 Probe 共用):先下载静态安装脚本、核对它的 SHA-256,一致才执行;
|
||||
面板地址、角色、一次性码、创世指纹和属主都走环境变量,命令里只有公开的值(见 access.md 第 10 节)
|
||||
|
||||
install.sh / install.ps1(packaging/install/,嵌入 Backend,静态文件,不替换任何值):
|
||||
├─ 向面板取 CI 签名的发布清单,用脚本里内置的发布公钥验签,按清单里的 digest 拉镜像
|
||||
├─ 用 Docker 或 Podman 起容器,身份和配置存在命名卷里;节点名字默认取主机名
|
||||
└─ 容器入口用代码注册:fleet-enroll --server … --join jt_… --genesis … --controllers …
|
||||
├─ 先验操作员集合链(创世指纹、属主在集合里),验不过就不交出密钥
|
||||
├─ 注册元数据(MoShiro 定义):{ probe_tcp_port, probe_udp_ports, public_host?, kinds[] }
|
||||
└─ 写 trust.json(创世指纹、链、属主)和 agent.json(本地硬上限)
|
||||
下载压缩包的二进制安装(packaging/install.sh)做同样的注册,另外创建系统用户 moshiro、启用 moshiro-agent.service,见 deployment.md。
|
||||
|
||||
面板收到 OnEnrolled(MoShiro 挂钩)
|
||||
├─ public_host 未提供时,来源 IP 是公网地址才用;之后 hello 里 Agent 上报本机公网地址(`addresses`),面板自动填 public_host 和 ipv6_host,手填的优先
|
||||
├─ 按“线路任务模板”为该 Agent 计算有效任务集(TaskSet)
|
||||
└─ 面板把新的 Agent 列表作为期望状态推给所有 Probe → Probe 自动连接新 Agent
|
||||
从安装到开始出数据,全程不需要手工配置
|
||||
```
|
||||
|
||||
名称冲突、审批、接管、禁用等行为都采用 Fleet 的默认规则。
|
||||
|
||||
## 3. Probe 身份与 Agent 列表同步
|
||||
|
||||
Probe 直接连面板(Fleet 长连接,身份是下面生成的密钥),Agent 列表由面板作为期望状态下发;测量数据仍然只走 Probe → Agent。
|
||||
|
||||
```text
|
||||
面板:POST /api/admin/join-tokens {role: "probe"} → 一次性加入令牌 jt_…
|
||||
Probe 机器:fleet-enroll --server … --join jt_… --name home --identity-dir …
|
||||
├─ 本地生成两把密钥:node.key(连面板认证)和 tls.pfx 里的密钥(连 Agent 对端认证);面板从不接触私钥
|
||||
├─ 注册请求带两把公钥(pubkey、tls_pubkey)和持有证明;面板据此写 probes 记录,ID 前缀 prb_
|
||||
└─ 吊销 /revoke 会让面板和所有 Agent 断开它;换密钥 = 吊销后重新注册(没有 rotate)
|
||||
probe.json:{ fleet: { server, identity_dir }, restart_at?, limits? } ← limits 是本地硬上限,面板无法修改;在路由器上建议调低
|
||||
|
||||
Probe 启动
|
||||
├─ 连接 server:Fleet 长连接(用 probe_id 和私钥认证),fleet.hello 带上系统、架构和构建信息
|
||||
├─ 面板推送期望状态 probe_agents:{ version, agents: [ {agent_id, host, tcp_port, udp_ports, public_key}... ] }
|
||||
├─ 调和:新增的 Agent → 建立点对点会话(peer 用途签名);被移除或禁用的 → 断开
|
||||
└─ 之后列表的任何变化(Agent 注册、禁用、地址或证书变化),面板都重新推送同一种期望状态;
|
||||
Probe 用 Fleet.Hosting.ConfigStore 把最新列表写回 agents.json(这是配置,不是测量数据;面板暂时连不上时,Probe 靠它继续测量)
|
||||
```
|
||||
|
||||
- 每个 Agent 通过自己的期望状态(`TaskSet.peers`)获得 Probe 的公钥,用来验证点对点会话的签名。
|
||||
- **哪些 Probe 连哪些 Agent 由面板决定,始终是 Probe 主动拨出**:设置 `fleet.auto_link`(默认开)打开时,所有未吊销的 Probe 和所有 Agent 两两连;关闭时只连手动选中的。每一对还可以在管理端 Probe 页的「线路」矩阵里手动强制开或关(`PUT /api/admin/links/{probe_id}/{agent_id}`),手动设置优先于自动连接。结果同时用在 Probe 的 Agent 列表、Agent 的 `peers` 和线路目录(关掉的线路从总览里消失,历史数据还在)。
|
||||
- 吊销 Probe:`POST /api/admin/probes/{id}/revoke`,旧公钥立即从所有 Agent 的期望状态中移除,旧会话随之断开;要继续用就重新注册。
|
||||
|
||||
## 4. Agent 本地配置(`/etc/moshiro/agent.json`,面板无法修改)
|
||||
|
||||
```json
|
||||
{
|
||||
"fleet": { "server": "https://panel.example.com", "identity_dir": "/etc/moshiro" },
|
||||
"listen": { "control_port": 7443, "udp_ports": [7443], "tcp_ports": [7444] },
|
||||
"limits": {
|
||||
"max_total_mbps": 200,
|
||||
"max_total_pps": 20000,
|
||||
"allowed_target_cidrs": [],
|
||||
"max_concurrent_tests": 1
|
||||
},
|
||||
"fleet_policy": { "allow_methods": ["moshiro.test.run", "moshiro.test.cancel"] }
|
||||
}
|
||||
```
|
||||
|
||||
- `limits` 是**本地硬上限**:期望任务集超出这些范围时,调和器拒绝执行,并在 `rejected[]` 中给出原因。
|
||||
- `allowed_target_cidrs` 默认为空,即不允许执行任何目标任务;需要时由节点所有者在本机显式开启。
|
||||
- `listen` 默认不包含 443。要用 443 测量,需要在本机把它加入 `udp_ports` 或 `tcp_ports`(端口不能被真实服务占用),见 [measurement.md](../design/measurement.md) 第 8 节。
|
||||
- `fleet_policy.allow_methods` 在 Fleet 默认值的基础上进一步收窄。
|
||||
|
||||
## 5. 相关 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/agent/v1/enroll` | 由 `MapFleetEnrollment` 提供 |
|
||||
| GET | `/api/agent/v1/rpc` | 由 `MapFleetLink` 提供(升级为 WebSocket) |
|
||||
| GET/POST/DELETE | `/api/admin/join-tokens` | 管理加入令牌 |
|
||||
| POST | `/api/admin/agents/{id}/approve`、`/disable`、`/enable`、`/takeover` | 审批与启停 |
|
||||
| GET/PATCH/DELETE | `/api/admin/probes`、`/probes/{id}` | Probe 的列表、改名、删除 |
|
||||
| POST | `/api/admin/probes/{id}/revoke` | 吊销(换密钥就是吊销之后重新注册,没有轮换) |
|
||||
| POST | `/api/admin/agents/{id}/probes/{probe_id}/disconnect` | 让 Agent 断开某个 Probe |
|
||||
| GET | `/install`、`/install.ps1` | 静态安装脚本(Agent 和 Probe 共用,不带参数,哈希在一个版本内固定) |
|
||||
| GET | `/install/manifest?code=…`、`/install/manifest.sig?code=…` | 凭一次性代码取该角色已签名的发布清单(原样,一个字节也不改) |
|
||||
|
||||
|
||||
## Agent 的本地配置:主机状态上报
|
||||
|
||||
`agent.json` 的 `reporting` 段(面板无法修改):
|
||||
|
||||
```json
|
||||
{ "reporting": { "host_stats": true, "host_interval_s": 2 } }
|
||||
```
|
||||
|
||||
`host_stats` 为 true(默认)时,Agent 每隔 `host_interval_s` 秒用 `moshiro.host` 上报一份主机资源占用的汇总数字(CPU、内存、根分区、网卡累计流量与网速、运行时长;没有型号、主机名、IP);设为 false 就一个字也不上报。
|
||||
@@ -0,0 +1,198 @@
|
||||
# 零信任设计
|
||||
|
||||
> **实现状态**:协议方向声明表和卡口、Probe 自己生成密钥、预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定都已实现;测量数据的产生者签名、入库配额、Probe 拨号地址限制等还没做(第 3 节有逐项状态)。
|
||||
|
||||
Agent、Backend、Probe 之间互相不信任。这份文档回答三个问题:现在谁听谁的、被攻破之后最坏会怎样、怎么用密码学把信任从 Backend 身上拿走。账户、能力、passkey 和入网信任链的具体做法见 [access.md](access.md);纯采集的边界、本地硬上限和签名更新见 [architecture.md](../design/architecture.md) 第 5 节,这里不重复。
|
||||
|
||||
## 1. 范围
|
||||
|
||||
**要防的**
|
||||
|
||||
- Backend(或 Backend 前面的 Cloudflare Tunnel、frp、Caddy 等中间环节)被攻破或被劫持之后,给节点下发恶意内容。
|
||||
- Agent 或 Probe 自己被入侵之后,去影响 Backend 或对端。
|
||||
|
||||
**有意不做的**
|
||||
|
||||
- **进程沙箱**(seccomp、pids 和内存上限、出站白名单)。Agent 和 Probe 的定位是 1 核 1 GB 的小机器,不能吃资源。
|
||||
- **供应链的多签、冷却期和可复现构建**。代码风险靠 PR 评审降一个等级;发布仍然是单把发布密钥签名(见 [deployment.md](../ops/deployment.md))。
|
||||
- 顺带记一条:Agent 的单元挂了 `/:/host/root:ro`、`/proc`、`/sys`,被攻破的 Agent 能遍历宿主机。它们多半是不必要的(宿主网络模式下容器自己的 `/proc` 就是宿主的,磁盘用量可以对已挂载的 `/etc/os-release` 做 `statvfs`),属于沙箱范畴,先不动。
|
||||
|
||||
## 2. 威胁模型
|
||||
|
||||
| 编号 | 假设被攻破的是 | 现实来源 |
|
||||
| --- | --- | --- |
|
||||
| T1 | Backend,或它前面的中间环节 | 链路经过 Cloudflare Tunnel、frp、Caddy,它们都能看到 wss 的明文 |
|
||||
| T2 | 某个 Agent(在 VPS 上,旁边还有别的服务) | VPS 被入侵 |
|
||||
| T3 | 某个 Probe(在客户内网里) | 客户机器被入侵 |
|
||||
| T4 | 管理员的会话或令牌 | 令牌泄露 |
|
||||
| T5 | 管理员的浏览器 | XSS,或者节点上报的字符串被渲染 |
|
||||
|
||||
## 3. 缺口与状态
|
||||
|
||||
```text
|
||||
CI 发布私钥(单把)
|
||||
│ 签名清单
|
||||
管理员 ──令牌/会话──▶ ┌──────────┐ ◀── 原始块、事件、主机数字
|
||||
│ Backend │
|
||||
└──┬───┬───┘
|
||||
任务集、对端公钥、 │ │ Agent 名单(host:port,Probe 全部照拨)
|
||||
重启计划、更新清单 ▼ ▼
|
||||
┌──────┐ ┌──────┐
|
||||
│Agent │◀─▶│Probe │ Probe 私钥由节点自己生成
|
||||
└──────┘ └──────┘
|
||||
```
|
||||
|
||||
| # | 缺口 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| G1 | 节点不验证 Backend 的身份,只靠 TLS 加公共 CA | **已解决**:操作员集合里登记服务器密钥,节点要求服务器在认证挑战里签名,链路用应用层加密([access.md](access.md) 第 8 节)。对 Backend 自己被攻破没有帮助 |
|
||||
| G2 | Probe 的私钥由 Backend 生成 | **已解决**:Probe 自己生成密钥,用加入令牌注册,私钥只在节点上(破坏性:旧 `probe.json` 失效) |
|
||||
| G3 | Probe 会拨 Backend 给的任意 `host:port` | 未解决:被攻破的 Backend 能让客户内网里的 Probe 去探测内网;本地 `limits` 只有速率、pps 和 `allowed_target_cidrs` |
|
||||
| G4 | Agent 的 TCP 端口对任何人完成 TLS 握手并出示证书 | **已解决**:预握手敲门,见第 8 节(破坏性:旧 Probe 被拒绝) |
|
||||
| G5 | Backend 入库只限制了单块的大小,没有按节点的速率或配额 | 未解决:恶意节点可以灌满磁盘 |
|
||||
| G6 | 对端公钥、Agent 地址全由 Backend 决定 | 未解决:操作员集合不包含成员名册,被攻破的 Backend 能把恶意 Probe 加进 Agent 的白名单,或把 Probe 指向恶意 Agent |
|
||||
| G7 | 方法的方向和角色没有统一声明 | **已解决**:声明表和卡口,见第 6 节 |
|
||||
| G8 | 测量数据没有产生者签名(块只有 CRC32C) | 未解决:中间环节可以改写测量数据;被攻破的 Agent 可以伪造 Probe 侧的时间戳 |
|
||||
| G9 | 入库只核对了 `agent_id`,不核对块里的 `series`(`line:<probe_id>`) | 未解决:被攻破的 Agent 可以为从没连过它的 Probe 灌入假线路数据 |
|
||||
| G10 | 没有合理性检查(`EchoOps` 不检查回显时间戳的一致性) | 未解决:恶意 Agent 可以谎报 `t2`、`t3` |
|
||||
| G11 | Probe 的一把密钥多处使用 | **已解决**:面向 Backend 和面向对等面的是独立的密钥,用途前缀做域隔离 |
|
||||
|
||||
没有问题的部分:UDP 端口对未认证的包静默丢弃,不回应(`DropInvalid`,有测试);Probe 本身没有 TCP 监听;任务有本地硬上限;更新清单有签名和 `seq` 防回滚。
|
||||
|
||||
## 4. 核心思路:信任锚点从 Backend 移走
|
||||
|
||||
要防 Backend 被攻破,**改变信任的内容必须由 Backend 之外的密钥授权**。Backend 只搬运、计算和存储,不创造信任:
|
||||
|
||||
```text
|
||||
① 发布密钥(CI) ──▶ 「跑什么代码」 清单签名、seq 防重放
|
||||
② 操作员密钥(管理员 passkey)──▶ 「谁是操作员、哪个是合法的 Backend」 操作员集合,Backend 持有不了
|
||||
③ 节点自己的密钥 ──▶ 「我是我」 Agent 和 Probe 都自己生成
|
||||
|
||||
被攻破的 Backend 能做的:拒绝服务、看数据、改成员名册和任务(G3、G6)。
|
||||
不能做的:改操作员集合、冒充节点、塞恶意更新(发布清单要 CI 签名,节点的更新器还会再验)。
|
||||
```
|
||||
|
||||
## 5. 入网信任链
|
||||
|
||||
详细做法见 [access.md](access.md),这里只留结论:
|
||||
|
||||
- 节点安装时固定创世指纹和属主;节点注册时先验操作员集合链(创世指纹、passkey 签名、属主在集合里),验不过就不交出密钥。之后只认从这个创世出发的新版本,节点持久化见过的最大版本,旧版本不可回放。
|
||||
- 操作员集合里还登记了 Backend 的服务器密钥(G1)。
|
||||
- 加节点不需要 passkey:创建加入码是普通操作,命令里只有公开的值;自动化用带 `fleet.enroll` 的 API key。信任来自节点注册时固定的创世指纹和属主。
|
||||
- **没有采用**逐节点的「成员证明」链(Grant、Ticket、Binding):被攻破的 Backend 往名册里塞节点,拿不到比 Backend 已经有的更多的东西。所以名册(G6)仍然由 Backend 决定。
|
||||
|
||||
## 6. 协议的方向与平面
|
||||
|
||||
### 6.1 三个平面,各认各的凭证
|
||||
|
||||
```text
|
||||
外部面(人、浏览器、脚本) /api/public(匿名只读,SSE) /api/admin(Cookie + CSRF,或 API 令牌)
|
||||
/install /openapi /healthz /metrics
|
||||
节点面(节点主动拨入) /api/agent/v1/enroll(加入令牌,一次性) /api/agent/v1/rpc(wss,节点签名认证)
|
||||
对等面(Probe 拨 Agent) TLS 控制 7443/tcp、数据 7443/udp、7444/tcp(先过敲门)
|
||||
```
|
||||
|
||||
- **每个平面只认自己的凭证**:节点面不接受令牌和 Cookie;外部面不接受节点签名;对等面只认敲门通过的成员。
|
||||
- **连接方向固定**:节点面永远是节点拨入 Backend,Backend 从不主动连节点,所谓「下发」只是在节点拨入的连接上发下行消息;对等面永远是 Probe 拨 Agent,Agent 不拨 Probe。
|
||||
- **外部面不能直通节点面**:管理端的操作(例如发起测试)只会触发 Backend 里一个**强类型**的下行方法,管理端不能指定方法名,也不能把请求体原样转给节点(`IAgentGateway` 里全是强类型方法)。
|
||||
- 三个平面共用同一个监听端口和同一条 ASP.NET 管线,靠路径和凭证类型区分。把节点面和外部面做成可以分别配置监听地址(默认仍同一个),这样管理面可以只对内网或 VPN 开放,是待定的目标。
|
||||
|
||||
### 6.2 每个方法声明方向、类型和角色
|
||||
|
||||
**同一个方法名只允许一个发起方**,不存在「双向方法」。一个方法声明:发起方(`B→A`、`B→P`、`A→B`、`P→B`、`P→A`、`A→P`)、类型(请求有响应、通知无响应)、角色(`A`、`P`、`A+P`)、授权(无,或为 extension 预留的 `operator`)。方向总表见 [protocol.md](../design/protocol.md),以代码里的声明为准。
|
||||
|
||||
强制方式:
|
||||
|
||||
1. **一份声明表,单一真源**:框架的 `fleet.*` 在 `Fleet.Rpc/MethodTable.cs` 的 `FleetMethodTable`,`moshiro.*` 和两条二进制通道在 `MoShiro.Protocol` 的 `MoShiroMethodTable`;角色是 `NodeRoles`(服务端按节点 ID 前缀判断)。声明表按(平面,名字)查找,同名方法在两个平面里可以有不同的发起方(`fleet.state.sync` 在节点面是 Backend→节点,在对等面是 Agent→Probe)。
|
||||
2. **卡口在 `RpcRouter`**:创建时必须给出本侧(`RpcSide`)和声明表。**注册**处理函数时核对方向、类型、角色,不符就在启动时失败;**分发**时拒绝没有声明、方向不对、请求和通知用反了、角色不允许的消息,回 `fleet_protocol_violation`;**发送**前核对本侧有没有资格发起(违反是程序缺陷,抛异常)。
|
||||
3. **违规处理**:`RpcSession` 和服务端的 `NodeConnection` 对违规计数,超过上限(`MaxViolations`,默认 20)就断开连接;二进制通道也在服务端按声明核对。
|
||||
4. **响应匹配**:只接受对应未决请求 ID 的响应,不接受主动「响应」。
|
||||
5. **对账测试**:`Fleet.Tests/MethodDirectionTests` 覆盖卡口本身;`MoShiro.Backend.Tests/ProtocolDeclarationTests` 核对「每个方法常量都声明了」「文档里写了每个方法」「各角色该处理的方法都注册了处理函数」「每条声明的通道都有收件箱」。
|
||||
6. **下行消息只是建议,不是命令**:节点按本地硬上限执行或拒绝;任何带副作用的敏感能力都不走核心的下行消息,见第 9 节。
|
||||
|
||||
### 6.3 各类下行消息怎么处置
|
||||
|
||||
| 下行内容 | 授权 | 被攻破的 Backend 最多能做什么 |
|
||||
| --- | --- | --- |
|
||||
| 操作员集合新版本 | **操作员 passkey 签名**,节点自己验 | 什么都改不了;能转发旧的合法版本,版本号递增防回放 |
|
||||
| 更新清单 | 发布密钥(CI) | 不能伪造;能转发旧的合法清单,`seq` 防回滚;节点的更新器还会用本机公钥再验 |
|
||||
| 成员名册、对端公钥、Agent 地址 | Backend(G3、G6 未解决) | 能把恶意 Probe 加进 Agent 的白名单,或把 Probe 指向恶意 Agent |
|
||||
| 任务集、测试请求 | Backend 计算;节点本地硬上限兜底 | 只能在本地速率和端口上限内发测量流量 |
|
||||
| 重启计划(`moshiro.schedule`) | Backend;节点本地启动后 10 分钟内不触发 | 最多让节点每 10 分钟重启一次 |
|
||||
| 未来的 `extension`(exec 等) | **属主 passkey 签名的一次性能力令牌**,见第 9 节 | 什么都做不了 |
|
||||
|
||||
## 7. 节点自己被攻破时(设计,未实现)
|
||||
|
||||
### 7.1 测量数据的可信(G8、G9、G10)
|
||||
|
||||
测量数据是这个项目的产出,不能只靠传输层可信:链路有 Cloudflare 这样的中间环节,Agent 又要转发 Probe 的数据。
|
||||
|
||||
- **产生者签名**:每个列块由 Probe 签名,每个原始块由上传它的节点签名;Agent 转发 Probe 的块时原样保留签名。签名覆盖块的摘要(SHA-256),签名串带用途前缀 `chunk` 和节点 ID。Backend 入库时逐个验签,不通过的丢弃并计数。每 10 秒一块,一次 ECDSA 验签的开销可以忽略。
|
||||
- **归属校验**:入库时核对块里的 Probe 是和这个 Agent 有线路的成员,不是就丢弃并计数。
|
||||
- **合理性检查**:`t3−t2` 必须小于 `t4−t1`,各时间戳单调;违反的样本不计入指标,记为异常并产生事件。
|
||||
- 边界:这样中间环节改不了数据,被攻破的 Agent 改不了 Probe 一侧的数据;但它仍然可以谎报自己一侧的 `t2`、`t3`,合理性检查只能限制它的幅度,因为这一侧的真值只有它自己知道。
|
||||
|
||||
### 7.2 对 Backend 的资源和输入
|
||||
|
||||
- **按节点的配额**:每个节点的字节、块数和速率上限,只是计数,不占资源;全局再限制同时解码的块数(单块解码上限是 64 MiB)。
|
||||
- **节点上报的字符串**:`hello` 里的 `platform`、事件的 `detail` 等一律当不可信数据,入库前统一校验长度、字符集和换行,不依赖前端转义;管理端不渲染成 HTML。
|
||||
- **匿名公开接口**:登录已经有限流,但热力图、分布这类重查询只有范围和指标数的上限,要加每源限速和结果缓存。
|
||||
- **webhook**:实现之前先定约束:只允许 HTTPS、禁止私网和回环地址、不跟随重定向、限制超时和响应大小、解析之后再检查地址。
|
||||
|
||||
### 7.3 对对端和身份
|
||||
|
||||
- 被攻破的 Probe 只能拿到 Backend 授权的会话,Agent 的速率上限由本地兜底;被攻破的 Agent 发来的数据只会影响它自己的线路。
|
||||
- **Probe 只拨公网地址**(G3):固定 SPKI 只能防止建立会话,防不了探测本身;要在 Probe 本地拒绝私网、回环、链路本地地址。
|
||||
- **名册里的主机名解析之后再检查地址**:签名保护的是字符串,不是解析结果;默认只放行公网地址,内网需要节点本地显式允许。
|
||||
- **UDP 会话迁移要做路径验证**:协议允许会话在 NAT 映射变化后跟随新的源地址,路径上的攻击者可以借此把会话牵到自己的地址。迁移前先做一次挑战应答,验证前限制发送量(不超过收到字节数的 3 倍),类似 QUIC 的 path validation。
|
||||
- **不协商、不降级**:现在固定 ECDSA P-256 和固定的签名串格式,没有算法协商,保持这一点。
|
||||
- Agent 的 `max_total_mbps` 默认是 1200,对 1 核 1 GB 的小机器没有意义,却是被攻破的 Backend 能让 Agent 向某个 Probe 打出去的上限。默认值应该保守,需要的人再调高;`allowed_target_cidrs` 默认为空是对的。
|
||||
|
||||
## 8. 监听隐身:预握手敲门
|
||||
|
||||
目标:Agent 的 TCP 端口对没有完成挑战的人**不写出任何字节**,包括 TLS 的 ServerHello,扫描器拿不到证书和 TLS 指纹(G4)。UDP 端口本来就是静默的。
|
||||
|
||||
```text
|
||||
Probe Agent TCP 7443 / 7444
|
||||
│── TCP 连接 ───────────────────────────────────▶ accept(内核已完成三次握手)
|
||||
│── 敲门包 32 字节:nonce(16) │ tag(16) ─────────▶ 读 32 字节(限时 3 秒)
|
||||
│ tag = HMAC(K_pair, 时间窗 ‖ nonce) 用名册里每个 Probe 的 K_pair 逐个核对
|
||||
│ ├ 合法:继续 TLS 握手 → 现有的签名认证
|
||||
│◀──────────── TLS ServerHello ─────────────────── └ 不合法或超时:什么都不发,直接断开
|
||||
```
|
||||
|
||||
- `K_pair = HKDF(ECDH(Probe 身份私钥, Agent 身份公钥), "moshiro-knock-v1")`:只有名册里的成员算得出;时间窗 30 秒,接受前后各一个窗;`nonce` 在窗内去重,防重放;每个来源地址限速(默认每来源每 10 秒 300 次)。
|
||||
- **实现**:`Fleet.Identity.Knock`(构造与核对)和 `KnockGate`(读 32 字节、名册、nonce 去重、按来源限速);控制端口(`PeerListener`)和数据端口(`TcpAcceptor`)共用同一个关卡,Probe 一侧 `PeerConnector` 和 `TcpLink` 在 TCP 连接建立后、TLS 之前发敲门包。名册是期望状态里的 `peers`(Probe 的对等面公钥);`probe_agents` 下发的是 Agent 的 `public_key`(Agent 在 hello 里上报)。陌生连接得到的是被关闭的连接,没有任何字节。
|
||||
|
||||
**局限**:TCP 的 SYN-ACK 由内核发出,用户态进程拦不住,所以端口在扫描器眼里仍是「开着,但不说话」,**不是「没监听」**。真正的没监听有两条路:等级二的 UDP 单包授权(Probe 先发签名的 UDP 敲门包,Agent 才临时开 TCP 监听几秒,只放行敲门者的源地址;代价是多一个往返、UDP 丢包重试、多出口源地址可能对不上),或宿主防火墙规则(需要 root,不适合小机器,不做)。先做等级一,等级二可选。
|
||||
|
||||
## 9. extension:敏感能力的归宿
|
||||
|
||||
Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec、web ssh 和终端、文件读写、抓包等)都不进核心,做成 extension,**用户可选安装,默认没有**,现在都没有实现。
|
||||
|
||||
- **独立进程,不是插件**:单独的二进制和镜像,和核心之间没有进程内接口,核心不加载任何外部代码、不用反射和动态加载,核心的 NativeAOT 和「不能 exec」都不受影响。
|
||||
- **授权只认节点的属主**:每次调用的能力令牌必须由这个节点绑定的属主 passkey 签名,账户角色不赋予权力;管理员只能删除节点或卸载 extension。交互式会话端到端加密,Backend 只转发密文。属主和会话密钥的设计见 [access.md](access.md) 第 9 节。
|
||||
- **独立身份和发布**:extension 有自己的密钥、自己向 Backend 注册,在名册里是单独的成员(记录它依附的节点 ID);独立的镜像和清单(`component` 不同,例如 `ext-ssh`),装不装、升不升级都不影响核心。
|
||||
- **独立命名空间**:extension 的方法只能是 `ext.<名字>.*`,同样进方向声明表;核心的声明表里没有 `ext.*`,核心收到一律拒绝。
|
||||
- **本地显式启用**:extension 的本地配置里有 `enabled`、允许的操作员凭证、允许的命令集或目标范围,默认关闭。
|
||||
- **不做的**:核心里的任何 exec、文件写、加载 extension;用 extension 绕过核心的本地硬上限。
|
||||
|
||||
## 10. 密码学原语
|
||||
|
||||
| 用途 | 现状 |
|
||||
| --- | --- |
|
||||
| 身份签名 | ECDSA P-256 加 SHA-256(BCL) |
|
||||
| 数据面逐包认证 | HMAC-SHA256 截断到 16 字节(`UdpPacket`、`TcpWire`) |
|
||||
| 会话密钥派生 | HKDF-SHA256(`SessionKeys`) |
|
||||
| 控制链路加密 | AES-256-GCM(每个方向一把密钥,严格递增的计数器) |
|
||||
| 令牌哈希、SPKI 指纹 | SHA-256 |
|
||||
| 原始块完整性 | CRC32C,不是密码学校验 |
|
||||
|
||||
**BLAKE3 的评估(已定:不采用)**:ECDSA P-256 本身用 SHA-256,令牌哈希和 SPKI 指纹也继续用 SHA-256,所以 BLAKE3 是**多一个**哈希族,不是换掉一个;BCL 里没有,Probe 和 Agent 不引用 NuGet 包,只能自己实现几百行的便携版本加官方测试向量,这是要长期维护的密码学代码;托管实现没有 SHA-NI 加速,在 x86 上未必更快。若以后在目标硬件上实测发现逐包 MAC 是瓶颈,再单独评估,届时只替换 `Fleet.Identity` 里 `Mac` 和 `Kdf` 两个静态函数,调用方不动。
|
||||
|
||||
## 11. 还没定的事
|
||||
|
||||
1. 敲门等级二(UDP 单包授权)要不要做。
|
||||
2. 节点面和外部面是否拆成两个监听端口:拆的话部署文档、Caddy 和隧道的配置都要跟着改。
|
||||
3. extension 的第一个具体实现做不做 web ssh,放在哪个阶段;静态数据私钥来源(WebAuthn PRF 还是浏览器里不可导出的密钥)和会话 AEAD 的选择。
|
||||
4. 测量数据签名放进块格式的哪个位置,是否影响现有 `MSRC` 容器版本。
|
||||
5. Agent 的 `max_total_mbps` 默认值调到多少(比如 100)。
|
||||
Reference in new issue
Block a user