/** * 覆盖网络 · **序③ 一机一钥 + 信任根** 单测 —— 身份层是不是"真的挡得住"。 * * ## 这个文件要回答的四个问题(= `交接单_一机一钥与信任根_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, readFileSync, existsSync } 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 { OverlaySerialGuard, generateAuthorityKey, generateNodeKey, loadRevocations, loadRootSet, loadTrustedSigners, nodeKeyFingerprint, parseRevocationList, parseRootSet, parseSignerSet, publicKeyOfPrivate, serialScopeKey, signNodeGrant, signProof, signRevocations, signRootSet, signSignerSet, signerSetPayload, verifyNodeGrant, verifyPeerGrant, verifyProof, verifyRevocations, verifyRootSet, verifySignerSet, writeSignedFile, } 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 } /** * 一套"根 → 签名者 → 节点"的密钥与凭据(**建网的标准动作**:测试与部署脚本同一形状)。 * * ⚠️ F1.5:三份文档都带 `serial`(签名覆盖它)。**形状不变** —— `serial` 是**追加**在 * 载荷末尾的可选字段(老文档缺它 ⇒ 视为 `0`),所以本函数只是把"签发时盖序号"补上。 */ function buildAuthority(network = OPS_NETWORK, serial = 1) { const root = generateAuthorityKey() const signer = generateAuthorityKey() const signerSet = { version: 1, network, issuedAt: new Date(NOW).toISOString(), serial, 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 ?? '', // 凭据序号逐设备独立(键含 nodeKey)⇒ 默认 1 不会与"另一台设备的 1"冲突。 serial: opts.serial ?? 1, } return { node, grant, sig: signNodeGrant(grant, signer.privateKeyPem) } } return { root, signer, signerSet, signerSetSig, issue } } /** 吊销清单(签名者签发)。 */ function revocationOf(signerPem, hosts, nodeKeys = [], serial = 1) { const doc = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), serial, hosts, nodeKeys } return { doc, sig: signRevocations(doc, signerPem) } } /** 撤根声明(**根**阈值联签:对同一份载荷各签一次,`sigs` 收齐)。 */ function rootSetOf(doc, rootPems) { return { doc, sigs: rootPems.map((pem) => signRootSet(doc, pem)) } } /** 一个**独立的**序号水位文件(每个用例一份,⛔ 不跨用例共用 —— 否则断言互相污染)。 */ function freshSerialGuard(t) { const dir = mkdtempSync(join(tmpdir(), 'dshs-serial-')) t.after(() => rmSync(dir, { recursive: true, force: true })) const file = join(dir, 'overlay-identity-state.json') return { file, guard: new OverlaySerialGuard({ file }) } } /** * 手工发一个 `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), /非法/) }) /* ─────────── F:F1.5 防重放序号(G1)+ 撤根机制(G6) ─────────── */ /** * F1.5 的验收判据落点。**五类负控逐条对应交接单 §6.2**,每条都要能"做坏一点就被抓到"。 * * 走的是**新入口**(`loadTrustedSigners` / `loadRevocations` / `loadRootSet`),⛔ 不是 * 直接调 `verifySignerSet` —— 后者只验签、不含序号判定,用它测不出重放。 */ test('F1 🔴 兼容性:**无 serial 的老文档**在新代码下必须仍验得过(升级窗口不许掐死)', () => { const auth = buildAuthority() // 模拟 F1.5 **之前**那份代码签的东西:载荷末尾没有 serial 段。 const oldSet = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), signers: [auth.signer.publicKey] } const oldSig = signSignerSet(oldSet, auth.root.privateKeyPem) const verdict = verifySignerSet(oldSet, oldSig, [auth.root.publicKey]) assert.equal(verdict.ok, true, `老文档必须验得过(载荷缺 serial 段 ⇒ 与旧版逐字相同),实测:${JSON.stringify(verdict)}`) assert.equal(verdict.doc.serial, 0, '缺失 ⇒ 视为 0(= 老格式)') // 载荷形状:**serial 段整段不出现**(多一行 "0" 就会让所有存量文档永远验不过 —— 实测踩过) assert.equal(verdict.payload.split('\n').length, 5, `老文档载荷必须是 5 段,实测:${JSON.stringify(verdict.payload)}`) const fresh = { ...oldSet, serial: 7 } assert.equal( verifySignerSet(fresh, signSignerSet(fresh, auth.root.privateKeyPem), [auth.root.publicKey]).ok, true, '带序号的新文档照旧验得过', ) assert.equal(signerSetPayload(fresh).split('\n').length, 6, '带序号的载荷是 6 段(末尾多一项)') }) test('F2 序号判定:新鲜接受 ⇒ 重放拒 ⇒ 同号拒 ⇒ 且**先落盘后采用**', async (t) => { const auth = buildAuthority() const dir = mkdtempSync(join(tmpdir(), 'dshs-serial-')) t.after(() => rmSync(dir, { recursive: true, force: true })) const stateFile = join(dir, 'state.json') const guard = new OverlaySerialGuard({ file: stateFile }) const setFile = join(dir, 'signerset.json') const write = (doc) => writeSignedFile(setFile, doc, signSignerSet(doc, auth.root.privateKeyPem)) const load = () => loadTrustedSigners({ roots: [auth.root.publicKey], directSigners: [], signerSetFile: setFile, serialGuard: guard }) write({ ...auth.signerSet, serial: 5 }) const first = await load() assert.equal(first.error, undefined, `serial=5 首次装载必须过,实测:${first.error}`) write({ ...auth.signerSet, serial: 4 }) const replay = await load() assert.equal(replay.keys.length, 0, '重放更旧的序号 ⇒ 不得取出任何签名者') assert.match(replay.error ?? '', /stale-serial/, `重放必须具名 stale-serial,实测:${replay.error}`) write({ ...auth.signerSet, serial: 5 }) assert.match((await load()).error ?? '', /stale-serial/, '**等于也拒**(重复下发同一号没有理由再接受一次)') // 先落盘后采用:水位文件此刻**已经**是 5(不是"采用时才写") assert.equal( readFileSync(stateFile, 'utf8').includes(`"signerset|${OPS_NETWORK}": 5`), true, '水位必须在采用前已落盘', ) write({ ...auth.signerSet, serial: 6 }) assert.equal((await load()).error, undefined, '更高的序号必须接受') }) test('F3 🔴 次序:**验签必须在序号之前**(垃圾签名+超大序号不得顶死水位)', async (t) => { const auth = buildAuthority() const dir = mkdtempSync(join(tmpdir(), 'dshs-serial-')) t.after(() => rmSync(dir, { recursive: true, force: true })) const stateFile = join(dir, 'state.json') const guard = new OverlaySerialGuard({ file: stateFile }) const setFile = join(dir, 'signerset.json') // 签名**长度合法**(64 字节)但签名者**不在受信根里** ⇒ 走到 `signature-mismatch` 那一步, // 而序号顶到天花板。若先比序号,水位会被顶死、此后一切合法文档全 stale(零成本永久 DoS)。 const evil = { ...auth.signerSet, serial: Number.MAX_SAFE_INTEGER } const rogue = generateAuthorityKey() writeSignedFile(setFile, evil, signSignerSet(evil, rogue.privateKeyPem)) const r = await loadTrustedSigners({ roots: [auth.root.publicKey], directSigners: [], signerSetFile: setFile, serialGuard: guard, }) assert.match(r.error ?? '', /signature-mismatch/, `必须先失败在验签上,实测:${r.error}`) assert.equal(existsSync(stateFile), false, '验签失败**不得**触碰水位文件(碰了就是 DoS 面)') const good = { ...auth.signerSet, serial: 1 } writeSignedFile(setFile, good, signSignerSet(good, auth.root.privateKeyPem)) assert.equal( (await loadTrustedSigners({ roots: [auth.root.publicKey], directSigners: [], signerSetFile: setFile, serialGuard: guard })) .error, undefined, '水位未被污染 ⇒ 一份合法的 serial=1 仍必须被接受(证明 DoS 面没成立)', ) }) test('F4 🔴 负控①/② 重放与同号 ⇒ `stale-serial`(同一入口两种形态)', async (t) => { const { file, guard } = freshSerialGuard(t) const key = serialScopeKey({ kind: 'revocation', network: OPS_NETWORK }) assert.equal((await guard.check(key, 1)).ok, true, '水位 0 时 serial=1 必须接受') assert.equal((await guard.check(key, 1)).reason, 'stale-serial', '负控②:同号重复 ⇒ 拒(严格大于才接受)') assert.equal((await guard.check(key, 0)).reason, 'stale-serial', '负控①:更旧的序号 ⇒ 拒') assert.equal((await guard.check(key, 2)).ok, true, '更高的序号必须接受') assert.equal((await guard.check(key, 0)).reason, 'stale-serial', '水位 ≥1 之后老格式文档也必须拒') // 另一把键是**独立水位**(T-8:两个独立序列、两个键) assert.equal( (await guard.check(serialScopeKey({ kind: 'revocation', network: U_A }), 1)).ok, true, 'A 网的文档不得推进 B 网的水位(按网隔离)', ) assert.equal(existsSync(file), true, '水位文件必须已落盘') }) test('F5 🔴 负控(T-10)状态文件坏掉 ⇒ **失败关闭**,⛔ 不当成水位 0', async (t) => { const auth = buildAuthority() const dir = mkdtempSync(join(tmpdir(), 'dshs-serial-')) t.after(() => rmSync(dir, { recursive: true, force: true })) const stateFile = join(dir, 'state.json') const setFile = join(dir, 'signerset.json') const doc = { ...auth.signerSet, serial: 3 } writeSignedFile(setFile, doc, signSignerSet(doc, auth.root.privateKeyPem)) const load = (guard) => loadTrustedSigners({ roots: [auth.root.publicKey], directSigners: [], signerSetFile: setFile, serialGuard: guard }) // 情形一:文件**不存在** ⇒ 视为水位 0 ⇒ 接受(首次升级的正常形态) assert.equal((await load(new OverlaySerialGuard({ file: stateFile }))).error, undefined, '文件不存在 ⇒ 视为水位 0(不是错误)') // 情形二:文件**存在但坏掉** ⇒ 拒,且拒因具名 writeFileSync(stateFile, '{ 这不是 JSON') assert.match( (await load(new OverlaySerialGuard({ file: stateFile }))).error ?? '', /serial-state-unreadable/, '坏掉的状态文件必须失败关闭', ) // 情形三:形状不对(缺 seen 对象)⇒ 同样失败关闭 writeFileSync(stateFile, JSON.stringify({ version: 1 })) assert.match( (await load(new OverlaySerialGuard({ file: stateFile }))).error ?? '', /serial-state-unreadable/, '形状不对也必须失败关闭', ) }) test('F6 负控③ 单把根签"撤掉其余所有根" ⇒ `insufficient-root-signatures`(单根先手被挡)', () => { const r1 = generateAuthorityKey() const r2 = generateAuthorityKey() const r3 = generateAuthorityKey() // 文档:r1 想撤掉 r2、r3(= 先手把其余根全撤掉)。候选签名者只剩 r1 自己。 const doc = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), serial: 1, revoked: [r2.publicKey, r3.publicKey], } const verdict = verifyRootSet(doc, [signRootSet(doc, r1.privateKeyPem)], [r1.publicKey, r2.publicKey, r3.publicKey], 2) assert.equal(verdict.ok, false, '单根先手必须被挡(否则一把沦陷的根就能锁死全网)') assert.equal(verdict.reason, 'insufficient-root-signatures') }) test('F7 负控④ 同一个签名**复制两份** ⇒ 按公钥去重后只有 1 票 ⇒ 拒', () => { const root = generateAuthorityKey() const doc = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), serial: 1, revoked: [] } const sig = signRootSet(doc, root.privateKeyPem) const duplicated = verifyRootSet(doc, [sig, sig], [root.publicKey, generateAuthorityKey().publicKey], 2) assert.equal(duplicated.ok, false, '同一签名复制两份**不得**凑出 2 票') assert.equal(duplicated.reason, 'insufficient-root-signatures') // 换一把**真的另一把根**来签 ⇒ 2 票 ⇒ 过(证明上面拒的不是"恒拒") const other = generateAuthorityKey() const both = verifyRootSet(doc, [sig, signRootSet(doc, other.privateKeyPem)], [root.publicKey, other.publicKey], 2) assert.equal(both.ok, true, '两把不同的根合签 ⇒ 必须过') }) test('F8 🔴 负控(T-7)阈值分母 = `effectiveRoots.length` ⇒ 撤掉其余所有根**仍需 2 票**', () => { const rogue = generateAuthorityKey() // 场景:**沦陷的那把根自己是唯一签名人**(`envRoots` 里只有它自己 + 两个它想撤的根)。 const good2 = generateAuthorityKey() const envRoots = [rogue.publicKey, good2.publicKey] // 它签一份"撤掉 good2"的声明 ⇒ 候选签名者只剩它自己。 // 🔴 若分母用**候选数**:min(K, 1) = 1 ⇒ 它**一票就够了** ⇒ 单根先手把网络锁死。 // ✅ 分母用 **effectiveRoots.length = 2** ⇒ 需要 2 票,它只有 1 票 ⇒ 拒。 const doc = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), serial: 1, revoked: [good2.publicKey], } const solo = verifyRootSet(doc, [signRootSet(doc, rogue.privateKeyPem)], envRoots, 2) assert.equal(solo.ok, false, '单根撤掉其余所有根**必须**被拒(分母 = effectiveRoots.length = 2,⛔ 不是候选数 1)') assert.equal(solo.reason, 'insufficient-root-signatures') // 被撤的那把**根本没资格投票**:它自己签的也不算 const selfSig = signRootSet(doc, good2.privateKeyPem) assert.equal(verifyRootSet(doc, [selfSig], envRoots, 2).ok, false, '被撤者不得为自己那份声明投票') // 对照:**不动被撤集合**(`revoked: []`)的两把合签 ⇒ 2 票 ⇒ 过(证明上面拒的不是"恒拒") const neutral = { ...doc, revoked: [] } const both = verifyRootSet( neutral, [signRootSet(neutral, rogue.privateKeyPem), signRootSet(neutral, good2.privateKeyPem)], envRoots, 2, ) assert.equal(both.ok, true, '两把不同的根合签(无人被撤)⇒ 必须过') }) test('F9 撤根装载:**有效根集 = env − revoked**,且被撤的根签的名单此后被拒', async (t) => { const r1 = generateAuthorityKey() const r2 = generateAuthorityKey() const r3 = generateAuthorityKey() const signer = generateAuthorityKey() // ⚠️ **三把根**(不是两把):`K=2` 的副作用就是"根集合下限 = 2 把"——用两把根去撤其中一把, // 被撤者不能投票 ⇒ 候选只剩 1 把 ⇒ 1 票 < 2 ⇒ **撤不掉**。这是设计的**已知副作用** // (设计件 §3.2:"`K` 事实上是根集合下限的旋钮"),⇒ 本测试按设计取 3 把。 const envRoots = [r1.publicKey, r2.publicKey, r3.publicKey] const dir = mkdtempSync(join(tmpdir(), 'dshs-roset-')) t.after(() => rmSync(dir, { recursive: true, force: true })) const rosetFile = join(dir, 'rootset.json') const guard = new OverlaySerialGuard({ file: join(dir, 'state.json') }) // 未配撤根文件 ⇒ 有效根集 = env,**且不报错**(合法形态,G6 可单独灰度) const none = await loadRootSet({ file: '', envRoots, minSigs: 2, serialGuard: guard }) assert.deepEqual(none.roots, envRoots) assert.equal(none.error, undefined, '没配撤根 = 无撤根,⛔ 不是错误') // 配了撤根:**两把好根(r1、r2)合签**撤掉 r3 ⇒ 票数 = 2(r3 被撤,无权投票) const doc = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), serial: 1, revoked: [r3.publicKey] } const roset = rootSetOf(doc, [r1.privateKeyPem, r2.privateKeyPem]) // 多签文件形状 = `{doc, sigs:[…]}`(复数);`readSignedFile` 读出 `sigs` 后由 `loadRootSet` 交给 // `verifyRootSet`。⛔ **不要**在这里顺手调 `writeSignedFile`(那是单签形状,会把文件覆盖掉 ⇒ // `no-signature`,本测试第一版就踩了这个坑)。 writeFileSync(rosetFile, JSON.stringify({ doc: roset.doc, sigs: roset.sigs }, null, 2)) const r = await loadRootSet({ file: rosetFile, envRoots, minSigs: 2, serialGuard: guard }) assert.equal(r.error, undefined, `撤根声明必须被接受,实测:${r.error}`) assert.deepEqual(r.roots, [r1.publicKey, r2.publicKey], '有效根集必须剔除被撤的 r3') // ② 被撤根 r3 签的名单 ⇒ 用 effective 验 ⇒ 拒;未被撤的 r1 照旧有效 const set = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), serial: 1, signers: [signer.publicKey] } const rejected = verifySignerSet(set, signSignerSet(set, r3.privateKeyPem), r.roots) assert.equal(rejected.ok, false) assert.equal(rejected.reason, 'signature-mismatch', '撤根之后,被撤者签的名单必须被拒') assert.equal(verifySignerSet(set, signSignerSet(set, r1.privateKeyPem), r.roots).ok, true, '撤根不得牵连别的根') // ③ 阈值不足(只有 r1 一把签,而要 2 票)⇒ 拒,⛔ 不回落到 env const solo = { ...doc, serial: 2 } writeFileSync(rosetFile, JSON.stringify({ doc: solo, sigs: [signRootSet(solo, r1.privateKeyPem)] }, null, 2)) const bad = await loadRootSet({ file: rosetFile, envRoots, minSigs: 2, serialGuard: guard }) assert.match(bad.error ?? '', /rootset-rejected: insufficient-root-signatures/, '阈值不足必须具名拒') assert.equal(bad.roots, undefined, '⛔ 拒的时候不得给出任何根集(不许回落到 env ⇒ 那等于被撤的根复活)') // ④ 反向操作:发一份 `revoked: []` 且序号**更高**的声明 ⇒ r3 复活(撤根是**可逆**的) const restore = { ...doc, serial: 3, revoked: [] } writeFileSync( rosetFile, JSON.stringify({ doc: restore, sigs: [signRootSet(restore, r1.privateKeyPem), signRootSet(restore, r2.privateKeyPem)] }, null, 2), ) const back = await loadRootSet({ file: rosetFile, envRoots, minSigs: 2, serialGuard: guard }) assert.equal(back.error, undefined, `恢复声明必须被接受,实测:${back.error}`) assert.deepEqual(back.roots, envRoots, '`revoked: []` + 更高序号 ⇒ 有效根集重新包含 r3(可逆,⛔ 不需删文件)') }) test('F10 撤根序号:重放更旧的撤根声明 ⇒ `stale-serial`(G6 与 G1 是同一套判据)', async (t) => { const r1 = generateAuthorityKey() const r2 = generateAuthorityKey() const r3 = generateAuthorityKey() const envRoots = [r1.publicKey, r2.publicKey, r3.publicKey] const dir = mkdtempSync(join(tmpdir(), 'dshs-roset-')) t.after(() => rmSync(dir, { recursive: true, force: true })) const rosetFile = join(dir, 'rootset.json') const guard = new OverlaySerialGuard({ file: join(dir, 'state.json') }) // 两把好根(r1、r2)合签 —— 与 F9 同款拓扑(被撤的 r3 无权投票) const sign = (d) => [signRootSet(d, r1.privateKeyPem), signRootSet(d, r2.privateKeyPem)] const base = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString() } const put = (d) => writeFileSync(rosetFile, JSON.stringify({ doc: d, sigs: sign(d) }, null, 2)) const load = () => loadRootSet({ file: rosetFile, envRoots, minSigs: 2, serialGuard: guard }) put({ ...base, serial: 5, revoked: [r3.publicKey] }) assert.equal((await load()).error, undefined, 'serial=5 的撤根声明先接受') // 撤销**之前**的旧声明(若被接受 ⇒ r3 复活 ⇒ 撤根被抹掉) put({ ...base, serial: 4, revoked: [] }) const replay = await load() assert.match(replay.error ?? '', /rootset-rejected: stale-serial/, '重放旧的撤根声明 ⇒ 必须具名 stale-serial') assert.equal(replay.roots, undefined, '被拒时不得给出根集(否则撤根被旧声明抹掉)') }) test('F11 负控⑤ 含分隔符的构造输入 ⇒ 解析阶段即拒(`bad-document`)', () => { const auth = buildAuthority() const base = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString() } // 往 hosts 项里塞换行:若解析器放行,`hosts.join(',')` 之后就能伪造出载荷段 assert.equal(parseRevocationList({ ...base, hosts: ['w106\nw47'], nodeKeys: [] }), undefined, '带换行的 hostId ⇒ 拒') // 往 signers 项里塞换行:`normalizePublicKey` 解析不出来 ⇒ 整份拒 assert.equal(parseSignerSet({ ...base, signers: [`${auth.signer.publicKey}\nx`] }), undefined, '带换行的公钥 ⇒ 拒') // 往撤根项里塞换行:同上 assert.equal(parseRootSet({ ...base, serial: 1, revoked: [`${auth.root.publicKey}\nx`] }), undefined, '带换行的根公钥 ⇒ 拒') // 对照:干净的那一份必须解析得出来(否则上面三条可能只是"恒拒") assert.notEqual(parseRevocationList({ ...base, hosts: ['w106'], nodeKeys: [] }), undefined) assert.notEqual(parseSignerSet({ ...base, signers: [auth.signer.publicKey] }), undefined) }) test('F12 `serial` 显式写成坏值 ⇒ `bad-document`;**缺失** ⇒ 兼容(不是错误)', () => { const auth = buildAuthority() const base = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), signers: [auth.signer.publicKey] } for (const bad of [0, -1, 2.5, '3', Number.MAX_SAFE_INTEGER + 2]) { assert.equal(parseSignerSet({ ...base, serial: bad }), undefined, `serial=${JSON.stringify(bad)} 必须被拒(bad-document)`) } assert.equal(parseSignerSet(base)?.serial, 0, '缺失 ⇒ 视为 0(老格式兼容,⛔ 不是错误)') assert.equal(parseSignerSet({ ...base, serial: 1 })?.serial, 1, 'serial=1 是合法起点(0 保留给老文档)') }) test('F13 吊销清单序号:重放撤销之前的旧清单 ⇒ `revocations-rejected: stale-serial`(G1 第三份文档)', async (t) => { const auth = buildAuthority() const dir = mkdtempSync(join(tmpdir(), 'dshs-rev-')) t.after(() => rmSync(dir, { recursive: true, force: true })) const revFile = join(dir, 'revocations.json') const guard = new OverlaySerialGuard({ file: join(dir, 'state.json') }) const put = (doc) => writeSignedFile(revFile, doc, signRevocations(doc, auth.signer.privateKeyPem)) const load = () => loadRevocations({ file: revFile, trustedSignerKeys: [auth.signer.publicKey], serialGuard: guard }) const base = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), nodeKeys: [] } // 先接受"撤了 w106"的那份(serial=2) put({ ...base, serial: 2, hosts: ['w106'] }) const ok = await load() assert.equal(ok.error, undefined, `serial=2 的撤销清单必须过,实测:${ok.error}`) assert.deepEqual(ok.list?.hosts, ['w106']) // 再重放**撤销之前**的旧清单(serial=1,hosts=[])⇒ 必须拒(否则已撤节点重新被放行) put({ ...base, serial: 1, hosts: [] }) const replay = await load() assert.match(replay.error ?? '', /stale-serial/, `重放旧清单必须具名 stale-serial,实测:${replay.error}`) assert.equal(replay.list, undefined, '被拒时不得给出清单(⛔ 拒绝 ≠ 回退)') })