Files
dsh_shenxian/test/instance-credential.test.mjs
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

328 lines
16 KiB
JavaScript
Raw Permalink 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.
/**
* 覆盖网络 · **序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, '台账保留旧行为审计痕迹(⛔ 不删行)')
})