feat(control-plane): real X.509 agent leaf issuance (closes enrollment↔mTLS gap)
- ca/issue.ts: real X.509 v3 Ed25519 leaf with SPIFFE URI SAN, signed by the intermediate; SAN built via relay-auth spiffeIdFor so it can't drift - ca/csr.ts: parse real PKCS#10 (agent's PEM) + async PoP verify; decodeCsrWire accepts PEM or base64(DER) - main.ts/env.ts: real issuer when CA_INTERMEDIATE_KEY_PATH present, else dev fallback - interop.test.ts: oracle proving relay-auth verifyAgentCert accepts the leaf (6 tests) - deps: @peculiar/x509, reflect-metadata
This commit is contained in:
@@ -35,7 +35,7 @@ describe('T8 signHostLeaf — INV14 registry-gated + CSR PoP', () => {
|
||||
const forged = new Uint8Array(96)
|
||||
forged.set(publicKeyRaw, 0)
|
||||
forged.set(randomBytes(64), 32)
|
||||
expect(verifyCsrPoP(forged).ok).toBe(false)
|
||||
expect((await verifyCsrPoP(forged)).ok).toBe(false)
|
||||
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, forged)).rejects.toBeInstanceOf(LeafSignError)
|
||||
expect(signSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
220
control-plane/test/interop.test.ts
Normal file
220
control-plane/test/interop.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* OBJECTIVE ORACLE (INV4/INV14) — proves the control-plane's REAL leaf issuer emits an X.509 leaf
|
||||
* that relay-auth's `verifyAgentCert` accepts, WITHOUT deploying either service.
|
||||
*
|
||||
* A throwaway Ed25519 root→intermediate chain is generated in-test; a leaf is issued through
|
||||
* `createRealLeafSigner` for a random account/host + agent pubkey; then relay-auth verifies the
|
||||
* leaf against the intermediate+root bundle with a fake host registry. Positive path asserts
|
||||
* `{ ok: true, hostId, accountId }`; negatives assert the exact reject reasons.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto, randomUUID } from 'node:crypto'
|
||||
import { verifyAgentCert, defaultParseX509 } from 'relay-auth/src/agent/verify-mtls.js'
|
||||
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
|
||||
import type { HostRecord } from 'relay-contracts'
|
||||
import { createMemoryStores } from '../src/store/memory.js'
|
||||
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||
import { createRealLeafSigner, loadRealLeafSigner } from '../src/ca/issue.js'
|
||||
import { buildCsr } from '../src/ca/csr.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { generateEd25519 } from '../src/util/crypto.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const TRUST_DOMAIN = 'example.com'
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
interface Ca {
|
||||
readonly intKey: CryptoKey
|
||||
readonly issuerName: x509.Name
|
||||
readonly caChainDer: readonly Uint8Array[]
|
||||
readonly caChainPem: string
|
||||
readonly intermediateKeyPem: string
|
||||
readonly intermediateCertPem: string
|
||||
readonly rootCertPem: string
|
||||
}
|
||||
|
||||
type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
|
||||
|
||||
function derToPemLabeled(der: Uint8Array, label: string): string {
|
||||
const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''
|
||||
return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
async function makeCa(): Promise<Ca> {
|
||||
const subtle = webcrypto.subtle
|
||||
const rootKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||
const intKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||
const now = Date.now()
|
||||
const rootCert = await x509.X509CertificateGenerator.createSelfSigned({
|
||||
serialNumber: '01',
|
||||
name: 'CN=relay-root',
|
||||
notBefore: new Date(now - DAY_MS),
|
||||
notAfter: new Date(now + DAY_MS),
|
||||
keys: rootKeys,
|
||||
signingAlgorithm: { name: 'Ed25519' },
|
||||
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
|
||||
})
|
||||
const intCert = await x509.X509CertificateGenerator.create({
|
||||
serialNumber: '02',
|
||||
subject: 'CN=relay-intermediate',
|
||||
issuer: rootCert.subjectName,
|
||||
notBefore: new Date(now - DAY_MS),
|
||||
notAfter: new Date(now + DAY_MS),
|
||||
publicKey: intKeys.publicKey,
|
||||
signingKey: rootKeys.privateKey,
|
||||
signingAlgorithm: { name: 'Ed25519' },
|
||||
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
|
||||
})
|
||||
const intKeyPkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', intKeys.privateKey))
|
||||
return {
|
||||
intKey: intKeys.privateKey,
|
||||
issuerName: intCert.subjectName,
|
||||
caChainDer: [new Uint8Array(intCert.rawData), new Uint8Array(rootCert.rawData)],
|
||||
caChainPem: `${intCert.toString('pem')}\n${rootCert.toString('pem')}`,
|
||||
intermediateKeyPem: derToPemLabeled(intKeyPkcs8, 'PRIVATE KEY'),
|
||||
intermediateCertPem: intCert.toString('pem'),
|
||||
rootCertPem: rootCert.toString('pem'),
|
||||
}
|
||||
}
|
||||
|
||||
function derToPem(der: Uint8Array): string {
|
||||
return new x509.X509Certificate(der).toString('pem')
|
||||
}
|
||||
|
||||
/** Minimal relay-contracts HostRecord for the fake registry (only accountId/status are read). */
|
||||
function fakeHost(hostId: string, accountId: string, status: HostRecord['status']): HostRecord {
|
||||
return {
|
||||
hostId,
|
||||
accountId,
|
||||
subdomain: 'host',
|
||||
agentPubkey: new Uint8Array(32),
|
||||
enrollFpr: 'fpr',
|
||||
status,
|
||||
lastSeen: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
revokedAt: status === 'revoked' ? new Date().toISOString() : null,
|
||||
} as HostRecord
|
||||
}
|
||||
|
||||
async function issueLeaf() {
|
||||
const ca = await makeCa()
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const accountId = randomUUID()
|
||||
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||
const host = await hosts.bindHost({
|
||||
accountId,
|
||||
subdomain: 'alice',
|
||||
agentPubkey: publicKeyRaw,
|
||||
enrollFpr: fingerprint(publicKeyRaw),
|
||||
})
|
||||
const signer = createRealLeafSigner({
|
||||
hosts: stores.hosts,
|
||||
intermediateKey: ca.intKey,
|
||||
issuerName: ca.issuerName,
|
||||
caChainDer: ca.caChainDer,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
})
|
||||
const { cert, caChain } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
|
||||
return { ca, accountId, hostId: host.hostId, publicKeyRaw, leafPem: derToPem(cert), caChain }
|
||||
}
|
||||
|
||||
describe('interop: control-plane real leaf ⇄ relay-auth verifyAgentCert', () => {
|
||||
test('valid leaf for an active enrolled host → { ok: true, hostId, accountId }', async () => {
|
||||
const { ca, accountId, hostId, leafPem, caChain } = await issueLeaf()
|
||||
expect(caChain).toHaveLength(2) // intermediate + root DER
|
||||
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||
expect(res).toEqual({ ok: true, hostId, accountId })
|
||||
})
|
||||
|
||||
test('SPIFFE accountId ≠ registry accountId → spiffe_account_mismatch', async () => {
|
||||
const { ca, hostId, leafPem } = await issueLeaf()
|
||||
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, randomUUID(), 'online') : null) }
|
||||
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toBe('spiffe_account_mismatch')
|
||||
})
|
||||
|
||||
test('revoked host → host_revoked', async () => {
|
||||
const { ca, accountId, hostId, leafPem } = await issueLeaf()
|
||||
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'revoked') : null) }
|
||||
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toBe('host_revoked')
|
||||
})
|
||||
|
||||
test('expired leaf → expired', async () => {
|
||||
const ca = await makeCa()
|
||||
const accountId = randomUUID()
|
||||
const hostId = randomUUID()
|
||||
const { publicKeyRaw } = generateEd25519()
|
||||
const spiffe = spiffeIdFor(accountId, hostId, TRUST_DOMAIN)
|
||||
const prefix = Uint8Array.from([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00])
|
||||
const spki = new Uint8Array(44)
|
||||
spki.set(prefix, 0)
|
||||
spki.set(publicKeyRaw, 12)
|
||||
const subjectKey = await webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
|
||||
const now = Date.now()
|
||||
const expiredLeaf = await x509.X509CertificateGenerator.create({
|
||||
serialNumber: '0e',
|
||||
subject: `CN=${hostId}`,
|
||||
issuer: ca.issuerName,
|
||||
notBefore: new Date(now - 2 * DAY_MS),
|
||||
notAfter: new Date(now - DAY_MS), // already expired
|
||||
publicKey: subjectKey,
|
||||
signingKey: ca.intKey,
|
||||
signingAlgorithm: { name: 'Ed25519' },
|
||||
extensions: [new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }])],
|
||||
})
|
||||
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||
const res = await verifyAgentCert(
|
||||
expiredLeaf.toString('pem'),
|
||||
ca.caChainPem,
|
||||
Math.floor(Date.now() / 1000),
|
||||
registry,
|
||||
defaultParseX509,
|
||||
)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toBe('expired')
|
||||
})
|
||||
|
||||
test('boot path: loadRealLeafSigner (PEM material) issues a verifiable leaf', async () => {
|
||||
const ca = await makeCa()
|
||||
const stores = createMemoryStores()
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const accountId = randomUUID()
|
||||
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||
const host = await hosts.bindHost({
|
||||
accountId,
|
||||
subdomain: 'boot',
|
||||
agentPubkey: publicKeyRaw,
|
||||
enrollFpr: fingerprint(publicKeyRaw),
|
||||
})
|
||||
const signer = await loadRealLeafSigner({
|
||||
hosts: stores.hosts,
|
||||
intermediateKeyPem: ca.intermediateKeyPem,
|
||||
intermediateCertPem: ca.intermediateCertPem,
|
||||
rootCertPem: ca.rootCertPem,
|
||||
trustDomain: TRUST_DOMAIN,
|
||||
})
|
||||
const { cert } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
|
||||
const registry = {
|
||||
getById: async (id: string) => (id === host.hostId ? fakeHost(host.hostId, accountId, 'online') : null),
|
||||
}
|
||||
const res = await verifyAgentCert(derToPem(cert), ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||
expect(res).toEqual({ ok: true, hostId: host.hostId, accountId })
|
||||
})
|
||||
|
||||
test('intermediate-only bundle (no root) → chain_invalid', async () => {
|
||||
const { ca, accountId, hostId, leafPem } = await issueLeaf()
|
||||
const intermediateOnly = ca.caChainPem.split('-----END CERTIFICATE-----')[0]! + '-----END CERTIFICATE-----\n'
|
||||
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||
const res = await verifyAgentCert(leafPem, intermediateOnly, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.reason).toBe('chain_invalid')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user