feat(v24): 零信任优先安装 —— 拿到控制端公钥就走 ed25519,否则明确告警

## 为什么不是直接改 auth_mode 默认值
ed25519 的**私钥必须在控制端**,被控端安装时拿不到公钥。硬改默认值会让新装的控制端
连不上(还得手工改配置回退)—— 比「有条件就走零信任 + 无条件时明确告警」更糟。
所以这里的"默认"是**优先顺序**,不是死值。

## 做法
install.ps1 新增 -ClientAclFile(默认探测脚本同目录 client-acl.json,内容即
`nodeagent keygen` 打印的那一条 acl.clients JSON):
- 有且含 client_id+pubkey → auth_mode=ed25519,写 acl{default_effect=deny, clients=[…]},
  绿色确认 + 打印 --auth-mode ed25519 连接示例
- 无 / 缺字段 → 保持 psk,但输出**醒目黄色告警**:说明 client_id 可自报的固有风险
  + 三步切换指引(keygen → 存 client-acl.json → 重跑 install.cmd)
install.cmd 无需改动(install.ps1 自行探测)。

## 验证(真机,不是推理)
- **PowerShell 5.1 语法校验**:PARSE_OK,BOM 保留(EF BB BF)
- **等效逻辑三 CASE**:
  A 有文件 → auth_mode=ed25519 + default_effect=deny + clients 完整(allow/deny/note 保留)
  B 无文件 → auth_mode=psk 且**不写 acl 字段**
  C 缺 pubkey → WARN 并回退 psk(不写半截 ACL)
- 编码守卫:ps1-encoding 24 例全过(BOM + 无 U+FFFD)
- 新增 tests/unit/install-zerotrust.test.mjs(8 例源码断言:参数、探测路径、
  useZeroTrust 条件化、deny 兜底、字段校验、告警文案、连接示例、ConvertTo-Json -Depth 6)

## 文档
docs/SECURITY.md 新增 §4.7 并更新 §5 清单 —— 把 v23 的三项(握手封禁/会话上限/
安装期密钥)与本次 ed25519 默认化一并标为已完成,只留「审计链外部锚定」待做。

## 数据
单测 250 → **258** 全绿;7 包构建通过。
This commit is contained in:
jkooe committed 2026-10-09 23:53:28 +08:00
1 parent 7e63963e4f
commit 8b97fc6106
3 files changed
+142 -15

No files matched your search

