/** * 覆盖网络 序③:**四层密钥模型 + 离线信任根**(一机一钥 / 入网即签名 / 本地校验 / 可单台吊销)。 * * ## 它治的病(一句话) * `keys.ts` 的 HMAC 预共享密钥解决的是"**爆炸半径 = 那一台**",但**没有解决"控制面自己被攻破"**: * 密钥表放在控制面(47)上,谁改得动那张表,谁就能塞进一台自己的节点 —— 而所有对端都会照单全收, * 因为对端**没有任何独立的判据**可以否决它。 * * ⇒ 本模块引入一个控制面**看不到也改不了**的信任源(根 + 签名者),让"**能不能入网**"这件事 * 由**节点自己在本地**判定(D2:校验位置 = 节点本地,relay 侧只做**辅助**准入)。 * * ## 四层(形状照抄 Tailnet Lock,见 `覆盖网络_问题逐条推演与解决方案_20260916.md §A4`) * * | 层 | 放哪 | 用途 | 丢失后果 | * |---|---|---|---| * | **根(离线)** | 用户手里(本工作区 `0600` 文件 / 纸质恢复码 / 离线 U 盘) | **只**授权/撤销"签名者" | 最严重 ⇒ 全网重建 | * | **签名者(在线,多把)** | 管理员设备(现网 = 47,`/etc/dshs/overlay-signer-key.pem`) | 签发节点入网凭据 | 换一把(根仍在) | * | **节点密钥(每机一把)** | 每台机器(`0600`,属主正确) | 设备身份;对接时证明"握有私钥" | 该设备重签 | * | **会话密钥(内存)** | 隧道 | 传输加密 | 无感 | * * ⚠️ **根密钥的用途边界(⛔ 别搞错)**:它**只用于授权 / 撤销"签名者"** —— * **不签发节点、不加密数据、不参与会话**。⇒ "根在线"**不带来任何性能问题**,唯一影响是**安全** * 与**恢复**(`交接单_一机一钥与信任根_20260917.md §4.3.1`)。 * * ## 🔴 四条不可动摇的判据(改动前先读) * 1. **失败关闭**:任一校验未命中 ⇒ 返回**带具体原因**的 `ok:false`,⛔ 绝不静默回退到共享凭据、 * ⛔ 绝不回落默认机。原因必须**结构化**(`IdentityReason`),否则"配错了"会伪装成"网络不通" * (本线反复复发的那类病,见 `交接单_relay落地R2-R4 §12` 的 A1)。 * 2. **不可验 = 不接受**:受信根 / 受信签名者为空 ⇒ **拒绝**(与 `directory.ts` 的 `no-trusted-keys` 同款)。 * 3. **签名覆盖全部字段**:载荷用**规范拼接**(`*Payload()`),⛔ 不用 `JSON.stringify` * (键序变化 ⇒ 载荷变 ⇒ 验签结果随 TS 版本漂移;`directory.ts` 已踩过这条)。 * 4. **本模块只做身份,不做寻址**:不碰 `network.ts` 的地址规划、不碰 `directory.ts` 的地址下发。 * 它回答的唯一问题是「**这台机器是不是被授权进入这张网**」。 * * 纯函数(载荷 / 解析 / 签发 / 验签 / 指纹)与 IO(密钥文件读写)分开放 —— 与 `network.ts` / * `directory.ts` 同一风格,前者可单测、后者只做搬运。 * * @module dshs/net/relay/identity */ import { createHash, createPrivateKey, createPublicKey as cryptoCreatePublicKey, generateKeyPairSync, randomBytes, sign as cryptoSign, verify as cryptoVerify, } from 'node:crypto' import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { publicKeyFrom } from './directory.js' import { isHostId, isNetworkId } from './network.js' /** 文档结构版本。未知版本 ⇒ 拒绝(而不是"尽力解析")。 */ export const IDENTITY_VERSION = 1 /** 载荷标签:换格式时改这里 ⇒ 老节点**验签失败 ⇒ 失败关闭**(不会误读新格式)。 */ export const SIGNER_SET_TAG = 'dshs-overlay-signerset/v1' export const NODE_GRANT_TAG = 'dshs-overlay-nodegrant/v1' export const REVOCATION_TAG = 'dshs-overlay-revocation/v1' /** * Ed25519 会话证明(node proof)的**域分隔串**。 * * 为什么要它:节点私钥**同时**用来签入网凭据的持有证明与握手的挑战签名 —— 两种签名若不带上 * 各自的域标签,一个签名就可能被搬到另一个场景里重用(跨协议签名混淆)。带上标签后两者互不可用。 */ export const PROOF_TAG = 'dshs-overlay-node-proof/v1' /** 节点密钥文件的默认落点(**0600**,属主 = 跑 dsh 的那个用户)。 */ export const DEFAULT_NODE_KEY_FILE = '/etc/dshs/node.key' // ── 类型 ──────────────────────────────────────────────────────────────────── /** **签名者集合**(由**根**签发)—— 回答"哪些签名者被授权签发节点"。 */ export interface SignerSet { version: number /** 该集合所属的网。**签名者只在它被授权的网里有效**(跨网签发 ⇒ 拒)。 */ network: string /** 签发时间(ISO 8601)。 */ issuedAt: string /** 受根授权的签名者公钥(PEM 或裸 32 字节 hex/base64)。 */ signers: string[] } /** **节点入网凭据**(由**签名者**签发)—— 回答"这台机器被授权进入这张网"。 */ export interface NodeGrant { version: number network: string hostId: string /** 该节点的公钥(**裸 32 字节 hex 小写**)。 */ nodeKey: string issuedAt: string /** 过期时间(ISO 8601);**空串 = 不过期**(运维网的常驻机器适用,照 Tailnet 的 tagged 语义)。 */ expiresAt: string } /** * **吊销清单**(由**签名者**签发)—— 撤销单台 ≈ 只动这一份清单,**不牵动全网换密钥**。 * * ⚠️ 撤销**签名者**不走这里(那是"根签一份新的 SignerSet"的职责);本清单只撤**节点**。 */ export interface RevocationList { version: number network: string issuedAt: string /** 被撤销的 hostId(再签一份 grant 也不生效 —— 必须先把它从清单里移除)。 */ hosts: string[] /** 被撤销的**节点公钥**(设备被盗场景:hostId 可能被复用,公钥不会)。 */ nodeKeys: string[] } /** 拒绝原因(**结构化**,直接进日志/HELLO_ERR 的 `why`)。 */ export type IdentityReason = | 'bad-document' | 'no-signature' | 'bad-signature-length' | 'no-trusted-keys' | 'signature-mismatch' | 'network-mismatch' | 'host-mismatch' | 'key-mismatch' | 'expired' | 'not-yet-valid' | 'revoked-host' | 'revoked-node-key' /** 通用验签结论:**失败一律带具体原因**。 */ export type IdentityVerdict = { ok: true; doc: T; payload: string } | { ok: false; reason: IdentityReason } // ── 纯函数:载荷(规范拼接,⛔ 不用 JSON.stringify)──────────────────────────── export function signerSetPayload(set: SignerSet): string { return [SIGNER_SET_TAG, String(set.version), set.network, set.issuedAt, set.signers.join(',')].join('\n') } export function nodeGrantPayload(grant: NodeGrant): string { return [ NODE_GRANT_TAG, String(grant.version), grant.network, grant.hostId, grant.nodeKey, grant.issuedAt, grant.expiresAt, ].join('\n') } export function revocationPayload(list: RevocationList): string { return [REVOCATION_TAG, String(list.version), list.network, list.issuedAt, list.hosts.join(','), list.nodeKeys.join(',')].join('\n') } /** * 会话证明的载荷:`\n`。 * * `challenge` 由调用方决定(relay 握手用 `hostId|ts|nonce|portsCsv`,与本模块解耦)—— * 本模块只保证"域分隔"与"签名/验签用的是同一个串"。 */ export function proofPayload(challenge: string): string { return `${PROOF_TAG}\n${challenge}` } // ── 纯函数:公钥 / 指纹 ─────────────────────────────────────────────────────── /** 裸 32 字节 Ed25519 公钥 → 小写 hex;解析不出来 ⇒ `undefined`。 */ export function normalizePublicKey(spec: string): string | undefined { const key = publicKeyFrom(spec) if (key === undefined) return undefined const der = key.export({ type: 'spki', format: 'der' }) as Buffer // SPKI DER 前 12 字节是固定前缀,其后 32 字节即裸公钥(directory.ts 已用同一常量)。 return der.subarray(der.length - 32).toString('hex') } /** * 公钥指纹:`sha256(裸 32 字节)` 的前 16 位 hex。 * * **为什么不是直接显示公钥**:指纹是给人看的(日志 / 清点 / 演练报告),16 位 hex 足够唯一, * 且**不可反推**。判据里"两台机器指纹不同"要的正是这个。 * ⚠️ 指纹**不是**校验依据(那是验签的职责)⇒ 永不参与任何准入判断。 */ export function nodeKeyFingerprint(spec: string): string | undefined { const raw = normalizePublicKey(spec) if (raw === undefined) return undefined return createHash('sha256').update(Buffer.from(raw, 'hex')).digest('hex').slice(0, 16) } /** * 从**私钥 PEM** 推出对应的裸公钥 hex —— 免去"到处再存一份公钥"的错配风险。 * * Node 的 `createPublicKey()` **直接吃私钥 `KeyObject`**(PKCS#8 里本就带着公钥), * 所以这里一行就够;写成函数是为了让"公钥从哪来"在调用方是**同一个**答案。 */ export function publicKeyOfPrivate(privateKeyPem: string): string { const priv = createPrivateKey(privateKeyPem) if (priv.asymmetricKeyType !== 'ed25519') { throw new Error(`identity key must be ed25519 (got ${String(priv.asymmetricKeyType)})`) } const der = cryptoCreatePublicKey(priv).export({ type: 'spki', format: 'der' }) as Buffer return der.subarray(der.length - 32).toString('hex') } // ── 纯函数:解析(只校验形状与取值域,**不验签**)───────────────────────────── function asRecord(raw: unknown): Record | undefined { if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined return raw as Record } function isoString(v: unknown, maxLen = 40): string | undefined { if (typeof v !== 'string' || v.length > maxLen) return undefined if (Number.isNaN(Date.parse(v))) return undefined return v } function specList(v: unknown, max = 16): string[] | undefined { if (!Array.isArray(v) || v.length > max) return undefined const out: string[] = [] for (const item of v) { if (typeof item !== 'string' || item.trim() === '') return undefined const norm = normalizePublicKey(item) if (norm === undefined) return undefined if (!out.includes(norm)) out.push(norm) } return out } /** 严格解析签名者集合。任一条不合规 ⇒ `undefined`(宁可不接受,也不要"半成品")。 */ export function parseSignerSet(raw: unknown): SignerSet | undefined { const r = asRecord(raw) if (r === undefined) return undefined if (r.version !== IDENTITY_VERSION) return undefined const network = typeof r.network === 'string' ? r.network.trim() : '' if (!isNetworkId(network)) return undefined const issuedAt = isoString(r.issuedAt) if (issuedAt === undefined) return undefined const signers = specList(r.signers) // **空集合 = 无授权签名者** ⇒ 不合法:它会让"签名者全部被撤"与"配置忘了填"长得一样。 if (signers === undefined || signers.length === 0) return undefined return { version: IDENTITY_VERSION, network, issuedAt, signers } } /** 严格解析节点凭据。 */ export function parseNodeGrant(raw: unknown): NodeGrant | undefined { const r = asRecord(raw) if (r === undefined) return undefined if (r.version !== IDENTITY_VERSION) return undefined const network = typeof r.network === 'string' ? r.network.trim() : '' if (!isNetworkId(network)) return undefined const hostId = typeof r.hostId === 'string' ? r.hostId.trim() : '' if (!isHostId(hostId)) return undefined const nodeKey = typeof r.nodeKey === 'string' ? normalizePublicKey(r.nodeKey) : undefined if (nodeKey === undefined) return undefined const issuedAt = isoString(r.issuedAt) if (issuedAt === undefined) return undefined // `expiresAt` 允许空串(= 不过期),非空则必须是合法 ISO。 if (r.expiresAt !== '' && typeof r.expiresAt !== 'string') return undefined const expiresAt = r.expiresAt === '' ? '' : isoString(r.expiresAt) if (expiresAt === undefined) return undefined return { version: IDENTITY_VERSION, network, hostId, nodeKey, issuedAt, expiresAt } } /** 严格解析吊销清单。 */ export function parseRevocationList(raw: unknown): RevocationList | undefined { const r = asRecord(raw) if (r === undefined) return undefined if (r.version !== IDENTITY_VERSION) return undefined const network = typeof r.network === 'string' ? r.network.trim() : '' if (!isNetworkId(network)) return undefined const issuedAt = isoString(r.issuedAt) if (issuedAt === undefined) return undefined const hosts = stringList(r.hosts, 256, (s) => isHostId(s)) if (hosts === undefined) return undefined const nodeKeys = stringList(r.nodeKeys, 256, (s) => normalizePublicKey(s) !== undefined) if (nodeKeys === undefined) return undefined return { version: IDENTITY_VERSION, network, issuedAt, hosts, nodeKeys: nodeKeys.map((s) => normalizePublicKey(s) as string), } } function stringList(v: unknown, max: number, ok: (s: string) => boolean): string[] | undefined { if (!Array.isArray(v) || v.length > max) return undefined const out: string[] = [] for (const item of v) { if (typeof item !== 'string') return undefined const s = item.trim() if (s === '' || !ok(s)) return undefined if (!out.includes(s)) out.push(s) } return out } // ── 纯函数:签发 / 验签 ─────────────────────────────────────────────────────── /** 通用验签:`sig` 为 base64;`keys` 为受信公钥 spec 列表(PEM 或裸 32 字节)。 */ function verifySigned(payload: string, sig: unknown, keys: readonly string[]): IdentityReason | 'ok' { if (typeof sig !== 'string' || sig.trim() === '') return 'no-signature' const sigBuf = Buffer.from(sig.trim(), 'base64') if (sigBuf.length !== 64) return 'bad-signature-length' // **受信公钥为空 ⇒ 拒绝**(不可验 = 不接受)——与 directory.ts 同一条判据。 if (keys.length === 0) return 'no-trusted-keys' const data = Buffer.from(payload, 'utf8') for (const spec of keys) { const key = publicKeyFrom(spec) if (key === undefined) continue if (cryptoVerify(null, data, key, sigBuf)) return 'ok' } return 'signature-mismatch' } function signPayload(payload: string, privateKeyPem: string): string { const key = createPrivateKey(privateKeyPem) if (key.asymmetricKeyType !== 'ed25519') { throw new Error(`identity key must be ed25519 (got ${String(key.asymmetricKeyType)})`) } return cryptoSign(null, Buffer.from(payload, 'utf8'), key).toString('base64') } /** * **既有验签实现的唯一出口**(序㉘ · 单 B **纯扩展**)。 * * 为什么要有它:内容面的「组密钥凭据」`(network, group, epoch, keyId)` 必须走**同一条** * 信任链(同一批受信签名者、同一套"不可验 = 不接受"判据)。⛔ 不允许在 `content/crypto.ts` * 里再写一份 `crypto.verify` —— 那会造出**两套验签实现**,日后必然分叉。 * * ⚠️ 本地函数**语义一行未改**(只是把既有 `verifySigned` 暴露出去):返回值仍是 * `'ok'` 或**具名** `IdentityReason`(⛔ 不吞错、⛔ 不合并原因)。 */ export function verifySignedPayload( payload: string, sig: unknown, trustedKeys: readonly string[], ): IdentityReason | 'ok' { return verifySigned(payload, sig, trustedKeys) } /** * **既有签发实现的唯一出口**(与上面的 `verifySignedPayload` 成对;序㉘ · 单 B **纯扩展**)。 * * 用途:组密钥凭据的**签发**要走同一把签名者钥匙、同一套规范载荷(`*Payload()`)—— * ⛔ 不许在 CLI 里另写一份 `crypto.sign`。⚠️ 仍然**强制 `ed25519`**(`signPayload` 内校验)。 */ export function signPayloadWith(privateKeyPem: string, payload: string): string { return signPayload(payload, privateKeyPem) } /** 验一份签名者集合(是否被**根**授权)。 */ export function verifySignerSet(raw: unknown, sig: unknown, trustedRootKeys: readonly string[]): IdentityVerdict { const doc = parseSignerSet(raw) if (doc === undefined) return { ok: false, reason: 'bad-document' } const payload = signerSetPayload(doc) const verdict = verifySigned(payload, sig, trustedRootKeys) if (verdict !== 'ok') return { ok: false, reason: verdict } return { ok: true, doc, payload } } /** 验一份节点凭据(是否由**受信签名者**签发)。 */ export function verifyNodeGrant( raw: unknown, sig: unknown, trustedSignerKeys: readonly string[], ): IdentityVerdict { const doc = parseNodeGrant(raw) if (doc === undefined) return { ok: false, reason: 'bad-document' } const payload = nodeGrantPayload(doc) const verdict = verifySigned(payload, sig, trustedSignerKeys) if (verdict !== 'ok') return { ok: false, reason: verdict } return { ok: true, doc, payload } } /** 验一份吊销清单(是否由**受信签名者**签发)。 */ export function verifyRevocations( raw: unknown, sig: unknown, trustedSignerKeys: readonly string[], ): IdentityVerdict { const doc = parseRevocationList(raw) if (doc === undefined) return { ok: false, reason: 'bad-document' } const payload = revocationPayload(doc) const verdict = verifySigned(payload, sig, trustedSignerKeys) if (verdict !== 'ok') return { ok: false, reason: verdict } return { ok: true, doc, payload } } /** 签发签名者集合(**只跑在离线的根密钥持有者那一侧**)。 */ export function signSignerSet(set: SignerSet, rootPrivateKeyPem: string): string { return signPayload(signerSetPayload(set), rootPrivateKeyPem) } /** 签发节点凭据(跑在**在线签名者**那一侧,现网 = 47)。 */ export function signNodeGrant(grant: NodeGrant, signerPrivateKeyPem: string): string { return signPayload(nodeGrantPayload(grant), signerPrivateKeyPem) } /** 签发吊销清单(跑在**在线签名者**那一侧)。 */ export function signRevocations(list: RevocationList, signerPrivateKeyPem: string): string { return signPayload(revocationPayload(list), signerPrivateKeyPem) } /** 会话证明:用**节点私钥**对挑战签名(证明"握有私钥",光有凭据不够)。 */ export function signProof(nodePrivateKeyPem: string, challenge: string): string { return signPayload(proofPayload(challenge), nodePrivateKeyPem) } /** 验会话证明。**公钥解析不出来 ⇒ 拒**(不静默当作"这张网没有身份要求")。 */ export function verifyProof(nodeKeySpec: string, challenge: string, sig: unknown): boolean { const key = publicKeyFrom(nodeKeySpec) if (key === undefined) return false if (typeof sig !== 'string' || sig.trim() === '') return false const sigBuf = Buffer.from(sig.trim(), 'base64') if (sigBuf.length !== 64) return false return cryptoVerify(null, Buffer.from(proofPayload(challenge), 'utf8'), key, sigBuf) } // ── 本地校验的单一入口(**节点自己判**,D2)────────────────────────────────── /** {@link verifyPeerGrant} 的上下文。 */ export interface PeerVerifyContext { /** 受根授权的签名者公钥(**直接**受信;要么来自 env,要么来自一份已验签的 SignerSet)。 */ trustedSignerKeys: readonly string[] /** 期望的网。给 ⇒ 必须一致(跨网签发 ⇒ `network-mismatch`)。 */ network?: string /** 期望的 hostId。给 ⇒ 必须一致(凭据被搬到别的 hostId 上 ⇒ `host-mismatch`)。 */ hostId?: string /** 期望的节点公钥(**裸 32 字节 hex**)。给 ⇒ 必须一致(私钥被换过 ⇒ `key-mismatch`)。 */ nodeKey?: string /** 当前有效的吊销清单(已验签;未配 ⇒ 只警告不拒 —— 见 `loadRevocations`)。 */ revocations?: RevocationList /** 注入点:默认 `Date.now()`。 */ nowMs?: number } /** * **本地校验一份对端凭据** —— 本模块唯一的高层入口(relay 与各节点都调它)。 * * 校验顺序(**先结构、再签名、再语义、最后吊销**):任何一个不过 ⇒ 立即带原因返回。 * ⛔ 绝不"跳过某一步继续":那种"尽力而为"的校验正是"看起来验过了"的来源。 */ export function verifyPeerGrant( raw: unknown, sig: unknown, ctx: PeerVerifyContext, ): IdentityVerdict { const verdict = verifyNodeGrant(raw, sig, ctx.trustedSignerKeys) if (!verdict.ok) return verdict const g = verdict.doc if (ctx.network !== undefined && g.network !== ctx.network) return { ok: false, reason: 'network-mismatch' } if (ctx.hostId !== undefined && g.hostId !== ctx.hostId) return { ok: false, reason: 'host-mismatch' } if (ctx.nodeKey !== undefined && g.nodeKey !== normalizePublicKey(ctx.nodeKey)) { return { ok: false, reason: 'key-mismatch' } } const now = ctx.nowMs ?? Date.now() const issued = Date.parse(g.issuedAt) // 允许 5 分钟的签发时刻容差(签发机与校验机的时钟不可能逐毫秒对齐)。 if (Number.isFinite(issued) && issued > now + 5 * 60_000) return { ok: false, reason: 'not-yet-valid' } if (g.expiresAt !== '') { const exp = Date.parse(g.expiresAt) if (Number.isFinite(exp) && exp <= now) return { ok: false, reason: 'expired' } } const rev = ctx.revocations if (rev !== undefined) { // 吊销清单与凭据必须**同网**;不同网 ⇒ 清单不适用(凭据本身已通过 network 校验)。 if (rev.network === g.network) { if (rev.hosts.includes(g.hostId)) return { ok: false, reason: 'revoked-host' } if (rev.nodeKeys.includes(g.nodeKey)) return { ok: false, reason: 'revoked-node-key' } } } return verdict } // ── 环境变量(relay 独立进程与 dsh 进程共用同一套口径)────────────────────── function envList(env: NodeJS.ProcessEnv, name: string): string[] { const raw = env[name] if (raw === undefined) return [] return [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''))] } /** * 受信**根**公钥(用于验 `SignerSet`)。 * * ⚠️ 与 `directory.ts` 的 `DSHS_OVERLAY_DIR_PUBKEYS` 是**两把不同的密钥、两个不同的用途**: * 那把签"地址目录"(可轮换的下发物),这把签"签名者名单"(身份根)。⛔ 不要合并成一个变量 —— * 合并等于让"能换地址的人"顺带能加签名者。 */ export function identityEnvTrustedRoots(env: NodeJS.ProcessEnv = process.env): string[] { return envList(env, 'DSHS_OVERLAY_ROOT_PUBKEYS') } /** 受信**签名者**公钥(快速通道:直接信任某把签名者,不必先摆一份 SignerSet)。 */ export function identityEnvTrustedSigners(env: NodeJS.ProcessEnv = process.env): string[] { return envList(env, 'DSHS_OVERLAY_SIGNER_PUBKEYS') } /** 是否**强制**要求节点凭据(`1` / `true` / `yes` ⇒ 开)。缺省关(存量节点平滑过渡,见 §8)。 */ export function identityEnvRequire(env: NodeJS.ProcessEnv = process.env): boolean { const raw = (env.DSHS_OVERLAY_REQUIRE_IDENTITY ?? '').trim().toLowerCase() return raw === '1' || raw === 'true' || raw === 'yes' } // ── IO:密钥与凭据文件(只做搬运,判据全在上面)───────────────────────────── /** 生成一把 Ed25519 节点密钥。返回**私钥 PEM** 与**裸公钥 hex**。 */ export function generateNodeKey(): { privateKeyPem: string; publicKey: string } { const { privateKey, publicKey } = generateKeyPairSync('ed25519') const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string const der = publicKey.export({ type: 'spki', format: 'der' }) as Buffer return { privateKeyPem, publicKey: der.subarray(der.length - 32).toString('hex') } } /** 生成一把 Ed25519 根 / 签名者密钥(同形,分开只为读起来不漏"这把是干什么用的")。 */ export const generateAuthorityKey = generateNodeKey function writeFile0600(file: string, text: string): void { mkdirSync(dirname(file), { recursive: true }) const tmp = `${file}.tmp-${randomBytes(4).toString('hex')}` writeFileSync(tmp, text, { mode: 0o600 }) renameSync(tmp, file) // `mode` 在**已存在**的文件上不生效(rename 覆盖时权限随 tmp)⇒ 显式再 chmod 一次, // 保证"恢复演练 / 重签"之后权限仍然收窄(否则一次 rename 就能把 0600 悄悄放宽)。 chmodSync(file, 0o600) } /** * **读或生成**节点密钥(`0600`)。 * * ⛔ 不"生成一把临时的顶上去":身份是**长期**的,静默生成新身份 = 让"这台机器被吊销过" * 这件事在下一次重启后自动失效(吊销形同虚设)。 */ export function loadOrCreateNodeKey( file: string, log: (line: string) => void = (): void => undefined, ): { privateKeyPem: string; publicKey: string; created: boolean } { if (file === '') throw new Error('node key file path is empty') if (existsSync(file)) { const privateKeyPem = readFileSync(file, 'utf8') const publicKey = publicKeyOfPrivate(privateKeyPem) // 权限收窄是**幂等**的:每次装载都确认一遍(被人 `chmod 644` 过也能自愈)。 chmodSync(file, 0o600) return { privateKeyPem, publicKey, created: false } } const key = generateNodeKey() writeFile0600(file, key.privateKeyPem) log(`[identity] 生成新节点密钥:${file}(0600)指纹=${nodeKeyFingerprint(key.publicKey) ?? '-'}`) return { ...key, created: true } } /** 读一份 `{doc, sig}` 形式的签名文件;文件不存在 ⇒ `undefined`(**不抛**,便于"未配"分支)。 */ export function readSignedFile(file: string): { doc: unknown; sig: unknown } | undefined { if (file === '') return undefined let raw: string try { raw = readFileSync(file, 'utf8') } catch { return undefined } let parsed: unknown try { parsed = JSON.parse(raw) } catch { return undefined } const r = asRecord(parsed) if (r === undefined) return undefined return { doc: r.doc, sig: r.sig } } /** 写一份 `{doc, sig}` 形式的签名文件(同目录临时文件 + `rename`,**0600**)。 */ export function writeSignedFile(file: string, doc: unknown, sig: string): void { if (file === '') throw new Error('signed file path is empty') writeFile0600(file, `${JSON.stringify({ doc, sig }, null, 2)}\n`) } /** * 装载**受信签名者**:`SignerSet`(经根验签)+ env 里直接列的签名者。 * * ## 失败关闭在这个函数里的确切形态 * - 没配根、也没配签名者 ⇒ 返回 `{ keys: [] }`(调用方据此判定"不可验 = 不接受"); * - **配了根、但 SignerSet 读不到 / 验不过** ⇒ 返回 `{ keys: [], error }` —— ⛔ **不回落**到 * "那先用 env 里那几个吧":那种回落会让"签名者名单被换掉"表现成"一切正常"。 * * ⚠️ 返回的签名者**还要过一道网校验**(`set.network` 必须等于本机所在网)—— 由调用方传入 * `network` 完成(这里不猜本机在哪张网)。 */ export function loadTrustedSigners(opts: { roots: readonly string[] directSigners: readonly string[] signerSetFile?: string network?: string log?: (line: string) => void }): { keys: string[]; error?: string; signerSet?: SignerSet } { const log = opts.log ?? ((): void => undefined) const keys: string[] = [...opts.directSigners] let signerSet: SignerSet | undefined const file = opts.signerSetFile ?? '' if (file === '') { // 无签名者集合文件:只剩"直接受信签名者"这一条路(开发期形态)。 return { keys } } const loaded = readSignedFile(file) if (loaded === undefined) { return { keys: [], error: `signer-set-unreadable: ${file}` } } const verdict = verifySignerSet(loaded.doc, loaded.sig, opts.roots) if (!verdict.ok) { return { keys: [], error: `signer-set-rejected: ${verdict.reason}` } } signerSet = verdict.doc if (opts.network !== undefined && signerSet.network !== opts.network) { return { keys: [], error: `signer-set-network-mismatch: ${signerSet.network} != ${opts.network}` } } for (const k of signerSet.signers) if (!keys.includes(k)) keys.push(k) log(`[identity] 受信签名者 ${keys.length} 把(来自 ${file},net=${signerSet.network},经根验签通过)`) return { keys, signerSet } } /** * 装载**吊销清单**(经签名者验签)。 * * ⚠️ 与签名者集合**不同的一条**:清单**没配** ⇒ 返回 `undefined` 但**不报错** * (`keys.ts` 的 HMAC 已经把"未登记的 hostId"挡住了,吊销清单是"已登记但被撤"的这一层); * 清单**配了却验不过** ⇒ 返回 `{ error }`,调用方必须**拒**(清单被改坏 ⇒ 撤销失效 ⇒ 不能当没事)。 */ export function loadRevocations(opts: { file?: string trustedSignerKeys: readonly string[] log?: (line: string) => void }): { list?: RevocationList; error?: string } { const file = opts.file ?? '' if (file === '') return {} const loaded = readSignedFile(file) if (loaded === undefined) return { error: `revocations-unreadable: ${file}` } const verdict = verifyRevocations(loaded.doc, loaded.sig, opts.trustedSignerKeys) if (!verdict.ok) return { error: `revocations-rejected: ${verdict.reason}` } opts.log?.(`[identity] 吊销清单:net=${verdict.doc.network} hosts=[${verdict.doc.hosts.join(',')}] nodeKeys=${verdict.doc.nodeKeys.length}`) return { list: verdict.doc } } /** 装载本机的 `{grant, sig}`;未配 ⇒ `undefined`(调用方按"本机还没入网凭据"处理)。 */ export function loadNodeGrant(file: string): { grant: NodeGrant; sig: string } | undefined { const loaded = readSignedFile(file) if (loaded === undefined) return undefined const grant = parseNodeGrant(loaded.doc) if (grant === undefined || typeof loaded.sig !== 'string') return undefined return { grant, sig: loaded.sig } } /** * **本机节点身份**(relay 客户端侧的唯一装配入口)—— `main.ts` / worker / Manager 都走它。 * * 返回 `undefined` 的确切语义是「**本机没有身份可用**」,而不是「身份校验通过」: * - 密钥文件或凭据文件**任一没配** ⇒ `undefined`(过渡期形态:只做 HMAC); * - 配了却**读不出来 / 凭据不合法** ⇒ **抛**(⛔ 不静默退化成"没身份" —— * 那会让"凭据坏了"表现成"relay 没要求身份时一切正常",等 relay 一开强制就整台失联, * 而排障时看到的是"配置明明写了")。 * * ⚠️ 这里**不校验凭据签名**(那是 relay 与本机对端的事):本机只是"把我的证书带上"。 * 本机对**自己**的凭据也不做本地校验 —— 它若无效,relay 会明确拒(`identity-*` 原因码), * 那条日志才是可诊断的那一条。 */ export function loadClientIdentity(opts: { keyFile: string grantFile: string log?: (line: string) => void }): { privateKeyPem: string; grant: NodeGrant; grantSig: string } | undefined { const keyFile = (opts.keyFile ?? '').trim() const grantFile = (opts.grantFile ?? '').trim() if (keyFile === '' || grantFile === '') return undefined const node = loadOrCreateNodeKey(keyFile, opts.log) const loaded = loadNodeGrant(grantFile) if (loaded === undefined) { throw new Error(`node grant file ${grantFile} is missing or malformed (need {"doc":{...},"sig":""})`) } opts.log?.( `[identity] 本机节点 ${loaded.grant.network}/${loaded.grant.hostId} 指纹=${nodeKeyFingerprint(node.publicKey) ?? '-'}`, ) return { privateKeyPem: node.privateKeyPem, grant: loaded.grant, grantSig: loaded.sig } }