diff --git a/docs/SECURITY.md b/docs/SECURITY.md index ff3e726..b21a3f6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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. 应急处置 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d869a26..938805b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -45,7 +45,11 @@ param( # v23:从**标准输入**读密钥(而非命令行 -Key)。 # 为什么:命令行参数对同机其他用户可见(任务管理器/Get-CimInstance Win32_Process # 的 CommandLine 字段就是明文)。install.cmd 用 `echo | 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 { '' } $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 " -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 "" diff --git a/tests/unit/install-zerotrust.test.mjs b/tests/unit/install-zerotrust.test.mjs new file mode 100644 index 0000000..6070b16 --- /dev/null +++ b/tests/unit/install-zerotrust.test.mjs @@ -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 不足会被截成字符串'); +});