+30 -5
View File
@@ -116,16 +116,41 @@ nodeagent keygen --psk --id mac_02
| 未登记的 client_id 用共享 `key` | ✅ 通过(**旧部署行为不变**) |
| 已登记的 client_id 用共享 `key` | ❌ 拒绝(**专属钥匙优先**,不被共享 key 绕过) |
## 4.7 零信任优先安装(v24)
安装时的认证模式选择,从「默认 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。
## 5. 还没做的(后续批次)
| 项 | 说明 |
|---|---|
| ~~发现报文最小化~~ | ✅ **v22 已做**(见 §4.5) |
| ed25519 默认化 | 新装仍默认 psk;v22 提供了更轻量的替代:`keys{}` 每客户端一把 PSK(见 §4.6) |
| 握手失败封禁 | 目前只计数 + 审计,无按 IP 的临时拉黑 |
| 审计链签名 | 哈希链可被「有 root 的攻击者」整链重写,缺外部锚定 |
| 会话上限 | 无最大连接数与空闲断开 |
| 安装期密钥传递 | `install.cmd` 用命令行 `-Key` 传参,同机其他用户 `ps` 可见 |
| ~~ed25519 默认化~~ | ✅ **v24 已做**(零信任优先安装,见 §4.7);另有 v22 的 `keys{}` 每客户端一把 PSK 作为轻量替代 |
| ~~握手失败封禁~~ | ✅ **v23 已做**:连续失败达阈值 → 指数退避封禁(封顶 24h),成功即清零,支持白名单豁免 |
| ~~会话上限~~ | ✅ **v23 已做**:并发上限 8 + 30 分钟空闲断开(有任务/订阅在跑时保守不踢) |
| ~~安装期密钥传递~~ | ✅ **v23 已做**:`install.cmd` 改用管道喂 stdin(`-KeyFromStdin`),密钥不再出现在命令行 |
| 审计链外部锚定 | 哈希链可被「有 root 的攻击者」整链重写;需能把链头同步到独立位置以便比对 |
## 6. 应急处置
+55 -10
View File
@@ -45,7 +45,11 @@ param(
# v23:从**标准输入**读密钥(而非命令行 -Key)。
# 为什么:命令行参数对同机其他用户可见(任务管理器/Get-CimInstance Win32_Process
# 的 CommandLine 字段就是明文)。install.cmd 用 `echo <key>| powershell ...` 喂进来。
[switch]$KeyFromStdin
[switch]$KeyFromStdin,
# v24 零信任优先:控制端公钥文件路径(内容是 `nodeagent keygen` 打印的那一条 JSON,
# 即 acl.clients 的一项)。给了它 → auth_mode 直接走 ed25519(零信任),
# 不再依赖可被自报的 client_id。留空则自动探测脚本同目录的 client-acl.json。
[string]$ClientAclFile = ""
)
$ErrorActionPreference = "Stop"
@@ -151,7 +155,23 @@ if (-not $Key) {
Write-Ok "Generated a new pre-shared key"
}
$cfgJson = @{
# --- v24: zero-trust-first -------------------------------------------------
# PSK 有一个固有弱点:client_id 是调用方**自报**的,所以"谁知道共享 key,谁就能
# 冒充任意未登记的 client_id"—— ACL 的身份维度在 psk 模式下形同虚设。
# ed25519 没有这个问题(身份由私钥签名决定)。故:**只要拿到控制端公钥就走 ed25519**。
$aclEntry = $null
$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
$aclPath = if ($ClientAclFile) { $ClientAclFile } else { Join-Path $scriptDir 'client-acl.json' }
if (Test-Path $aclPath) {
try { $aclEntry = Get-Content $aclPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $aclEntry = $null }
if ($aclEntry -and (-not $aclEntry.client_id -or -not $aclEntry.pubkey)) {
Write-Warn "client-acl.json is missing client_id or pubkey - ignored"
$aclEntry = $null
}
}
$useZeroTrust = ($aclEntry -ne $null)
$cfgObj = [ordered]@{
node_id = $NodeId
host = "0.0.0.0"
port = $Port
@@ -159,8 +179,16 @@ $cfgJson = @{
key = $Key
log_level = "info"
allow_input = [bool]$AllowInput
auth_mode = "psk"
} | ConvertTo-Json
auth_mode = if ($useZeroTrust) { "ed25519" } else { "psk" }
}
if ($useZeroTrust) {
# default_effect=deny:未在 clients 中登记的一律拒绝(零信任兜底)
$cfgObj.acl = @{
default_effect = "deny"
clients = @($aclEntry)
}
}
$cfgJson = $cfgObj | ConvertTo-Json -Depth 6
# 必须写「无 BOM」的 UTF-8:Windows PowerShell 5.1 的 `Set-Content -Encoding UTF8`
# 会写入 BOM(U+FEFF),导致 Node 侧 JSON.parse 报 "Unexpected token ''"
[System.IO.File]::WriteAllText($cfgPath, $cfgJson, (New-Object System.Text.UTF8Encoding($false)))
@@ -292,11 +320,28 @@ if ($certFp) {
Write-Host " $certFp" -ForegroundColor White
}
Write-Host ""
Write-Host " On the Mac, run:" -ForegroundColor Cyan
$primaryIp = if ($ips.Count -gt 0) { $ips[0] } else { '<target-ip>' }
$insecureFlag = if ($NoTls) { "" } else { " --insecure" }
$primaryIp = if ($ips.Count -gt 0) { $ips[0] } else { '<被控端IP>' }
Write-Host " nodeagent connect $primaryIp --port $Port --key $Key$insecureFlag"
Write-Host ""
Write-Host " Zero-trust (optional): run 'nodeagent keygen' on the Mac, then add the" -ForegroundColor DarkGray
Write-Host " printed entry into this file's acl.clients and set auth_mode to 'ed25519'." -ForegroundColor DarkGray
if ($useZeroTrust) {
Write-Host " Zero-trust is ON (auth_mode=ed25519, default_effect=deny)." -ForegroundColor Green
Write-Host " Registered client_id: $($aclEntry.client_id)" -ForegroundColor Green
Write-Host ""
Write-Host " On the Mac, connect with the private key you generated:" -ForegroundColor Cyan
Write-Host " nodeagent connect $primaryIp --port $Port --id $($aclEntry.client_id) --auth-mode ed25519$insecureFlag"
} else {
Write-Host " On the Mac, run:" -ForegroundColor Cyan
Write-Host " nodeagent connect $primaryIp --port $Port --key $Key$insecureFlag"
Write-Host ""
Write-Host " ================ SECURITY WARNING ================" -ForegroundColor Yellow
Write-Host " This agent runs in PSK mode, where the client_id is" -ForegroundColor Yellow
Write-Host " SELF-REPORTED: anyone holding the shared key can" -ForegroundColor Yellow
Write-Host " impersonate any unregistered client_id, so the ACL" -ForegroundColor Yellow
Write-Host " identity dimension is effectively void." -ForegroundColor Yellow
Write-Host ""
Write-Host " To switch to zero-trust (recommended):" -ForegroundColor Cyan
Write-Host " 1) On the Mac: nodeagent keygen --id <name>" -ForegroundColor Cyan
Write-Host " 2) Save the printed JSON as client-acl.json next to this script" -ForegroundColor Cyan
Write-Host " 3) Re-run install.cmd (it auto-detects the file)" -ForegroundColor Cyan
Write-Host " ====================================================" -ForegroundColor Yellow
}
Write-Host ""
+57
View File
@@ -0,0 +1,57 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* 零信任优先安装(v24)的**源码断言**。
*
* 为什么用源码断言:install.ps1 是 5.1 脚本,Node 侧无法直接执行它;
* 而这里要守的是「关键决策逻辑不许被误删」—— 一旦有人重构掉 acl 写入或
* 告警文案,测试立刻红。运行时的正确性已在真机用等效片段验证过
* (CASE A ed25519+deny / CASE B psk 无 acl / CASE C 缺字段回退 psk)。
*/
const ROOT = fileURLToPath(new URL('../../', import.meta.url));
const ps1 = readFileSync(join(ROOT, 'scripts', 'install.ps1'), 'utf8');
test('install.ps1 有 ClientAclFile 参数(零信任入口)', () => {
assert.match(ps1, /\[string\]\$ClientAclFile/, '缺少 -ClientAclFile 参数');
});
test('install.ps1 默认探测脚本同目录的 client-acl.json', () => {
assert.match(ps1, /client-acl\.json/, '缺少默认探测路径');
assert.match(ps1, /\$scriptDir/, '应用 $scriptDir 兜底(-Command 调用时 $PSScriptRoot 为空)');
});
test('install.ps1 有 useZeroTrust 判定且 auth_mode 条件化', () => {
assert.match(ps1, /\$useZeroTrust\s*=/, '缺少 useZeroTrust 判定');
assert.match(ps1, /auth_mode\s*=\s*if \(\$useZeroTrust\)/, 'auth_mode 必须按零信任开关条件化');
});
test('零信任分支写入 acl 且 default_effect=deny(兜底拒绝)', () => {
assert.match(ps1, /default_effect\s*=\s*"deny"/, 'default_effect 必须是 deny(未登记一律拒)');
assert.match(ps1, /clients\s*=\s*@\(\$aclEntry\)/, '缺少 clients 数组写入');
});
test('缺 client_id/pubkey 时忽略并回退(不写半截 ACL)', () => {
assert.match(ps1, /-not \$aclEntry\.client_id -or -not \$aclEntry\.pubkey/, '缺少字段校验');
assert.match(ps1, /\$aclEntry = \$null/, '校验失败必须置空以回退');
});
test('psk 路径必须给出醒目安全告警(不能静默降级)', () => {
assert.match(ps1, /SECURITY WARNING/, 'psk 分支缺少醒目告警');
assert.match(ps1, /SELF-REPORTED/, '告警须说明 client_id 可自报这一固有问题');
assert.match(ps1, /nodeagent keygen/, '告警须给出切换命令');
assert.match(ps1, /Yellow/, '告警须用醒目颜色(Yellow)');
});
test('零信任路径给出正确的连接指引(--id + --auth-mode)', () => {
assert.match(ps1, /--auth-mode ed25519/, '缺少 ed25519 连接示例');
assert.match(ps1, /Zero-trust is ON/, '缺少启用确认输出');
});
test('安装写入的 JSON 用 -Depth 6(acl 嵌套不会被截断)', () => {
assert.match(ps1, /ConvertTo-Json -Depth 6/, 'acl 是嵌套结构,Depth 不足会被截成字符串');
});