From e841fbb187c1be24e46b9bcf5e63d29d4f22bb49 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:42:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(v22):=20=E7=AC=AC=E4=BA=8C=E6=89=B9?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=AF=8F=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=8B=AC=E7=AB=8B=20PSK=20?= =?UTF-8?q?+=20=E5=8F=91=E7=8E=B0=E5=B9=BF=E6=92=AD=E6=9C=80=E5=B0=8F?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接着 v21 的两件 P0,补齐第二批清单里的两项(对应原建议 ③/④ 的一半)。 ## ① 每客户端一把 PSK(`agent.json` 的 `keys{}`)—— 让 ACL 的身份维度真正有效 **问题**:psk 模式下 `client_id` 由客户端自报(server.ts 原注释自认), 一把共享 key 意味着**持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。 **做法**:按 `client_id` 分配独立钥匙;未登记者回落到共享 `key`(旧部署行为不变)。 比 ed25519 更轻量(不用管公钥),同样是「一把一个身份」。 **实测**(五场景全对): | 场景 | 结果 | |---|---| | alice 用自己的 key | ✅ | | **alice 的 key 冒用 bob 身份** | ❌ E_AUTH_FAILED ← 核心安全性质 | | bob 用自己的 key | ✅ | | 未登记 carol 用共享 key | ✅(向后兼容) | | 已登记 alice 用共享 key | ❌ 拒绝(专属优先,不被共享 key 绕过) | 配套:`nodeagent keygen --psk --id mac_02` 生成专属 key 并打印可直接粘贴的 agent.json 片段; 鉴权日志带 `key=scoped|shared-fallback|shared`,审计区分「专属钥匙失败」与「回落共享失败」。 ## ② 发现广播最小化(`discovery.detail` + `discovery.secret`)—— 收情报泄漏 **问题**:UDP 广播未认证,默认明文告知 IP/端口/认证方式/键鼠开关,等于给攻击者一份目标清单。 **做法**: - `detail: "minimal"` → 顶层只剩 `service / version / node_id / ts`, 真实信息收进 `detail{}`(**实测顶层已无 host/port/auth_mode/input_enabled**) - 配 `secret` → `detail` 带 HMAC 签名(`node_id|ts|JSON(detail)`), 控制端 `discover --discover-secret <同一密钥>` 验签 → 列表标 **🔒 已认证**; 未配/验签失败标 **⚠️ 明文**(明示「仅作参考、不可当凭据」) - 配了 secret 自动启用 minimal;未配置时启动横幅**醒目告警**提示收敛 **实测**三视角:正确密钥 🔒 / 无密钥 ⚠️ / 错密钥 ⚠️(验签失败正确降级)✓ ## 验证 - 单测 159 → **162**(新增签名往返一致性:错密钥/篡改详情/改时间戳均须验签失败) - e2e **29** 全绿;本轮改动为纯加法(新增可选字段),未破坏既有行为 - 文档 `SECURITY.md` 新增 §4.5 / §4.6,并把「发现最小化」从待办划掉 ## 剩余待办(SECURITY.md §5) 握手失败封禁 / 审计链签名与外部锚定 / 会话上限与空闲断开 / 安装期密钥改用文件传递 / ed25519 仍非默认(v22 的 `keys{}` 已是更轻量的等价物) --- apps/agent/src/config.ts | 22 +++++++- apps/agent/src/discovery.ts | 88 ++++++++++++++++++++++++------- apps/agent/src/index.ts | 16 ++++++ apps/agent/src/server.ts | 32 ++++++++--- apps/cli/src/cmd/conn.ts | 44 ++++++++++++++-- apps/cli/src/options.ts | 4 ++ apps/cli/src/types.ts | 4 ++ docs/SECURITY.md | 48 ++++++++++++++++- packages/client/src/discovery.ts | 53 ++++++++++++++++--- tests/unit/discovery-sig.test.mjs | 41 ++++++++++++++ 10 files changed, 312 insertions(+), 40 deletions(-) create mode 100644 tests/unit/discovery-sig.test.mjs diff --git a/apps/agent/src/config.ts b/apps/agent/src/config.ts index ed6e11c..a113e94 100644 --- a/apps/agent/src/config.ts +++ b/apps/agent/src/config.ts @@ -8,8 +8,17 @@ export interface AgentConfig { host: string; port: number; tls: boolean; - /** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI */ + /** v1 简化:预共享密钥存于 600 权限文件;v2 升级 DPAPI。**所有调用方共用**,仅适合信任网络 */ key: string; + /** + * v22:**每客户端一把**预共享密钥(`client_id` → key)。 + * + * 为什么需要它:psk 模式下 `client_id` 由客户端自报,历史上「一把共享 key」等于 + * **持有者可冒充任意 client_id**,ACL 的身份维度形同虚设。 + * 改为每个 client_id 一把钥匙后,「知道 key A 就只能以 A 的身份进来」—— + * 身份才真正被绑定。未登记的 client_id 回落到上面的 `key`(旧部署行为不变)。 + */ + keys?: Record; log_level: 'debug' | 'info' | 'warn'; /** * v2:是否允许输入控制(鼠标 / 键盘)。 @@ -53,6 +62,17 @@ export interface AgentConfig { broadcast?: string; /** 广播间隔毫秒(默认 5000) */ interval_ms?: number; + /** + * v22:广播详情级别。 + * - `full`(默认,向后兼容):携带地址/端口/认证方式/键鼠开关等全部信息 + * - `minimal`:只广播「存在 + node_id + 时间」,详情放进 `detail` 字段; + * 配了 `secret` 时详情会被 **HMAC 认证**(控制端持钥才看得到、且能验证真伪) + * ⚠️ 广播是**未认证**的:任何人收 UDP 都能看到内容。minimal 仍会暴露 + * 「这里有一台可被接管的机器」,但不再泄露地址、端口与认证方式。 + */ + detail?: 'full' | 'minimal'; + /** v22:详情认证密钥(配合 detail=minimal 使用;配了就会自动启用 minimal) */ + secret?: string; }; /** * v5:文件访问白名单(根目录列表)。 diff --git a/apps/agent/src/discovery.ts b/apps/agent/src/discovery.ts index 3a2af2a..132f5de 100644 --- a/apps/agent/src/discovery.ts +++ b/apps/agent/src/discovery.ts @@ -1,5 +1,5 @@ import { createSocket, type Socket } from 'node:dgram'; -import { DEFAULT_DISCOVERY_PORT } from '@nodeagent/protocol'; +import { DEFAULT_DISCOVERY_PORT, computeHmac } from '@nodeagent/protocol'; import { currentCertFingerprint } from './certs.js'; export { DEFAULT_DISCOVERY_PORT }; @@ -10,20 +10,41 @@ export interface BeaconPayload { service: 'nodeagent'; version: string; node_id: string; + /** + * 以下字段在 `full` 模式下直给在顶层;在 `minimal` 模式下收在 `detail` 里 + * (顶层只留「存在性 + node_id + 时间」)。故为可选。 + */ /** 被控端可达地址(由被控端自行判定) */ - host: string; - port: number; - tls: boolean; - auth_mode: string; + host?: string; + port?: number; + tls?: boolean; + auth_mode?: string; /** 需要输入控制开关的状态,便于控制端提示 */ - input_enabled: boolean; + input_enabled?: boolean; /** * v21:TLS 证书指纹(sha256 hex)。 * ⚠️ 这是**未认证**广播,可被伪造 —— 只用于「看起来像不像」,不能作为身份凭据; * 真正的身份确认发生在握手后(auth_ok 里带指纹,且控制端会严格比对)。 */ cert_sha256?: string | null; - platform: string; + platform?: string; + /** + * v22:`true` 表示这是**最小化**广播 —— 顶层只有存在性信息, + * 真实地址/端口/认证方式等在 `detail` 里(有 secret 时带 `sig` 认证)。 + */ + minimal?: boolean; + /** v22:最小化广播里的详情(可能带 `sig`,由持有 secret 的控制端验证) */ + detail?: { + host: string; + port: number; + tls: boolean; + auth_mode: string; + input_enabled: boolean; + cert_sha256?: string | null; + platform: string; + }; + /** v22:详情签名(HMAC-SHA256 over `node_id|ts|JSON(detail)`),由持有 secret 的控制端验证 */ + sig?: string; ts: number; } @@ -34,8 +55,14 @@ export interface BeaconOptions { /** 广播地址;局域网内通常为 255.255.255.255 */ broadcast: string; intervalMs: number; - /** 报文中除 ts 外的固定字段 */ - base: Omit; + /** 报文中除 ts 外的固定字段(full 模式直给顶层的那组) */ + base: Required< + Pick + >; + /** v22:详情级别;配了 secret 也会自动启用 minimal */ + detail?: 'full' | 'minimal'; + /** v22:详情认证密钥(HMAC-SHA256 over node_id|ts|detail) */ + secret?: string; } export interface Beacon { @@ -63,16 +90,39 @@ export function startBeacon(opts: BeaconOptions, onError?: (msg: string) => void const send = (): void => { if (!ready) return; - const buf = Buffer.from( - JSON.stringify({ - service: SERVICE_TAG, - version: PROTOCOL_VERSION, - cert_sha256: currentCertFingerprint(), - ...opts.base, - ts: Date.now(), - } satisfies BeaconPayload), - 'utf8', - ); + const ts = Date.now(); + // v22 详情分级:minimal 模式只广播「存在性」,详情移入 detail{} 并用 secret 做 HMAC。 + // 未配 secret 时 detail 仍然明文(此时 sig 缺省,控制端会标为「未认证」)。 + const full = { + host: opts.base.host, + port: opts.base.port, + tls: opts.base.tls, + auth_mode: opts.base.auth_mode, + input_enabled: opts.base.input_enabled, + cert_sha256: currentCertFingerprint(), + platform: opts.base.platform, + }; + const minimal = opts.detail === 'minimal' || !!opts.secret; + const payload: BeaconPayload = minimal + ? { + service: SERVICE_TAG, + version: PROTOCOL_VERSION, + node_id: opts.base.node_id, + minimal: true, + detail: full, + sig: opts.secret + ? computeHmac(opts.secret, `${opts.base.node_id}|${ts}|${JSON.stringify(full)}`) + : undefined, + ts, + } + : { + service: SERVICE_TAG, + version: PROTOCOL_VERSION, + cert_sha256: currentCertFingerprint(), + ...opts.base, + ts, + }; + const buf = Buffer.from(JSON.stringify(payload), 'utf8'); try { socket.send(buf, 0, buf.length, opts.port, opts.broadcast, (err) => { if (err) onError?.(`广播失败: ${err.message}`); diff --git a/apps/agent/src/index.ts b/apps/agent/src/index.ts index 3051e8c..356cdb4 100644 --- a/apps/agent/src/index.ts +++ b/apps/agent/src/index.ts @@ -61,6 +61,9 @@ async function main(): Promise { port: config.discovery?.port ?? DEFAULT_DISCOVERY_PORT, broadcast: config.discovery?.broadcast ?? '255.255.255.255', intervalMs: config.discovery?.interval_ms ?? 5000, + // v22:详情分级 + 认证(配了 secret 自动启用 minimal) + detail: config.discovery?.detail ?? 'full', + secret: config.discovery?.secret, base: { node_id: config.node_id, host: ips[0] ?? '127.0.0.1', @@ -88,6 +91,19 @@ async function main(): Promise { console.log(` 能力 : ${CAPABILITY_MANIFEST.length} 项`); console.log(` 认证模式 : ${auditMode}${auditMode === 'ed25519' ? `(已登记 ${config.acl?.clients.length ?? 0} 个调用方)` : ''}`); console.log(` 输入控制 : ${config.allow_input === true ? '已开启' : '已禁用(默认)'}`); + // v22:明文广播会暴露「地址/端口/认证方式/键鼠是否开启」,未收敛时必须提醒 + if (config.discovery?.enabled !== false && !useHub) { + const minimal = config.discovery?.detail === 'minimal' || !!config.discovery?.secret; + if (!minimal) { + console.log( + ' ⚠️ 发现广播为「明文全量」:同网段可见 IP/端口/认证方式/键鼠开关。' + + '建议在 agent.json 设 discovery.detail="minimal"(可再配 secret 加密详情)', + ); + } else if (!config.discovery?.secret) { + console.log(' ℹ️ 发现广播已最小化,但 detail 未配 secret —— 详情仍是明文(可加 secret 让其带签名)'); + } + } + const discoOn = config.discovery?.enabled !== false; console.log( ` 局域网发现 : ${discoOn ? `已开启(UDP ${config.discovery?.port ?? DEFAULT_DISCOVERY_PORT} 心跳广播)` : '已关闭'}`, diff --git a/apps/agent/src/server.ts b/apps/agent/src/server.ts index 877e96a..f608c5b 100644 --- a/apps/agent/src/server.ts +++ b/apps/agent/src/server.ts @@ -132,6 +132,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore { } const clientId = params.client_id ?? '?'; let authorized: string[] | null = null; + let keyKindUsed = 'ed25519'; if (authMode === 'ed25519') { const client = aclPolicy.clients.find((c) => c.client_id === clientId); @@ -156,13 +157,28 @@ export function createAgentCore(cfg: AgentConfig): AgentCore { clientId, CAPABILITY_MANIFEST.map((c) => c.name), ); - } else if (!verifyHmac(cfg.key, state.nonce, params.hmac ?? '')) { - log('warn', `鉴权失败 client_id=${clientId}`); - audit({ type: 'auth.failure', client_id: clientId, remote: state.remote, reason: '预共享密钥校验失败' }); - sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败'); - // 稍作延迟再关闭,确保错误响应先送达控制端 - setTimeout(() => ws.close(), 100); - return; + } else { + // v22:优先用「该 client_id 专属的钥匙」,未登记才回落到共享 key。 + // 这样「知道 key A」就只能以 A 的身份进来 —— ACL 的身份维度在 psk 模式下才有意义。 + const scopedKey = cfg.keys?.[clientId]; + const keyForClient = scopedKey ?? cfg.key; + const keyKind = scopedKey ? 'scoped' : cfg.keys ? 'shared-fallback' : 'shared'; + if (!keyForClient || !verifyHmac(keyForClient, state.nonce, params.hmac ?? '')) { + log('warn', `鉴权失败 client_id=${clientId} key=${keyKind}`); + audit({ + type: 'auth.failure', + client_id: clientId, + remote: state.remote, + reason: scopedKey + ? '该 client_id 的专属密钥校验失败' + : '预共享密钥校验失败(未登记专属密钥,已回落到共享 key)', + }); + sendError(ws, req.id, ErrorCodes.AUTH_FAILED, '预共享密钥校验失败'); + // 稍作延迟再关闭,确保错误响应先送达控制端 + setTimeout(() => ws.close(), 100); + return; + } + keyKindUsed = keyKind; } state.nonce = null; // 一次性,用后即废 @@ -172,7 +188,7 @@ export function createAgentCore(cfg: AgentConfig): AgentCore { state.authorized = authorized; log( 'info', - `鉴权通过 client_id=${clientId} mode=${authMode}` + + `鉴权通过 client_id=${clientId} mode=${authMode} key=${keyKindUsed}` + (authorized ? `,授权 ${authorized.length}/${CAPABILITY_MANIFEST.length} 项能力` : ''), ); audit({ diff --git a/apps/cli/src/cmd/conn.ts b/apps/cli/src/cmd/conn.ts index b035823..721c60c 100644 --- a/apps/cli/src/cmd/conn.ts +++ b/apps/cli/src/cmd/conn.ts @@ -25,8 +25,9 @@ import { } from '@nodeagent/client'; import { CapabilityNames, - matchPattern, DEFAULT_DISCOVERY_PORT, + generateSharedKey, + matchPattern, type CapabilityDescriptor, type InvokeResult, } from '@nodeagent/protocol'; @@ -176,6 +177,31 @@ export async function cmdRemove(name: string | undefined, opts: Options): Promis export async function cmdKeygen(opts: Options): Promise { const clientId = opts.id ?? 'mac_01'; + + // v22:`--psk` 生成**每客户端专属**的预共享密钥(配合被控端 agent.json 的 keys{})。 + // 与 ed25519 的区别:这是「对称」方案 —— 实现简单、不用管公钥,但同样是**一把一个身份** + // (知道 A 的 key 就冒充不了 B),适合不方便管理公钥的场景。 + if (opts.psk === true) { + const psk = generateSharedKey(); + console.log('✓ 已生成该 client_id 的专属预共享密钥(psk 模式)'); + console.log(` client_id : ${clientId}`); + console.log(` 密钥 : ${psk}`); + console.log('\n请把下面这段加入被控端 agent.json(可与其它 client_id 并列):\n'); + console.log( + JSON.stringify( + { keys: { [clientId]: psk } }, + null, + 2, + ), + ); + console.log('\n然后控制端这样连(key 即上面的密钥):'); + console.log(` nodeagent connect <被控端IP> --port 8765 --key ${psk} --id ${clientId} --insecure`); + console.log( + '\n提示:登记了 keys{} 之后,未登记的 client_id 会回落到共享 key(若被控端仍配有 key)。', + ); + return; + } + const keys = createKeys(clientId); console.log('✓ 已生成 Ed25519 密钥对'); console.log(` client_id : ${keys.client_id}`); @@ -206,7 +232,7 @@ export async function cmdDiscover(opts: Options): Promise { let nodes: Awaited>; try { - nodes = await discoverOnce(waitMs, { port }); + nodes = await discoverOnce(waitMs, { port, secret: opts.discoverSecret }); } catch (err) { fail(`监听失败:${err instanceof Error ? err.message : String(err)}`); } @@ -222,11 +248,21 @@ export async function cmdDiscover(opts: Options): Promise { if (opts.json) return printJson(nodes); console.log(`发现 ${nodes.length} 台被控端:\n`); - console.log(` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} 平台`); + console.log( + ` ${'节点 ID'.padEnd(16)} ${'地址'.padEnd(20)} ${'协议'.padEnd(6)} ${'认证'.padEnd(8)} ${'来源'.padEnd(10)} 平台`, + ); for (const n of nodes) { const ctl = n.input_enabled ? ' [输入控制已开]' : ''; + // v22:广播本身是未认证的,只有验签通过的才标 🔒 + const trust = n.authenticated ? '🔒 已认证' : '⚠️ 明文'; console.log( - ` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${n.platform}${ctl}`, + ` ${n.node_id.padEnd(16)} ${`${n.host}:${n.port}`.padEnd(20)} ${(n.tls ? 'wss' : 'ws').padEnd(6)} ${n.auth_mode.padEnd(8)} ${trust.padEnd(10)} ${n.platform}${ctl}`, + ); + } + if (nodes.some((n) => !n.authenticated) && !opts.discoverSecret) { + console.log( + '\n ℹ️ 标「⚠️ 明文」的条目:广播可被同网段任意伪造,其地址/认证方式仅作参考。' + + '\n 要可信发现,请给被控端 discovery.secret,并在此用 --discover-secret <同一密钥>。', ); } const first = nodes[0]!; diff --git a/apps/cli/src/options.ts b/apps/cli/src/options.ts index 88d60b3..77cc77c 100644 --- a/apps/cli/src/options.ts +++ b/apps/cli/src/options.ts @@ -94,6 +94,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri url: { type: 'string' }, sha256: { type: 'string' }, 'dry-run': { type: 'boolean', default: false }, + psk: { type: 'boolean', default: false }, + 'discover-secret': { type: 'string' }, 'forget-cert': { type: 'boolean', default: false }, check: { type: 'boolean', default: false }, force: { type: 'boolean', default: false }, @@ -152,6 +154,8 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri url: values['url'] as string | undefined, sha256: values['sha256'] as string | undefined, dryRun: Boolean(values['dry-run']), + psk: Boolean(values['psk']), + discoverSecret: values['discover-secret'] as string | undefined, forgetCert: Boolean(values['forget-cert']), check: Boolean(values['check']), force: Boolean(values['force']), diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index ce8fd51..9476ceb 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -55,6 +55,10 @@ export interface Options { kind?: string; /** v12 宏变量 */ vars?: string[]; + /** v22 发现广播认证密钥 */ + discoverSecret?: string; + /** v22 每客户端 PSK */ + psk?: boolean; /** v21 证书指纹 */ forgetCert?: boolean; /** v19 拉取式自更新 */ diff --git a/docs/SECURITY.md b/docs/SECURITY.md index cb88b17..ff3e726 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -72,12 +72,56 @@ nodeagent connect <地址> --port 8765 --key <密钥> --insecure --forget-cert - 判定按**前缀比特**而非字符串相等 —— 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 绕过) | + ## 5. 还没做的(后续批次) | 项 | 说明 | |---|---| -| 发现报文最小化 | UDP 广播目前明文告知 IP/端口/认证方式/键鼠是否开启(可被伪造,仅作发现用) | -| ed25519 默认化 | 新装仍默认 psk,ACL 的身份维度默认无效 | +| ~~发现报文最小化~~ | ✅ **v22 已做**(见 §4.5) | +| ed25519 默认化 | 新装仍默认 psk;v22 提供了更轻量的替代:`keys{}` 每客户端一把 PSK(见 §4.6) | | 握手失败封禁 | 目前只计数 + 审计,无按 IP 的临时拉黑 | | 审计链签名 | 哈希链可被「有 root 的攻击者」整链重写,缺外部锚定 | | 会话上限 | 无最大连接数与空闲断开 | diff --git a/packages/client/src/discovery.ts b/packages/client/src/discovery.ts index 89c4576..757a4a5 100644 --- a/packages/client/src/discovery.ts +++ b/packages/client/src/discovery.ts @@ -1,4 +1,5 @@ import { createSocket, type RemoteInfo, type Socket } from 'node:dgram'; +import { verifyHmac } from '@nodeagent/protocol'; /** 发现到的被控端。 */ export interface DiscoveredNode { @@ -12,6 +13,11 @@ export interface DiscoveredNode { auth_mode: string; input_enabled: boolean; platform: string; + /** + * v22:详情是否**通过 HMAC 验签**(被控端配了 `discovery.secret` 且我方也持有)。 + * false/未定义 = 明文广播(任何人可伪造)→ 此时这些字段仅作展示,**不可当凭据**。 + */ + authenticated?: boolean; /** 最近一次收到广播的时间 */ last_seen: number; } @@ -23,6 +29,11 @@ export interface DiscoveryOptions { ttlMs?: number; /** 报文日志(调试用) */ onLog?: (msg: string) => void; + /** + * v22:发现广播的**认证密钥**。与被控端 `discovery.secret` 一致时, + * 最小化广播里的详情会带 HMAC 签名,我方验签通过才采信(`authenticated: true`)。 + */ + secret?: string; } interface RawBeacon { @@ -35,6 +46,20 @@ interface RawBeacon { input_enabled?: boolean; platform?: string; ts?: number; + cert_sha256?: string | null; + /** v22:最小化广播标记(顶层只剩存在性,详情在 detail 里) */ + minimal?: boolean; + detail?: { + host?: string; + port?: number; + tls?: boolean; + auth_mode?: string; + input_enabled?: boolean; + cert_sha256?: string | null; + platform?: string; + }; + /** v22:详情签名(HMAC over `node_id|ts|JSON(detail)`) */ + sig?: string; } /** @@ -79,15 +104,31 @@ export class Discovery { } if (raw.service !== 'nodeagent' || !raw.node_id) return; + // v22:最小化广播 —— 顶层只有存在性,真实信息在 detail 里。 + // 持有 secret 时**必须验签**通过才采信 detail;否则标记为未认证(仅作展示)。 + const detail = raw.minimal && raw.detail ? raw.detail : raw; + let authenticated = false; + if (raw.minimal && raw.detail) { + if (this.opts.secret && raw.sig) { + authenticated = verifyHmac( + this.opts.secret, + `${raw.node_id}|${raw.ts}|${JSON.stringify(raw.detail)}`, + raw.sig, + ); + } + } + const node: DiscoveredNode = { node_id: raw.node_id, + // 地址永远取「报文来源 IP」—— 一定可达(自报地址仅作参考) host: rinfo.address, - advertised_host: raw.host ?? rinfo.address, - port: Number(raw.port ?? 8765), - tls: raw.tls !== false, - auth_mode: raw.auth_mode ?? 'psk', - input_enabled: raw.input_enabled === true, - platform: raw.platform ?? 'unknown', + advertised_host: detail.host ?? rinfo.address, + port: Number(detail.port ?? 8765), + tls: detail.tls !== false, + auth_mode: detail.auth_mode ?? 'psk', + input_enabled: detail.input_enabled === true, + platform: detail.platform ?? 'unknown', + authenticated, last_seen: Date.now(), }; diff --git a/tests/unit/discovery-sig.test.mjs b/tests/unit/discovery-sig.test.mjs new file mode 100644 index 0000000..3c5adc6 --- /dev/null +++ b/tests/unit/discovery-sig.test.mjs @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { computeHmac, verifyHmac } from '../../packages/protocol/dist/index.js'; + +/** + * v22:发现广播的详情签名。 + * 被控端与控制端必须**逐字节同构**,否则「🔒 已认证」永远不亮 —— 故这里做往返一致性测试。 + */ +const detail = { + host: '192.168.1.100', + port: 8765, + tls: true, + auth_mode: 'psk', + input_enabled: false, + cert_sha256: null, + platform: 'win32', +}; +const nodeId = 'win_01'; +const ts = 1790000000000; +const SECRET = 'S3CRET-DISCOVERY-KEY'; + +test('详情签名:同一输入两端算出同一值并验签通过', () => { + const payload = `${nodeId}|${ts}|${JSON.stringify(detail)}`; + const sig = computeHmac(SECRET, payload); + assert.ok(verifyHmac(SECRET, payload, sig), '控制端应验签通过'); +}); + +test('详情签名:错误密钥 / 篡改详情 / 改时间戳 都必须验签失败', () => { + const payload = `${nodeId}|${ts}|${JSON.stringify(detail)}`; + const sig = computeHmac(SECRET, payload); + assert.ok(!verifyHmac('wrong-key', payload, sig), '错误密钥应失败'); + const tampered = `${nodeId}|${ts}|${JSON.stringify({ ...detail, port: 9999 })}`; + assert.ok(!verifyHmac(SECRET, tampered, sig), '篡改详情应失败'); + assert.ok(!verifyHmac(SECRET, `${nodeId}|${ts + 1}|${JSON.stringify(detail)}`, sig), '改时间戳应失败'); +}); + +test('详情签名:字段顺序变化会改变签名(提醒:两侧都按对象字面量顺序拼)', () => { + const a = computeHmac(SECRET, JSON.stringify({ x: 1, y: 2 })); + const b = computeHmac(SECRET, JSON.stringify({ y: 2, x: 1 })); + assert.notEqual(a, b); +});