Files
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

199 lines
19 KiB
Markdown
Raw Permalink 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.
# 零信任设计
> 实现状态:协议方向声明表和卡口、拨号方 自己生成密钥、预握手敲门、操作员集合与创世固定、服务器身份和安全链路、属主固定都已实现;测量数据的产生者签名、入库配额、拨号方 拨号地址限制等还没做(第 3 节有逐项状态)。
Agent、Backend、拨号方 之间互相不信任。这份文档回答三个问题:现在谁听谁的、被攻破之后最坏会怎样、怎么用密码学把信任从 Backend 身上拿走。账户、能力、passkey 和入网信任链的具体做法见 [access.md](access.md);节点能力边界、本地硬上限和签名更新见 [architecture.md](../design/architecture.md) 第 5 节,这里不重复。
## 1. 范围
要防的
- Backend(或 Backend 前面的 Cloudflare Tunnel、frp、Caddy 等中间环节)被攻破或被劫持之后,给节点下发恶意内容。
- Agent 或 拨号方 自己被入侵之后,去影响 Backend 或对端。
有意不做的
- 进程沙箱(seccomp、pids 和内存上限、出站白名单)。Agent 的定位是 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 | 某个 拨号方(在客户内网里) | 客户机器被入侵 |
| T4 | 管理员的会话或令牌 | 令牌泄露 |
| T5 | 管理员的浏览器 | XSS,或者节点上报的字符串被渲染 |
## 3. 缺口与状态
```text
CI 发布私钥(单把)
│ 签名清单
管理员 ──令牌/会话──▶ ┌──────────┐ ◀── 原始块、事件、主机数字
│ Backend │
└──┬───┬───┘
任务集、对端公钥、 │ │ Agent 名单(host:port,拨号方 全部照拨)
重启计划、更新清单 ▼ ▼
┌──────┐ ┌──────┐
│Agent │◀─▶│拨号方 │ 拨号方 私钥由节点自己生成
└──────┘ └──────┘
```
| # | 缺口 | 状态 |
| --- | --- | --- |
| G1 | 节点不验证 Backend 的身份,只靠 TLS 加公共 CA | 已解决:操作员集合里登记服务器密钥,节点要求服务器在认证挑战里签名,链路用应用层加密([access.md](access.md) 第 8 节)。对 Backend 自己被攻破没有帮助 |
| G2 | 拨号方 的私钥由 Backend 生成 | 已解决:拨号方 自己生成密钥,用加入令牌注册,私钥只在节点上(破坏性:旧 `agent.json` 失效) |
| G3 | 拨号方 会拨 Backend 给的任意 `host:port` | 未解决:被攻破的 Backend 能让客户内网里的 拨号方 去探测内网;本地 `limits` 只有速率、pps 和 `allowed_target_cidrs` |
| G4 | Agent 的 TCP 端口对任何人完成 TLS 握手并出示证书 | 已解决:预握手敲门,见第 8 节(破坏性:旧 拨号方 被拒绝) |
| G5 | Backend 入库只限制了单块的大小,没有按节点的速率或配额 | 未解决:恶意节点可以灌满磁盘 |
| G6 | 对端公钥、Agent 地址全由 Backend 决定 | 未解决:操作员集合不包含成员名册,被攻破的 Backend 能把恶意 拨号方 加进 Agent 的白名单,或把 拨号方 指向恶意 Agent |
| G7 | 方法的方向和角色没有统一声明 | 已解决:声明表和卡口,见第 6 节 |
| G8 | 测量数据没有产生者签名(块只有 CRC32C) | 未解决:中间环节可以改写测量数据;被攻破的 Agent 可以伪造 拨号方 侧的时间戳 |
| G9 | 入库只核对了 `agent_id`,不核对块里的 `series`(`line:<dialer_id>`) | 未解决:被攻破的 Agent 可以为从没连过它的 拨号方 灌入假线路数据 |
| G10 | 没有合理性检查(`EchoOps` 不检查回显时间戳的一致性) | 未解决:恶意 Agent 可以谎报 `t2`、`t3` |
| G11 | 拨号方 的一把密钥多处使用 | 已解决:面向 Backend 和面向对等面的是独立的密钥,用途前缀做域隔离 |
没有问题的部分:UDP 端口对未认证的包静默丢弃,不回应(`DropInvalid`,有测试);拨号方 本身没有 TCP 监听;任务有本地硬上限;更新清单有签名和 `seq` 防回滚。
## 4. 信任锚点
改变信任的内容由 Backend 之外的密钥授权,Backend 只搬运、计算和存储:
```text
① 发布密钥(CI) ──▶ 「跑什么代码」 清单签名、seq 防重放
② 操作员密钥(管理员 passkey)──▶ 「谁是操作员、哪个是合法的 Backend」 操作员集合,Backend 持有不了
③ 节点自己的密钥 ──▶ 「我是我」 Agent 都自己生成
被攻破的 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,节点签名认证)
对等面(拨号方 拨 Agent) TLS 控制 7443/tcp、数据 7443/udp、7444/tcp(先过敲门)
```
- 每个平面只认自己的凭证:节点面不接受令牌和 Cookie;外部面不接受节点签名;对等面只认敲门通过的成员。
- 连接方向固定:节点面永远是节点拨入 Backend,Backend 从不主动连节点,所谓「下发」只是在节点拨入的连接上发下行消息;对等面永远是 拨号方 拨 Agent,Agent 不拨 拨号方。
- 外部面不能直通节点面:管理端的操作(例如发起测试)只会触发 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→拨号方)。
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 未解决) | 能把恶意 拨号方 加进 Agent 的白名单,或把 拨号方 指向恶意 Agent |
| 任务集、测试请求 | Backend 计算;节点本地硬上限兜底 | 只能在本地速率和端口上限内发测量流量 |
| 重启计划(`moshiro.schedule`) | Backend;节点本地启动后 10 分钟内不触发 | 最多让节点每 10 分钟重启一次 |
| 未来的 `extension`(exec 等,尚未实现) | 属主 passkey 签名的一次性能力令牌,见第 9 节 | 设计目标是没有属主签名就无法调用 |
## 7. 节点自己被攻破时(设计,未实现)
### 7.1 测量数据的可信(G8、G9、G10)
测量数据是这个项目的产出,不能只靠传输层可信:链路有 Cloudflare 这样的中间环节,Agent 又要转发 拨号方 的数据。
- 产生者签名:每个列块由 拨号方 签名,每个原始块由上传它的节点签名;Agent 转发 拨号方 的块时原样保留签名。签名覆盖块的摘要(SHA-256),签名串带用途前缀 `chunk` 和节点 ID。Backend 入库时逐个验签,不通过的丢弃并计数。每 10 秒一块,一次 ECDSA 验签的开销可以忽略。
- 归属校验:入库时核对块里的 拨号方 是和这个 Agent 有线路的成员,不是就丢弃并计数。
- 合理性检查:`t3−t2` 必须小于 `t4−t1`,各时间戳单调;违反的样本不计入指标,记为异常并产生事件。
- 边界:这样中间环节改不了数据,被攻破的 Agent 改不了 拨号方 一侧的数据;但它仍然可以谎报自己一侧的 `t2`、`t3`,合理性检查只能限制它的幅度,因为这一侧的真值只有它自己知道。
### 7.2 对 Backend 的资源和输入
- 按节点的配额:每个节点的字节、块数和速率上限,只是计数,不占资源;全局再限制同时解码的块数(单块解码上限是 64 MiB)。
- 节点上报的字符串:`hello` 里的 `platform`、事件的 `detail` 等一律当不可信数据,入库前统一校验长度、字符集和换行,不依赖前端转义;管理端不渲染成 HTML。
- 匿名公开接口:登录已经有限流,但热力图、分布这类重查询只有范围和指标数的上限,要加每源限速和结果缓存。
- webhook:实现之前先定约束:只允许 HTTPS、禁止私网和回环地址、不跟随重定向、限制超时和响应大小、解析之后再检查地址。
### 7.3 对对端和身份
- 被攻破的 拨号方 只能拿到 Backend 授权的会话,Agent 的速率上限由本地兜底;被攻破的 Agent 发来的数据只会影响它自己的线路。
- 拨号方 只拨公网地址(G3):固定 SPKI 只能防止建立会话,防不了探测本身;要在 拨号方 本地拒绝私网、回环、链路本地地址。
- 名册里的主机名解析之后再检查地址:签名保护的是字符串,不是解析结果;默认只放行公网地址,内网需要节点本地显式允许。
- QUIC 会话迁移要做路径验证:协议允许会话在 NAT 映射变化后跟随新的源地址,路径上的攻击者可以借此把会话牵到自己的地址。迁移前先做一次挑战应答,验证前限制发送量(不超过收到字节数的 3 倍),由 QUIC path validation 提供。
- 不协商、不降级:现在固定 ECDSA P-256 和固定的签名串格式,没有算法协商,保持这一点。
- Agent 的 `max_total_mbps` 默认是 1200,对 1 核 1 GB 的小机器没有意义,却是被攻破的 Backend 能让 Agent 向某个 拨号方 打出去的上限。默认值应该保守,需要的人再调高;`allowed_target_cidrs` 默认为空是对的。
## 8. 监听隐身:预握手敲门
目标:Agent 的 TCP 端口对没有完成挑战的人不写出任何字节,包括 TLS 的 ServerHello,扫描器拿不到证书和 TLS 指纹(G4)。UDP 端口本来就是静默的。
```text
拨号方 Agent TCP 7443 / 7444
│── TCP 连接 ───────────────────────────────────▶ accept(内核已完成三次握手)
│── 敲门包 32 字节:nonce(16) │ tag(16) ─────────▶ 读 32 字节(限时 3 秒)
│ tag = HMAC(K_pair, 时间窗 ‖ nonce) 用名册里每个 拨号方 的 K_pair 逐个核对
│ ├ 合法:继续 TLS 握手 → 现有的签名认证
│◀──────────── TLS ServerHello ─────────────────── └ 不合法或超时:什么都不发,直接断开
```
- `K_pair = HKDF(ECDH(拨号方 身份私钥, Agent 身份公钥), "moshiro-knock-v1")`:只有名册里的成员算得出;时间窗 30 秒,接受前后各一个窗;`nonce` 在窗内去重,防重放;每个来源地址限速(默认每来源每 10 秒 300 次)。
- 实现:`Fleet.Identity.Knock`(构造与核对)和 `KnockGate`(读 32 字节、名册、nonce 去重、按来源限速);控制端口(`PeerListener`)和数据端口(`TcpAcceptor`)共用同一个关卡,拨号方 一侧 `PeerConnector` 和 `TcpLink` 在 TCP 连接建立后、TLS 之前发敲门包。名册是期望状态里的 `peers`(拨号方 的对等面公钥);`dial_targets` 下发的是 Agent 的 `public_key`(Agent 在 hello 里上报)。陌生连接得到的是被关闭的连接,没有任何字节。
局限:TCP 的 SYN-ACK 由内核发出,用户态进程拦不住,所以端口在扫描器眼里仍是「开着,但不说话」,不是「没监听」。真正的没监听有两条路:等级二的 UDP 单包授权(拨号方 先发签名的 UDP 敲门包,Agent 才临时开 TCP 监听几秒,只放行敲门者的源地址;代价是多一个往返、UDP 丢包重试、多出口源地址可能对不上),或宿主防火墙规则(需要 root,不适合小机器,不做)。先做等级一,等级二可选。
## 9. extension:敏感能力的归宿
当前 Agent 的核心只包含测量与上报。敏感能力(exec、web ssh 和终端、文件读写、抓包等)计划做成 extension,由用户选择安装,默认不启用,目前都没有实现。
- 独立进程:单独的二进制和镜像,和核心之间没有进程内接口,核心不加载外部代码、不用反射和动态加载,核心的 NativeAOT 和构建期限制不受影响。
- 授权只认节点的属主:每次调用的能力令牌必须由这个节点绑定的属主 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 字节(`MeasurementDatagram`、`TcpWire`) |
| 会话密钥派生 | HKDF-SHA256(`SessionKeys`) |
| 控制链路加密 | AES-256-GCM(每个方向一把密钥,严格递增的计数器) |
| 令牌哈希、SPKI 指纹 | SHA-256 |
| 原始块完整性 | CRC32C,不是密码学校验 |
BLAKE3 的评估(已定:不采用):ECDSA P-256 本身用 SHA-256,令牌哈希和 SPKI 指纹也继续用 SHA-256,所以 BLAKE3 是多一个哈希族,不是换掉一个;BCL 里没有,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)。