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:
102
control-plane/test/api-extra.test.ts
Normal file
102
control-plane/test/api-extra.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
96
control-plane/test/api.test.ts
Normal file
96
control-plane/test/api.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
61
control-plane/test/audit.test.ts
Normal file
61
control-plane/test/audit.test.ts
Normal 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[0m\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'])
|
||||
})
|
||||
})
|
||||
73
control-plane/test/ca.test.ts
Normal file
73
control-plane/test/ca.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
21
control-plane/test/db.test.ts
Normal file
21
control-plane/test/db.test.ts
Normal 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; --"])
|
||||
})
|
||||
})
|
||||
68
control-plane/test/env.test.ts
Normal file
68
control-plane/test/env.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
40
control-plane/test/flat-store.test.ts
Normal file
40
control-plane/test/flat-store.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
62
control-plane/test/metering.test.ts
Normal file
62
control-plane/test/metering.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
134
control-plane/test/pairing.test.ts
Normal file
134
control-plane/test/pairing.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
148
control-plane/test/registry.test.ts
Normal file
148
control-plane/test/registry.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
65
control-plane/test/revoke.test.ts
Normal file
65
control-plane/test/revoke.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
84
control-plane/test/rotate.test.ts
Normal file
84
control-plane/test/rotate.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
90
control-plane/test/routing.test.ts
Normal file
90
control-plane/test/routing.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user