/** * 覆盖网络 · **序47 S4/S5:服务器实例的设备凭据**(平台侧代签 + 投递 + 续签)单测。 * * ## 这个文件要回答的六个问题 * 1. **首签**:凭据文件落在**实例 home**(`/users//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--<公钥前 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, '台账保留旧行为审计痕迹(⛔ 不删行)') })