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