Files
nodeagent/docs/SECURITY.md
T
jkooe 28ce95f045 feat(audit): 审计锚点外部化落地 —— 判定逻辑收敛为 protocol 纯函数 + 控制端工具 + 云盘链路
## 为什么(一句话)
锚点的安全性完全来自「它在攻击者碰不到的地方」。与被审计日志同机的锚点等于没锚:
被控端一旦被 root,重写日志后顺手改掉锚点即可。

## 本次改动

### ① 判定逻辑收敛(消除「两份真相」)
把 `compareWithAnchors` 的三条判定抽到 **`packages/protocol/src/audit-anchor.ts`** 的
**纯函数 `judgeAnchors(head, anchors)`**(无 IO,不知路径):
- agent 端 `compareWithAnchors` 改为「读文件 + 调 judgeAnchors」
- 控制端脚本同样调用它
→ 同一份日志不可能出现「被控端说一致、控制端说被改」的分歧
- 附带 `parseAnchorsFromJsonl`(坏行跳过,一行坏数据不毁整份锚点)
- agent 侧类型改为 re-export,既有导入路径不变(**9 个原有锚点单测全绿,行为零变化**)

### ② 控制端工具 `scripts/anchor-audit.mjs`
- `collect`:拉链头 → 生成一行锚点 JSONL(并打印 `ANCHOR_JSON=` 便于 automation 取用)
- `verify`:读锚点文件(可从云盘下载)+ 拉链头 → 判定,`ok:false` 时**退出码 2** + 醒目告警
- 走 CLI 而非自己实现连接(复用其多设备配置/证书钉住/daemon,避免又一份实现)
- 支持 `--head-file`:**离线也能验证判定链路**(本轮即用它做了 fixture 验证)

### ③ 云盘链路
- 建 `…/Project/NodeAgent/anchors/`(与全量备份分开目录)
- 每日 automation 更新为「拉链头 → 上传云盘(一天一个带日期的文件)→ 简报」,
  并规定每月首日额外做一次「下载历史锚点 → 链外比对」

## 验证
- **纯函数重构零行为变化**:原有 audit-anchor 单测 9/9 全绿;全仓 273 单测全绿;7 包构建
- **工具离线验证**(fixture):一致 → 「与锚点完全一致」退出码 0;
  篡改 → 「链头哈希与锚点不一致」退出码 2;三个错误分支均给出明确提示
- 云盘:`anchors/` 目录创建成功(1/1 步),目录内容已复核
- ⚠️ 端到端(真拉链头 + 真上传)**待被控端上线**;脚本的 CLI 分支已做错误处理验证
2026-10-10 19:14:09 +08:00

