## 问题(皇上指出) 项目里两套编号并行,无法判断哪个是"当前版本": - **发布版本**(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 全绿
10 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),所以 "刚锚定就比对"通常显示"链已增长" —— 属预期,不是篡改。
如何真正用起来:定期(建议每日)调用 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,或直接不接入陌生网络 |