feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)

Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
import { describe, test, expect, beforeEach } from 'vitest'
import type { FastifyInstance } from 'fastify'
import { createMemoryStores } from '../src/store/memory.js'
import { buildControlPlane } from '../src/main.js'
import { loadEnv } from '../src/env.js'
import { generateEd25519 } from '../src/util/crypto.js'
import { buildCsr } from '../src/ca/csr.js'
import { bytesToBase64 } from '../src/util/bytes.js'
import { nodeIdentityFromRequest, type RequestWithTls } from '../src/node-auth/identity.js'
import type { CapabilityVerifier } from '../src/api/authz.js'
import type { CapabilityToken, CapabilityRight } from 'relay-contracts'
import type { Stores } from '../src/store/ports.js'
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
const env = loadEnv({
PG_URL: 'postgres://u:p@localhost:5432/cp',
REDIS_URL: 'redis://localhost:6379',
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(new Uint8Array(32).fill(1)),
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
BASE_DOMAIN: 'term.example.com',
})
const verifier: CapabilityVerifier = {
verify(raw, expectedAud, now): CapabilityToken {
if (raw !== 'tokenA') throw new Error('invalid token')
return { sub: ACCOUNT_A, aud: expectedAud, host: 'x', rights: ['manage'] as CapabilityRight[], iat: now, exp: now + 3600, jti: 'jti-A' }
},
}
let app: FastifyInstance
let stores: Stores
beforeEach(async () => {
stores = createMemoryStores()
const built = await buildControlPlane(env, { stores, verifier })
app = built.app
await app.ready()
})
const auth = { authorization: 'Bearer tokenA' }
describe('T11 provisioning API — remaining routes', () => {
test('POST /accounts creates an account (201)', async () => {
const res = await app.inject({ method: 'POST', url: '/accounts', headers: auth, payload: { plan: 'pro' } })
expect(res.statusCode).toBe(201)
expect(JSON.parse(res.body).plan).toBe('pro')
})
test('POST /accounts/:id/status suspends own account', async () => {
// must be the caller's own account id (A) — create A explicitly in store
const { createAccountRegistry } = await import('../src/registry/accounts.js')
const reg = createAccountRegistry({ accounts: stores.accounts })
await stores.accounts.insert({ accountId: ACCOUNT_A, plan: 'free', createdAt: new Date().toISOString(), status: 'active' })
void reg
const res = await app.inject({ method: 'POST', url: `/accounts/${ACCOUNT_A}/status`, headers: auth, payload: { status: 'suspended' } })
expect(res.statusCode).toBe(200)
expect(JSON.parse(res.body).status).toBe('suspended')
})
test('GET /accounts/:id/hosts returns ownership-scoped list', async () => {
const res = await app.inject({ method: 'GET', url: `/accounts/${ACCOUNT_A}/hosts`, headers: auth })
expect(res.statusCode).toBe(200)
expect(Array.isArray(JSON.parse(res.body))).toBe(true)
})
test('end-to-end enroll: issue code then POST /enroll → 201 EnrollResult', async () => {
const issued = await app.inject({ method: 'POST', url: `/accounts/${ACCOUNT_A}/pairing-codes`, headers: auth })
const code = JSON.parse(issued.body).code as string
const { publicKeyRaw, privateKey } = generateEd25519()
const res = await app.inject({
method: 'POST',
url: '/enroll',
payload: {
code,
agentPubkey: bytesToBase64(publicKeyRaw),
csr: bytesToBase64(buildCsr(privateKey, publicKeyRaw)),
},
})
expect(res.statusCode).toBe(201)
const body = JSON.parse(res.body)
expect(body.subdomain).toBeDefined()
expect(body.cert).toContain('BEGIN CERTIFICATE')
expect(typeof body.hostContentSecret).toBe('string')
})
})
describe('T9 nodeIdentityFromRequest wrapper', () => {
test('derives nodeId from an authorized TLS peer cert subject CN', () => {
const req: RequestWithTls = {
socket: { authorized: true, getPeerCertificate: () => ({ subject: { CN: 'spiffe://relay/node-7' } }) },
}
expect(nodeIdentityFromRequest(req).nodeId).toBe('spiffe://relay/node-7')
})
test('unauthenticated socket → 401', () => {
const req: RequestWithTls = { socket: { authorized: false, getPeerCertificate: () => undefined } }
expect(() => nodeIdentityFromRequest(req)).toThrow(/mutually authenticated/)
})
})

View File

