Files
dsh_shenxian/test/overlay-identity.test.mjs
T
admin 452924d89c feat(config): 涉密内容外置到配置目录(档案 140)
把散落在代码里的真实部署值统一收进 config/,代码改为引用配置,
使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。

新增 config/:platform.env.example(模板)· load.sh(shell 加载器)·
index.cjs(node 加载器)· README.md(键一览与优先级)。
真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。

TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用):
platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。
config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由
DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径,
src/** 注释中性化 116 行/53 文件。

scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径
一律改从配置取;web/wake.html 的注册域白名单改为运行时从
location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737
保留值,并把「内置种子必须为空」固化为回归断言。

取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有);
全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归
(/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。
2026-09-19 15:12:19 +08:00

527 lines
24 KiB
JavaScript
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.
/**
* 覆盖网络 · **序③ 一机一钥 + 信任根** 单测 —— 身份层是不是"真的挡得住"。
*
* ## 这个文件要回答的四个问题(= `交接单_一机一钥与信任根_20260917.md §5/§6`)
* 1. **四层模型**:根(离线)→ 签名者(在线)→ 节点(每机一把)→ 会话(复用 TLS)
* 的载体能不能**真签发、真验签**?(A 组)
* 2. **入网 = 签名**:篡改过的入网凭据**建不起会话**,且原因**结构化**?(B 组 + D 组)
* 3. **relay 准入按 `hostId` + 成员资格**:拿 A 网的凭据进 B 网必须被拒?(C 组 + D1)
* 4. **撤销单台 ≠ 全网换密钥**:撤 w106 之后,w47 照旧在线?(D6)
*
* ## 🔴 "先红后绿"在本文件里的落点(⛔ 不是口头声明)
* 身份校验**默认不强制**(`requireIdentity=false`,存量过渡)⇒ 同一份**被搬家的凭据**,
* 在 `verifyIdentity:false` 的服务器上**能注册进去**(= 没有这道门时的攻击面,**红**),
* 在 `verifyIdentity:true` 的服务器上**被拒**(**绿**)。两组用**同一份坏凭据、同一个 hostId**,
* 唯一变量就是那一道门 ⇒ 证明"挡住它的正是这道门",而不是别的巧合(D5)。
*
* 运行:`node --test test/overlay-identity.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。
*
* @module test/overlay-identity
*/
import assert from 'node:assert/strict'
import { createHmac, randomBytes } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { RelayClient } from '../lib/net/relay/client.js'
import {
generateAuthorityKey,
generateNodeKey,
nodeKeyFingerprint,
publicKeyOfPrivate,
signNodeGrant,
signProof,
signRevocations,
signSignerSet,
verifyNodeGrant,
verifyPeerGrant,
verifyProof,
verifyRevocations,
verifySignerSet,
} from '../lib/net/relay/identity.js'
import { describeKeyEntry, loadKeysFile, parseKeysInline } from '../lib/net/relay/keys.js'
import { OPS_NETWORK } from '../lib/net/relay/network.js'
import { RelayServer } from '../lib/net/relay/server.js'
import { MUX, decodeMux, encodeJsonFrame } from '../lib/net/relay/wire.js'
const BASE = 47100
const SPAN = 200
const PATH = '/dshs-relay'
const U_A = 'u:alpha'
const U_B = 'u:beta'
const NOW = Date.parse('2026-09-17T00:00:00Z')
/* ─────────────────────────── 工具 ─────────────────────────── */
async function waitFor(fn, ms = 4000) {
const until = Date.now() + ms
while (Date.now() < until) {
if (fn()) return true
await new Promise((r) => setTimeout(r, 20))
}
return false
}
/** 一套"根 → 签名者 → 节点"的密钥与凭据(**建网的标准动作**:测试与部署脚本同一形状)。 */
function buildAuthority(network = OPS_NETWORK) {
const root = generateAuthorityKey()
const signer = generateAuthorityKey()
const signerSet = {
version: 1,
network,
issuedAt: new Date(NOW).toISOString(),
signers: [signer.publicKey],
}
const signerSetSig = signSignerSet(signerSet, root.privateKeyPem)
const issue = (hostId, opts = {}) => {
const node = generateNodeKey()
const grant = {
version: 1,
network: opts.network ?? network,
hostId,
nodeKey: node.publicKey,
issuedAt: opts.issuedAt ?? new Date(NOW).toISOString(),
expiresAt: opts.expiresAt ?? '',
}
return { node, grant, sig: signNodeGrant(grant, signer.privateKeyPem) }
}
return { root, signer, signerSet, signerSetSig, issue }
}
/** 吊销清单(签名者签发)。 */
function revocationOf(signerPem, hosts, nodeKeys = []) {
const doc = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), hosts, nodeKeys }
return { doc, sig: signRevocations(doc, signerPem) }
}
/**
* 手工发一个 `HELLO` 并取回**首个回帧**(用于构造"正常 client 不会发"的帧)。
*
* ⚠️ MAC 输入串与 `client.ts` 逐字同源(**不含身份字段**)—— 身份是**追加**字段、不动 MAC;
* 这一条本身就是被测对象之一(旧客户端算出的 MAC 必须仍被接受)。
*/
async function rawHello(wsUrl, opts) {
const { hostId, secret, portsCsv = '', network, identity } = opts
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, { once: true })
ws.addEventListener('error', () => reject(new Error('ws open failed')), { once: true })
})
const ts = Date.now()
const nonce = randomBytes(16).toString('hex')
const challenge = `${hostId}|${ts}|${nonce}|${portsCsv}`
const mac = createHmac('sha256', Buffer.from(secret, 'hex')).update(challenge).digest('hex')
const payload = { v: 1, hostId, ts, nonce, portsCsv, mac }
if (network !== undefined) payload.network = network
if (identity !== undefined) {
const { nodePrivateKeyPem, nodeSigOverride, ...rest } = identity
Object.assign(payload, rest)
// 默认按**正确的挑战串**签(走真实路径);给了 override 就按 override(用来构造"签名错")。
if (nodeSigOverride !== undefined) payload.nodeSig = nodeSigOverride
else if (nodePrivateKeyPem !== undefined) payload.nodeSig = signProof(nodePrivateKeyPem, challenge)
}
ws.send(encodeJsonFrame(MUX.HELLO, 0, payload))
const frame = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(undefined), 3000)
ws.addEventListener('message', (ev) => {
clearTimeout(timer)
resolve(decodeMux(Buffer.from(ev.data)))
})
ws.addEventListener('close', () => {
clearTimeout(timer)
resolve(undefined)
})
})
return { ws, frame }
}
function reasonOf(frame) {
assert.ok(frame !== undefined, '必须得到结构化回帧')
return JSON.parse(frame.payload.toString('utf8')).reason
}
/** 建一个 relay:所有 host 都是"拨号方"(端口表为空)⇒ 不需要任何 TCP 回显服务。 */
async function startRelay(t, opts = {}) {
const hosts = opts.hosts ?? {}
const srv = new RelayServer({
port: 0,
// ⚠️ 必须是 `Map`(服务端按 `keys.get(hostId)` 取;传普通对象会得到
// `this.opts.keys.get is not a function` 这种**与判据无关**的崩 —— 会淹掉真正的结论)。
keys: new Map(Object.entries(hosts)),
instancePortBase: BASE,
instancePortSpan: SPAN,
dialers: new Map([[OPS_NETWORK, new Set(Object.keys(hosts))]]),
trustedSignerKeys: opts.trustedSignerKeys ?? [],
revocations: opts.revocations,
requireIdentity: opts.requireIdentity ?? false,
verifyIdentity: opts.verifyIdentity ?? true,
log: opts.log ?? (() => {}),
})
await srv.start()
t.after(async () => {
await srv.stop()
})
return { srv, url: `ws://127.0.0.1:${srv.boundPort}${PATH}` }
}
/* ─────────── A:四层密钥模型(根 → 签名者) ─────────── */
test('A1 根签名的签名者集合:受信根验得过', () => {
const { root, signer, signerSet, signerSetSig } = buildAuthority()
const verdict = verifySignerSet(signerSet, signerSetSig, [root.publicKey])
assert.equal(verdict.ok, true, `根签的集合必须验得过,实测:${JSON.stringify(verdict)}`)
assert.deepEqual(verdict.doc.signers, [signer.publicKey])
})
test('A2 篡改签名者集合(加一把签名者后沿用旧签名)⇒ **signature-mismatch**', () => {
const { root, signerSet, signerSetSig } = buildAuthority()
const evil = generateAuthorityKey()
const tampered = { ...signerSet, signers: [...signerSet.signers, evil.publicKey] }
const verdict = verifySignerSet(tampered, signerSetSig, [root.publicKey])
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'signature-mismatch', '加签名者 = 加攻击面 ⇒ 必须失败关闭')
})
test('A3 **不可验 = 不接受**:一把受信根都没有 ⇒ no-trusted-keys(不"先用着")', () => {
const { signerSet, signerSetSig } = buildAuthority()
const verdict = verifySignerSet(signerSet, signerSetSig, [])
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'no-trusted-keys')
})
test('A4 换一把没被授权的根来签 ⇒ 不认(根是唯一信任源)', () => {
const a = buildAuthority()
const b = buildAuthority()
const crossSig = signSignerSet(a.signerSet, b.root.privateKeyPem)
const verdict = verifySignerSet(a.signerSet, crossSig, [a.root.publicKey])
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'signature-mismatch')
})
/* ─────────── B:节点凭据 + 本地校验(D2 的判据落点) ─────────── */
test('B1 合法凭据:受信签名者签发 ⇒ 过,且能取出 hostId / 网 / 节点公钥', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106')
const verdict = verifyNodeGrant(grant, sig, [auth.signer.publicKey])
assert.equal(verdict.ok, true)
assert.equal(verdict.doc.hostId, 'w106')
assert.equal(verdict.doc.network, OPS_NETWORK)
assert.equal(verdict.doc.nodeKey, grant.nodeKey)
})
test('B2 🔴 核心:**篡改 hostId 后沿用原签名** ⇒ signature-mismatch(凭据不可搬家)', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106')
const moved = { ...grant, hostId: 'w47' }
const verdict = verifyNodeGrant(moved, sig, [auth.signer.publicKey])
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'signature-mismatch', '凭据被搬到别的 hostId 上必须拒(签名覆盖 hostId)')
})
test('B3 期望网不符 ⇒ network-mismatch(跨网签发进不来)', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106')
const verdict = verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], network: U_B })
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'network-mismatch')
})
test('B4/B5 期望 hostId 不符 ⇒ host-mismatch;期望公钥不符 ⇒ key-mismatch', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106')
const other = generateNodeKey()
assert.equal(
verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], hostId: 'w47' }).reason,
'host-mismatch',
)
assert.equal(
verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], nodeKey: other.publicKey }).reason,
'key-mismatch',
'私钥被换过(公钥对不上)⇒ 拒',
)
// 对得上的那一组必须**过** —— 否则上面两条可能只是"恒拒",测了个寂寞
assert.equal(
verifyPeerGrant(grant, sig, {
trustedSignerKeys: [auth.signer.publicKey],
network: OPS_NETWORK,
hostId: 'w106',
nodeKey: grant.nodeKey,
}).ok,
true,
)
})
test('B6 过期凭据 ⇒ expired(带过期时间的节点会真的过期)', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106', {
issuedAt: new Date(NOW - 120_000).toISOString(),
expiresAt: new Date(NOW - 60_000).toISOString(),
})
const verdict = verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], nowMs: NOW })
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'expired')
})
test('B7/B8 吊销:撤 hostId ⇒ revoked-host;撤节点公钥 ⇒ revoked-node-key;撤单台不牵连别的', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106')
const byHost = { ...revocationOf(auth.signer.privateKeyPem, ['w106']).doc }
const byKey = { ...revocationOf(auth.signer.privateKeyPem, [], [grant.nodeKey]).doc }
const other = { ...revocationOf(auth.signer.privateKeyPem, ['manager']).doc }
assert.equal(
verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], revocations: byHost }).reason,
'revoked-host',
)
assert.equal(
verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], revocations: byKey }).reason,
'revoked-node-key',
'设备被盗场景:hostId 可能被复用,公钥不会 ⇒ 两条都要能撤',
)
assert.equal(
verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], revocations: other }).ok,
true,
'撤销单台不得牵动其它节点',
)
})
test('B9 吊销清单本身也要**验签**(清单被改坏 ⇒ 撤销失效 ⇒ 不能当没事)', () => {
const auth = buildAuthority()
const rogue = generateAuthorityKey()
const good = revocationOf(auth.signer.privateKeyPem, ['w106'])
assert.equal(verifyRevocations(good.doc, good.sig, [auth.signer.publicKey]).ok, true)
const forged = revocationOf(rogue.privateKeyPem, ['w106'])
assert.equal(verifyRevocations(forged.doc, forged.sig, [auth.signer.publicKey]).reason, 'signature-mismatch')
// 篡改内容后沿用原签名
const tampered = { ...good.doc, hosts: ['w47'] }
assert.equal(verifyRevocations(tampered, good.sig, [auth.signer.publicKey]).reason, 'signature-mismatch')
})
test('B10 会话证明(proof):握有私钥才签得出;换公钥 / 改挑战 ⇒ 验不过', () => {
const auth = buildAuthority()
const { node } = auth.issue('w106')
const challenge = 'w106|123|abc|'
const sig = signProof(node.privateKeyPem, challenge)
assert.equal(verifyProof(node.publicKey, challenge, sig), true)
const other = generateNodeKey()
assert.equal(verifyProof(other.publicKey, challenge, sig), false, '别的公钥验不过 ⇒ 证明绑定了具体私钥')
assert.equal(verifyProof(node.publicKey, `${challenge}x`, sig), false, '挑战被改 ⇒ 验不过(防重放 / 防挪用)')
})
test('B11 非受信签名者签发的凭据 ⇒ signature-mismatch(名单之外一律不认)', () => {
const auth = buildAuthority()
const rogue = generateAuthorityKey()
const node = generateNodeKey()
const grant = {
version: 1,
network: OPS_NETWORK,
hostId: 'w106',
nodeKey: node.publicKey,
issuedAt: new Date(NOW).toISOString(),
expiresAt: '',
}
const sig = signNodeGrant(grant, rogue.privateKeyPem)
const verdict = verifyNodeGrant(grant, sig, [auth.signer.publicKey])
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'signature-mismatch')
})
test('B12 指纹:每机一把 ⇒ 指纹互不相同;解析不出来就 undefined(不猜)', () => {
const a = generateNodeKey()
const b = generateNodeKey()
assert.notEqual(nodeKeyFingerprint(a.publicKey), nodeKeyFingerprint(b.publicKey), '每机一把 ⇒ 指纹必须不同')
assert.equal(nodeKeyFingerprint(a.publicKey), nodeKeyFingerprint(a.publicKey))
assert.equal(nodeKeyFingerprint('not-a-key'), undefined)
})
/* ─────────── C:keys 表带"网"(成员资格的唯一判据) ─────────── */
test('C1 旧写法(裸 hostId + hex 串)⇒ 归入运维网 ops(现网配置一字不改)', () => {
const s = randomBytes(32).toString('hex')
const map = parseKeysInline(`w-1:${s}`)
// 键 = **逻辑名**(序③)⇒ 裸 `w-1` 归一成 `ops/w-1`
assert.deepEqual(map.get('ops/w-1'), { network: OPS_NETWORK, secret: s })
assert.equal(describeKeyEntry('w-1', map.get('ops/w-1')), 'w-1', 'ops 下省略网络前缀(日志读法与 R5 一致)')
})
test('C2 新写法:`网/hostId:secret` 与 `网:hostId:secret` 都切得出网络(切点是**最后一个冒号**)', () => {
const s = randomBytes(32).toString('hex')
const slash = parseKeysInline(`${U_A}/pc-1:${s}`)
assert.deepEqual(slash.get(U_A + '/pc-1'), { network: U_A, secret: s })
// ⚠️ 网络 id 自己含 `:`(`u:5`)⇒ 用**第一个**冒号切会切出网络段 `u`,
// 于是"配置写错"就长得像"这张网不存在"(静默拒绝)——这正是本线反复要根治的病。
const colon = parseKeysInline(`u:5:pc-1:${s}`)
assert.deepEqual(colon.get('u:5/pc-1'), { network: 'u:5', secret: s })
assert.equal(describeKeyEntry('pc-1', colon.get('u:5/pc-1')), 'u:5/pc-1')
})
test('C3 网络段非法 / 密钥长度不对 ⇒ **抛**(不静默回落:那会让"配错"伪装成"网络不通")', () => {
const s = randomBytes(32).toString('hex')
assert.throws(() => parseKeysInline(`u/pc-1:${s}`), /非法/, '裸 `u` 是保留字,不是合法网名')
assert.throws(() => parseKeysInline(`pc-1:deadbeef`), /64 hex/)
assert.throws(() => parseKeysInline('no-colon'), /malformed/)
})
/* ─────────── D:端到端(relay 准入 + 身份) ─────────── */
test('D1 成员资格:声称别的网 ⇒ **network-mismatch**(失败关闭,不回落 ops)', async (t) => {
const s = randomBytes(32).toString('hex')
const { url } = await startRelay(t, { hosts: { w106: s } })
const { ws, frame } = await rawHello(url, { hostId: 'w106', secret: s, network: U_B })
t.after(() => ws.close())
assert.equal(frame.type, MUX.HELLO_ERR)
assert.equal(reasonOf(frame), 'network-mismatch', '密钥表说它在 ops,声称别张网 ⇒ 必须拒')
})
test('D2 未登记的 hostId ⇒ unknown-host(默认拒绝)', async (t) => {
const s = randomBytes(32).toString('hex')
const { url } = await startRelay(t, { hosts: { w106: s } })
const { ws, frame } = await rawHello(url, { hostId: 'nobody', secret: s })
t.after(() => ws.close())
assert.equal(reasonOf(frame), 'unknown-host')
})
test('D3 强制身份但客户端**不带**凭据 ⇒ identity-incomplete(明确拒,不放行)', async (t) => {
const s = randomBytes(32).toString('hex')
const auth = buildAuthority()
const { url } = await startRelay(t, {
hosts: { w106: s },
trustedSignerKeys: [auth.signer.publicKey],
requireIdentity: true,
})
const { ws, frame } = await rawHello(url, { hostId: 'w106', secret: s, network: OPS_NETWORK })
t.after(() => ws.close())
assert.equal(reasonOf(frame), 'identity-incomplete')
})
test('D4 relay 侧**没有**受信签名者时,带了凭据也拒(不可验 = 不接受)', async (t) => {
const s = randomBytes(32).toString('hex')
const auth = buildAuthority()
const { grant, sig, node } = auth.issue('w106')
const { url } = await startRelay(t, { hosts: { w106: s }, trustedSignerKeys: [] })
const { ws, frame } = await rawHello(url, {
hostId: 'w106',
secret: s,
network: OPS_NETWORK,
identity: { nodeKey: node.publicKey, grant, grantSig: sig, nodePrivateKeyPem: node.privateKeyPem },
})
t.after(() => ws.close())
assert.equal(reasonOf(frame), 'identity-no-trusted-signers')
})
test('D5 🔴 **先红后绿**:同一份"被搬家的凭据" —— 没有这道门时能注册(红),有门时被拒(绿)', async (t) => {
const s = randomBytes(32).toString('hex')
const auth = buildAuthority()
const good = auth.issue('w106')
// 篡改形态:把 w106 的凭据"搬家"到 w47 并沿用原签名 —— 这正是"控制面被攻破后插入节点"的形状。
const evilGrant = { ...good.grant, hostId: 'w47' }
const identity = { nodeKey: good.node.publicKey, grant: evilGrant, grantSig: good.sig }
const hosts = { w106: s, w47: s }
/* ① 红:门关着(verifyIdentity:false)⇒ **同一份坏凭据确实注册进去了** */
const open = await startRelay(t, { hosts, trustedSignerKeys: [auth.signer.publicKey], verifyIdentity: false })
const red = await rawHello(open.url, { hostId: 'w47', secret: s, network: OPS_NETWORK, identity })
t.after(() => red.ws.close())
assert.equal(red.frame.type, MUX.HELLO_ACK, '【红】门关着时坏凭据能注册 —— 这就是被治的攻击面,必须有实测')
assert.ok(open.srv.status().counters.authed > 0)
/* ② 绿:门开着(verifyIdentity:true)⇒ 同一份坏凭据被拒 */
const closed = await startRelay(t, { hosts, trustedSignerKeys: [auth.signer.publicKey] })
const green = await rawHello(closed.url, { hostId: 'w47', secret: s, network: OPS_NETWORK, identity })
t.after(() => green.ws.close())
assert.equal(reasonOf(green.frame), 'identity-signature-mismatch', '搬家的凭据必须被签名校验挡住')
assert.equal(closed.srv.status().counters.identityOk, 0)
assert.equal(closed.srv.status().sessions.length, 0, '被拒的节点不得留下任何会话')
})
test('D6 凭据合法但**节点证明**是错的 ⇒ identity-bad-proof(有证书 ≠ 有私钥)', async (t) => {
const s = randomBytes(32).toString('hex')
const auth = buildAuthority()
const { grant, sig, node } = auth.issue('w106')
const attacker = generateNodeKey()
const { url } = await startRelay(t, { hosts: { w106: s }, trustedSignerKeys: [auth.signer.publicKey] })
const { ws, frame } = await rawHello(url, {
hostId: 'w106',
secret: s,
network: OPS_NETWORK,
identity: {
nodeKey: node.publicKey, // 声明的是**真**公钥(凭据也对得上)
grant,
grantSig: sig,
nodeSigOverride: signProof(attacker.privateKeyPem, 'anything'), // 但签名不是那把私钥签的
},
})
t.after(() => ws.close())
assert.equal(reasonOf(frame), 'identity-bad-proof', '凭据是公开可转发的 ⇒ 必须再证"握有私钥"')
})
test('D7 合法身份 ⇒ 真 `RelayClient` 连得上;**撤销单台不牵动全网**', async (t) => {
const sA = randomBytes(32).toString('hex')
const sB = randomBytes(32).toString('hex')
const auth = buildAuthority()
const a = auth.issue('w106')
const b = auth.issue('w47')
const rev = revocationOf(auth.signer.privateKeyPem, ['w106'])
assert.equal(verifyRevocations(rev.doc, rev.sig, [auth.signer.publicKey]).ok, true, '清单本身先要验得过')
const { srv, url } = await startRelay(t, {
hosts: { w106: sA, w47: sB },
trustedSignerKeys: [auth.signer.publicKey],
revocations: rev.doc,
requireIdentity: true,
})
/** 用**真** `RelayClient` 连(证明客户端侧字段拼得对,不是只有 rawHello 能过)。 */
const connect = (hostId, secret, issued) => {
const c = new RelayClient({
url,
hostId,
secret,
ports: [],
dialer: true,
identity: { privateKeyPem: issued.node.privateKeyPem, grant: issued.grant, grantSig: issued.sig },
log: () => {},
})
c.start()
return c
}
const cA = connect('w106', sA, a)
const cB = connect('w47', sB, b)
t.after(() => {
cA.stop()
cB.stop()
})
assert.ok(await waitFor(() => cB.status().state === 'up'), '未被撤的 w47 必须连上(撤销不得牵动全网)')
assert.equal(await waitFor(() => cA.status().state === 'up', 1500), false, '被撤的 w106 不得连上')
assert.equal(srv.status().counters.identityOk, 1, '只有一台通过身份校验')
assert.equal(
srv.status().sessions.filter((x) => x.hostId === 'w106').length,
0,
'被撤节点不得在 relay 侧留下会话',
)
assert.equal(publicKeyOfPrivate(a.node.privateKeyPem), a.grant.nodeKey, '客户端带的 nodeKey 必须与凭据里那把一致')
})
/* ─────────── E:文件面(密钥落点与原地升级) ─────────── */
test('E1 keys 文件:新旧写法可**共存**(原地升级 ⇒ 可原子替换 + 可回滚)', (t) => {
const dir = mkdtempSync(join(tmpdir(), 'dshs-keys-'))
t.after(() => rmSync(dir, { recursive: true, force: true }))
const s1 = randomBytes(32).toString('hex')
const s2 = randomBytes(32).toString('hex')
const file = join(dir, 'relay-keys.json')
writeFileSync(file, JSON.stringify({ 'w-1': s1, w106: { network: OPS_NETWORK, secret: s2 } }))
const map = loadKeysFile(file)
assert.deepEqual(map.get('ops/w-1'), { network: OPS_NETWORK, secret: s1 })
assert.deepEqual(map.get('ops/w106'), { network: OPS_NETWORK, secret: s2 })
// 非法网络段 ⇒ **装载即抛**("配置错就炸",不变成运行期的静默拒绝)
writeFileSync(file, JSON.stringify({ bad: { network: 'u', secret: s1 } }))
assert.throws(() => loadKeysFile(file), /非法/)
})