把散落在代码里的真实部署值统一收进 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 未被误建)。
527 lines
24 KiB
JavaScript
527 lines
24 KiB
JavaScript
/**
|
||
* 覆盖网络 · **序③ 一机一钥 + 信任根** 单测 —— 身份层是不是"真的挡得住"。
|
||
*
|
||
* ## 这个文件要回答的四个问题(= `交接单_一机一钥与信任根_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), /非法/)
|
||
})
|