Files
MoShiro/docs/security.md
T

313 lines
32 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.
# 零信任设计
> **实现状态:阶段零(协议方向声明表和卡口,第 8 节)已实现,其余是设计稿,尚未实现。** 第 3 节的缺口是对当前代码的核对结果(G7 已解决);第 4 节起是目标设计和实施顺序。第 14 节列出还没定的事。
Agent、Backend、Probe 之间互相不信任。这份文档只回答三个问题:现在谁听谁的、被攻破之后最坏会怎样、怎么用密码学把信任从 Backend 身上拿走。纯采集的边界、本地硬上限和签名更新见 [architecture.md](architecture.md) 第 5 节,这里不重复。
## 1. 范围
**要防的**
- Backend(或 Backend 前面的 Cloudflare Tunnel、frp、Caddy 等中间环节)被攻破或被劫持之后,给节点下发恶意内容。
- Agent 或 Probe 自己被入侵之后,去影响 Backend 或对端。
**有意不做的**
- **进程沙箱**(seccomp、pids 和内存上限、出站白名单)。Agent 和 Probe 的定位是 1 核 1 GB 的小机器,不能吃资源。
- **供应链的多签、冷却期和可复现构建**。代码风险靠 PR 评审降一个等级;发布仍然是单把发布密钥签名(见 [deployment.md](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 私钥由 Backend 生成
└──────┘ └──────┘
```
| # | 缺口 | 依据 | 后果 |
| --- | --- | --- | --- |
| G1 | 节点不验证 Backend 的身份,只靠 TLS 加公共 CA | `Fleet.Link` 里没有证书固定,也没有应用层验证 | 链路上任一环节被攻破,就能给节点下发任务集、Agent 名单、对端公钥、重启计划(更新清单有签名,不受影响) |
| G2 | Probe 的私钥由 Backend 生成 | `ProbeService` 里的 `NodeIdentity.Generate`(创建和轮换) | Backend 接触过 Probe 的私钥,可以冒充它;Agent 的密钥是自己生成的,没有这个问题 |
| G3 | Probe 会拨 Backend 给的任意 `host:port` | `AgentDirectory`、`AgentConnection` 不做任何地址检查;本地 `limits` 只有速率和 pps | 被攻破的 Backend 能让客户内网里的 Probe 去探测内网。固定 SPKI 只能防止建立会话,防不了探测本身 |
| G4(已修,阶段二) | Agent 的 TCP 端口对任何人完成 TLS 握手并出示证书 | `TcpAcceptor`、`PeerListener` 在 `AcceptTcpClient` 之后直接 `AuthenticateAsServer` | 自签证书、TLS 参数、JARM 都能被扫描器指纹识别,一眼看出这是个服务 |
| G5 | Backend 入库只限制了单块的大小 | `MaxDecodedChunkBytes` 等;没有找到按节点的速率或配额 | 恶意节点可以灌满磁盘 |
| G6 | 对端公钥、Agent 地址全由 Backend 决定 | `TaskSet.Peers`、`ProbeAgentList` | 被攻破的 Backend 能把恶意 Probe 加进 Agent 的白名单,或把 Probe 指向恶意 Agent |
| G7(已解决) | 方法的方向和角色没有统一声明:原来靠「谁注册了处理器」隐式决定 | `NodeConnection.HandleMessageAsync` 只按方法名分发到路由器;文档里的 `B→A`、`A→B` 只存在于 [protocol.md](protocol.md),没有对账 | 新增一个方法时,方向写反、角色写错(例如 Probe 调了只给 Agent 的方法)不会被任何东西发现 |
| G8 | **测量数据没有产生者签名** | `Raw/` 的编解码里没有任何签名或 MAC,块只有 CRC32C;Probe 的列块(通道 17)经 Agent 合并进原始块再上传 | 隧道等中间环节可以改写测量数据;被攻破的 Agent 可以伪造 Probe 侧的时间戳(`t1`、`t4`) |
| G9 | **入库只核对了 `agent_id`** | `IngestService` 在 `header.AgentId != agent` 之后直接采用块头里的 `series`(形如 `line:<probe_id>`),全文没有出现 Probe | 被攻破的 Agent 可以为从没连过它的 Probe 灌入假线路数据 |
| G10 | **没有合理性检查** | `EchoOps` 里没有对回显时间戳做任何一致性检查 | 恶意 Agent 可以谎报 `t2`、`t3`,把自己的处理时延说成任意值,让线路 RTT 好看或难看 |
| G11 | **Probe 的一把密钥多处使用** | [architecture.md](architecture.md) 第 5 节写明同一把用来连 Backend 和连 Agent;已有用途前缀(`auth`、`peer`)做域隔离 | 一处泄露,两处都沦陷 |
没有问题的部分:UDP 端口对未认证的包静默丢弃,不回应(`DropInvalid`,有测试);Probe 本身没有 TCP 监听;任务有本地硬上限;更新清单有签名和 `seq` 防回滚。
## 4. 核心思路:信任锚点从 Backend 移走
要防 Backend 被攻破,**下行内容必须由 Backend 之外的密钥授权**。Backend 只搬运、计算和存储,不创造信任:
```text
① 发布密钥(CI) ──▶ 「跑什么代码」 已有:清单签名、seq 防重放
② 操作员密钥(管理员本人) ──▶ 「谁是舰队成员、去哪拨」 新增:Backend 持有不了
③ 节点自己的密钥 ──▶ 「我是我」 已有;Probe 要改成自己生成
被攻破的 Backend 能做的:拒绝服务、看数据。
不能做的:加入新节点、改地址让 Probe 去探内网、塞恶意更新、冒充节点。
```
## 5. 入网信任链
下文和其他章节里的「名册」指节点本地验过链的成员表。**详细设计见 [access.md](access.md)**(账户、角色、能力、API key、签名链、流程和实施顺序)。这里只留结论:
- 名册不再是「每次变更都要操作员签一整张表」,而是一条可离线验证的链:节点固定创世 owner 公钥 → 操作员集合(带版本,owner 签)→ 委托(passkey 签,限定范围和时间)→ 入网券(委托的密钥签)→ 节点绑定(节点注册时用安装命令里的 `K_code` 签)。加上 passkey 当场签的吊销表。
- Agent 只接纳链验得过的 Probe,Probe 只拨链验得过的 Agent,并且只拨公网地址(缺口 G3、G6 因此消失,不需要另外的拨号白名单)。
- **不可回放**:节点持久化见过的最大操作员集合版本和吊销表版本,Backend 无法拿旧文档复活已吊销的节点。
- 加节点不需要 passkey:创建加入码是普通操作,命令里只有公开的值(创世指纹、属主、脚本哈希);自动化用带 `fleet.enroll` 的 API key。信任来自节点注册时固定的创世指纹和属主。
## 6. 操作员凭证
操作员凭证就是 passkey(WebAuthn,ES256,attestation 选 `none`)。节点本地固定创世 owner 公钥,其余凭证靠 owner 签名的操作员集合同步,不用重装节点;每条凭证带角色(`owner`、`admin`)和限制(例如单张入网券的最长有效期),由节点自己执行。
- 登录可以用密码、passkey 或 OIDC,会话能力相同;但**只有 passkey 能给操作员集合签名**:节点验证不了 IdP 的断言,也不该让密码和 IdP 进入信任域。OIDC 只是账户的另一种登录方式(绑定 `iss + sub`)。
- 自动化凭证:`enroll` API key 创建时由 passkey 签一份委托;CI 和测试用软件 ECDSA 凭证,写进测试的操作员集合。
- 恢复:passkey 全部丢失时,重装节点并换创世公钥;所以每个账户至少注册两个 passkey。
## 7. 各类下行消息怎么处置
| 下行内容 | 授权 | 被攻破的 Backend 最多能做什么 |
| --- | --- | --- |
| 舰队成员、对端公钥 | **操作员签名**的入网链和吊销表 | 什么都改不了;Agent 地址由 Backend 下发,但 Probe 只拨公网地址 |
| 更新清单 | 发布密钥(已有) | 不能伪造;能转发旧的合法清单,`seq` 防回滚 |
| 任务集、测试请求 | Backend 计算;节点本地硬上限兜底(已有) | 只能在名册成员之间、本地速率和端口上限内发测量流量 |
| 重启计划(`moshiro.schedule`) | Backend;节点本地启动后 10 分钟内不触发 | 最多让节点每 10 分钟重启一次 |
| 未来的 `extension`(exec 等) | **操作员签名的一次性能力令牌**,见第 11 节 | 什么都做不了 |
另外 Backend 自己对节点也有一条应用层身份:Backend 有长期签名密钥,节点在注册时固定它,下行消息带签名、节点 ID、序号和有效期。它防的是你的 Cloudflare、frp 链路被劫持(G1);对 Backend 自己被攻破没有帮助,那一类靠名册。
## 8. 协议的方向与平面
### 8.1 三个平面,各认各的凭证
```text
外部面(人、浏览器、脚本) /api/public(匿名只读,SSE) /api/admin(Cookie + CSRF,或 API 令牌)
/install.sh /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 开放,节点面单独放出去。
### 8.2 每个方法声明方向、类型和角色
**同一个方法名只允许一个发起方**,不存在「双向方法」。需要两个方向交互时,定义两个方法。一个方法声明四件事:
| 声明 | 取值 | 含义 |
| --- | --- | --- |
| 发起方 | `B→A`、`B→P`、`A→B`、`P→B`、`P→A`、`A→P` | 只有它能发出这个方法,对方收到就是违规 |
| 类型 | 请求(有响应)、通知(无响应) | 通知不允许回复;响应只对应一个未决的请求 ID,且那个方法必须是请求 |
| 角色 | `A`、`P`、`A+P` | 节点角色不对就拒绝(Probe 不能调 Agent 的方法) |
| 授权 | 无、`operator`(需要操作员签名的能力令牌) | 为 extension 预留,核心方法都是「无」 |
方向总表(来自 [protocol.md](protocol.md),实现时以代码里的声明为准):
| 方法 | 方向 | 类型 | 角色 |
| --- | --- | --- | --- |
| `fleet.challenge`、`fleet.state.sync`、`fleet.outbox.ack`、`fleet.revoked` | B→节点 | 见 Fleet 说明 | A+P |
| `fleet.auth`、`fleet.hello`、`fleet.heartbeat` | 节点→B | 见 Fleet 说明 | A+P |
| `moshiro.test.run`、`moshiro.test.cancel`、`moshiro.probe.disconnect` | B→A | 请求 | A |
| `moshiro.update.offer`、`moshiro.update.apply`、`moshiro.schedule` | B→节点 | 通知 | A+P |
| `moshiro.test.status`、`moshiro.event`、`moshiro.host` | A→B | 通知 | A |
| 二进制通道 16(`RawChunk`) | A→B | 发件箱 | A |
| 对等面的方法和通道 17(列块) | P→A、A→P,按 [protocol.md](protocol.md) | 按声明 | P、A |
### 8.3 强制方式
1. **一份声明表,单一真源**,放在 `MoShiro.Protocol`(Fleet 自己的 `fleet.*` 放在 Fleet);文档里的方向表由它生成或对账。
2. **发送前检查**:发错方向是编程错误,直接失败。
3. **接收时检查**:收到不该由对方发起的方法、未声明的方法、角色不对的方法,丢弃并计数,连续违规断开连接。
4. **响应匹配**:只接受对应未决请求 ID 的响应,不接受主动「响应」。
5. **对账测试**(`ArchitectureTests`):声明表和实际注册的处理器逐项比对,新增方法绕不过声明。
6. **下行消息只是建议,不是命令**:节点按本地硬上限执行或拒绝;任何带副作用的敏感能力都不走核心的下行消息,见第 11 节。
**已实现**(阶段零):
- 声明表:`Fleet.Rpc/MethodTable.cs`(`MethodSpec`、`ChannelSpec`、`MethodTable`);框架的 `fleet.*` 在 `FleetMethodTable`,`moshiro.*` 和两条二进制通道在 `MoShiro.Protocol` 的 `MoShiroMethodTable`;角色是 `NodeRoles`(服务端按节点 ID 前缀判断)。声明表按(平面,名字)查找,同名方法在两个平面里可以有不同的发起方(`fleet.state.sync` 在节点面是 Backend→节点,在对等面是 Agent→Probe)。
- 卡口:`RpcRouter` 创建时必须给出本侧(`RpcSide`)和声明表。**注册**处理函数时核对方向、类型、角色,不符就在启动时失败;**分发**时拒绝没有声明、方向不对、请求和通知用反了、角色不允许的消息,回 `fleet_protocol_violation` 并报告违规;**发送**前核对本侧有没有资格发起(`CheckSend`,违反是程序缺陷,抛异常)。
- 违规处理:`RpcSession` 和服务端的 `NodeConnection` 对违规计数,超过上限(`MaxViolations`,默认 20)就断开连接;二进制通道也在服务端按声明核对(节点只能往声明过、并且允许它的角色使用的通道发)。
- 对账测试:`Fleet.Tests/MethodDirectionTests` 覆盖卡口本身;`MoShiro.Backend.Tests/ProtocolDeclarationTests` 核对「每个方法常量都声明了」「文档里写了每个方法」「Backend、Agent、Probe 两个平面各自该处理的方法都注册了处理函数」「每条声明的通道都有收件箱」。
- 这次落地时对账发现了两处不一致并修掉:文档漏了 `fleet.peer.challenge`、`fleet.peer.auth`;Agent 的 `moshiro.schedule` 处理函数原来只在传入重启控制器时才注册。
## 9. 节点自己被攻破时:数据可信与归属
### 9.1 测量数据的可信(G8、G9、G10)
测量数据是这个项目的产出,不能只靠传输层可信:你的链路有 Cloudflare 这样的中间环节,Agent 又要转发 Probe 的数据。
- **产生者签名**:每个列块由 Probe 签名,每个原始块由上传它的节点签名;Agent 转发 Probe 的块时原样保留 Probe 的签名。签名覆盖块的摘要(见第 13 节),签名串带用途前缀 `chunk` 和节点 ID。Backend 入库时逐个验签,不通过的丢弃并计数。每 10 秒一块,一次 ECDSA 验签的开销可以忽略。
- **归属校验**:入库时核对块里的 Probe 是名册里和这个 Agent 有线路的成员,不是就丢弃并计数;线路只由名册里的(Probe,Agent)对产生。
- **合理性检查**:`t3−t2` 必须小于 `t4−t1`,各时间戳单调;违反的样本不计入指标,记为异常并产生事件。
- 边界:这样中间环节改不了数据,被攻破的 Agent 改不了 Probe 一侧的数据;但它仍然可以谎报自己一侧的 `t2`、`t3`,合理性检查只能限制它的幅度,消除不了,因为这一侧的真值只有它自己知道。
### 9.2 对 Backend 的资源和输入
- **按节点的配额**:每个节点的字节、块数和速率上限,只是计数,不占资源;全局再限制同时解码的块数(单块解码上限是 64 MiB,多个节点同时发大块可以耗尽内存)。
- **节点上报的字符串**:`hello` 里的 `platform`、事件的 `detail` 等一律当不可信数据,入库前统一校验长度、字符集和换行,不依赖前端转义;管理端加 CSP,不渲染成 HTML。
- **匿名公开接口**:登录已经有限流,但热力图、分布这类重查询只有范围和指标数的上限,要加每源限速和结果缓存(TODO 里的人机验证会部分覆盖)。
- **webhook**(TODO 里有):实现之前先定约束:只允许 HTTPS、禁止私网和回环地址、不跟随重定向、限制超时和响应大小、解析之后再检查地址。
- 解码器保持大小上限;模糊测试已在 TODO。
### 9.3 对对端和身份
- 被攻破的 Probe 只能拿到名册授权的会话,Agent 的速率上限由本地兜底;被攻破的 Agent 发来的数据只会影响它自己的线路。
- **身份被偷**:操作员签一份新名册吊销它,所有节点立即拒绝,且因为 `version` 递增不可回放。
- **Probe 私钥要自己生成**:现在 Backend 知道它(G2),名册也防不了这一点。改成和 Agent 一样用加入令牌注册,私钥只在节点上。
- **密钥按用途拆开**(G11):面向 Backend 的和面向对等面的是两把独立的 P-256 密钥;敲门、会话等对称用途用 KDF 加用途标签派生,不复用签名密钥直接做别的事。
### 9.4 对设计的补充要求(实现时核实现状)
- **所有下行状态文档都防回放**:节点持久化每种状态文档见过的最大 `version`,低版本一律拒绝,不只是名册。
- **UDP 会话迁移要做路径验证**:协议允许会话在 NAT 映射变化后跟随新的源地址,路径上的攻击者可以借此把会话牵到自己的地址。迁移前先做一次挑战应答,验证前限制发送量(不超过收到字节数的 3 倍),类似 QUIC 的 path validation。
- **名册里的主机名解析之后再检查地址**:签名保护的是字符串,不是解析结果;默认只放行公网地址,内网需要节点本地显式允许;名册里优先写地址字面量。
- **不协商、不降级**:现在固定 ECDSA P-256 和固定的签名串格式,没有算法协商,保持这一点;升级是硬失败,任何「兼容旧版」的开关都要有明确的下线期限。
### 9.5 默认值
Agent 的 `max_total_mbps` 默认是 1200,对 1 核 1 GB 的小机器没有意义,却是被攻破的 Backend 能让 Agent 向某个 Probe 打出去的上限。默认值应该保守,需要的人再调高;`allowed_target_cidrs` 默认为空是对的。
## 10. 监听隐身:预握手敲门
目标: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")`:只有名册里的成员算得出;ECDH 和签名共用同一把 P-256 密钥时,用不同的 HKDF 标签做域隔离。
- 时间窗 30 秒,接受前后各一个窗(容忍时钟偏差);`nonce` 在窗内去重,防重放;每个来源地址限速,避免陌生人用随机数据消耗 HMAC 计算。
- 这是破坏性变更:旧 Probe 不发敲门包,会被拒绝。
- **已实现**:`Fleet.Identity.Knock`(构造与核对)和 `KnockGate`(读 32 字节、名册、nonce 去重、按来源限速,默认每来源每 10 秒 300 次);控制端口(`PeerListener`)和数据端口(`TcpAcceptor`)共用同一个关卡,Probe 一侧 `PeerConnector` 和 `TcpLink` 在 TCP 连接建立后、TLS 之前发敲门包。名册是期望状态里的 `peers`(Probe 的对等面公钥)。Probe 需要 Agent 的证书公钥来派生配对密钥,所以 `probe_agents` 下发的是 `public_key`(Agent 在 hello 里上报,面板自己算指纹)。陌生连接得到的是被关闭的连接(Windows 上可能表现为重置),没有任何字节。
**局限,必须说清楚**:TCP 的 SYN-ACK 由内核发出,用户态进程拦不住,所以端口在扫描器眼里仍是「开着,但不说话」,**不是「没监听」**。要做到真正的没监听,有两条路:
- **等级二:UDP 单包授权**。TCP 平时不监听;Probe 先发一个签名的 UDP 敲门包(UDP 对陌生包本来就静默),Agent 才临时开 TCP 监听几秒,并只放行敲门者的源地址。代价是每次连接多一个往返、要处理 UDP 丢包重试、多出口的 Probe 源地址可能对不上。
- 宿主防火墙规则:需要 root,不适合小鸡节点,不做。
先做等级一,等级二作为可选。
## 11. extension:敏感能力的归宿
Agent 和 Probe 的核心永远是纯采集器。**所有敏感能力**(exec、web ssh 和终端、文件读写、抓包等)都不进核心,做成 extension,**用户可选安装,默认没有**。这样核心保持 NativeAOT、体积小、没有可被滥用的能力,需要这些功能的人装上,不需要的人什么都不用管,便利性不丢。
```text
节点主机
┌ 核心容器(NativeAOT,纯采集)┐ ┌ extension 容器(可选,例如 ssh)┐
│ 身份 A:节点自己的密钥 │ │ 身份 E:自己的密钥,独立注册 │
│ 方法:moshiro.* │ │ 方法:ext.ssh.* │
└───────────────┬──────────────┘ └───────────────┬────────────────┘
│ wss(节点面) │ wss(节点面,另一条独立的会话)
▼ ▼
Backend(核心不知道 extension,extension 不能调用核心)
```
- **独立进程,不是插件**: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 只转发密文,不应该能读到终端里的内容。做法见 [access.md](access.md) 第 8.2 节:属主 passkey 授权、临时 ECDH 加 AEAD,静态数据用属主的 `enc_pubkey` 封装。
- **不做的**:核心里的任何 exec、文件写、加载 extension;用 extension 绕过核心的本地硬上限(extension 有它自己的上限,核心的不放宽)。
## 12. 实施顺序
```text
零 协议方向声明表 + 收发校验 + 对账测试 G7,第 8 节;同时把 NodeConnection 按职责拆开(第 13.2 节) 已完成,不破坏
一 Probe 自己生成密钥 + 加入令牌注册 + 密钥按用途拆开 G2、G11 已完成,破坏性:旧 probe.json 失效
二 预握手敲门(等级一) G4;先基于现有的对端公钥列表,名册出来后换成名册 已完成,破坏性:旧 Probe 被拒绝
三 访问控制 + 入网信任链 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)
可选 等级二:UDP 单包授权;UDP 会话迁移的路径验证
```
阶段一和二是敲门的前提(双方身份密钥都由节点自己生成)。项目允许破坏性变更,当前只有三台节点,重新注册的成本很小。
## 13. 密码学原语与代码落点
### 13.1 原语现状,以及为什么不用 BLAKE3
| 用途 | 现状 |
| --- | --- |
| 身份签名 | ECDSA P-256 加 SHA-256(BCL) |
| 数据面逐包认证 | HMAC-SHA256 截断到 16 字节(`UdpPacket`、`TcpWire`) |
| 会话密钥派生 | HKDF-SHA256(`SessionKeys`) |
| 令牌哈希、SPKI 指纹 | SHA-256 |
| 原始块完整性 | CRC32C,不是密码学校验 |
**BLAKE3 的评估(已定:不采用)**:
- 适合的位置:数据面的逐包 MAC(原生的 keyed 模式,一个原语、32 字节密钥)、KDF(`derive_key` 自带用途标签,比手拼 HKDF 的 `info` 更不容易出错)、给块做摘要再签名(第 9.1 节需要一个块摘要)。
- 不能替换的位置:ECDSA P-256 本身用 SHA-256,令牌哈希和 SPKI 指纹也继续用 SHA-256。所以 BLAKE3 是**多一个**哈希族,不是换掉一个,两套原语的表面都要维护。
- 代价:BCL 里没有;Probe 和 Agent 不引用 NuGet 包,只能在仓库里自己实现,几百行的便携实现加官方测试向量,这是要长期维护的密码学代码。托管实现没有 SHA-NI 加速,而 .NET 的 SHA-256 走系统 OpenSSL、有硬件加速,所以在 x86 上未必更快;收益主要在没有 SHA 指令的 ARM、MIPS 路由器上。
- 结论:**不采用,原语保持现状**(签名 ECDSA P-256 加 SHA-256,逐包 MAC 用 HMAC-SHA256,KDF 用 HKDF-SHA256):不增加一套要长期维护的自实现密码学代码,也不多一个哈希族。块摘要(第 9.1 节)用 SHA-256。若以后在目标硬件上实测发现逐包 MAC 是瓶颈,再单独评估,届时只替换 `Fleet.Identity` 里 `Mac` 和 `Kdf` 两个静态函数,调用方不动。
### 13.2 代码落点与分层原则
目标是**每个机制只有一个落点,层数尽量少,方便维护**:
1. **一个机制,一个落点,纯函数优先**:输入字节、输出判定,不引入新的接口、适配层、端口或注册。节点侧继续不用 DI。
2. **声明和校验放在同一个项目**:`fleet.*` 的声明表在 `Fleet.Rpc`,`moshiro.*` 的在 `MoShiro.Protocol`;校验统一在 `RpcRouter`,节点和服务端共用,这是唯一的卡口。
3. **新代码进现有项目,不新增项目**:Fleet 侧进 `Identity`、`Rpc`、`Link`;Backend 侧的入库检查就放在 `IngestService`,不为安全另建一层。
4. **先收缩再加**:`NodeConnection`(703 行)里混着认证、收包分发、状态推送、在线状态和帧编解码,先按职责拆成同一项目里的几个文件,再往里加方向校验;否则每加一个安全机制它就更难读。**已拆**:`NodeConnection.cs`(生命周期)、`.Auth`、`.Receive`、`.Send`、`.State`、`.Wire` 六个 partial 文件,原来的 729 行主文件现在 143 行。
| 机制 | 落点 | 调用点 |
| --- | --- | --- |
| 方向和角色声明、收发校验 | 声明:`Fleet.Rpc`、`MoShiro.Protocol`;校验:`RpcRouter` | 节点和服务端各一处创建路由器的地方 |
| 对账测试 | `ArchitectureTests`(已有) | 比对声明表和实际注册的处理器 |
| 敲门的构造与验证 | `Fleet.Identity` 里一个静态类 | 服务端两处:`Fleet.Link.PeerListener`(7443)、`Measurement.TcpAcceptor`(7444);客户端两处:`PeerConnector`、`TcpLink` |
| 操作员凭证验签、名册 | `Fleet.Identity`(三种凭证各一个验签函数) | `Fleet.Link` 的 `StateHandler` 采用名册,用 `ConfigStore` 持久化版本 |
| 块和列块的签名与验签 | `MoShiro.Protocol/Raw`(格式和签名都在这里) | 签名:Probe 的列块、节点上传的原始块;验签:`IngestService` |
| 归属校验 | `IngestService` | 入库的唯一入口 |
| 合理性检查 | `Backend.Analysis` 的 `EchoOps` | 计算指标的入口 |
| 密钥按用途拆开、`Mac`、`Kdf` | `Fleet.Identity` | 原有的使用方 |
## 14. 还没定的事
访问控制与入网信任链的细节、待确认的默认值见 [access.md](access.md) 第 17 节。这里只列其余的:
1. 敲门先做等级一,等级二先记 TODO(已按此实现等级一)。
2. 严格程度:节点没有固定操作员公钥就拒绝一切 Backend 给的对端(默认),弱信任模式是安装时的显式选择(见 access.md 第 8 节)。
3. 节点面和外部面是否拆成两个监听端口(8.1 的目标):拆的话部署文档、Caddy 和隧道的配置都要跟着改。
4. extension 的交互式会话**已定**:端到端加密,Backend 只转发密文(见 access.md 第 8.2 和 8.3 节);剩下的是静态数据私钥来源和 AEAD 的选择。
5. extension 的第一个具体实现做不做 web ssh,放在哪个阶段。
6. 块摘要固定 SHA-256,不预留算法字段(不协商、不降级,见第 9.4 节)。
7. Agent 的 `max_total_mbps` 默认值调到多少(第 9.5 节),比如 100。
8. 块的签名放进格式的哪个位置、是否影响现有 `MSRC` 容器版本(第 9.1 节)。