# 安全模型与加固指南 > 面向使用者与运维者。协议细节见 `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`,或直接不接入陌生网络 |