2 Commits
Author SHA1 Message Date
jkooe 159ae2bf6f chore(release): v1.3.0 2026-10-05 02:26:21 +08:00
jkooe 0d59d6032b feat(v21): 第一批安全加固 —— 证书指纹钉住 + 来源网段白名单
对应审查建议的 P0 两条:这两项是**真实可利用的攻击路径**,其余(发现最小化/失败封禁等)
留待后续批次。

## ① 证书指纹钉住(TOFU)—— 挡同网段中间人
自签证书没有 CA 链,历史上客户端只能 `insecure` 接受任意证书 → 攻击者可**透明中继**:
拿不到钥匙(挑战-应答挡得住冒名),但能读到截屏/剪贴板/键入/exec 输出。

- 被控端:`install.ps1` 打印证书指纹(sha256 of DER)供**带外核对**;
  `system.info.build.cert_sha256` 与 `auth_ok.build.cert_sha256` 上报
- 控制端:首次连接自动钉进 `nodes.<名>.cert_sha256`;之后**严格比对**,
  不一致即 `E_CERT_MISMATCH`(-32422) 拒连,并提示 `--forget-cert` 重钉
- 额外做**交叉确认**:实际握手证书 vs auth_ok 自报,不一致同样拒绝(防自报被篡改)
- 指纹基于 **DER**(不是 PEM 字节),保证与 Windows .NET `GetRawCertData()` 同值

**踩坑记录(写进代码注释,避免后人重走)**:
1. `ws` 客户端的 `upgrade` 事件**只传 IncomingMessage**,拿不到 socket
2. socket 要在 `open` 之后从 `ws._socket` 取(TLSSocket)
3. Node ≥15 的 `getPeerCertificate()` 返回**对象**,DER 在 `.raw` 上(老版本才是 Buffer)
   —— 现已双兼容

## ② 来源网段白名单 `allow_from` —— 收敛暴露面
被控端默认监听 `0.0.0.0`(远程接入所需),但**换到不可信网络就自动暴露**。

- 支持 CIDR / 单 IP / `*`;**未配置 = 放行全部**(保持向后兼容,不打断现有部署)
- 拒绝发生在**业务握手之前**(不做 TLS/挑战应答,省资源),并记 `net.deny` 审计
- 判定按**前缀比特**而非字符串相等:Node 在 IPv6 监听上把 IPv4 客户端报成
  `::ffff:a.b.c.d`,已归一化(单测覆盖)
- `system.info.network.allow_from` 上报状态;`nodeagent info` 一眼看出是否已收敛

## 验证(本地真跑,非纸面)
- 白名单不含本机 → 连接被拒(agent 侧 WARN + 审计留痕)✓
- 白名单含本机 → 连上,并**自动钉住**指纹 ✓
- **换掉被控端证书后重连 → `E_CERT_MISMATCH` 拒绝**(期望 2ed4957b… / 实际 ca8dc20c…)✓
- 新增 e2e 用例(自起 TLS agent)覆盖上述四种情形 + agent 自报与实际证书一致性 ✓
- 新增单测 9 例(CIDR 前缀匹配 / IPv4-mapped 归一 / IPv6 zone / PEM≡DER / 篡改即变)
- 全量:单测 **159**(150→159)· e2e **29**(28→29)全绿

## 文档
新增 `docs/SECURITY.md`:信任模型(钥匙=root)、认证现状与**已知限制**(ACL 身份维度仅在
ed25519 有效)、本次两项加固的用法、尚未收敛的 6 项、应急处置表。
2026-10-05 02:25:19 +08:00
32 changed files with 624 additions and 10 deletions

No files matched your search

+1
View File
@@ -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) —— 远程能力边界与真实故障案例(含杀软误拦)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/agent",
"version": "1.2.1",
"version": "1.3.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+2
View File
@@ -14,6 +14,8 @@ export type AuditType =
| 'rate.limited'
/** v16:网络配置变更(两阶段提交) */
| 'net.change'
/** v21:来源不在 allow_from 被拒 */
| 'net.deny'
/** v19:agent 自更新(拉取式) */
| 'agent.update';
+20 -1
View File
@@ -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<unknown> {
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) {
+18
View File
@@ -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;
}
}
+8
View File
@@ -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) */
+8
View File
@@ -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),
+13 -1
View File
@@ -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(() => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/cli",
"version": "1.2.1",
"version": "1.3.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+13
View File
@@ -85,6 +85,19 @@ export async function cmdConnect(host: string, opts: Options): Promise<void> {
// 连接成功才落盘,避免把错配置写进设备表
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;
+11
View File
@@ -108,6 +108,17 @@ export async function cmdInfo(opts: Options): Promise<void> {
// 先打版本与兼容性(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) =>
+2
View File
@@ -161,6 +161,7 @@ export async function withClientDirect<T>(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,
+1
View File
@@ -4,6 +4,7 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
用法:
nodeagent connect <host> [--port 8765] [--key <key>] [--insecure] [--id mac_01] [--auth-mode ed25519]
配置并连接被控端(握手成功后打印能力清单)
首次连接会自动钉住被控端证书指纹(TOFU);被控端重装/换证书后用 --forget-cert 重新钉住
经 Hub 中转: 追加 --hub-token <Hub 令牌> --hub-node <被控端 node_id> (v6)
nodeagent keygen [--id mac_01] 生成 Ed25519 密钥对并输出被控端 ACL 配置片段 (v3 零信任)
+2
View File
@@ -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,
+2
View File
@@ -55,6 +55,8 @@ export interface Options {
kind?: string;
/** v12 宏变量 */
vars?: string[];
/** v21 证书指纹 */
forgetCert?: boolean;
/** v19 拉取式自更新 */
url?: string;
sha256?: string;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/hub",
"version": "1.2.1",
"version": "1.3.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/mcp",
"version": "1.2.1",
"version": "1.3.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+93
View File
@@ -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`,或直接不接入陌生网络 |
+12
View File
@@ -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. 升级顺序建议
**先升级被控端,再升级控制端**:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "nodeagent",
"private": true,
"version": "1.2.1",
"version": "1.3.0",
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
"type": "module",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/client",
"version": "1.2.1",
"version": "1.3.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
+64
View File
@@ -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<void> = 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;
}
+7
View File
@@ -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(同一控制端接入多台时可区分身份) */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/protocol",
"version": "1.2.1",
"version": "1.3.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
@@ -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:
+3
View File
@@ -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<number, string> = {
[ErrorCodes.EXECUTION_FAILED]: 'E_EXECUTION_FAILED',
[ErrorCodes.PROTOCOL_MISMATCH]: 'E_PROTOCOL_MISMATCH',
[ErrorCodes.UNSUPPORTED_PLATFORM]: 'E_UNSUPPORTED_PLATFORM',
[ErrorCodes.CERT_MISMATCH]: 'E_CERT_MISMATCH',
};
/** 协议错误对象。 */
+1
View File
@@ -7,3 +7,4 @@ export * from './ed25519.js';
export * from './acl.js';
export * from './validate.js';
export * from './capabilities.js';
export * from './net.js';
+1 -1
View File
@@ -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;
}
+115
View File
@@ -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(':')}`;
}
+18
View File
@@ -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" }
+112
View File
@@ -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));
+85
View File
@@ -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-----`;