- 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
221 lines
9.5 KiB
TypeScript
221 lines
9.5 KiB
TypeScript
/**
|
|
* 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')
|
|
})
|
|
})
|