## 为什么(一句话)
锚点的安全性完全来自「它在攻击者碰不到的地方」。与被审计日志同机的锚点等于没锚:
被控端一旦被 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 分支已做错误处理验证
12 KiB
安全模型与加固指南
面向使用者与运维者。协议细节见
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 接受任意证书 —— 于是同网段的中间人可以
透明中继(拿不到钥匙,但能读到截屏/剪贴板/键入内容)。现在:
- 被控端在
install.ps1输出里打印 TLS 证书指纹(sha256 of DER) - 控制端首次连接时把它钉进
~/.nodeagent/config.json(nodes.<名>.cert_sha256) - 之后每次连接严格比对,不一致直接拒绝:
✗ 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 约束) |
两个关键设计:
- 锚点不能只记
head_hash—— 日志轮转会丢弃最旧段使条目数下降,只记哈希会把 轮转误判成篡改。故记四元组{entries, head_hash, head_ts, rotated_segments}, 比对时先按轮转对齐再判尾部。三条判定:- 条目数减少且无轮转 → 疑似整链重写(
ok:false) - 条目数与段数都没变但哈希不同 → 该段被重写(
ok:false) - 条目数增长 / 发生轮转 → 锚点仍在链上(
ok:true,附 detail 供人工核对)
- 条目数减少且无轮转 → 疑似整链重写(
- 追加式而非覆盖式 —— 若锚点可覆盖,攻击者重写链后再"刷新"锚点即可抹掉痕迹。
已知行为:锚定调用自身也会写一条审计(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) | |
✅ 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,或直接不接入陌生网络 |