- 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
126 lines
5.7 KiB
TypeScript
126 lines
5.7 KiB
TypeScript
/**
|
|
* Real X.509 leaf issuance (INV14). After the shared `assertLeafGate` passes, emit an X.509 v3
|
|
* Ed25519 leaf whose subject key is the enrolled agent pubkey and whose only SAN is the host's
|
|
* SPIFFE-ID URI — signed by the intermediate Ed25519 key. This is what `relay-auth`'s
|
|
* `verifyAgentCert` accepts (it walks leaf → intermediate → self-signed root and parses the
|
|
* `URI:spiffe://relay.<domain>/account/<a>/host/<h>` SAN).
|
|
*
|
|
* The SPIFFE-ID is built with relay-auth's OWN builder (`spiffeIdFor`, deep-imported) so the emitted
|
|
* SAN can never drift from the verifier's parser. The intermediate PRIVATE key is a WebCrypto
|
|
* `CryptoKey` imported non-extractable (never serialised back out — INV9).
|
|
*
|
|
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep it first.
|
|
*/
|
|
import 'reflect-metadata'
|
|
import * as x509 from '@peculiar/x509'
|
|
import { webcrypto, randomBytes } from 'node:crypto'
|
|
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
|
|
import type { HostStore } from '../store/ports.js'
|
|
import { assertLeafGate, DEFAULT_LEAF_TTL_SEC, type LeafSigner } from './sign.js'
|
|
|
|
x509.cryptoProvider.set(webcrypto)
|
|
|
|
/** Backdate notBefore slightly to tolerate small clock skew between control-plane and relay. */
|
|
const CLOCK_SKEW_SEC = 60
|
|
|
|
export interface RealLeafSignerDeps {
|
|
readonly hosts: HostStore
|
|
/** Intermediate Ed25519 PRIVATE signing key (WebCrypto, non-extractable). */
|
|
readonly intermediateKey: CryptoKey
|
|
/** Intermediate subject as a Name — used verbatim as the leaf issuer so `checkIssued` matches. */
|
|
readonly issuerName: x509.Name
|
|
/** DER of [intermediate, root] returned to the agent as its CA bundle (INV14). */
|
|
readonly caChainDer: readonly Uint8Array[]
|
|
/** Bare trust domain; the SPIFFE builder prepends `relay.`. */
|
|
readonly trustDomain: string
|
|
readonly leafTtlSec?: number
|
|
}
|
|
|
|
/** Import a raw 32-byte Ed25519 public key as a verifying WebCrypto CryptoKey (via SPKI DER). */
|
|
async function importEd25519Public(raw: Uint8Array): Promise<CryptoKey> {
|
|
const prefix = Uint8Array.from([
|
|
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
|
])
|
|
const spki = new Uint8Array(prefix.length + raw.length)
|
|
spki.set(prefix, 0)
|
|
spki.set(raw, prefix.length)
|
|
return webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
|
|
}
|
|
|
|
/**
|
|
* Build the production leaf signer. Every issued leaf: X.509 v3, Ed25519 subject = agentPubkey,
|
|
* URI SAN = the host's SPIFFE-ID, CA:false, KeyUsage digitalSignature, EKU clientAuth, validity
|
|
* [now-skew, now+ttl]. Returns leaf DER + the injected CA chain DER; `redeem.ts` PEM-wraps both.
|
|
*/
|
|
export function createRealLeafSigner(deps: RealLeafSignerDeps): LeafSigner {
|
|
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
|
return {
|
|
async signHostLeaf(hostId, agentPubkey, csr) {
|
|
const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr)
|
|
const spiffe = spiffeIdFor(host.accountId, host.hostId, deps.trustDomain)
|
|
const subjectKey = await importEd25519Public(agentPubkey)
|
|
const now = Date.now()
|
|
const leaf = await x509.X509CertificateGenerator.create({
|
|
serialNumber: randomBytes(16).toString('hex'),
|
|
subject: `CN=${host.hostId}`,
|
|
issuer: deps.issuerName,
|
|
notBefore: new Date(now - CLOCK_SKEW_SEC * 1000),
|
|
notAfter: new Date(now + ttl * 1000),
|
|
publicKey: subjectKey,
|
|
signingKey: deps.intermediateKey,
|
|
signingAlgorithm: { name: 'Ed25519' },
|
|
extensions: [
|
|
new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }]),
|
|
new x509.BasicConstraintsExtension(false, undefined, true),
|
|
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
|
|
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
|
|
],
|
|
})
|
|
return { cert: new Uint8Array(leaf.rawData), caChain: deps.caChainDer }
|
|
},
|
|
}
|
|
}
|
|
|
|
export interface LoadRealLeafSignerInput {
|
|
readonly hosts: HostStore
|
|
/** Intermediate Ed25519 private key, PKCS#8 PEM. */
|
|
readonly intermediateKeyPem: string
|
|
/** Intermediate certificate, PEM (single block). */
|
|
readonly intermediateCertPem: string
|
|
/** Self-signed root certificate, PEM (single block). */
|
|
readonly rootCertPem: string
|
|
readonly trustDomain: string
|
|
readonly leafTtlSec?: number
|
|
}
|
|
|
|
function pemToDer(pem: string): ArrayBuffer {
|
|
const body = pem.replace(/-----BEGIN [^-]+-----/g, '').replace(/-----END [^-]+-----/g, '').replace(/\s+/g, '')
|
|
const bytes = Buffer.from(body, 'base64')
|
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
|
}
|
|
|
|
/**
|
|
* Construct a real leaf signer from PEM material (boot path). Imports the intermediate private key
|
|
* (non-extractable — never re-serialised, INV9) and derives the issuer Name + CA chain DER from the
|
|
* certs. THROWS on unreadable/malformed material so the control-plane fails fast at boot.
|
|
*/
|
|
export async function loadRealLeafSigner(input: LoadRealLeafSignerInput): Promise<LeafSigner> {
|
|
const intermediateKey = await webcrypto.subtle.importKey(
|
|
'pkcs8',
|
|
pemToDer(input.intermediateKeyPem),
|
|
{ name: 'Ed25519' },
|
|
false, // non-extractable: the raw private key can never leave the process (INV9)
|
|
['sign'],
|
|
)
|
|
const intermediateCert = new x509.X509Certificate(input.intermediateCertPem)
|
|
const rootCert = new x509.X509Certificate(input.rootCertPem)
|
|
return createRealLeafSigner({
|
|
hosts: input.hosts,
|
|
intermediateKey,
|
|
issuerName: intermediateCert.subjectName,
|
|
caChainDer: [new Uint8Array(intermediateCert.rawData), new Uint8Array(rootCert.rawData)],
|
|
trustDomain: input.trustDomain,
|
|
...(input.leafTtlSec !== undefined ? { leafTtlSec: input.leafTtlSec } : {}),
|
|
})
|
|
}
|