对应审查建议的 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 项、应急处置表。
116 lines
4.6 KiB
TypeScript
116 lines
4.6 KiB
TypeScript
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(':')}`;
|
||
}
|