Files
dsh_shenxian/test/overlay-identity.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

885 lines
44 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, 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, '被拒时不得给出清单(⛔ 拒绝 ≠ 回退)')
})