## 为什么(一句话)
锚点的安全性完全来自「它在攻击者碰不到的地方」。与被审计日志同机的锚点等于没锚:
被控端一旦被 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 分支已做错误处理验证
228 lines
12 KiB
Markdown
228 lines
12 KiB
Markdown
# 安全模型与加固指南
|
||
|
||
> 面向使用者与运维者。协议细节见 `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`,或直接不接入陌生网络 |
|