Files
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

12 KiB
Raw Permalink Blame History

安全模型与加固指南

面向使用者与运维者。协议细节见 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 位指纹,与控制端首次连接打印的 🔐 已钉住被控端证书指纹 比对 —— 不一致说明你连的不是那台机器。

被控端重装系统 / 证书重建后指纹必然改变,此时显式重钉:

nodeagent connect <地址> --port 8765 --key <密钥> --insecure --forget-cert

查看当前状态:nodeagent info(会显示「已钉住 … 本次一致 ✓ / 未钉住」)

4. v21 加固二:来源网段白名单(allow_from)

被控端默认监听 0.0.0.0(为了能远程接入)。这意味着机器一换网络就自动暴露 —— 连到咖啡厅/酒店/便利店的 Wi-Fi 时,同网段任何人都能看到它的 8765 端口。

在被控端 agent.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 / 端口 / 用什么认证 / 键鼠是否开着」。

"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 分配独立钥匙:

# 控制端:为某个 client_id 生成专属 PSK
nodeagent keygen --psk --id mac_02
// 被控端 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」**:

# 控制端(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 照样通过。

做法:把链头记到链外,攻击者改不了已经落到别处的历史记录。

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:

# 每日:拉链头 → 生成锚点(再由云盘能力上传到链外)
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,或直接不接入陌生网络