Files
dsh_shenxian/test/instance-credential.test.mjs
T

327 lines
16 KiB
JavaScript
Raw Normal View History

/**
* 覆盖网络 · **序47 S4/S5:服务器实例的设备凭据**(平台侧代签 + 投递 + 续签)单测。
*
* ## 这个文件要回答的六个问题
* 1. **首签**:凭据文件落在**实例 home**(`<dataRoot>/users/<id>/home/.overlay-device.json`)、
* `0600`、台账出 `kind='instance'` 条目、relay 密钥表新增该 hostId、registry 已批准,
* 且 grant 能被 `identity.ts` 的**验签路径**验过(⛔ 不是"写了个文件"就算过)。
* 2. **幂等**:租约充裕时再调 ⇒ `unchanged`,**一个字节都不写**(台账 `grant_renewals` 不动、
* 密钥表不出现新的 `*.bak-*`)。这条钉住"每次实例启动都续签一遍"的代价是 0。
* 3. **续签**:租约将尽 ⇒ 同 `hostId`、**同 hmacSecret**(⛔ 不轮换 ⇒ 不会把还在用旧文件的实例
* 自己拒掉),`grant_renewals + 1`,且 relay 密钥表**未被重写**(`relayKeyRotated=false`)。
* 4. **⛔ 不猜不重建**:文件形状非法 ⇒ 具名 `credential-file-corrupt`,**文件逐字节保持原样**、
* 台账不新增(重建会换 hostId ⇒ 绕过停发并多占配额位,见模块头注纪律 ③)。
* 5. **停发即拒**:台账 `status='revoked'` ⇒ `device-revoked`(403),文件保持原样。
* 6. **收编(纪律 ⑤)**:新签发改变 hostId 时,该用户**其它** `kind='instance'` 条目被置
* `revoked` 并从 registry 移除 ⇒ "删文件重建"这条处置不会静默留下孤儿设备 / 烧配额。
*
* 运行:`node --test test/instance-credential.test.mjs`(Node ≥ 22;测 `lib/` 产物,先 `npm run build`)。
*
* @module test/instance-credential
*/
import assert from 'node:assert/strict'
import { existsSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { LocalUserFs } from '../lib/fs/local-user-fs.js'
import {
ensureInstanceCredential,
parseInstanceCredential,
startInstanceCredentialRenewal,
INSTANCE_DEVICE_FILE,
} from '../lib/net/relay/instance-credential.js'
import { generateNodeKey, verifyPeerGrant } from '../lib/net/relay/identity.js'
import { loadKeysFile } from '../lib/net/relay/keys.js'
import { loadRegistry } from '../lib/net/relay/registry.js'
const USER = 'u47'
/* ─────────────────────────── 工具 ─────────────────────────── */
/**
* 设备台账(v12/v13 `overlay_devices`)**内存桩** —— 只复刻被判据依赖的四条语义:
* ① 首次插入 `grantRenewals = 1`,之后 **+1**;② `status` 不被续签覆盖(停发粘性);
* ③ `kind` 缺省 `desktop`;④ `setOverlayDeviceStatus` 对不存在的行回 `false`。
*/
function ledgerStub(seed = []) {
const rows = new Map()
for (const r of seed) rows.set(`${r.network}/${r.hostId}`, { kind: 'desktop', status: 'active', grantRenewals: 0, ...r })
return {
rows,
async findOverlayDevice(network, hostId) {
return rows.get(`${network}/${hostId}`)
},
async upsertOverlayDevice(input) {
const key = `${input.network}/${input.hostId}`
const at = input.at ?? Date.now()
const prev = rows.get(key)
const row = {
network: input.network,
hostId: input.hostId,
userId: input.userId,
nodeKey: input.nodeKey,
kind: input.kind ?? 'desktop',
status: prev?.status ?? 'active',
leaseExpiresAt: input.leaseExpiresAt,
grantIssuedAt: at,
grantRenewals: (prev?.grantRenewals ?? 0) + 1,
}
rows.set(key, row)
return row
},
async listOverlayDevices(network) {
return [...rows.values()].filter((r) => network === undefined || r.network === network)
},
async setOverlayDeviceStatus(network, hostId, status) {
const key = `${network}/${hostId}`
const prev = rows.get(key)
if (prev === undefined) return false
rows.set(key, { ...prev, status })
return true
},
}
}
/** 一组临时环境:签名者私钥 + 密钥表 + registry + 真 `LocalUserFs`(要验 0600 / 落点)。 */
function makeEnv(t, extraEnv = {}) {
const dir = mkdtempSync(join(tmpdir(), 'dshs-seq47-inst-'))
t.after(() => rmSync(dir, { recursive: true, force: true }))
const dataRoot = join(dir, 'data')
const signer = generateNodeKey()
const signerKeyFile = join(dir, 'signer-key.pem')
writeFileSync(signerKeyFile, signer.privateKeyPem, { mode: 0o600 })
const relayKeysFile = join(dir, 'relay-keys.json')
writeFileSync(relayKeysFile, '{}\n', { mode: 0o600 })
const vars = {
DSHS_OVERLAY_SIGNER_KEY_FILE: signerKeyFile,
DSHS_RELAY_KEYS_FILE: relayKeysFile,
DSHS_OVERLAY_NODES_FILE: join(dir, 'nodes.json'),
DESKTOP_GRANT_TTL_HOURS: '24',
DESKTOP_GRANT_MAX_DEVICES: '10',
...extraEnv,
}
for (const [k, v] of Object.entries(vars)) process.env[k] = v
const userFs = new LocalUserFs((id) => join(dataRoot, 'users', id))
const credFile = join(dataRoot, 'users', USER, 'home', INSTANCE_DEVICE_FILE)
return { dir, dataRoot, signer, signerKeyFile, relayKeysFile, userFs, credFile, vars }
}
/** 台账里的实例条目(`kind='instance'`)。 */
function instanceRows(db) {
return [...db.rows.values()].filter((r) => r.kind === 'instance')
}
function bakCount(dir) {
return readdirSync(dir).filter((f) => f.startsWith('relay-keys.json.bak-')).length
}
/* ─────────────────────────── 用例 ─────────────────────────── */
test('S4 首签:凭据落实例 home(0600)+ 台账 kind=instance + 密钥表 + registry + grant 可验签', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const res = await ensureInstanceCredential({ db, userFs: env.userFs }, USER, { reason: 'test' })
assert.equal(res.ok, true, JSON.stringify(res))
assert.equal(res.action, 'issued')
assert.equal(res.network, `u:${USER}`)
assert.equal(res.renewals, 1)
// ① 文件落在**实例 home**(不是 ws、不是平台目录),权限 0600,内容可解析
assert.ok(existsSync(env.credFile), `凭据文件应存在:${env.credFile}`)
const text = readFileSync(env.credFile, 'utf8')
const cred = parseInstanceCredential(text)
assert.notEqual(cred, undefined, '凭据文件应能被严格解析')
assert.equal(cred.hostId, res.hostId)
assert.equal(cred.kind, 'instance')
assert.equal(cred.version, 1)
if (process.platform !== 'win32') {
assert.equal(statSync(env.credFile).mode & 0o777, 0o600, '凭据必须 0600')
}
// ② hostId 口径 = `d-<userId>-<公钥前 8 位>`(§④ 已定规则,与用户态端点同源)
assert.equal(cred.hostId, `d-${USER}-${cred.nodeKey.slice(0, 8)}`)
assert.equal(cred.nodeKey.length, 64)
// ③ 台账:kind='instance' + active + 租约与回执一致
const rows = instanceRows(db)
assert.equal(rows.length, 1)
assert.equal(rows[0].status, 'active')
assert.equal(rows[0].nodeKey, cred.nodeKey)
assert.equal(new Date(rows[0].leaseExpiresAt).toISOString(), res.expiresAt)
// ④ relay 密钥表:新增该 hostId(否则实例的 HELLO 永远对不上 MAC)
const keys = loadKeysFile(env.relayKeysFile)
assert.ok(
[...keys.keys()].some((k) => k === `${res.network}/${res.hostId}`),
`密钥表应含 ${res.network}/${res.hostId},实得 ${[...keys.keys()].join(',')}`,
)
// ⑤ registry:已批准(⇒ 立刻进入该网的派生白名单)
const reg = loadRegistry(env.vars.DSHS_OVERLAY_NODES_FILE)
const node = reg.nodes[`${res.network}/${res.hostId}`]
assert.equal(node?.status, 'approved')
// ⑥ grant 能被**产品自己的验签路径**验过(network / hostId / nodeKey 三者都要对得上)
const verdict = verifyPeerGrant(cred.grant.doc, cred.grant.sig, {
trustedSignerKeys: [env.signer.publicKey],
network: res.network,
hostId: res.hostId,
nodeKey: cred.nodeKey,
})
assert.equal(verdict.ok, true, `grant 应验签通过,实得 ${JSON.stringify(verdict)}`)
// ⑦ ⛔ 回执 / 回执的序列化里不得出现密钥材料(本模块的返回值会进日志)
const dumped = JSON.stringify(res)
assert.ok(!dumped.includes('PRIVATE KEY'), '回执不得含私钥')
assert.ok(!dumped.includes(cred.hmacSecret), '回执不得含 hmacSecret')
})
test('S5 幂等:租约充裕 ⇒ unchanged,且一个字节都不写', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(first.action, 'issued')
const textBefore = readFileSync(env.credFile, 'utf8')
const baksBefore = bakCount(env.dir)
const again = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(again.ok, true)
assert.equal(again.action, 'unchanged')
assert.equal(again.hostId, first.hostId)
assert.equal(again.renewals, 1, '未续签 ⇒ grant_renewals 不应增长')
assert.equal(readFileSync(env.credFile, 'utf8'), textBefore, 'unchanged 不得改写文件')
assert.equal(bakCount(env.dir), baksBefore, 'unchanged 不得产生密钥表备份')
assert.equal(instanceRows(db).length, 1)
})
test('S5 续签:同 hostId + 同 hmacSecret,grant_renewals +1,密钥表不重写', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
const credBefore = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
const baksBefore = bakCount(env.dir)
const renewed = await ensureInstanceCredential({ db, userFs: env.userFs }, USER, { force: true, reason: 'test-force' })
assert.equal(renewed.ok, true, JSON.stringify(renewed))
assert.equal(renewed.action, 'renewed')
assert.equal(renewed.hostId, first.hostId, '续签必须同 hostId(⛔ 不换设备)')
assert.equal(renewed.renewals, 2, 'grant_renewals 应为 2')
assert.equal(renewed.relayKeyRotated, false, '复用同一把密钥 ⇒ 密钥表不应被重写')
const credAfter = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
assert.equal(credAfter.nodeKey, credBefore.nodeKey)
assert.equal(credAfter.nodePrivateKeyPem, credBefore.nodePrivateKeyPem)
assert.equal(credAfter.hmacSecret, credBefore.hmacSecret, '⛔ 不轮换 hmacSecret(否则旧进程被自己拒)')
assert.equal(bakCount(env.dir), baksBefore, '密钥表值未变 ⇒ 不得产生备份文件')
assert.equal(instanceRows(db).length, 1, '⛔ 不得出现第二条实例设备')
assert.equal(loadKeysFile(env.relayKeysFile).size, 1)
})
test('S5 定时续签:只扫 kind=instance 且 active 的租户网,桌面条目不动', async (t) => {
const env = makeEnv(t)
const stale = new Date(Date.now() - 60_000).toISOString()
const db = ledgerStub([
{ network: `u:${USER}`, hostId: `d-${USER}-aaaaaaaa`, userId: USER, nodeKey: 'a'.repeat(64), kind: 'instance', status: 'active', leaseExpiresAt: 0, grantRenewals: 3 },
{ network: 'u:desktopguy', hostId: 'd-desktopguy-bbbbbbbb', userId: 'desktopguy', nodeKey: 'b'.repeat(64), kind: 'desktop', status: 'active', leaseExpiresAt: 0, grantRenewals: 1 },
{ network: 'u:revoked', hostId: 'd-revoked-cccccccc', userId: 'revoked', nodeKey: 'c'.repeat(64), kind: 'instance', status: 'revoked', leaseExpiresAt: 0, grantRenewals: 1 },
{ network: 'ops', hostId: 'manager', userId: 'x', nodeKey: 'd'.repeat(64), kind: 'instance', status: 'active', leaseExpiresAt: 0, grantRenewals: 1 },
])
// 给 USER 铺一份"租约将尽"的真凭据(先签发,再把文件里的 expiresAt 改旧)
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(first.ok, true, JSON.stringify(first))
const cred = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
writeFileSync(env.credFile, JSON.stringify({ ...cred, expiresAt: stale }, null, 2) + '\n')
const handle = startInstanceCredentialRenewal({ db, userFs: env.userFs }, { intervalMs: 0 })
assert.equal(handle.armed, false, 'intervalMs=0 ⇒ 只留手动一遍')
const report = await handle.runOnce()
handle.stop()
assert.equal(report.scanned, 1, `只该扫到 u:${USER}(桌面 / 已停发 / 非租户网都不动),实得 ${report.scanned}`)
assert.equal(report.renewed, 1)
assert.equal(report.issued, 0)
assert.deepEqual(report.failed, [])
const after = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
assert.equal(after.hostId, cred.hostId)
assert.equal(Date.parse(after.expiresAt) > Date.now(), true, '续签后租约应重新变新')
// 台账里那些非 instance 的条目一个都没动
const desktopRow = [...db.rows.values()].find((r) => r.hostId === 'd-desktopguy-bbbbbbbb')
assert.equal(desktopRow.grantRenewals, 1)
})
test('⛔ 不猜不重建:文件形状非法 ⇒ 具名拒绝且文件逐字节保持原样', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(first.action, 'issued')
const rowsBefore = db.rows.size
for (const bad of ['{not json', '{}', JSON.stringify({ version: 1, kind: 'instance' })]) {
writeFileSync(env.credFile, bad)
const res = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(res.ok, false, `应拒绝:${bad.slice(0, 24)}`)
assert.equal(res.reason, 'credential-file-corrupt')
assert.equal(readFileSync(env.credFile, 'utf8'), bad, '⛔ 拒绝路径不得改写文件')
assert.equal(db.rows.size, rowsBefore, '⛔ 拒绝路径不得新增台账条目')
}
// 公私钥不成对(改成另一把私钥)同样按 corrupt 处理 —— 否则故障会推迟到拨号时表现为"网络问题"
const cred = parseInstanceCredential(
JSON.stringify({
version: 1,
kind: 'instance',
network: `u:${USER}`,
hostId: `d-${USER}-00000000`,
nodeKey: 'e'.repeat(64),
nodePrivateKeyPem: generateNodeKey().privateKeyPem,
hmacSecret: 'f'.repeat(64),
grant: { doc: { v: 1 }, sig: 'x' },
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
renewals: 1,
}),
)
assert.equal(cred, undefined, '公私钥不成对应判为不可用')
})
test('停发即拒:台账 revoked ⇒ device-revoked(403),文件保持原样', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
const textBefore = readFileSync(env.credFile, 'utf8')
await db.setOverlayDeviceStatus(`u:${USER}`, first.hostId, 'revoked')
const res = await ensureInstanceCredential({ db, userFs: env.userFs }, USER, { force: true })
assert.equal(res.ok, false)
assert.equal(res.reason, 'device-revoked')
assert.equal(res.status, 403)
assert.equal(readFileSync(env.credFile, 'utf8'), textBefore, '被停发的设备不得拿到新凭据')
})
test('纪律 ⑤ 收编:新签发换 hostId ⇒ 旧的实例条目被置 revoked 并退出 registry', async (t) => {
const env = makeEnv(t)
const db = ledgerStub()
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(first.action, 'issued')
// 模拟"凭据文件丢了"(我们自己写进处置流程的那条动作)⇒ 下一次 ensure 会重新签发
rmSync(env.credFile)
const again = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
assert.equal(again.ok, true, JSON.stringify(again))
assert.equal(again.action, 'issued')
assert.notEqual(again.hostId, first.hostId, '重建必然换 hostId(平台不留私钥副本)')
assert.deepEqual(again.superseded, [first.hostId], '旧条目应被点名收编')
const old = db.rows.get(`u:${USER}/${first.hostId}`)
assert.equal(old.status, 'revoked', '旧条目应被置 revoked')
assert.equal(old.kind, 'instance')
const reg = loadRegistry(env.vars.DSHS_OVERLAY_NODES_FILE)
assert.equal(reg.nodes[`u:${USER}/${first.hostId}`], undefined, '旧节点应已退出 registry 白名单')
assert.equal(reg.nodes[`u:${USER}/${again.hostId}`]?.status, 'approved')
assert.equal(instanceRows(db).length, 2, '台账保留旧行为审计痕迹(⛔ 不删行)')
})