228 lines
12 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.
# 安全模型与加固指南
> 面向使用者与运维者。协议细节见 `DEVELOPMENT.md`,发版与升级见 `VERSIONING.md`。
## 1. 信任模型:钥匙 = 那台机器的 root
`system.shell.exec` 能执行任意命令,所以**拿到预共享密钥(PSK)就等于拿到被控端的 root**。
请按 root 密码的标准保管它:不外传、不进版本库、不进公开安装包、不贴进聊天/工单。
被控端首启时用 CSPRNG(`crypto.getRandomValues`,32 字节 / 256 bit)在本机生成密钥,
只存在 `<数据目录>/agent.json`,**不进任何分发包**。
## 2. 认证做了什么(已具备)
| 机制 | 说明 |
|---|---|
| 挑战-应答 | `hello → challenge(nonce) → auth(HMAC / Ed25519 签名)`,**钥匙全程不上网** |
| nonce 一次性 | 用后即废(`server.ts` 置 null),抓包重放无效 |
| 协议主版本校验 | 不一致直接 `E_PROTOCOL_MISMATCH` 拒连 |
| 能力级 ACL | `deny → allow → 默认拒绝`(**身份维度仅在 ed25519 模式有效**) |
| 调用限速 | 按 client_id 滑动窗口 |
| 审计链 | 每条含前一条哈希,可验篡改 |
| 键鼠控制 | **默认禁用**,需显式开启 |
> ⚠️ **PSK 模式的固有限制**:`client_id` 由客户端自报,ACL 的「身份」维度挡不住
> 持有同一把钥匙的人伪装成任意 client_id。要真正按身份授权,请用 **ed25519**
> (`nodeagent keygen` → 公钥登记到被控端 ACL → `--auth-mode ed25519`)。
## 3. v21 加固一:证书指纹钉住(TOFU)
自签证书没有 CA 链,历史上只能 `insecure` 接受任意证书 —— 于是同网段的中间人可以
**透明中继**(拿不到钥匙,但能读到截屏/剪贴板/键入内容)。现在:
1. 被控端在 `install.ps1` 输出里打印 **TLS 证书指纹**(sha256 of DER)
2. 控制端**首次**连接时把它钉进 `~/.nodeagent/config.json`(`nodes.<名>.cert_sha256`)
3. 之后每次连接**严格比对**,不一致直接拒绝:
```
✗ E_CERT_MISMATCH: 证书指纹不匹配,拒绝连接:期望 2ed4957b… 实际 ca8dc20c…
(被控端可能重装了、或中间人冒充)
```
**带外核对(重要)**:装机时屏幕/终端上会印一个 64 位指纹,与控制端首次连接打印的
`🔐 已钉住被控端证书指纹` 比对 —— 不一致说明你连的不是那台机器。
被控端**重装系统 / 证书重建**后指纹必然改变,此时显式重钉:
```bash
nodeagent connect <地址> --port 8765 --key <密钥> --insecure --forget-cert
```
查看当前状态:`nodeagent info`(会显示「已钉住 … 本次一致 ✓ / 未钉住」)
## 4. v21 加固二:来源网段白名单(`allow_from`)
被控端默认监听 `0.0.0.0`(为了能远程接入)。这意味着**机器一换网络就自动暴露** ——
连到咖啡厅/酒店/便利店的 Wi-Fi 时,同网段任何人都能看到它的 8765 端口。
在被控端 `agent.json` 里限制来源:
```json
{
"host": "0.0.0.0",
"port": 8765,
"allow_from": ["192.168.0.0/16", "10.0.0.0/8"]
}
```
- 支持 CIDR、单 IP、以及 `*`(放行全部)
- **未配置 = 放行全部**(保持向后兼容);`nodeagent info` 会提示尚未收敛
- 拒绝发生在**业务握手之前**(不浪费 TLS/挑战应答开销),并记 `net.deny` 审计
- 判定按**前缀比特**而非字符串相等 —— Node 在 IPv6 监听上把 IPv4 客户端报成
`::ffff:a.b.c.d`,已做归一化
## 4.5 发现广播最小化(v22)
UDP 广播是**未认证**的:默认配置下同网段任何人都能看到
「哪台机器可接管 / IP / 端口 / 用什么认证 / 键鼠是否开着」。
```json
"discovery": { "detail": "minimal", "secret": "<与控制端相同的密钥>" }
```
- `detail: "minimal"`:顶层只留 `service / version / node_id / ts`,
真实信息收进 `detail{}`(**同网段看不到地址、端口、认证方式、键鼠开关**)
- 配了 `secret`:`detail` 带 **HMAC 签名**(`node_id|ts|JSON(detail)`),
控制端用同一密钥验签 → 列表里显示 **🔒 已认证**;没配/验签失败显示 **⚠️ 明文**(仅作参考)
- 控制端:`nodeagent discover --discover-secret <同一密钥>`
- 未配置时 agent 启动会打印醒目告警,提示收敛
> 仍会暴露「这里有一台可被接管的机器」(存在性无法隐藏,除非关广播或改用 Hub)。
> 真正的身份确认始终发生在**握手之后**(`auth_ok` 带证书指纹,控制端严格比对)。
## 4.6 每客户端一把 PSK(v22,psk 模式下的身份绑定)
psk 模式里 `client_id` 由客户端自报,所以**一把共享 key 意味着持有者可冒充任意 client_id**,
ACL 的身份维度形同虚设。v22 起支持按 client_id 分配独立钥匙:
```bash
# 控制端:为某个 client_id 生成专属 PSK
nodeagent keygen --psk --id mac_02
```
```json
// 被控端 agent.json(可并列多个 client_id)
{ "keys": { "mac_02": "<那把密钥>" } }
```
效果(已实测):
| 场景 | 结果 |
|---|---|
| alice 用自己的 key | ✅ 通过 |
| **alice 的 key 冒用 bob 身份** | ❌ `E_AUTH_FAILED` |
| bob 用自己的 key | ✅ 通过 |
| 未登记的 client_id 用共享 `key` | ✅ 通过(**旧部署行为不变**) |
| 已登记的 client_id 用共享 `key` | ❌ 拒绝(**专属钥匙优先**,不被共享 key 绕过) |
## 4.7 零信任优先安装(v2.0.0)
安装时的认证模式选择,从「默认 psk + 可选 ed25519」改为**「能拿到控制端公钥就走 ed25519」**:
```bash
# 控制端(Mac)
nodeagent keygen --id mac_01 # 输出一段 JSON(acl.clients 的一项)
# 把它保存为 client-acl.json,放在被控端安装包同目录,然后:
# 双击 install.cmd(自动探测 client-acl.json)
```
行为:
| 条件 | `auth_mode` | 写入的配置 | 输出 |
|---|---|---|---|
| 侦测到 `client-acl.json`(含 client_id + pubkey) | **ed25519** | `acl.default_effect="deny"` + `clients=[…]` | 绿色确认 + `--auth-mode ed25519` 连接示例 |
| 无该文件,或缺 client_id/pubkey | psk | 不写 acl | **醒目黄色告警**(说明 `client_id` 可自报的风险 + 三步切换指引) |
> **为什么不是直接改默认值为 ed25519**:ed25519 的**私钥必须在控制端**,被控端安装时
> 拿不到公钥。硬改默认值会让新装的控制端连不上(还要手工改配置回退),比"有条件就走零信任
> + 无条件时明确告警"更糟。所以这里的"默认"是**优先顺序**,而不是死值。
>
> 真机验证(2026-10-09):CASE A 有文件 → `ed25519` + `default_effect=deny` + clients 完整;
> CASE B 无文件 → `psk` 且**不写 acl 字段**;CASE C 缺 pubkey → 告警并回退 psk。
## 4.8 审计链外部锚定(v2.0.0)
**问题**:链内哈希只能发现"改一条"(其后全部需重算)。「有 root 的攻击者」可以
**整链重写** —— 从零重建一份自洽的假日志,`system.audit.verify` 照样通过。
**做法**:把链头记到**链外**,攻击者改不了已经落到别处的历史记录。
```bash
nodeagent --node win audit head --compare # 看链头 + 与最近锚点比对
nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
```
| 能力 | 风险 | 说明 |
|---|---|---|
| `system.audit.head` | low | 返回 `{entries, head_hash, head_ts, rotated_segments, file_bytes}`;`compare=true` 附比对结论 |
| `system.audit.anchor` | medium | 把链头**追加**写入锚点文件(默认 `<数据目录>/audit-anchors.jsonl`;指定 path 时受 `fs_roots` 约束) |
**两个关键设计**:
1. **锚点不能只记 `head_hash`** —— 日志轮转会丢弃最旧段使条目数下降,只记哈希会把
**轮转误判成篡改**。故记四元组 `{entries, head_hash, head_ts, rotated_segments}`,
比对时先按轮转对齐再判尾部。三条判定:
- 条目数减少且无轮转 → **疑似整链重写**(`ok:false`)
- 条目数与段数都没变但哈希不同 → **该段被重写**(`ok:false`)
- 条目数增长 / 发生轮转 → 锚点仍在链上(`ok:true`,附 detail 供人工核对)
2. **追加式而非覆盖式** —— 若锚点可覆盖,攻击者重写链后再"刷新"锚点即可抹掉痕迹。
**已知行为**:锚定调用自身也会写一条审计(server 层统一记录 invoke),所以
"刚锚定就比对"通常显示"链已增长" —— 属预期,不是篡改。
### 外部化路径(2026-10 落地)
锚点的安全性**完全来自「它在攻击者碰不到的地方」** —— 与被审计日志同机的锚点等于没有锚。
故分两层存放:
| 层 | 位置 | 能防住 |
|---|---|---|
| L1 | 控制端(Mac)本地 | 被控端单点被 root |
| **L2** | **移动云盘 `…/Project/NodeAgent/anchors/`** | **控制端也被攻破**(锚点在腾讯云,被控端 root 也碰不到) |
**控制端工具** `scripts/anchor-audit.mjs`:
```bash
# 每日:拉链头 → 生成锚点(再由云盘能力上传到链外)
node scripts/anchor-audit.mjs collect --node win --out /tmp/anchor-$(date +%Y%m%d).jsonl
# 每月(或怀疑时):用链外锚点比对当前链
node scripts/anchor-audit.mjs verify --anchors <从云盘下载的锚点文件> --node win
# ok:false → 退出码 2,并给出「疑似整链重写 / 链头哈希不一致」的明细
```
**为什么判定必须由控制端做**:被控端是**不可信方** —— 它若被 root,会重写日志、
也会把自己的锚点改掉,然后回报"一切正常"。所以"锚点存哪、和什么比"只能由控制端掌握。
**判定逻辑只有一份**:`packages/protocol/src/audit-anchor.ts` 的 `judgeAnchors`(纯函数),
被控端 `compareWithAnchors` 与控制端脚本**都调用它** —— 若两端各写一份,同一份日志会
出现"被控端说一致、控制端说被改"的荒谬局面。
**云盘侧的两点约束(都正好合意)**:① 不支持覆盖/删除 → 锚点天然「追加不可改写」;
② 一天一个文件(`nodeagent-anchor-YYYYMMDD.jsonl`)→ 保留历史锚点,可定位被改区间。
**如何真正用起来**:定期(建议每日)调用 `system.audit.anchor`,并用
`system.audit.head --compare` 巡检;若把锚点文件指向**另一台机器/网盘同步目录**,
外部锚定的价值才真正落地(本机文件被整盘重写时锚点也随之消失)。
## 5. 还没做的(后续批次)
| 项 | 说明 |
|---|---|
| ~~发现报文最小化~~ | ✅ **v22 已做**(见 §4.5) |
| ~~ed25519 默认化~~ | ✅ **v2.0.0 已做**(零信任优先安装,见 §4.7);另有 v22 的 `keys{}` 每客户端一把 PSK 作为轻量替代 |
| ~~握手失败封禁~~ | ✅ **v2.0.0 已做**:连续失败达阈值 → 指数退避封禁(封顶 24h),成功即清零,支持白名单豁免 |
| ~~会话上限~~ | ✅ **v2.0.0 已做**:并发上限 8 + 30 分钟空闲断开(有任务/订阅在跑时保守不踢) |
| ~~安装期密钥传递~~ | ✅ **v2.0.0 已做**:`install.cmd` 改用管道喂 stdin(`-KeyFromStdin`),密钥不再出现在命令行 |
| ~~审计链外部锚定~~ | ✅ **v2.0.0 已做**(见 §4.8):链头可读 + 锚点追加落盘 + 锚点比对 |
## 6. 应急处置
| 场景 | 处置 |
|---|---|
| 怀疑钥匙泄漏 | 换钥匙(被控端删 `agent.json` 的 `key` 后重启 → 首启重新生成并打印),或改用 ed25519 |
| 证书被换 / 重装后连不上 | `nodeagent connect … --forget-cert` 重钉指纹 |
| 怀疑被中间人 | 用 `install.ps1` 打印的指纹做带外核对;不一致立即断网排查 |
| 机器要带去不可信网络 | 事先配 `allow_from`,或直接不接入陌生网络 |