Files
nodeagent/docs/SECURITY.md
T
jkooe e23788d717 refactor(versioning): 统一版本号 —— semver 为唯一版本号,开发批次代号并入其归属版本
## 问题(皇上指出)
项目里两套编号并行,无法判断哪个是"当前版本":
- **发布版本**(semver):v1.1.0 ~ v1.6.0、v2.0.0
- **开发批次代号**:v1 ~ v26(README 路线表 / 代码注释 / 文档)
时间线证据:v1.1.0 首发(09-29)时批次已推进到 v12.4 —— **二者从来不是一对一**(26 批次只发过 10 版)。

## 规则(新增 docs/VERSIONING.md §0 版本号唯一性规范)
- **唯一版本号 = semver `vX.Y.Z`**,两个来源必须永远一致:git tag 与 package.json(构建期注入 agent)
- **批次代号不是版本号**,不再单独出现;需要区分同版内多批改动时用**功能名**
- 附「批次 → 归属发布版本」映射表(按 tag 时间线与功能内容推定,并注明以 CHANGELOG 为准)

## 统一动作
1. **批次 v23 ~ v26 → v2.0.0**(22 文件 / 53 处)—— 它们确实属于 v2.0.0
2. **臣的批次号 v1.7 / v1.8 → v2.0.0**(27 文件)—— 未单独发版
3. **简写补全三段式**:v1.5 → v1.5.0、v1.6 → v1.6.0
4. **README 路线表**:`**v1**` → `**批次 1**`;表头加「批次 ≠ 版本号」说明并链到 §0;
   补齐批次 16~29 与 **v2.0.0 发布行**;删掉过时的「待办」行(提到的分支早已删除)
5. `docs/VERSIONING.md` 里**故意保留** 3 处批次号 —— 那是规范说明本身

## 保留项(真实发布版本,不动)
v1.1.0 / v1.2.1 / v1.3.0 / v1.4.0~v1.4.3 / v1.5.0 / v1.6.0 / v2.0.0(10 个 tag)

## 验证
- 版本号一致性:package.json = tag = 8 个 workspace = tauri.conf.json = **2.0.0** ✓
- v1.7/v1.8/v23~v26 全仓残留 **3 处**(均在 VERSIONING.md 规范说明中,属预期)
- 7 包构建 · 单测 273 · e2e 30 · ps1-encoding 24 全绿
2026-10-10 14:27:12 +08:00

197 lines
10 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),所以
"刚锚定就比对"通常显示"链已增长" —— 属预期,不是篡改。
**如何真正用起来**:定期(建议每日)调用 `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`,或直接不接入陌生网络 |