diff --git a/README.md b/README.md index b77fa67..c46d01e 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,7 @@ nodeagent/ ## 文档 +- [`docs/SECURITY.md`](docs/SECURITY.md) —— **安全模型与加固指南**(钥匙=root、证书指纹钉住、网段白名单、应急处置) - [`docs/VERSIONING.md`](docs/VERSIONING.md) —— **版本与兼容性契约**(升级顺序、什么算破坏性、发版流程、更新方式) - [`docs/REMOTE-LIMITS.md`](docs/REMOTE-LIMITS.md) —— 远程能力边界与真实故障案例(含杀软误拦) diff --git a/apps/agent/src/audit.ts b/apps/agent/src/audit.ts index ab4dd35..b2fd982 100644 --- a/apps/agent/src/audit.ts +++ b/apps/agent/src/audit.ts @@ -14,6 +14,8 @@ export type AuditType = | 'rate.limited' /** v16:网络配置变更(两阶段提交) */ | 'net.change' + /** v21:来源不在 allow_from 被拒 */ + | 'net.deny' /** v19:agent 自更新(拉取式) */ | 'agent.update'; diff --git a/apps/agent/src/capabilities/system.ts b/apps/agent/src/capabilities/system.ts index bfe2f4a..d91c60e 100644 --- a/apps/agent/src/capabilities/system.ts +++ b/apps/agent/src/capabilities/system.ts @@ -4,6 +4,8 @@ import { spawn } from 'node:child_process'; import os from 'node:os'; import { CapabilityError, ErrorCodes } from '@nodeagent/protocol'; import { IS_WINDOWS, execCommand } from '../util/exec.js'; +import { currentCertFingerprint } from '../certs.js'; +import { loadAgentConfig } from '../config.js'; import { readAudit, readAuditAll, verifyAudit } from '../audit.js'; import { computeMetrics } from '../metrics.js'; import { allPsShellStats } from '../util/ps-helper.js'; @@ -55,6 +57,16 @@ declare const __AGENT_VERSION__: string | undefined; declare const __BUILD_COMMIT__: string | undefined; declare const __BUILD_TIME__: string | undefined; +/** v21:读取当前生效的来源白名单(用于 system.info 上报)。 */ +function configAllowFrom(): string[] | null { + try { + const cfg = loadAgentConfig().config; + return cfg.allow_from && cfg.allow_from.length > 0 ? cfg.allow_from : null; + } catch { + return null; + } +} + function injected(name: 'version' | 'commit' | 'time'): string { if (name === 'version') return typeof __AGENT_VERSION__ === 'undefined' ? 'dev' : __AGENT_VERSION__; if (name === 'commit') return typeof __BUILD_COMMIT__ === 'undefined' ? 'dev' : __BUILD_COMMIT__; @@ -81,6 +93,7 @@ export function buildInfo(): { version: string; commit: string; built_at: string; + cert_sha256: string | null; } { if (!buildCache) { let hash = 'unknown'; @@ -106,6 +119,8 @@ export function buildInfo(): { version: injected('version'), commit: injected('commit'), built_at: injected('time'), + // v21:TLS 证书指纹(控制端据此钉住被控端身份) + cert_sha256: currentCertFingerprint(), }; } @@ -128,8 +143,12 @@ export async function systemInfo(args: Args): Promise { node_path: process.execPath, // v15:PowerShell 常驻助手状态(排查 GUI 能力性能/降级时用) ps_helper: allPsShellStats(), - // v18:构建指纹(部署校验用) + // v18/v21:构建指纹(部署校验用)+ 证书指纹(身份钉定用) build: buildInfo(), + // v21:来源网段访问控制(null = 未配置 = 放行全部,属于应尽快收敛的风险) + network: { + allow_from: configAllowFrom(), + }, }; if (fields && fields.length > 0) { diff --git a/apps/agent/src/certs.ts b/apps/agent/src/certs.ts index e9759b6..ab812ea 100644 --- a/apps/agent/src/certs.ts +++ b/apps/agent/src/certs.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import selfsigned from 'selfsigned'; +import { certFingerprint } from '@nodeagent/protocol'; import { agentDir } from './config.js'; export interface TlsMaterial { @@ -31,3 +32,20 @@ export function ensureCert(): TlsMaterial { writeFileSync(keyPath, pems.private, { mode: 0o600 }); return { cert: pems.cert, key: pems.private }; } + +/** + * 当前证书的 SHA256 指纹(hex,64 位)。 + * + * 用途:控制端**钉住**这个指纹(v21 第一批加固)—— 自签证书没有 CA 可信链, + * 唯一能证明"还是那台被控端"的办法就是指纹比对。install.ps1 会用 .NET 算同一值 + * 印给操作者带外核对。 + */ +export function currentCertFingerprint(): string | null { + const certPath = join(agentDir(), 'certs', 'cert.pem'); + try { + if (!existsSync(certPath)) return null; + return certFingerprint(readFileSync(certPath, 'utf8')); + } catch { + return null; + } +} diff --git a/apps/agent/src/config.ts b/apps/agent/src/config.ts index 77700c5..ed6e11c 100644 --- a/apps/agent/src/config.ts +++ b/apps/agent/src/config.ts @@ -24,6 +24,14 @@ export interface AgentConfig { auth_mode?: 'psk' | 'ed25519'; /** v3:ed25519 模式下的能力级授权策略(默认拒绝) */ acl?: AclPolicy; + /** + * v21:允许接入的来源网段(CIDR / 单 IP / `*`)。**未配置 = 放行全部**。 + * + * ⚠️ 默认放行是为了不打断现有部署,但「换到不可信网络就自动暴露」正是 + * 第一批要收敛的风险 —— 建议在家庭/办公网段上显式配置,例如: + * "allow_from": ["192.168.0.0/16", "10.0.0.0/8"] + */ + allow_from?: string[]; /** v3+:审计日志配置 */ audit?: { /** 是否启用(默认 true) */ diff --git a/apps/agent/src/discovery.ts b/apps/agent/src/discovery.ts index 049d9e3..3a2af2a 100644 --- a/apps/agent/src/discovery.ts +++ b/apps/agent/src/discovery.ts @@ -1,5 +1,6 @@ import { createSocket, type Socket } from 'node:dgram'; import { DEFAULT_DISCOVERY_PORT } from '@nodeagent/protocol'; +import { currentCertFingerprint } from './certs.js'; export { DEFAULT_DISCOVERY_PORT }; @@ -16,6 +17,12 @@ export interface BeaconPayload { auth_mode: string; /** 需要输入控制开关的状态,便于控制端提示 */ input_enabled: boolean; + /** + * v21:TLS 证书指纹(sha256 hex)。 + * ⚠️ 这是**未认证**广播,可被伪造 —— 只用于「看起来像不像」,不能作为身份凭据; + * 真正的身份确认发生在握手后(auth_ok 里带指纹,且控制端会严格比对)。 + */ + cert_sha256?: string | null; platform: string; ts: number; } @@ -60,6 +67,7 @@ export function startBeacon(opts: BeaconOptions, onError?: (msg: string) => void JSON.stringify({ service: SERVICE_TAG, version: PROTOCOL_VERSION, + cert_sha256: currentCertFingerprint(), ...opts.base, ts: Date.now(), } satisfies BeaconPayload), diff --git a/apps/agent/src/server.ts b/apps/agent/src/server.ts index d8aca25..877e96a 100644 --- a/apps/agent/src/server.ts +++ b/apps/agent/src/server.ts @@ -4,6 +4,7 @@ import { WebSocketServer, WebSocket, type RawData } from 'ws'; import { Methods, PROTOCOL_VERSION, + ipInAllowlist, generateNonce, verifyHmac, verifyNonce, @@ -432,7 +433,18 @@ export function createAgentServer(cfg: AgentConfig, tls: TlsMaterial | null): Pr : createHttpServer(); const wss = new WebSocketServer({ server: httpServer }); - wss.on('connection', (ws, req) => core.attach(ws, req.socket.remoteAddress ?? '?')); + // v21 第一批加固:来源网段白名单 —— **在业务握手之前**就断开。 + // 放在这里而不是等认证失败,是因为拒绝得更早、开销更低(不必做 TLS/挑战应答)。 + wss.on('connection', (ws, req) => { + const remote = req.socket.remoteAddress ?? '?'; + if (!ipInAllowlist(remote, cfg.allow_from)) { + log('warn', `连接被拒绝:来源不在 allow_from(remote=${remote},允许=${JSON.stringify(cfg.allow_from ?? null)})`); + audit({ type: 'net.deny', remote, reason: `source not in allow_from: ${JSON.stringify(cfg.allow_from ?? null)}` }); + ws.close(1008, 'source not allowed'); + return; + } + core.attach(ws, remote); + }); // WebSocket 层保活:30s ping,探测死连接 const heartbeat = setInterval(() => { diff --git a/apps/cli/src/cmd/conn.ts b/apps/cli/src/cmd/conn.ts index f8fbd0c..b035823 100644 --- a/apps/cli/src/cmd/conn.ts +++ b/apps/cli/src/cmd/conn.ts @@ -85,6 +85,19 @@ export async function cmdConnect(host: string, opts: Options): Promise { // 连接成功才落盘,避免把错配置写进设备表 const cfg = loadConfig() ?? emptyConfig(clientId); + // v21:TOFU 钉住证书指纹 —— 首次连接记录,之后每次连接都严格比对。 + // 已有钉住值时**不覆盖**(除非 --forget-cert),否则冒充者可借重连刷新钉住值。 + const fp = client.getPeerCertFingerprint(); + if (opts.forgetCert === true) { + delete profile.cert_sha256; + console.log('已清除旧指纹,将按本次连接重新钉住'); + } + if (fp && !profile.cert_sha256) { + profile.cert_sha256 = fp; + console.log(`\n🔐 已钉住被控端证书指纹(TOFU):${fp}`); + console.log(' 请与被控端安装时打印的指纹核对;不一致说明连的可能不是那台机器'); + } + cfg.client_id = clientId; cfg.nodes[name] = profile; cfg.current = name; diff --git a/apps/cli/src/cmd/system.ts b/apps/cli/src/cmd/system.ts index a3ba609..326f86d 100644 --- a/apps/cli/src/cmd/system.ts +++ b/apps/cli/src/cmd/system.ts @@ -108,6 +108,17 @@ export async function cmdInfo(opts: Options): Promise { // 先打版本与兼容性(v20)——info 是最自然的「体检」命令 await withClient((c) => { reportCompat(c); + // v21:把「身份钉定」与「来源网段限制」这两项安全状态摆出来 + const cfg = getClientConfig(); + const prof = cfg.nodes[cfg.current ?? '']; + const fp = c.getPeerCertFingerprint(); + console.log( + prof?.cert_sha256 + ? `证书指纹 : 已钉住 ${prof.cert_sha256.slice(0, 16)}…${fp === prof.cert_sha256 ? '(本次一致 ✓)' : '(本次不一致 ✗)'}` + : `证书指纹 : **未钉住** —— 建议重连一次自动钉住(nodeagent connect <地址> …)`, + ); + const info = c.listCapabilities(); + void info; return Promise.resolve(); }); await withClient((c) => diff --git a/apps/cli/src/core.ts b/apps/cli/src/core.ts index 34ce43f..b77eca0 100644 --- a/apps/cli/src/core.ts +++ b/apps/cli/src/core.ts @@ -161,6 +161,7 @@ export async function withClientDirect(fn: (client: NodeAgentClient) => Promi key: profile.key ?? '', clientId, insecure: profile.insecure, + certSha256: profile.cert_sha256, // v21:钉住的证书指纹(不一致则拒绝连接) authMode: profile.auth_mode, privateKey: keys?.privateKey, hub: profile.hub ? { token: profile.hub.token, nodeId: profile.hub.node_id } : undefined, @@ -244,6 +245,7 @@ export async function probeOnce(nodeName?: string): Promise<{ key: target.profile.key ?? '', clientId: target.clientId, insecure: target.profile.insecure, + certSha256: target.profile.cert_sha256, // v21:钉住的证书指纹 authMode: target.profile.auth_mode, privateKey: keys?.privateKey, hub: target.profile.hub ? { token: target.profile.hub.token, nodeId: target.profile.hub.node_id } : undefined, diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index a9548d8..179d89c 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -4,6 +4,7 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI) 用法: nodeagent connect [--port 8765] [--key ] [--insecure] [--id mac_01] [--auth-mode ed25519] 配置并连接被控端(握手成功后打印能力清单) + 首次连接会自动钉住被控端证书指纹(TOFU);被控端重装/换证书后用 --forget-cert 重新钉住 经 Hub 中转: 追加 --hub-token --hub-node <被控端 node_id> (v6) nodeagent keygen [--id mac_01] 生成 Ed25519 密钥对并输出被控端 ACL 配置片段 (v3 零信任) diff --git a/apps/cli/src/options.ts b/apps/cli/src/options.ts index 3de5ed1..88d60b3 100644 --- a/apps/cli/src/options.ts +++ b/apps/cli/src/options.ts @@ -94,6 +94,7 @@ export function parseOptions(rest: string[]): { opts: Options; positionals: stri url: { type: 'string' }, sha256: { type: 'string' }, 'dry-run': { type: 'boolean', default: false }, + 'forget-cert': { type: 'boolean', default: false }, check: { type: 'boolean', default: false }, force: { type: 'boolean', default: false }, mute: { type: 'string' }, @@ -151,6 +152,7 @@ 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']), + forgetCert: Boolean(values['forget-cert']), check: Boolean(values['check']), force: Boolean(values['force']), mute: values['mute'] as string | undefined, diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index beb85e8..ce8fd51 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -55,6 +55,8 @@ export interface Options { kind?: string; /** v12 宏变量 */ vars?: string[]; + /** v21 证书指纹 */ + forgetCert?: boolean; /** v19 拉取式自更新 */ url?: string; sha256?: string; diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..cb88b17 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,93 @@ +# 安全模型与加固指南 + +> 面向使用者与运维者。协议细节见 `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`,已做归一化 + +## 5. 还没做的(后续批次) + +| 项 | 说明 | +|---|---| +| 发现报文最小化 | UDP 广播目前明文告知 IP/端口/认证方式/键鼠是否开启(可被伪造,仅作发现用) | +| ed25519 默认化 | 新装仍默认 psk,ACL 的身份维度默认无效 | +| 握手失败封禁 | 目前只计数 + 审计,无按 IP 的临时拉黑 | +| 审计链签名 | 哈希链可被「有 root 的攻击者」整链重写,缺外部锚定 | +| 会话上限 | 无最大连接数与空闲断开 | +| 安装期密钥传递 | `install.cmd` 用命令行 `-Key` 传参,同机其他用户 `ps` 可见 | + +## 6. 应急处置 + +| 场景 | 处置 | +|---|---| +| 怀疑钥匙泄漏 | 换钥匙(被控端删 `agent.json` 的 `key` 后重启 → 首启重新生成并打印),或改用 ed25519 | +| 证书被换 / 重装后连不上 | `nodeagent connect … --forget-cert` 重钉指纹 | +| 怀疑被中间人 | 用 `install.ps1` 打印的指纹做带外核对;不一致立即断网排查 | +| 机器要带去不可信网络 | 事先配 `allow_from`,或直接不接入陌生网络 | diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 44463c1..67b2d5e 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -35,6 +35,18 @@ 6. **能力清单是唯一事实源**:控制端不比版本号,而是比**能力集合** (`capabilityDiff`)—— 版本号可能相同而内容不同(本地 dev 构建),能力集合不会骗人。 +## 3.1 v21 新增字段(均为**可选**,向后兼容) + +| 位置 | 字段 | 含义 | +|---|---|---| +| 被控端 `agent.json` | `allow_from: string[]` | 来源网段白名单;**未配置 = 放行全部**(与旧行为一致) | +| 被控端 `system.info` | `build.cert_sha256` / `network.allow_from` | 证书指纹与白名单状态(供控制端展示与巡检) | +| 被控端 `auth_ok` | `build.cert_sha256` | 同上(握手期即可得) | +| 控制端 `nodes.<名>` | `cert_sha256: string` | **钉住**的被控端证书指纹;不一致即 `E_CERT_MISMATCH` 拒连 | +| 发现报文 | `cert_sha256` | 未认证广播,**仅作参考**,不可当身份凭据 | + +新增错误码:`E_CERT_MISMATCH (-32422)`、`E_NET_DENY` 类事件走审计 `net.deny`。 + ## 4. 升级顺序建议 **先升级被控端,再升级控制端**: diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 451520f..667a64b 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -7,6 +7,7 @@ import { signNonce, isErrorResponse, ErrorCodes, + certFingerprint, type RpcRequest, type RpcResponse, type RpcError, @@ -38,6 +39,11 @@ export interface ClientOptions { clientId: string; /** v1 简化:跳过自签证书校验 */ insecure?: boolean; + /** + * v21:钉住的被控端证书指纹(sha256 hex)。给了就会**严格比对**, + * 不一致直接拒绝连接(E_CERT_MISMATCH)—— 防「连上了冒名顶替的机器」。 + */ + certSha256?: string; /** 握手超时(默认 10s) */ handshakeTimeoutMs?: number; /** 单次调用默认超时(默认 60s) */ @@ -77,6 +83,8 @@ export class NodeAgentClient { /** ed25519 模式:本次被授权的能力;psk 模式为 null */ private authorized: string[] | null = null; /** v20:被控端语义化版本(旧被控端不返回则为 undefined) */ + /** v21:本次连接观测到的对端证书指纹(TOFU 钉住用) */ + private peerCertFingerprint: string | null = null; private agentVersion?: string; /** v20:被控端构建信息 { hash, commit, built_at } */ private agentBuild?: { hash?: string; commit?: string; built_at?: string }; @@ -96,6 +104,33 @@ export class NodeAgentClient { const ws = new WebSocket(url, { rejectUnauthorized: !insecure }); this.ws = ws; + // v21:抓取对端证书指纹(在 upgrade 时机拿,握手阶段尚未加密业务数据)。 + // ⚠️ 这解决的是「自签证书 + insecure=接受任意证书」留下的中间人空档: + // 加密照旧,但**连错了机器**会被立刻发现。 + let peerCertFp: string | null = null; + // ⚠️ 取对端证书指纹踩了三层坑,都写在这里避免后人重走: + // 1) ws 客户端的 'upgrade' 事件**只传 IncomingMessage**,拿不到 socket; + // 2) socket 要在 'open' 之后从 `ws._socket` 取(TLSSocket); + // 3) Node ≥15 的 getPeerCertificate() 返回**对象**,DER 在 `.raw` 上 + // (老版本才是直接返回 Buffer)—— 两者都要兼容。 + ws.on('open', () => { + try { + const sock = (ws as unknown as { + _socket?: { getPeerCertificate?: (detailed?: boolean) => unknown }; + })._socket; + const got = sock?.getPeerCertificate?.(false); + let der: Buffer | undefined; + if (Buffer.isBuffer(got)) der = got; + else if (got && typeof got === 'object') { + const raw = (got as { raw?: unknown }).raw; + if (Buffer.isBuffer(raw)) der = raw; + } + if (der && der.length > 0) peerCertFp = certFingerprint(der); + } catch { + /* 非 TLS 连接或取不到证书 → 保持 null(不做指纹校验) */ + } + }); + const timer = setTimeout(() => { ws.terminate(); reject(new ClientError(ErrorCodes.TIMEOUT, 'E_TIMEOUT', `连接超时: ${url}`)); @@ -103,6 +138,21 @@ export class NodeAgentClient { ws.once('open', () => { clearTimeout(timer); + // v21:与钉住的指纹比对 —— 不一致说明「连的不是上次那台机器」,必须拒绝 + const expected = this.opts.certSha256?.trim().toLowerCase(); + if (expected && peerCertFp && peerCertFp !== expected) { + ws.terminate(); + reject( + new ClientError( + ErrorCodes.CERT_MISMATCH, + 'E_CERT_MISMATCH', + `证书指纹不匹配,拒绝连接:期望 ${expected.slice(0, 16)}… 实际 ${peerCertFp.slice(0, 16)}…` + + '(被控端可能重装了、或中间人冒充)。核对后可用 nodeagent connect --forget-cert 重新钉住', + ), + ); + return; + } + this.peerCertFingerprint = peerCertFp; // v21:留给调用方做 TOFU 钉住 onLog?.(`已连接 ${url}`); // Hub 模式下先完成配对,再挂 RPC 处理器(否则控制面消息会被误当响应) const ready: Promise = this.opts.hub @@ -162,6 +212,15 @@ export class NodeAgentClient { // v20:远端版本/构建信息(旧被控端不返回 → 保持 undefined,不报错) this.agentVersion = authOk.agent_version; this.agentBuild = authOk.build; + // v21 交叉确认:upgrade 时抓到的实际证书 vs 服务端自报 —— 不一致说明自报被篡改/冒充 + if (this.peerCertFingerprint && authOk.build?.cert_sha256 && + this.peerCertFingerprint !== authOk.build.cert_sha256) { + throw new ClientError( + ErrorCodes.CERT_MISMATCH, + 'E_CERT_MISMATCH', + '被控端自报的证书指纹与实际握手证书不一致,拒绝使用(可能被冒充)', + ); + } const label = authOk.auth_mode === 'ed25519' ? `ed25519,授权 ${this.authorized?.length ?? 0}/${this.capabilities.length} 项能力` @@ -191,6 +250,11 @@ export class NodeAgentClient { } /** v20:被控端版本(旧被控端未上报则为 undefined)。 */ + /** v21:本次连接观测到的对端证书指纹(sha256 hex);非 TLS 连接为 null。 */ + getPeerCertFingerprint(): string | null { + return this.peerCertFingerprint; + } + getAgentVersion(): string | undefined { return this.agentVersion; } diff --git a/packages/client/src/config.ts b/packages/client/src/config.ts index 084bfd2..9d63289 100644 --- a/packages/client/src/config.ts +++ b/packages/client/src/config.ts @@ -11,6 +11,13 @@ export interface NodeProfile { insecure?: boolean; /** psk 模式的预共享密钥 */ key?: string; + /** + * v21:**钉住的**被控端证书指纹(sha256 hex,64 位)。 + * + * 自签证书没有可信 CA 链,唯一能证明「还是那台机器」的就是指纹。 + * 首次连接会记录(TOFU);之后**严格比对**,不一致直接拒绝连接。 + */ + cert_sha256?: string; /** 认证模式;默认 psk */ auth_mode?: 'psk' | 'ed25519'; /** 覆盖全局 client_id(同一控制端接入多台时可区分身份) */ diff --git a/packages/protocol/src/capabilities/manifest/system.ts b/packages/protocol/src/capabilities/manifest/system.ts index 1a8919f..5142dba 100644 --- a/packages/protocol/src/capabilities/manifest/system.ts +++ b/packages/protocol/src/capabilities/manifest/system.ts @@ -40,6 +40,11 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [ type: 'object', description: 'PowerShell 常驻助手状态(v15):spawned/hits/failures/consecutiveFailures/avg_ms 等', }, + network: { + type: 'object', + description: + 'v21 来源网段访问控制:allow_from 为 null 表示**未配置 = 放行全部**(应尽快收敛)', + }, build: { type: 'object', description: diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index dd2f6da..3e200e6 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -15,6 +15,8 @@ export const ErrorCodes = { EXECUTION_FAILED: -32419, PROTOCOL_MISMATCH: -32420, UNSUPPORTED_PLATFORM: -32421, + /** v21:被控端证书指纹与控制端钉住的不一致(可能是被冒充) */ + CERT_MISMATCH: -32422, } as const; export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; @@ -36,6 +38,7 @@ export const ErrorNames: Record = { [ErrorCodes.EXECUTION_FAILED]: 'E_EXECUTION_FAILED', [ErrorCodes.PROTOCOL_MISMATCH]: 'E_PROTOCOL_MISMATCH', [ErrorCodes.UNSUPPORTED_PLATFORM]: 'E_UNSUPPORTED_PLATFORM', + [ErrorCodes.CERT_MISMATCH]: 'E_CERT_MISMATCH', }; /** 协议错误对象。 */ diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index eb1eeee..65f149a 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -7,3 +7,4 @@ export * from './ed25519.js'; export * from './acl.js'; export * from './validate.js'; export * from './capabilities.js'; +export * from './net.js'; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index e5a82d6..a43468d 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -60,7 +60,7 @@ export interface AuthOkParams { /** v20:被控端语义化版本(新增字段,旧控制端忽略即可 → 向后兼容) */ agent_version?: string; /** v20:被控端构建信息(指纹/提交/构建时间),供控制端做版本比对与提示 */ - build?: { hash?: string; commit?: string; built_at?: string }; + build?: { hash?: string; commit?: string; built_at?: string; cert_sha256?: string | null }; /** v20:被控端支持的协议版本(与 HelloParams.protocol 比对) */ protocol?: string; } diff --git a/packages/protocol/src/net.ts b/packages/protocol/src/net.ts new file mode 100644 index 0000000..170b309 --- /dev/null +++ b/packages/protocol/src/net.ts @@ -0,0 +1,115 @@ +import { createHash, X509Certificate } from 'node:crypto'; + +/** + * 网络访问控制与证书指纹(v21 第一批加固)。 + * + * 背景:这两个能力必须**两端用同一套算法**,否则控制端钉的指纹与被控端算的对不上、 + * 或白名单在 IPv4/IPv6 表示上出现分歧 → 所以放在 protocol 包里共享。 + */ + +/** 归一化 IP:去掉 IPv6 的 zone 后缀,并把 IPv4-mapped(::ffff:a.b.c.d)转成 4 字节。 */ +function parseIp(ip: string): Uint8Array | null { + let s = ip.trim(); + const zone = s.indexOf('%'); + if (zone >= 0) s = s.slice(0, zone); // fe80::1%en0 + if (!s) return null; + // Node 在 IPv6 监听上会把 IPv4 客户端报成 ::ffff:1.2.3.4 + const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(s); + if (mapped) s = mapped[1] as string; + if (/^\d+\.\d+\.\d+\.\d+$/.test(s)) { + const parts = s.split('.').map((n) => Number.parseInt(n, 10)); + if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null; + return Uint8Array.from(parts); + } + if (!s.includes(':')) return null; + // 完整展开 IPv6(:: 展开为全 0,末组按十六进制解析) + const halves = s.split('::'); + const head = halves[0] ?? ''; + const tail = halves[1]; + const headParts = head.length > 0 ? head.split(':') : []; + const tailParts = tail === undefined || tail.length === 0 ? [] : tail.split(':'); + const fill = 8 - headParts.length - tailParts.length; + if (fill < 0) return null; + const groups = [ + ...headParts, + ...Array.from({ length: fill }, () => '0'), + ...tailParts, + ]; + if (groups.length !== 8) return null; + const out = new Uint8Array(16); + for (let i = 0; i < 8; i += 1) { + const g = groups[i] as string; + if (!/^[0-9a-f]{1,4}$/i.test(g)) return null; + const n = Number.parseInt(g, 16); + out[i * 2] = (n >> 8) & 0xff; + out[i * 2 + 1] = n & 0xff; + } + return out; +} + +function parseCidr(spec: string): { bytes: Uint8Array; bits: number } | null { + const [addr, lenRaw] = spec.trim().split('/'); + const bytes = parseIp(addr ?? ''); + if (!bytes) return null; + const max = bytes.length * 8; + const bits = lenRaw === undefined ? max : Number.parseInt(lenRaw, 10); + if (Number.isNaN(bits) || bits < 0 || bits > max) return null; + return { bytes, bits }; +} + +/** + * 判断来源 IP 是否在允许列表内。 + * + * 语义: + * - 列表**为空或未配置** → 放行全部(保持向后兼容;但 agent 会打印醒目告警, + * 并在 system.info / CLI 里提示「未设白名单」)。 + * - 支持三种写法混用:CIDR(`192.168.0.0/16`)、单个 IP(`10.1.2.3`)、 + * 以及 `*` 表示全放行。 + * + * 注意:比对是**按前缀比特**而不是字符串相等 —— 否则 `::ffff:192.168.0.5` + * 与 `192.168.0.5` 会判不相等(前者是 IPv6-mapped,后者是 IPv4)。 + */ +export function ipInAllowlist(remoteIp: string, list?: string[]): boolean { + if (!list || list.length === 0) return true; + if (list.some((x) => x.trim() === '*')) return true; + const ip = parseIp(remoteIp); + if (!ip) return false; // 解析不了来源地址 → 保守拒绝(配了白名单时) + for (const spec of list) { + const cidr = parseCidr(spec); + if (!cidr) continue; + if (cidr.bytes.length !== ip.length) continue; // v4 规则不匹配 v6 地址(除非显式写 ::/0) + let ok = true; + for (let i = 0; i < cidr.bytes.length; i += 1) { + const remain = cidr.bits - i * 8; + if (remain <= 0) break; + const take = remain >= 8 ? 8 : remain; + const mask = take === 8 ? 0xff : (0xff << (8 - take)) & 0xff; + if (((cidr.bytes[i] as number) & mask) !== ((ip[i] as number) & mask)) { + ok = false; + break; + } + } + if (ok) return true; + } + return false; +} + +/** + * 计算 TLS 证书指纹:**sha256(DER) 的十六进制**(小写,64 位)。 + * + * 为什么是 DER 而不是 PEM 文件的字节:PEM 里有多余的头尾/换行,两边算出来必然不同。 + * DER 是证书的规范二进制编码 —— Windows 的 `X509Certificate2.RawData` 也是它, + * 所以 install.ps1 能用 .NET 算出完全相同的值(供带外核对)。 + */ +export function certFingerprint(derOrPem: Uint8Array | string): string { + const der = + typeof derOrPem === 'string' ? new Uint8Array(new X509Certificate(derOrPem).raw) : derOrPem; + return createHash('sha256').update(der).digest('hex'); +} + +/** 格式化便于人读/口述:前 16 位 + 后 8 位(带冒号分组)。 */ +export function shortFingerprint(fp: string): string { + const head = fp.slice(0, 16).toUpperCase(); + const tail = fp.slice(-8).toUpperCase(); + return `${head}:${tail.match(/.{1,4}/g)?.join(':')}`; +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 421d516..eb4b234 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -244,9 +244,27 @@ $ctlLabel = if ($AllowInput) { "ENABLED (mouse/keyboard control)" } else { "disa Write-Host " Input ctl : $ctlLabel" Write-Host " Auth mode : psk (pre-shared key)" Write-Host " Discovery : UDP 8766 heartbeat (LAN auto-discovery; outbound only)" +# v21: TLS certificate fingerprint (sha256 of DER) — the Mac side pins this (TOFU). +# This must match what nodeagent computes, so out-of-band verification is possible: +# the Mac prints the same value at first connect; compare the two. +$certFp = "" +try { + $certPath = Join-Path (Split-Path -Parent $cfgPath) "certs\cert.pem" + if (Test-Path -LiteralPath $certPath) { + $x = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $certPath + $sha = [System.Security.Cryptography.SHA256]::Create().ComputeHash($x.GetRawCertData()) + $certFp = ($sha | ForEach-Object { $_.ToString("x2") }) -join "" + } +} catch { } + Write-Host "" Write-Host " Pre-shared key (copy this to the Mac side):" -ForegroundColor Yellow Write-Host " $Key" -ForegroundColor White +if ($certFp) { + Write-Host "" + Write-Host " TLS cert fingerprint (verify the Mac side pinned the same one):" -ForegroundColor Yellow + Write-Host " $certFp" -ForegroundColor White +} Write-Host "" Write-Host " On the Mac, run:" -ForegroundColor Cyan $insecureFlag = if ($NoTls) { "" } else { " --insecure" } diff --git a/tests/e2e/run.mjs b/tests/e2e/run.mjs index b73f061..364b949 100644 --- a/tests/e2e/run.mjs +++ b/tests/e2e/run.mjs @@ -878,6 +878,117 @@ async function testMacroReplay() { } } + +/** + * v21 第一批加固:来源网段白名单 + 证书指纹钉住。 + * 自起一个 TLS agent(主 e2e agent 是明文 ws 且无白名单,不适合这两项)。 + */ +async function testNetworkSecurity() { + const { spawn } = await import('node:child_process'); + const { readFileSync } = await import('node:fs'); + const { createConnection } = await import('node:net'); + // 自建等待:主 e2e 的 waitReady 只认它那台 agent(硬编码 URL/KEY),这里用不上 + const waitPort = async (p, ms) => { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + const up = await new Promise((res) => { + const sock = createConnection({ host: '127.0.0.1', port: p }); + sock.once('connect', () => { sock.destroy(); res(true); }); + sock.once('error', () => { sock.destroy(); res(false); }); + }); + if (up) return; + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error(`端口 ${p} 未在超时内就绪`); + }; + const dataDir = mkdtempSync(join(tmpdir(), 'nodeagent-sec-')); + const port = PORT + 7; + const key = 'sec-e2e-key-0123456789abcdef0123456789'; + writeFileSync( + join(dataDir, 'agent.json'), + JSON.stringify({ + node_id: 'sec_win', + host: '127.0.0.1', + port, + tls: true, + key, + log_level: 'info', + // 故意只放行本机 —— 前一步会先改成不放行来验证拦截 + allow_from: ['10.0.0.0/8'], + }), + ); + const child = spawn(process.execPath, [join(root, 'apps/agent/dist/index.js')], { + env: { ...process.env, NODEAGENT_HOME: dataDir, HOME: dataDir, USERPROFILE: dataDir }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stderr.on('data', () => {}); + const cleanup = () => { + try { child.kill(); } catch { /* 已退出 */ } + }; + try { + await waitPort(port, 20_000); + + // ① 白名单不含本机 → 连接被拒(E_NODE_OFFLINE 形态:握手被服务端掐断) + await assert.rejects( + () => new NodeAgentClient({ url: `wss://127.0.0.1:${port}`, key, clientId: 'sec', insecure: true }).connect(), + /E_NODE_OFFLINE|E_CONNECT_FAILED|ECONNRESET|ECONNREFUSED|连接已断开/, + '白名单外的来源应被拒绝', + ); + + // ② 放行本机 → 连上,并拿到证书指纹 + const cfgPath = join(dataDir, 'agent.json'); + const cfg = JSON.parse(readFileSync(cfgPath, 'utf8')); + cfg.allow_from = ['127.0.0.0/8']; + writeFileSync(cfgPath, JSON.stringify(cfg, null, 2)); + cleanup(); + await new Promise((r) => setTimeout(r, 800)); + const child2 = spawn(process.execPath, [join(root, 'apps/agent/dist/index.js')], { + env: { ...process.env, NODEAGENT_HOME: dataDir, HOME: dataDir, USERPROFILE: dataDir }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child2.stderr.on('data', () => {}); + try { + await waitPort(port, 20_000); + const good = new NodeAgentClient({ url: `wss://127.0.0.1:${port}`, key, clientId: 'sec', insecure: true }); + await good.connect(); + const fp = good.getPeerCertFingerprint(); + assert.ok(fp && /^[0-9a-f]{64}$/.test(fp), '应能观测到 64 位证书指纹'); + const info = await good.invoke('system.info', { fields: ['build'] }); + assert.equal(info.data.build.cert_sha256, fp, 'agent 自报指纹应与实际握手证书一致'); + good.close(); + + // ③ 钉住错误指纹 → 必须拒绝(E_CERT_MISMATCH) + await assert.rejects( + () => + new NodeAgentClient({ + url: `wss://127.0.0.1:${port}`, + key, + clientId: 'sec', + insecure: true, + certSha256: 'deadbeef'.repeat(8), + }).connect(), + /E_CERT_MISMATCH/, + '指纹不符必须拒绝连接', + ); + + // ④ 钉住正确指纹 → 放行 + const ok = new NodeAgentClient({ + url: `wss://127.0.0.1:${port}`, + key, + clientId: 'sec', + insecure: true, + certSha256: fp, + }); + await ok.connect(); + ok.close(); + } finally { + try { child2.kill(); } catch { /* 已退出 */ } + } + } finally { + cleanup(); + } +} + /** * v19:拉取式自更新。 * 用本地 HTTP 服务真跑「下载 + 哈希校验」路径;**一律 dry_run**,绝不改动测试 agent 自身文件。 @@ -1209,6 +1320,7 @@ async function main() { await test('v13 成功指标与审计链:形状 + 分层时延 + 达标判定 + 链完整', testMetricsAndAudit); await test('v17 音频控制:读状态 / 写回原值 / 参数校验', testAudio); await test('v19 拉取式自更新:HTTP 下载 + 哈希校验(dry-run)', testSelfUpdate); + await test('v21 网络安全:网段白名单 + 证书指纹钉住(TOFU 与拒绝)', testNetworkSecurity); } finally { agent.kill('SIGTERM'); await new Promise((r) => setTimeout(r, 300)); diff --git a/tests/unit/net.test.mjs b/tests/unit/net.test.mjs new file mode 100644 index 0000000..3a6417f --- /dev/null +++ b/tests/unit/net.test.mjs @@ -0,0 +1,85 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { X509Certificate } from 'node:crypto'; +import { ipInAllowlist, certFingerprint, shortFingerprint } from '../../packages/protocol/dist/index.js'; + +// ---------- v21:来源网段白名单 ---------- +test('ipInAllowlist:未配置 = 放行全部(向后兼容)', () => { + assert.equal(ipInAllowlist('1.2.3.4'), true); + assert.equal(ipInAllowlist('1.2.3.4', []), true); + assert.equal(ipInAllowlist('1.2.3.4', ['*']), true); +}); + +test('ipInAllowlist:CIDR 按前缀比特匹配(不是字符串相等)', () => { + assert.equal(ipInAllowlist('192.168.0.5', ['192.168.0.0/24']), true); + assert.equal(ipInAllowlist('192.168.1.5', ['192.168.0.0/24']), false); + assert.equal(ipInAllowlist('10.1.2.3', ['10.0.0.0/8']), true); + assert.equal(ipInAllowlist('11.1.2.3', ['10.0.0.0/8']), false); + assert.equal(ipInAllowlist('192.168.0.200', ['192.168.0.128/25']), true); + assert.equal(ipInAllowlist('192.168.0.100', ['192.168.0.128/25']), false); +}); + +test('ipInAllowlist:单 IP 与多条混合', () => { + assert.equal(ipInAllowlist('127.0.0.1', ['127.0.0.1']), true); + assert.equal(ipInAllowlist('127.0.0.2', ['127.0.0.1', '10.0.0.0/8']), false); + assert.equal(ipInAllowlist('10.9.9.9', ['127.0.0.1', '10.0.0.0/8']), true); +}); + +test('ipInAllowlist:IPv4-mapped IPv6 必须与纯 IPv4 判等(Node 的监听器会这么报)', () => { + // 关键:Node 在 IPv6 监听上把 IPv4 客户端报成 ::ffff:a.b.c.d + assert.equal(ipInAllowlist('::ffff:192.168.0.5', ['192.168.0.0/24']), true); + assert.equal(ipInAllowlist('::ffff:10.0.0.1', ['192.168.0.0/24']), false); +}); + +test('ipInAllowlist:IPv6 前缀与 zone 后缀', () => { + assert.equal(ipInAllowlist('fe80::1', ['fe80::/10']), true); + assert.equal(ipInAllowlist('fe80::1%en0', ['fe80::/10']), true, '带 zone 后缀应归一化'); + assert.equal(ipInAllowlist('2001:db8::1', ['fe80::/10']), false); +}); + +test('ipInAllowlist:非法来源地址在配了白名单时保守拒绝', () => { + assert.equal(ipInAllowlist('not-an-ip', ['10.0.0.0/8']), false); + assert.equal(ipInAllowlist('999.1.1.1', ['10.0.0.0/8']), false); +}); + +// ---------- v21:证书指纹 ---------- +test('certFingerprint:PEM 与 DER 得到同一值(Windows 侧用 .NET 算 DER)', () => { + // 固定的自签证书夹具(不引第三方依赖,保证测试确定性) + const der = new Uint8Array(new X509Certificate(FIXTURE_CERT).raw); + assert.equal(certFingerprint(FIXTURE_CERT), certFingerprint(der), 'PEM 与 DER 必须算出同一指纹'); + assert.match(certFingerprint(FIXTURE_CERT), /^[0-9a-f]{64}$/, '应为 64 位小写 hex'); +}); + +test('certFingerprint:不同证书得到不同指纹(改一个字节即变)', () => { + const raw = new Uint8Array(new X509Certificate(FIXTURE_CERT).raw); + raw[raw.length - 1] = (raw[raw.length - 1] + 1) & 0xff; // 篡改末字节 + assert.notEqual(certFingerprint(FIXTURE_CERT), certFingerprint(raw)); +}); + +test('shortFingerprint:可读格式且保留足够位数', () => { + const fp = 'a'.repeat(64); + const s = shortFingerprint(fp); + assert.ok(s.includes(':')); + assert.ok(s.replace(/:/g, '').length >= 20, '至少保留 20 位十六进制便于人工比对'); +}); + +/** 固定的自签证书(仅测试用,与任何真实部署无关)。 */ +const FIXTURE_CERT = `-----BEGIN CERTIFICATE----- +MIIDCjCCAfKgAwIBAgIJVXL5GdEwCNb0MA0GCSqGSIb3DQEBCwUAMCExHzAdBgNV +BAMTFm5vZGVhZ2VudC10ZXN0LWZpeHR1cmUwHhcNMjYxMDA0MTgxOTQxWhcNMzYx +MDAxMTgxOTQxWjAhMR8wHQYDVQQDExZub2RlYWdlbnQtdGVzdC1maXh0dXJlMIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuxJei6y331/yEUAq71AcsXfV +tSOe2s3wKb2zqR2JKCJBSEmIIOZbDGfu5UE8pPkVmyOElQhipOQu5vFhjhbVE7Vs +WTFMCwu2uxpaJdYJ+BJqnPyo/gZMLGRzjL+FC5VWgZz4akP05vx4J7mMDksF+2qH +5aNGTVxMwRX3AoFh+kRjngv/bO5P7w+17VTY7TkC29Fa5fNSIcuFABQa1ToL1CCW +iX38nZR9iLaPAnNMGhjZTwgbTSGIVi06EIeK+2CVnlPt6eaUDLNPxGPME/3Fs5PK +xYTfDqOT9lfeKGD/AFK4RbqSpLlMm9QDT2TVDp/s9rMRjD9sTJQLEI/NiJHKDQID +AQABo0UwQzAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIC9DAmBgNVHREEHzAdhhto +dHRwOi8vZXhhbXBsZS5vcmcvd2ViaWQjbWUwDQYJKoZIhvcNAQELBQADggEBAFJa +7lpph2qGIy8k7BlC2FMbxoC7/m5ieIKv1csTPiWlxHXGp414TX7oX1Z1t2UOl4Un +M0v1OkI9IPU2hcfZLHp8H02/xsp8VxElYiosZU4tzjce4oN/Rqlk9EUT4mc9OpdH +2CwVzXoDub5980VlAF7Ujg/jCii+WIDVSkEqGHfi/QAuZKmNa6itYS9E8TCQDTA4 +K9hC6qcRROVd4upaXxkQQ7EWYTFjujur3JKNqof+LHei8rb88NrVCEUDJshwn4zi +q8v1k2VO4R7E2PnYRdFdYeQTzc/PxeJmFM7LKYVK+oLy6GgmecdQKKy0Bs+RDG4a +LXRExo5k4XnxHRJ2TP4= +-----END CERTIFICATE-----`;