Files
nodeagent/packages/protocol/src/net.ts
T
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

116 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(':')}`;
}