@@ -0,0 +1,96 @@
import { describe, test, expect, beforeEach } from 'vitest'
import type { FastifyInstance } from 'fastify'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { buildControlPlane } from '../src/main.js'
import { loadEnv } from '../src/env.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
import { bytesToBase64 } from '../src/util/bytes.js'
import type { CapabilityVerifier } from '../src/api/authz.js'
import type { CapabilityToken, CapabilityRight } from 'relay-contracts'
import type { Stores } from '../src/store/ports.js'
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'
const env = loadEnv({
PG_URL: 'postgres://u:p@localhost:5432/cp',
REDIS_URL: 'redis://localhost:6379',
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(new Uint8Array(32).fill(1)),
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
BASE_DOMAIN: 'term.example.com',
})
// Fake P5 verifier: 'tokenA'→account A (manage), 'attachA'→account A (attach only). Else reject.
const verifier: CapabilityVerifier = {
verify(raw, expectedAud, now): CapabilityToken {
const base = { aud: expectedAud, host: 'x', iat: now, exp: now + 3600, jti: `jti-${raw}` }
if (raw === 'tokenA') return { ...base, sub: ACCOUNT_A, rights: ['manage'] as CapabilityRight[] }
if (raw === 'attachA') return { ...base, sub: ACCOUNT_A, rights: ['attach'] as CapabilityRight[] }
throw new Error('invalid token')
},
}
let app: FastifyInstance
let stores: Stores
let hostOfB: string
beforeEach(async () => {
stores = createMemoryStores()
const built = await buildControlPlane(env, { stores, verifier })
app = built.app
await app.ready()
// Bind a host owned by account B directly.
const hosts = createHostRegistry({ hosts: stores.hosts })
const { publicKeyRaw } = generateEd25519()
const host = await hosts.bindHost({ accountId: ACCOUNT_B, subdomain: 'bob', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
hostOfB = host.hostId
})
describe('T11 provisioning API (INV3/INV1/INV6)', () => {
test('HEADLINE: account A with forged {account_id:B} deleting B-owned host → 403', async () => {
const res = await app.inject({
method: 'DELETE',
url: `/hosts/${hostOfB}`,
headers: { authorization: 'Bearer tokenA' },
payload: { account_id: ACCOUNT_B }, // forged — must be ignored
})
expect(res.statusCode).toBe(403)
})
test('missing token → 401', async () => {
const res = await app.inject({ method: 'DELETE', url: `/hosts/${hostOfB}` })
expect(res.statusCode).toBe(401)
})
test('attach-scoped token cannot hit a manage route → 403', async () => {
const res = await app.inject({ method: 'DELETE', url: `/hosts/${hostOfB}`, headers: { authorization: 'Bearer attachA' } })
expect(res.statusCode).toBe(403)
})
test('owner CAN deprovision its own host → 204', async () => {
// give A its own host
const hosts = createHostRegistry({ hosts: stores.hosts })
const { publicKeyRaw } = generateEd25519()
const own = await hosts.bindHost({ accountId: ACCOUNT_A, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
const res = await app.inject({ method: 'DELETE', url: `/hosts/${own.hostId}`, headers: { authorization: 'Bearer tokenA' } })
expect(res.statusCode).toBe(204)
expect((await stores.hosts.get(own.hostId))?.status).toBe('revoked')
})
test('pairing-code route scoped to own account; foreign :id → 403', async () => {
const ok = await app.inject({ method: 'POST', url: `/accounts/${ACCOUNT_A}/pairing-codes`, headers: { authorization: 'Bearer tokenA' } })
expect(ok.statusCode).toBe(201)
expect(JSON.parse(ok.body).code).toBeDefined()
const foreign = await app.inject({ method: 'POST', url: `/accounts/${ACCOUNT_B}/pairing-codes`, headers: { authorization: 'Bearer tokenA' } })
expect(foreign.statusCode).toBe(403)
})
test('malformed enroll body → 400 (Zod at the boundary)', async () => {
const res = await app.inject({ method: 'POST', url: '/enroll', payload: { code: '' } })
expect(res.statusCode).toBe(400)
})
})

View File

@@ -0,0 +1,61 @@
import { describe, test, expect } from 'vitest'
import { createMemoryStores } from '../src/store/memory.js'
import { createAuditLog, MAX_META_VALUE_LEN } from '../src/audit/log.js'
import { createAccountRegistry } from '../src/registry/accounts.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
describe('T14 audit log (INV10 zero payload)', () => {
test('rejects a meta value carrying control/ANSI bytes (terminal payload guard)', async () => {
const { audit } = createMemoryStores()
const log = createAuditLog(audit)
await expect(
log.writeAuditEvent({
action: 'attach',
principalId: 'p',
accountId: 'a',
hostId: null,
ts: new Date().toISOString(),
meta: { out: 'ls\r\noutput' }, // looks like shell output
}),
).rejects.toThrow(/payload guard/)
})
test('rejects an over-long meta value', async () => {
const { audit } = createMemoryStores()
const log = createAuditLog(audit)
await expect(
log.writeAuditEvent({
action: 'manage',
principalId: 'p',
accountId: 'a',
hostId: null,
ts: new Date().toISOString(),
meta: { blob: 'x'.repeat(MAX_META_VALUE_LEN + 1) },
}),
).rejects.toThrow(/payload guard/)
})
test('append-only: entries are queryable, no update/delete surface exists', async () => {
const { audit } = createMemoryStores()
const log = createAuditLog(audit)
await log.writeAuditEvent({ action: 'kill', principalId: 'p', accountId: 'acct', hostId: 'h', ts: new Date().toISOString(), meta: {} })
const rows = await log.queryAudit('acct', '1970-01-01T00:00:00.000Z', '2999-01-01T00:00:00.000Z')
expect(rows.length).toBe(1)
expect(Object.keys(log)).not.toContain('update')
expect(Object.keys(log)).not.toContain('delete')
})
test('control-plane mutations emit audit entries (create + bind)', async () => {
const stores = createMemoryStores()
const log = createAuditLog(stores.audit)
const accounts = createAccountRegistry({ accounts: stores.accounts, audit: log })
const hosts = createHostRegistry({ hosts: stores.hosts, audit: log })
const acct = await accounts.createAccount('pro')
const { publicKeyRaw } = generateEd25519()
await hosts.bindHost({ accountId: acct.accountId, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
const rows = await log.queryAudit(acct.accountId, '1970-01-01T00:00:00.000Z', '2999-01-01T00:00:00.000Z')
expect(rows.map((r) => r.action).sort()).toEqual(['account.create', 'host.bind'])
})
})

View File

@@ -0,0 +1,73 @@
import { describe, test, expect, vi } from 'vitest'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createLeafSigner, LeafSignError } from '../src/ca/sign.js'
import { inProcessCaSigner } from '../src/boot/ca-wiring.js'
import { buildCsr, verifyCsrPoP } from '../src/ca/csr.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519, sealToX25519, openFromX25519, generateX25519 } from '../src/util/crypto.js'
import { randomBytes } from 'node:crypto'
function boundHost() {
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
return { stores, hosts }
}
describe('T8 signHostLeaf — INV14 registry-gated + CSR PoP', () => {
test('active registered host + valid CSR → cert signed under CA', async () => {
const { stores, hosts } = boundHost()
const { publicKeyRaw, privateKey } = generateEd25519()
const host = await hosts.bindHost({ accountId: '11111111-1111-4111-8111-111111111111', subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
const signer = createLeafSigner({ hosts: stores.hosts, signer: inProcessCaSigner(), caChainDer: [] })
const { cert } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
expect(cert.length).toBeGreaterThan(0)
})
test('CSR proof-of-possession fails even for a registered active key → reject, KMS never called', async () => {
const { stores, hosts } = boundHost()
const { publicKeyRaw } = generateEd25519()
const host = await hosts.bindHost({ accountId: '11111111-1111-4111-8111-111111111111', subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
const spy = inProcessCaSigner()
const signSpy = vi.spyOn(spy, 'sign')
const signer = createLeafSigner({ hosts: stores.hosts, signer: spy, caChainDer: [] })
// forge a CSR: correct embedded pub but a garbage signature
const forged = new Uint8Array(96)
forged.set(publicKeyRaw, 0)
forged.set(randomBytes(64), 32)
expect(verifyCsrPoP(forged).ok).toBe(false)
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, forged)).rejects.toBeInstanceOf(LeafSignError)
expect(signSpy).not.toHaveBeenCalled()
})
test('INV14 gate: unregistered pubkey → throws, KMS sign never invoked', async () => {
const { stores } = boundHost()
const { publicKeyRaw, privateKey } = generateEd25519()
const spy = inProcessCaSigner()
const signSpy = vi.spyOn(spy, 'sign')
const signer = createLeafSigner({ hosts: stores.hosts, signer: spy, caChainDer: [] })
await expect(signer.signHostLeaf('00000000-0000-4000-8000-000000000000', publicKeyRaw, buildCsr(privateKey, publicKeyRaw))).rejects.toBeInstanceOf(LeafSignError)
expect(signSpy).not.toHaveBeenCalled()
})
test('revoked host cannot be signed (INV12+INV14)', async () => {
const { stores, hosts } = boundHost()
const { publicKeyRaw, privateKey } = generateEd25519()
const host = await hosts.bindHost({ accountId: '11111111-1111-4111-8111-111111111111', subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
await hosts.setHostStatus(host.hostId, 'revoked')
const signer = createLeafSigner({ hosts: stores.hosts, signer: inProcessCaSigner(), caChainDer: [] })
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))).rejects.toBeInstanceOf(LeafSignError)
})
})
describe('host content-secret wrap (FIX 3 security property)', () => {
test('only the matching private key can unwrap; raw secret absent from blob', () => {
const recipient = generateX25519()
const secret = new Uint8Array(randomBytes(32))
const blob = sealToX25519(recipient.publicKeyRaw, secret)
expect(Buffer.from(blob).includes(Buffer.from(secret))).toBe(false) // raw secret not in ciphertext
expect(Buffer.from(openFromX25519(recipient.privateKey, blob)).equals(Buffer.from(secret))).toBe(true)
const wrong = generateX25519()
expect(() => openFromX25519(wrong.privateKey, blob)).toThrow()
})
})

View File

@@ -0,0 +1,21 @@
import { describe, test, expect } from 'vitest'
import { createQuery, type Queryable } from '../src/db/pool.js'
describe('T2 parameterized query wrapper (no SQLi path)', () => {
test('passes params SEPARATELY from SQL text — never interpolated', async () => {
const calls: { text: string; params: readonly unknown[] }[] = []
const fake: Queryable = {
async query(text, params) {
calls.push({ text, params })
return { rows: [{ ok: 1 }] }
},
}
const query = createQuery(fake)
const rows = await query<{ ok: number }>('SELECT * FROM hosts WHERE host_id = $1', ["'; DROP TABLE hosts; --"])
expect(rows).toEqual([{ ok: 1 }])
expect(calls[0]?.text).toBe('SELECT * FROM hosts WHERE host_id = $1')
// The malicious value stays a bound param — it is NOT concatenated into the SQL text.
expect(calls[0]?.text).not.toContain('DROP TABLE')
expect(calls[0]?.params).toEqual(["'; DROP TABLE hosts; --"])
})
})

View File

@@ -0,0 +1,68 @@
import { describe, test, expect } from 'vitest'
import { loadEnv, DEFAULT_PAIRING_TTL_SEC, DEFAULT_PAIRING_MAX_REDEEM_ATTEMPTS } from '../src/env.js'
import { bytesToBase64 } from '../src/util/bytes.js'
const validPubkey = bytesToBase64(new Uint8Array(32).fill(7))
const base = (): NodeJS.ProcessEnv => ({
PG_URL: 'postgres://u:p@localhost:5432/cp',
REDIS_URL: 'redis://localhost:6379',
CAPABILITY_SIGN_PUBKEY_B64: validPubkey,
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/intermediate.pem',
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
BASE_DOMAIN: 'term.example.com',
})
describe('T1 loadEnv (INV9 fail-fast)', () => {
test('empty env throws listing every missing required key', () => {
let msg = ''
try {
loadEnv({})
} catch (e) {
msg = e instanceof Error ? e.message : ''
}
expect(msg).toContain('PG_URL')
expect(msg).toContain('REDIS_URL')
expect(msg).toContain('CAPABILITY_SIGN_PUBKEY_B64')
expect(msg).toContain('CA_INTERMEDIATE_KMS_KEY_REF')
expect(msg).toContain('BASE_DOMAIN')
})
test('bad pgUrl throws', () => {
expect(() => loadEnv({ ...base(), PG_URL: 'not a url' })).toThrow(/PG_URL/)
})
test('valid map parses', () => {
const env = loadEnv(base())
expect(env.baseDomain).toBe('term.example.com')
expect(env.capabilitySignPubkey.length).toBe(32)
})
test('pairing ttl / max-attempts default when unset', () => {
const env = loadEnv(base())
expect(env.pairingTtlSec).toBe(DEFAULT_PAIRING_TTL_SEC)
expect(env.pairingMaxRedeemAttempts).toBe(DEFAULT_PAIRING_MAX_REDEEM_ATTEMPTS)
})
test('explicit pairing overrides parse', () => {
const env = loadEnv({ ...base(), PAIRING_TTL_SEC: '900', PAIRING_MAX_REDEEM_ATTEMPTS: '3' })
expect(env.pairingTtlSec).toBe(900)
expect(env.pairingMaxRedeemAttempts).toBe(3)
})
test('never echoes secret VALUES on failure (INV9)', () => {
const secret = bytesToBase64(new Uint8Array(32).fill(9))
try {
loadEnv({ ...base(), CAPABILITY_SIGN_PUBKEY_B64: secret, PG_URL: 'bad' })
} catch (e) {
const msg = e instanceof Error ? e.message : ''
expect(msg).not.toContain(secret)
}
})
test('capability pubkey wrong length rejected', () => {
const short = bytesToBase64(new Uint8Array(16))
expect(() => loadEnv({ ...base(), CAPABILITY_SIGN_PUBKEY_B64: short })).toThrow(/32 bytes/)
})
})

View File

@@ -0,0 +1,40 @@
import { describe, test, expect } from 'vitest'
import { createFlatStore, inMemoryFlatBackend } from '../src/flat-store.js'
describe('T0 flat store (v0.8 MVP)', () => {
test('provisionFlat stores hashes only — no raw token at rest (INV5)', async () => {
// Arrange
const backend = inMemoryFlatBackend()
const store = createFlatStore(backend)
// Act
const { agentToken, clientToken } = await store.provisionFlat('alice')
const row = await backend.bySubdomain('alice')
// Assert
expect(row).not.toBeNull()
const serialized = JSON.stringify(row)
expect(serialized).not.toContain(agentToken)
expect(serialized).not.toContain(clientToken)
expect(row?.agentTokenHash.startsWith('scrypt$')).toBe(true)
})
test('verifyAgentToken true for correct token, false for wrong', async () => {
const store = createFlatStore()
const { agentToken } = await store.provisionFlat('bob')
expect(await store.verifyAgentToken('bob', agentToken)).toBe(true)
expect(await store.verifyAgentToken('bob', 'wrong-token')).toBe(false)
})
test('unknown subdomain resolves to null / false', async () => {
const store = createFlatStore()
expect(await store.lookupBySubdomain('nope')).toBeNull()
expect(await store.verifyAgentToken('nope', 'x')).toBe(false)
})
test('two provisions of the same subdomain collide', async () => {
const store = createFlatStore()
await store.provisionFlat('carol')
await expect(store.provisionFlat('carol')).rejects.toThrow(/already provisioned/)
})
})

View File

@@ -0,0 +1,62 @@
import { describe, test, expect } from 'vitest'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createMeteringCollector, MeteringError } from '../src/metering/collect.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
import type { NodeIdentity } from '../src/node-auth/identity.js'
const node: NodeIdentity = { nodeId: 'spiffe://relay/node-a' }
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
async function withHost() {
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const { publicKeyRaw } = generateEd25519()
const host = await hosts.bindHost({ accountId: ACCOUNT, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
const collector = createMeteringCollector({ metering: stores.metering, hosts })
return { stores, hosts, host, collector }
}
describe('T12 metering (INV1/INV3-analog/INV8)', () => {
test('ingestSample derives accountId + nodeId server-side; append-only', async () => {
const { stores, host, collector } = await withHost()
await collector.ingestSample(node, { hostId: host.hostId, concurrentViewers: 3, sampledAt: new Date().toISOString() })
const rows = await stores.metering.query(ACCOUNT, '1970-01-01T00:00:00.000Z', '2999-01-01T00:00:00.000Z')
expect(rows.length).toBe(1)
expect(rows[0]?.accountId).toBe(ACCOUNT)
expect(rows[0]?.nodeId).toBe(node.nodeId)
})
test('forged attribution: unknown hostId rejected (cross-tenant metering impossible, INV1)', async () => {
const { collector } = await withHost()
await expect(
collector.ingestSample(node, { hostId: '99999999-9999-4999-8999-999999999999', concurrentViewers: 1, sampledAt: new Date().toISOString() }),
).rejects.toBeInstanceOf(MeteringError)
})
test('pairedHostCount excludes revoked hosts', async () => {
const { hosts, host, collector } = await withHost()
expect(await collector.pairedHostCount(ACCOUNT)).toBe(1)
await hosts.setHostStatus(host.hostId, 'revoked')
expect(await collector.pairedHostCount(ACCOUNT)).toBe(0)
})
test('rollupUsage computes viewer peak + viewer-hours over a window', async () => {
const { host, collector } = await withHost()
const t0 = '2026-06-30T00:00:00.000Z'
const t1 = '2026-06-30T01:00:00.000Z'
const to = '2026-06-30T02:00:00.000Z'
await collector.ingestSample(node, { hostId: host.hostId, concurrentViewers: 2, sampledAt: t0 })
await collector.ingestSample(node, { hostId: host.hostId, concurrentViewers: 4, sampledAt: t1 })
const usage = await collector.rollupUsage(ACCOUNT, t0, to)
expect(usage.viewerPeak).toBe(4)
expect(usage.viewerHours).toBeCloseTo(2 * 1 + 4 * 1, 5) // 2 viewers×1h + 4 viewers×1h
expect(usage.pairedHostPeak).toBe(1)
})
test('malformed sample → Zod error', async () => {
const { collector } = await withHost()
await expect(collector.ingestSample(node, { hostId: 'not-a-uuid', concurrentViewers: -1, sampledAt: 'x' })).rejects.toBeInstanceOf(MeteringError)
})
})

View File

@@ -0,0 +1,134 @@
import { describe, test, expect } from 'vitest'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createSubdomainAssigner } from '../src/subdomain/assign.js'
import { createPairingIssuer } from '../src/pairing/issue.js'
import { createPairingRedeemer, RedeemError } from '../src/pairing/redeem.js'
import { createLeafSigner } from '../src/ca/sign.js'
import { inProcessCaSigner, type CaSigner } from '../src/boot/ca-wiring.js'
import { buildCsr } from '../src/ca/csr.js'
import { generatePairingCode, formatPairingCode, normalizePairingCode, PAIRING_CODE_BITS } from '../src/pairing/code.js'
import { sha256Hex } from '../src/util/hash.js'
import { generateEd25519 } from '../src/util/crypto.js'
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
function harness(opts?: { ttlSec?: number; maxAttempts?: number; signer?: CaSigner }) {
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains })
const signer = opts?.signer ?? inProcessCaSigner()
const leafSigner = createLeafSigner({ hosts: stores.hosts, signer, caChainDer: [new Uint8Array([1, 2, 3])] })
const issuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: opts?.ttlSec ?? 600 })
const redeemer = createPairingRedeemer({
pairing: stores.pairing,
hosts,
subdomains,
leafSigner,
pairingMaxRedeemAttempts: opts?.maxAttempts ?? 5,
})
return { stores, issuer, redeemer }
}
function agentEnroll() {
const { publicKeyRaw, privateKey } = generateEd25519()
return { agentPubkey: publicKeyRaw, csr: buildCsr(privateKey, publicKeyRaw) }
}
describe('T7 pairing issuance (INV5, entropy)', () => {
test('code is >= 128-bit entropy, display round-trips to canonical', () => {
expect(PAIRING_CODE_BITS).toBeGreaterThanOrEqual(128)
const canonical = generatePairingCode()
expect(canonical.length).toBe(26)
expect(normalizePairingCode(formatPairingCode(canonical))).toBe(canonical)
})
test('stored row holds code_hash only — no raw code at rest (INV5)', async () => {
const { stores, issuer } = harness()
const { code } = await issuer.issuePairingCode(ACCOUNT)
const codeHash = sha256Hex(normalizePairingCode(code))
const row = await stores.pairing.get(codeHash)
expect(row).not.toBeNull()
expect(JSON.stringify(row)).not.toContain(normalizePairingCode(code))
expect(row?.redeemAttempts).toBe(0)
})
test('two issues → two distinct codes', async () => {
const { issuer } = harness()
const a = await issuer.issuePairingCode(ACCOUNT)
const b = await issuer.issuePairingCode(ACCOUNT)
expect(a.code).not.toBe(b.code)
})
})
describe('T8 redemption + BIND + cert + content-secret', () => {
test('happy path → EnrollResult with frozen fields, subject pubkey == agentPubkey', async () => {
const { issuer, redeemer } = harness()
const { code } = await issuer.issuePairingCode(ACCOUNT)
const { agentPubkey, csr } = agentEnroll()
const result = await redeemer.redeemPairingCode({ code, agentPubkey, csr })
expect(result.hostId).toMatch(/[0-9a-f-]{36}/)
expect(result.subdomain.length).toBeGreaterThan(0)
expect(result.cert).toContain('BEGIN CERTIFICATE')
expect(result.caChain).toContain('BEGIN CERTIFICATE')
expect(result.hostContentSecret.length).toBeGreaterThan(0)
// subject pubkey inside the cert == agentPubkey
const certJson = JSON.parse(Buffer.from(result.cert.split('\n').slice(1, -2).join(''), 'base64').toString())
const tbs = JSON.parse(Buffer.from(certJson.tbs, 'base64').toString())
expect(tbs.subjectSpki).toBe(Buffer.from(agentPubkey).toString('base64'))
})
test('hostContentSecret: wrapped, raw secret absent, distinct per enrollment (FIX 3)', async () => {
const { issuer, redeemer } = harness()
const c1 = (await issuer.issuePairingCode(ACCOUNT)).code
const c2 = (await issuer.issuePairingCode(ACCOUNT)).code
const r1 = await redeemer.redeemPairingCode({ code: c1, ...agentEnroll() })
const r2 = await redeemer.redeemPairingCode({ code: c2, ...agentEnroll() })
expect(Buffer.from(r1.hostContentSecret).equals(Buffer.from(r2.hostContentSecret))).toBe(false)
})
test('double-spend: two concurrent redeems → exactly one wins (CAS)', async () => {
const { issuer, redeemer } = harness()
const { code } = await issuer.issuePairingCode(ACCOUNT)
const results = await Promise.allSettled([
redeemer.redeemPairingCode({ code, ...agentEnroll() }),
redeemer.redeemPairingCode({ code, ...agentEnroll() }),
])
const ok = results.filter((r) => r.status === 'fulfilled')
const rejected = results.filter((r) => r.status === 'rejected')
expect(ok.length).toBe(1)
expect(rejected.length).toBe(1)
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(RedeemError)
expect(((rejected[0] as PromiseRejectedResult).reason as RedeemError).code).toBe('already_redeemed')
})
test('expired / unknown / CSR-substitution rejected', async () => {
const expiredH = harness({ ttlSec: -10 })
const { code } = await expiredH.issuer.issuePairingCode(ACCOUNT)
await expect(expiredH.redeemer.redeemPairingCode({ code, ...agentEnroll() })).rejects.toMatchObject({ code: 'expired' })
const h = harness()
await expect(h.redeemer.redeemPairingCode({ code: 'ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-Z', ...agentEnroll() })).rejects.toMatchObject({ code: 'unknown' })
// CSR embeds a DIFFERENT pubkey than the presented agentPubkey → bad_csr
const good = (await h.issuer.issuePairingCode(ACCOUNT)).code
const a = generateEd25519()
const b = generateEd25519()
const substitutedCsr = buildCsr(b.privateKey, b.publicKeyRaw)
await expect(h.redeemer.redeemPairingCode({ code: good, agentPubkey: a.publicKeyRaw, csr: substitutedCsr })).rejects.toMatchObject({ code: 'bad_csr' })
})
test('code-scoped lockout after maxAttempts — correct code then refused (Finding-4)', async () => {
const h = harness({ maxAttempts: 3 })
const { code } = await h.issuer.issuePairingCode(ACCOUNT)
const bad = generateEd25519()
const badCsr = buildCsr(bad.privateKey, bad.publicKeyRaw)
const victim = agentEnroll()
for (let i = 0; i < 3; i++) {
// substitution failure against the SAME code_hash
await expect(h.redeemer.redeemPairingCode({ code, agentPubkey: victim.agentPubkey, csr: badCsr })).rejects.toMatchObject({ code: 'bad_csr' })
}
// now even a correct redemption is locked out
await expect(h.redeemer.redeemPairingCode({ code, ...victim })).rejects.toMatchObject({ code: 'too_many_attempts' })
})
})

View File

@@ -0,0 +1,148 @@
import { describe, test, expect } from 'vitest'
import { createMemoryStores } from '../src/store/memory.js'
import { createAccountRegistry } from '../src/registry/accounts.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createSessionRegistry } from '../src/registry/sessions.js'
import {
createSubdomainAssigner,
isValidSubdomain,
normalizeSubdomain,
assembleFqdn,
} from '../src/subdomain/assign.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
function bindInput(accountId: string, subdomain: string) {
const { publicKeyRaw } = generateEd25519()
return { accountId, subdomain, agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) }
}
describe('T3 account registry (INV8)', () => {
test('createAccount returns unguessable uuid + active status + seed version', async () => {
const { accounts } = createMemoryStores()
const reg = createAccountRegistry({ accounts })
const a = await reg.createAccount('pro')
expect(a.accountId).toMatch(UUID_RE)
expect(a.status).toBe('active')
expect((await accounts.versions(a.accountId)).length).toBe(1)
})
test('setAccountStatus swaps snapshot; prior version stays readable from versions table', async () => {
const { accounts } = createMemoryStores()
const reg = createAccountRegistry({ accounts })
const a = await reg.createAccount('free')
const next = await reg.setAccountStatus(a.accountId, 'suspended')
expect(next.status).toBe('suspended')
const versions = await accounts.versions(a.accountId)
expect(versions.map((v) => v.status)).toEqual(['active', 'suspended'])
expect(versions.map((v) => v.version)).toEqual([1, 2]) // strictly monotonic
})
test('concurrent setAccountStatus → monotonic versions, no torn read (INV8)', async () => {
const { accounts } = createMemoryStores()
const reg = createAccountRegistry({ accounts })
const a = await reg.createAccount('team')
await Promise.all([
reg.setAccountStatus(a.accountId, 'suspended'),
reg.setAccountStatus(a.accountId, 'active'),
])
const versions = await accounts.versions(a.accountId)
expect(versions.map((v) => v.version)).toEqual([1, 2, 3])
})
})
describe('T4 host registry (INV1/INV4/INV8)', () => {
test('bindHost stores only the public key; enrollFpr is its fingerprint (INV4)', async () => {
const { hosts } = createMemoryStores()
const reg = createHostRegistry({ hosts })
const inp = bindInput('11111111-1111-4111-8111-111111111111', 'alice')
const host = await reg.bindHost(inp)
expect(host.enrollFpr).toBe(fingerprint(inp.agentPubkey))
const dump = JSON.stringify(await hosts.get(host.hostId))
expect(dump).not.toContain('PRIVATE')
})
test('ownsHost(A, hostOwnedByB) → false (INV1 tripwire fact)', async () => {
const { hosts } = createMemoryStores()
const reg = createHostRegistry({ hosts })
const a = '11111111-1111-4111-8111-111111111111'
const b = '22222222-2222-4222-8222-222222222222'
const host = await reg.bindHost(bindInput(a, 'alice'))
expect(await reg.ownsHost(a, host.hostId)).toBe(true)
expect(await reg.ownsHost(b, host.hostId)).toBe(false)
expect(await reg.ownsHost(a, 'unknown-host')).toBe(false) // deny-by-default
})
test('getHostBySubdomain resolves the stable name; setHostStatus versions', async () => {
const { hosts } = createMemoryStores()
const reg = createHostRegistry({ hosts })
const host = await reg.bindHost(bindInput('11111111-1111-4111-8111-111111111111', 'alice'))
expect((await reg.getHostBySubdomain('alice'))?.hostId).toBe(host.hostId)
const revoked = await reg.setHostStatus(host.hostId, 'revoked')
expect(revoked.status).toBe('revoked')
expect(revoked.revokedAt).not.toBeNull()
const versions = await hosts.versions(host.hostId)
expect(versions.map((v) => v.status)).toEqual(['offline', 'revoked'])
})
test('touchLastSeen does NOT create a version row (advisory cache, INV7)', async () => {
const { hosts } = createMemoryStores()
const reg = createHostRegistry({ hosts })
const host = await reg.bindHost(bindInput('11111111-1111-4111-8111-111111111111', 'alice'))
await reg.touchLastSeen(host.hostId)
await reg.touchLastSeen(host.hostId)
expect((await hosts.versions(host.hostId)).length).toBe(1)
})
test('bindHost rejects a mismatched enrollFpr', async () => {
const { hosts } = createMemoryStores()
const reg = createHostRegistry({ hosts })
const inp = { ...bindInput('11111111-1111-4111-8111-111111111111', 'alice'), enrollFpr: 'deadbeef' }
await expect(reg.bindHost(inp)).rejects.toThrow(/enrollFpr/)
})
})
describe('T5 session registry (INV3/INV6)', () => {
test('recordSession derives account_id from the host, ignoring any caller value', async () => {
const stores = createMemoryStores()
const hostReg = createHostRegistry({ hosts: stores.hosts })
const sessReg = createSessionRegistry({ sessions: stores.sessions, hosts: stores.hosts })
const owner = '11111111-1111-4111-8111-111111111111'
const host = await hostReg.bindHost(bindInput(owner, 'alice'))
const s = await sessReg.recordSession({ sessionId: 'sess-1', hostId: host.hostId })
expect(s.accountId).toBe(owner)
expect(await sessReg.sessionAccount('sess-1')).toBe(owner)
})
test('unknown session → null', async () => {
const stores = createMemoryStores()
const sessReg = createSessionRegistry({ sessions: stores.sessions, hosts: stores.hosts })
expect(await sessReg.sessionAccount('nope')).toBeNull()
})
})
describe('T6 subdomain assignment (INV1)', () => {
test('reserved words rejected; host-confusion inputs rejected', () => {
for (const bad of ['www', 'api', 'admin', '_']) expect(isValidSubdomain(bad)).toBe(false)
expect(isValidSubdomain('a.b')).toBe(false)
expect(isValidSubdomain('A LICE')).toBe(false)
expect(isValidSubdomain('-alice')).toBe(false)
expect(isValidSubdomain('alice')).toBe(true)
expect(normalizeSubdomain('A LICE!!')).toBe('alice')
expect(assembleFqdn('alice', 'term.example.com')).toBe('alice.term.example.com')
})
test('UNIQUE collision under concurrency → exactly one winner for the exact name', async () => {
const { subdomains } = createMemoryStores()
let n = 0
const assigner = createSubdomainAssigner({ subdomains, suffix: () => String(n++).padStart(4, '0') })
const [a, b] = await Promise.all([
assigner.assignSubdomain('acct-a', 'shared'),
assigner.assignSubdomain('acct-b', 'shared'),
])
expect(new Set([a, b]).size).toBe(2) // never collapses two tenants onto one label
expect([a, b].filter((x) => x === 'shared').length).toBe(1) // exactly one got the exact name
})
})

View File

@@ -0,0 +1,65 @@
import { describe, test, expect } from 'vitest'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createRoutingTable } from '../src/routing/table.js'
import { createInMemoryRevocationBus } from '../src/routing/bus.js'
import { createRevoker, createInMemoryRevokedTokenStore } from '../src/revoke/revoke.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
import type { KillSignal } from 'relay-contracts'
import type { NodeIdentity } from '../src/node-auth/identity.js'
const node: NodeIdentity = { nodeId: 'spiffe://relay/node-a' }
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
async function harness() {
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const routing = createRoutingTable({ routes: stores.routes })
const bus = createInMemoryRevocationBus()
const tokens = createInMemoryRevokedTokenStore()
const revoker = createRevoker({ hosts, routing, bus, tokens })
const { publicKeyRaw } = generateEd25519()
const host = await hosts.bindHost({ accountId: ACCOUNT, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
await routing.upsertRoute(node, host.hostId, { relayNodeId: node.nodeId, updatedAt: new Date().toISOString() }, 60)
return { stores, hosts, routing, bus, tokens, revoker, host }
}
describe('T13 revocation (INV12/INV8/INV10)', () => {
test('revokeHost: route dropped + host revoked + KillSignal on relay:revocations, zero payload', async () => {
const h = await harness()
const received: KillSignal[] = []
h.bus.subscribe((s) => received.push(s))
await h.revoker.revokeHost(h.host.hostId)
expect(await h.routing.resolveRoute(h.host.hostId)).toBeNull()
expect((await h.hosts.getHost(h.host.hostId))?.status).toBe('revoked')
expect(received.length).toBe(1)
expect(received[0]?.scope).toEqual({ kind: 'host', hostId: h.host.hostId })
// zero-payload: reason is short metadata, contains no terminal bytes
expect(received[0]?.reason).toBe('revoked')
})
test('revokeHost is idempotent', async () => {
const h = await harness()
await h.revoker.revokeHost(h.host.hostId)
await expect(h.revoker.revokeHost(h.host.hostId)).resolves.toBeUndefined()
})
test('revokeAccount publishes ONE account-scoped signal + cascades to hosts', async () => {
const h = await harness()
const received: KillSignal[] = []
h.bus.subscribe((s) => received.push(s))
await h.revoker.revokeAccount(ACCOUNT)
expect((await h.hosts.getHost(h.host.hostId))?.status).toBe('revoked')
const accountSignals = received.filter((s) => s.scope.kind === 'account')
expect(accountSignals.length).toBe(1)
})
test('revokeToken → isTokenRevoked true, stops validating', async () => {
const h = await harness()
const jti = 'jti-123'
expect(await h.revoker.isTokenRevoked(jti)).toBe(false)
await h.revoker.revokeToken(jti, Math.floor(Date.now() / 1000) + 3600)
expect(await h.revoker.isTokenRevoked(jti)).toBe(true)
})
})

View File

@@ -0,0 +1,84 @@
import { describe, test, expect } from 'vitest'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createLeafRenewer } from '../src/ca/rotate.js'
import { LeafSignError } from '../src/ca/sign.js'
import { buildCaSigner, inProcessCaSigner, type KmsResolver } from '../src/boot/ca-wiring.js'
import { buildCsr } from '../src/ca/csr.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
import { loadEnv } from '../src/env.js'
import { bytesToBase64 } from '../src/util/bytes.js'
import { randomBytes } from 'node:crypto'
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
async function boundHost() {
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const { publicKeyRaw, privateKey } = generateEd25519()
const host = await hosts.bindHost({ accountId: ACCOUNT, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
const renewer = createLeafRenewer({ hosts: stores.hosts, signer: inProcessCaSigner(), caChainDer: [] })
return { stores, hosts, host, publicKeyRaw, privateKey, renewer }
}
describe('T15 renewHostLeaf (INV14)', () => {
test('active host + valid CSR → fresh short-TTL leaf (same subject pubkey)', async () => {
const { host, publicKeyRaw, privateKey, renewer } = await boundHost()
const { cert } = await renewer.renewHostLeaf(host.hostId, buildCsr(privateKey, publicKeyRaw))
const certJson = JSON.parse(Buffer.from(cert).toString())
const tbs = JSON.parse(Buffer.from(certJson.tbs, 'base64').toString())
expect(tbs.subjectSpki).toBe(Buffer.from(publicKeyRaw).toString('base64'))
expect(tbs.renewed).toBe(true)
})
test('CSR proof-of-possession fails → reject even for an active host', async () => {
const { host, publicKeyRaw, renewer } = await boundHost()
const forged = new Uint8Array(96)
forged.set(publicKeyRaw, 0)
forged.set(randomBytes(64), 32) // garbage signature
await expect(renewer.renewHostLeaf(host.hostId, forged)).rejects.toBeInstanceOf(LeafSignError)
})
test('revoked host cannot renew (INV12+INV14 loop)', async () => {
const { hosts, host, publicKeyRaw, privateKey, renewer } = await boundHost()
await hosts.setHostStatus(host.hostId, 'revoked')
await expect(renewer.renewHostLeaf(host.hostId, buildCsr(privateKey, publicKeyRaw))).rejects.toBeInstanceOf(LeafSignError)
})
})
describe('T1/T15 buildCaSigner startup key-policy check (INV9, §3.1)', () => {
const env = () =>
loadEnv({
PG_URL: 'postgres://u:p@localhost:5432/cp',
REDIS_URL: 'redis://localhost:6379',
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(new Uint8Array(32).fill(1)),
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
BASE_DOMAIN: 'term.example.com',
})
test('fails fast when the KMS key ref cannot be resolved', async () => {
const resolver: KmsResolver = { async resolve() { throw new Error('no such key') } }
await expect(buildCaSigner(env(), resolver)).rejects.toThrow(/could not be resolved/)
})
test('fails fast when the key policy is broader than the service principal', async () => {
const resolver: KmsResolver = {
async resolve() {
return { signer: inProcessCaSigner(), policyRestrictedToServicePrincipal: false }
},
}
await expect(buildCaSigner(env(), resolver)).rejects.toThrow(/policy is broader/)
})
test('succeeds when policy is restricted', async () => {
const resolver: KmsResolver = {
async resolve() {
return { signer: inProcessCaSigner(), policyRestrictedToServicePrincipal: true }
},
}
await expect(buildCaSigner(env(), resolver)).resolves.toBeDefined()
})
})

View File

@@ -0,0 +1,90 @@
import { describe, test, expect } from 'vitest'
import { createMemoryStores } from '../src/store/memory.js'
import { createRoutingTable, RouteAuthError } from '../src/routing/table.js'
import { createNodeCoordinator } from '../src/routing/nodes.js'
import { createInMemoryRevocationBus } from '../src/routing/bus.js'
import { deriveNodeIdentity, NodeAuthError, type NodeIdentity } from '../src/node-auth/identity.js'
import type { KillSignal } from 'relay-contracts'
const nodeA: NodeIdentity = { nodeId: 'spiffe://relay/node-a' }
const nodeB: NodeIdentity = { nodeId: 'spiffe://relay/node-b' }
const HOST = '33333333-3333-4333-8333-333333333333'
describe('T9 node identity (INV3-analog)', () => {
test('authorized cert with CN → id; unauthenticated → 401; no CN → 401', () => {
expect(deriveNodeIdentity({ authorized: true, subjectCommonName: 'node-x' }).nodeId).toBe('node-x')
expect(() => deriveNodeIdentity({ authorized: false, subjectCommonName: 'node-x' })).toThrow(NodeAuthError)
expect(() => deriveNodeIdentity({ authorized: true, subjectCommonName: null })).toThrow(NodeAuthError)
expect(() => deriveNodeIdentity(null)).toThrow(NodeAuthError)
})
})
describe('T9 routing table (INV7)', () => {
test('upsert then resolve; foreign relayNodeId rejected', async () => {
const { routes } = createMemoryStores()
const table = createRoutingTable({ routes })
await table.upsertRoute(nodeA, HOST, { relayNodeId: nodeA.nodeId, updatedAt: new Date().toISOString() }, 60)
expect((await table.resolveRoute(HOST))?.relayNodeId).toBe(nodeA.nodeId)
await expect(
table.upsertRoute(nodeA, HOST, { relayNodeId: nodeB.nodeId, updatedAt: new Date().toISOString() }, 60),
).rejects.toBeInstanceOf(RouteAuthError)
})
test('heartbeat-TTL expiry ⇒ resolveRoute null (fails closed)', async () => {
const { routes } = createMemoryStores()
const table = createRoutingTable({ routes })
await table.upsertRoute(nodeA, HOST, { relayNodeId: nodeA.nodeId, updatedAt: new Date().toISOString() }, 0)
expect(await table.resolveRoute(HOST)).toBeNull()
})
test('foreign node cannot heartbeat/hijack; only holder drops', async () => {
const { routes } = createMemoryStores()
const table = createRoutingTable({ routes })
await table.upsertRoute(nodeA, HOST, { relayNodeId: nodeA.nodeId, updatedAt: new Date().toISOString() }, 60)
await table.heartbeatRoute(nodeB, HOST, 60) // foreign → no-op
expect((await table.resolveRoute(HOST))?.relayNodeId).toBe(nodeA.nodeId)
await expect(table.dropRoute(nodeB, HOST)).rejects.toBeInstanceOf(RouteAuthError)
await table.dropRoute(nodeA, HOST)
expect(await table.resolveRoute(HOST)).toBeNull()
})
})
describe('T10 node registry + graceful drain (INV7)', () => {
test('beginDrain flips status, returns routed host_ids, publishes per-host KillSignal', async () => {
const stores = createMemoryStores()
const bus = createInMemoryRevocationBus()
const table = createRoutingTable({
routes: stores.routes,
nodeStatus: async (id) => (await stores.nodes.get(id))?.status ?? null,
})
const coord = createNodeCoordinator({ nodes: stores.nodes, routes: stores.routes, bus })
const received: KillSignal[] = []
bus.subscribe((s) => received.push(s))
await coord.registerNode(nodeA, '10.0.0.1:9000')
await table.upsertRoute(nodeA, HOST, { relayNodeId: nodeA.nodeId, updatedAt: new Date().toISOString() }, 60)
const { hostIds } = await coord.beginDrain(nodeA)
expect(hostIds).toEqual([HOST])
expect((await stores.nodes.get(nodeA.nodeId))?.status).toBe('draining')
expect(received.map((s) => s.scope)).toEqual([{ kind: 'host', hostId: HOST }])
// a draining node stops receiving new upserts
await expect(
table.upsertRoute(nodeA, '44444444-4444-4444-8444-444444444444', { relayNodeId: nodeA.nodeId, updatedAt: new Date().toISOString() }, 60),
).rejects.toBeInstanceOf(RouteAuthError)
await coord.completeDrain(nodeA)
expect(await stores.nodes.get(nodeA.nodeId)).toBeNull()
})
test('PTY-survival: draining touches no host/session row', async () => {
const stores = createMemoryStores()
const bus = createInMemoryRevocationBus()
const coord = createNodeCoordinator({ nodes: stores.nodes, routes: stores.routes, bus })
await coord.registerNode(nodeA, 'addr')
await coord.beginDrain(nodeA)
// no host rows were ever created by drain
expect(await stores.hosts.get(HOST)).toBeNull()
})
})