Files
dsh_shenxian/src/net/relay/identity.ts
T
admin 09ce76f3af feat(overlay): 内容块级寻址 + 实例逐步拉起 + 骨干选路 + 组密钥加密(序24–㉛ 累积同步)
代码
- 内容分发块级寻址:新增 src/net/relay/content/{chunker,store,runtime,source,peer,crypto}.ts
- 组密钥(C 档)确定性加密:AES-256-GCM,块 id β′ = sha256(密文) 前 32 hex;双 epoch 过渡窗口
- 实例生命周期:三处 teardown() 不再杀实例(local/remote/leased-spawner);启动认领 + TCP 探活判孤儿
- 骨干选路:jitter 选路 + endpoint-target;relay client/server/wire/identity/directory/rendezvous/switcher 调整
- 工作台 src/web/server.ts、src/worker/relay-tunnel.ts 装配与候选链观测

脚本与测试
- scripts/overlay-{probe,keyring,jitter}.cjs 更新
- 探针新增 OBS-21(每连接候选数)/ OBS-22(teardown 静态守卫 + 认领面)/ OBS-23(组密钥加密)
- 新增 test/{orchestrator-teardown,orchestrator-rehydrate,overlay-content,overlay-jitter}.test.mjs;relay 两例更新

文档
- 新增交接单:覆盖网络-序24-内容分发块级寻址 / 序25-实例逐步拉起 / 序26-骨干稳定选路与加密
- INDEX.md、交接单/README.md、skills/dsh-auto-handoff-chain/SKILL.md 同步

验收(零回归,2026-09-18 08:0x 复核)
- npm test           201 tests / 200 pass / 0 fail / 1 skipped
- overlay-failover-drill --scene all --table   12 PASS / 0 SKIP / 0 FAIL
- overlay-probe --table                        23 PASS / 0 SKIP / 0 FAIL (rc=0)
2026-09-18 08:08:51 +08:00

685 lines
32 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.
/**
* 覆盖网络 序③:**四层密钥模型 + 离线信任根**(一机一钥 / 入网即签名 / 本地校验 / 可单台吊销)。
*
* ## 它治的病(一句话)
* `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<T> = { 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')
}
/**
* 会话证明的载荷:`<PROOF_TAG>\n<challenge>`。
*
* `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<string, unknown> | undefined {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
return raw as Record<string, unknown>
}
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<SignerSet> {
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<NodeGrant> {
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<RevocationList> {
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<NodeGrant> {
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":"<base64>"})`)
}
opts.log?.(
`[identity] 本机节点 ${loaded.grant.network}/${loaded.grant.hostId} 指纹=${nodeKeyFingerprint(node.publicKey) ?? '-'}`,
)
return { privateKeyPem: node.privateKeyPem, grant: loaded.grant, grantSig: loaded.sig }
}