Files
stack/apps/gateway/src/federation/ca.service.ts
jason.woltje fc1600b738
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline failed
fix(federation): security hardening — OID verification, atomic activation, audit on failure (#501)
2026-04-22 06:02:52 +00:00

681 lines
23 KiB
TypeScript

/**
* CaService — Step-CA client for federation grant certificate issuance.
*
* Responsibilities:
* 1. Build a JWK-provisioner One-Time Token (OTT) signed with the provisioner
* private key (ES256/ES384/RS256 per JWK kty/crv) carrying Mosaic-specific
* claims (`mosaic_grant_id`, `mosaic_subject_user_id`, `step.sha`) per the
* step-ca JWK provisioner protocol.
* 2. POST the CSR + OTT to the step-ca `/1.0/sign` endpoint over HTTPS,
* pinning the trust to the CA root cert supplied via env.
* 3. Return an IssuedCertDto containing the leaf cert, full chain, and
* serial number.
*
* Environment variables (all required at runtime — validated in constructor):
* STEP_CA_URL https://step-ca:9000
* STEP_CA_PROVISIONER_KEY_JSON JWK provisioner private key (JSON)
* STEP_CA_ROOT_CERT_PATH Absolute path to the CA root PEM
*
* Optional (only used for JWK PBES2 decrypt at startup if key is encrypted):
* STEP_CA_PROVISIONER_PASSWORD JWK provisioner password (raw string)
*
* Custom OID registry (PRD §6, docs/federation/SETUP.md):
* 1.3.6.1.4.1.99999.1 — mosaic_grant_id
* 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id
*
* Fail-loud contract:
* Every error path throws CaServiceError with a human-readable `remediation`
* field. Silent OID-stripping is NEVER allowed — if the sign response does
* not include the cert, we throw rather than return a cert that may be
* missing the custom extensions.
*/
import { Injectable, Logger } from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as https from 'node:https';
import { SignJWT, importJWK } from 'jose';
import { Pkcs10CertificateRequest, X509Certificate } from '@peculiar/x509';
import type { IssueCertRequestDto } from './ca.dto.js';
import { IssuedCertDto } from './ca.dto.js';
// ---------------------------------------------------------------------------
// Custom error class
// ---------------------------------------------------------------------------
export class CaServiceError extends Error {
readonly cause: unknown;
readonly remediation: string;
readonly code?: string;
constructor(message: string, remediation: string, cause?: unknown, code?: string) {
super(message);
this.name = 'CaServiceError';
this.cause = cause;
this.remediation = remediation;
this.code = code;
}
}
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
interface StepSignResponse {
crt: string;
ca?: string;
certChain?: string[];
}
interface JwkKey {
kty: string;
kid?: string;
use?: string;
alg?: string;
k?: string; // symmetric
n?: string; // RSA
e?: string;
d?: string;
x?: string; // EC
y?: string;
crv?: string;
[key: string]: unknown;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** UUID regex for validation */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Derive the JWT algorithm string from a JWK's kty/crv fields.
* EC P-256 → ES256, EC P-384 → ES384, RSA → RS256.
*/
function algFromJwk(jwk: JwkKey): string {
if (jwk.alg) return jwk.alg;
if (jwk.kty === 'EC') {
if (jwk.crv === 'P-384') return 'ES384';
return 'ES256'; // default for P-256 and Ed25519-style EC keys
}
if (jwk.kty === 'RSA') return 'RS256';
throw new CaServiceError(
`Unsupported JWK kty: ${jwk.kty}`,
'STEP_CA_PROVISIONER_KEY_JSON must be an EC (P-256/P-384) or RSA JWK private key.',
);
}
/**
* Compute SHA-256 fingerprint of the DER-encoded CSR body.
* step-ca uses this as the `step.sha` claim to bind the OTT to a specific CSR.
*/
function csrFingerprint(csrPem: string): string {
// Strip PEM headers and decode base64 body
const b64 = csrPem
.replace(/-----BEGIN CERTIFICATE REQUEST-----/, '')
.replace(/-----END CERTIFICATE REQUEST-----/, '')
.replace(/\s+/g, '');
let derBuf: Buffer;
try {
derBuf = Buffer.from(b64, 'base64');
} catch (err) {
throw new CaServiceError(
'Failed to base64-decode the CSR PEM body',
'Verify that csrPem is a valid PKCS#10 PEM-encoded certificate request.',
err,
);
}
if (derBuf.length === 0) {
throw new CaServiceError(
'CSR PEM decoded to empty buffer — malformed input',
'Provide a valid non-empty PKCS#10 PEM-encoded certificate request.',
);
}
return crypto.createHash('sha256').update(derBuf).digest('hex');
}
/**
* Send a JSON POST to the step-ca sign endpoint.
* Returns the parsed response body or throws CaServiceError.
*/
function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<StepSignResponse> {
return new Promise((resolve, reject) => {
const bodyStr = JSON.stringify(body);
const parsed = new URL(url);
const options: https.RequestOptions = {
hostname: parsed.hostname,
port: parsed.port ? parseInt(parsed.port, 10) : 443,
path: parsed.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
},
agent,
timeout: 5000,
};
const req = https.request(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (res.statusCode === 401) {
reject(
new CaServiceError(
`step-ca returned HTTP 401 — invalid or expired OTT`,
'Check STEP_CA_PROVISIONER_KEY_JSON. Ensure the mosaic-fed provisioner is configured in the CA.',
),
);
return;
}
if (res.statusCode && res.statusCode >= 400) {
reject(
new CaServiceError(
`step-ca returned HTTP ${res.statusCode}: ${raw.slice(0, 256)}`,
`Review the step-ca logs. Status ${res.statusCode} may indicate a CSR policy violation or misconfigured provisioner.`,
),
);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch (err) {
reject(
new CaServiceError(
'step-ca returned a non-JSON response',
'Verify STEP_CA_URL points to a running step-ca instance and that TLS is properly configured.',
err,
),
);
return;
}
resolve(parsed as StepSignResponse);
});
});
req.setTimeout(5000, () => {
req.destroy(new Error('Request timed out after 5000ms'));
});
req.on('error', (err: Error) => {
reject(
new CaServiceError(
`HTTPS connection to step-ca failed: ${err.message}`,
'Ensure STEP_CA_URL is reachable and STEP_CA_ROOT_CERT_PATH points to the correct CA root certificate.',
err,
),
);
});
req.write(bodyStr);
req.end();
});
}
/**
* Extract a decimal serial number from a PEM certificate.
* Throws CaServiceError on failure — never silently returns 'unknown'.
*/
function extractSerial(certPem: string): string {
let cert: crypto.X509Certificate;
try {
cert = new crypto.X509Certificate(certPem);
} catch (err) {
throw new CaServiceError(
'Failed to parse the issued certificate PEM',
'The certificate returned by step-ca could not be parsed. Check that step-ca is returning a valid PEM certificate.',
err,
'CERT_PARSE',
);
}
return cert.serialNumber;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
@Injectable()
export class CaService {
private readonly logger = new Logger(CaService.name);
private readonly caUrl: string;
private readonly rootCertPath: string;
private readonly httpsAgent: https.Agent;
private readonly jwk: JwkKey;
private cachedPrivateKey: crypto.KeyObject | null = null;
private readonly jwtAlg: string;
private readonly kid: string;
constructor() {
const caUrl = process.env['STEP_CA_URL'];
const provisionerKeyJson = process.env['STEP_CA_PROVISIONER_KEY_JSON'];
const rootCertPath = process.env['STEP_CA_ROOT_CERT_PATH'];
if (!caUrl) {
throw new CaServiceError(
'STEP_CA_URL is not set',
'Set STEP_CA_URL to the base URL of the step-ca instance, e.g. https://step-ca:9000',
);
}
// Enforce HTTPS-only URL
let parsedUrl: URL;
try {
parsedUrl = new URL(caUrl);
} catch (err) {
throw new CaServiceError(
`STEP_CA_URL is not a valid URL: ${caUrl}`,
'Set STEP_CA_URL to a valid HTTPS URL, e.g. https://step-ca:9000',
err,
);
}
if (parsedUrl.protocol !== 'https:') {
throw new CaServiceError(
`STEP_CA_URL must use HTTPS — got: ${parsedUrl.protocol}`,
'Set STEP_CA_URL to an https:// URL. Unencrypted connections to the CA are not permitted.',
);
}
if (!provisionerKeyJson) {
throw new CaServiceError(
'STEP_CA_PROVISIONER_KEY_JSON is not set',
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-encoded JWK for the mosaic-fed provisioner.',
);
}
if (!rootCertPath) {
throw new CaServiceError(
'STEP_CA_ROOT_CERT_PATH is not set',
'Set STEP_CA_ROOT_CERT_PATH to the absolute path of the step-ca root CA certificate PEM file.',
);
}
// Parse JWK once — do NOT store the raw JSON string as a class field
let jwk: JwkKey;
try {
jwk = JSON.parse(provisionerKeyJson) as JwkKey;
} catch (err) {
throw new CaServiceError(
'STEP_CA_PROVISIONER_KEY_JSON is not valid JSON',
'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-serialised JWK object for the mosaic-fed provisioner.',
err,
);
}
// Derive algorithm from JWK metadata
const jwtAlg = algFromJwk(jwk);
const kid = jwk.kid ?? 'mosaic-fed';
// Import the JWK into a native KeyObject — fail loudly if it cannot be loaded.
// We do this synchronously here by calling the async importJWK via a blocking workaround.
// Actually importJWK is async, so we store it for use during token building.
// We keep the raw jwk object for later async import inside buildOtt.
// NOTE: We do NOT store provisionerKeyJson string as a class field.
this.jwk = jwk;
this.jwtAlg = jwtAlg;
this.kid = kid;
this.caUrl = caUrl;
this.rootCertPath = rootCertPath;
// Read the root cert and pin it for all HTTPS connections.
let rootCert: string;
try {
rootCert = fs.readFileSync(this.rootCertPath, 'utf8');
} catch (err) {
throw new CaServiceError(
`Cannot read STEP_CA_ROOT_CERT_PATH: ${rootCertPath}`,
'Ensure the file exists and is readable by the gateway process.',
err,
);
}
this.httpsAgent = new https.Agent({
ca: rootCert,
rejectUnauthorized: true,
});
this.logger.log(`CaService initialised — CA URL: ${this.caUrl}`);
}
/**
* Lazily import the private key from JWK on first use.
* The key is cached in cachedPrivateKey after first import.
*/
private async getPrivateKey(): Promise<crypto.KeyObject> {
if (this.cachedPrivateKey !== null) return this.cachedPrivateKey;
try {
const key = await importJWK(this.jwk, this.jwtAlg);
// importJWK returns KeyLike (crypto.KeyObject | Uint8Array) — in Node.js it's KeyObject
this.cachedPrivateKey = key as unknown as crypto.KeyObject;
return this.cachedPrivateKey;
} catch (err) {
throw new CaServiceError(
'Failed to import STEP_CA_PROVISIONER_KEY_JSON as a cryptographic key',
'Ensure STEP_CA_PROVISIONER_KEY_JSON contains a valid JWK private key (EC P-256/P-384 or RSA).',
err,
);
}
}
/**
* Build the JWK-provisioner OTT signed with the provisioner private key.
* Algorithm is derived from the JWK kty/crv fields.
*/
private async buildOtt(params: {
csrPem: string;
grantId: string;
subjectUserId: string;
ttlSeconds: number;
csrCn: string;
}): Promise<string> {
const { csrPem, grantId, subjectUserId, ttlSeconds, csrCn } = params;
// Validate UUID shape for grant id and subject user id
if (!UUID_RE.test(grantId)) {
throw new CaServiceError(
`grantId is not a valid UUID: ${grantId}`,
'Provide a valid UUID (RFC 4122) for grantId.',
undefined,
'INVALID_GRANT_ID',
);
}
if (!UUID_RE.test(subjectUserId)) {
throw new CaServiceError(
`subjectUserId is not a valid UUID: ${subjectUserId}`,
'Provide a valid UUID (RFC 4122) for subjectUserId.',
undefined,
'INVALID_GRANT_ID',
);
}
const sha = csrFingerprint(csrPem);
const now = Math.floor(Date.now() / 1000);
const privateKey = await this.getPrivateKey();
const ott = await new SignJWT({
iss: this.kid,
sub: csrCn, // M1: set sub to identity from CSR CN
aud: [`${this.caUrl}/1.0/sign`],
iat: now,
nbf: now - 30, // 30 s clock-skew tolerance
exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h
jti: crypto.randomUUID(), // M2: unique token ID
// step.sha is the canonical field name used in the template — M3: keep only step.sha
step: { sha },
// Mosaic custom claims consumed by federation.tpl
mosaic_grant_id: grantId,
mosaic_subject_user_id: subjectUserId,
})
.setProtectedHeader({ alg: this.jwtAlg, typ: 'JWT', kid: this.kid })
.sign(privateKey);
return ott;
}
/**
* Validate a PEM-encoded CSR using @peculiar/x509.
* Verifies the self-signature, key type/size, and signature algorithm.
* Optionally verifies that the CSR's SANs match the expected set.
*
* Throws CaServiceError with code 'INVALID_CSR' on failure.
*/
private async validateCsr(pem: string, expectedSans?: string[]): Promise<string> {
let csr: Pkcs10CertificateRequest;
try {
csr = new Pkcs10CertificateRequest(pem);
} catch (err) {
throw new CaServiceError(
'Failed to parse CSR PEM as a valid PKCS#10 certificate request',
'Provide a valid PEM-encoded PKCS#10 CSR.',
err,
'INVALID_CSR',
);
}
// Verify self-signature
let valid: boolean;
try {
valid = await csr.verify();
} catch (err) {
throw new CaServiceError(
'CSR signature verification threw an error',
'The CSR self-signature could not be verified. Ensure the CSR is properly formed.',
err,
'INVALID_CSR',
);
}
if (!valid) {
throw new CaServiceError(
'CSR self-signature is invalid',
'The CSR must be self-signed with the corresponding private key.',
undefined,
'INVALID_CSR',
);
}
// Validate signature algorithm — reject MD5 and SHA-1
// signatureAlgorithm is HashedAlgorithm which extends Algorithm.
// Cast through unknown to access .name and .hash.name without DOM lib globals.
const sigAlgAny = csr.signatureAlgorithm as unknown as {
name?: string;
hash?: { name?: string };
};
const sigAlgName = (sigAlgAny.name ?? '').toLowerCase();
const hashName = (sigAlgAny.hash?.name ?? '').toLowerCase();
if (
sigAlgName.includes('md5') ||
sigAlgName.includes('sha1') ||
hashName === 'sha-1' ||
hashName === 'sha1'
) {
throw new CaServiceError(
`CSR uses a forbidden signature algorithm: ${sigAlgAny.name ?? 'unknown'}`,
'Use SHA-256 or stronger. MD5 and SHA-1 are not permitted.',
undefined,
'INVALID_CSR',
);
}
// Validate public key algorithm and strength via the algorithm descriptor on the key.
// csr.publicKey.algorithm is type Algorithm (WebCrypto) — use name-based checks.
// We cast to an extended interface to access curve/modulus info without DOM globals.
const pubKeyAlgo = csr.publicKey.algorithm as {
name: string;
namedCurve?: string;
modulusLength?: number;
};
const keyAlgoName = pubKeyAlgo.name;
if (keyAlgoName === 'RSASSA-PKCS1-v1_5' || keyAlgoName === 'RSA-PSS') {
const modulusLength = pubKeyAlgo.modulusLength ?? 0;
if (modulusLength < 2048) {
throw new CaServiceError(
`CSR RSA key is too short: ${modulusLength} bits (minimum 2048)`,
'Use an RSA key of at least 2048 bits.',
undefined,
'INVALID_CSR',
);
}
} else if (keyAlgoName === 'ECDSA') {
const namedCurve = pubKeyAlgo.namedCurve ?? '';
const allowedCurves = new Set(['P-256', 'P-384']);
if (!allowedCurves.has(namedCurve)) {
throw new CaServiceError(
`CSR EC key uses disallowed curve: ${namedCurve}`,
'Use EC P-256 or P-384. Other curves are not permitted.',
undefined,
'INVALID_CSR',
);
}
} else if (keyAlgoName === 'Ed25519') {
// Ed25519 is explicitly allowed
} else {
throw new CaServiceError(
`CSR uses unsupported key algorithm: ${keyAlgoName}`,
'Use EC (P-256/P-384), Ed25519, or RSA (≥2048 bit) keys.',
undefined,
'INVALID_CSR',
);
}
// Extract SANs if expectedSans provided
if (expectedSans && expectedSans.length > 0) {
// Get SANs from CSR extensions
const sanExtension = csr.extensions?.find(
(ext) => ext.type === '2.5.29.17', // Subject Alternative Name OID
);
const csrSans: string[] = [];
if (sanExtension) {
// Parse the raw SAN extension — store as stringified for comparison
// @peculiar/x509 exposes SANs through the parsed extension
const sanExt = sanExtension as { names?: Array<{ type: string; value: string }> };
if (sanExt.names) {
for (const name of sanExt.names) {
csrSans.push(name.value);
}
}
}
const csrSanSet = new Set(csrSans);
const expectedSanSet = new Set(expectedSans);
const missing = expectedSans.filter((s) => !csrSanSet.has(s));
const extra = csrSans.filter((s) => !expectedSanSet.has(s));
if (missing.length > 0 || extra.length > 0) {
throw new CaServiceError(
`CSR SANs do not match expected set. Missing: [${missing.join(', ')}], Extra: [${extra.join(', ')}]`,
'The CSR must include exactly the SANs specified in the issuance request.',
undefined,
'INVALID_CSR',
);
}
}
// Return the CN from the CSR subject for use as JWT sub
const cn = csr.subjectName.getField('CN')?.[0] ?? '';
return cn;
}
/**
* Submit a CSR to step-ca and return the issued certificate.
*
* Throws `CaServiceError` on any failure (network, auth, malformed input).
* Never silently swallows errors — fail-loud is a hard contract per M2-02 review.
*/
async issueCert(req: IssueCertRequestDto): Promise<IssuedCertDto> {
// Clamp TTL to 15-minute maximum (H2)
const ttl = Math.min(req.ttlSeconds ?? 300, 900);
this.logger.debug(
`issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${ttl}s`,
);
// Validate CSR — real cryptographic validation (H3)
const csrCn = await this.validateCsr(req.csrPem);
const ott = await this.buildOtt({
csrPem: req.csrPem,
grantId: req.grantId,
subjectUserId: req.subjectUserId,
ttlSeconds: ttl,
csrCn,
});
const signUrl = `${this.caUrl}/1.0/sign`;
const requestBody = {
csr: req.csrPem,
ott,
validity: {
duration: `${ttl}s`,
},
};
this.logger.debug(`Posting CSR to ${signUrl}`);
const response = await httpsPost(signUrl, requestBody, this.httpsAgent);
if (!response.crt) {
throw new CaServiceError(
'step-ca sign response missing the "crt" field',
'This is unexpected — the step-ca instance may be misconfigured or running an incompatible version.',
);
}
// Build certChainPem: prefer certChain array, fall back to ca field, fall back to crt alone.
let certChainPem: string;
if (response.certChain && response.certChain.length > 0) {
certChainPem = response.certChain.join('\n');
} else if (response.ca) {
certChainPem = response.crt + '\n' + response.ca;
} else {
certChainPem = response.crt;
}
const serialNumber = extractSerial(response.crt);
// CRIT-1: Verify the issued certificate contains both Mosaic OID extensions
// with the correct values. Step-CA's federation.tpl encodes each as an ASN.1
// UTF8String TLV: tag 0x0C + 1-byte length + UUID bytes. We skip 2 bytes
// (tag + length) to extract the raw UUID string.
const issuedCert = new X509Certificate(response.crt);
const decoder = new TextDecoder();
const grantIdExt = issuedCert.getExtension('1.3.6.1.4.1.99999.1');
if (!grantIdExt) {
throw new CaServiceError(
'Issued certificate is missing required Mosaic OID: mosaic_grant_id',
'The Step-CA federation.tpl template did not embed OID 1.3.6.1.4.1.99999.1. Check the provisioner template configuration.',
undefined,
'OID_MISSING',
);
}
const grantIdInCert = decoder.decode(grantIdExt.value.slice(2));
if (grantIdInCert !== req.grantId) {
throw new CaServiceError(
`Issued certificate mosaic_grant_id mismatch: expected ${req.grantId}, got ${grantIdInCert}`,
'The Step-CA issued a certificate with a different grant ID than requested. This may indicate a provisioner misconfiguration or a MITM.',
undefined,
'OID_MISMATCH',
);
}
const subjectUserIdExt = issuedCert.getExtension('1.3.6.1.4.1.99999.2');
if (!subjectUserIdExt) {
throw new CaServiceError(
'Issued certificate is missing required Mosaic OID: mosaic_subject_user_id',
'The Step-CA federation.tpl template did not embed OID 1.3.6.1.4.1.99999.2. Check the provisioner template configuration.',
undefined,
'OID_MISSING',
);
}
const subjectUserIdInCert = decoder.decode(subjectUserIdExt.value.slice(2));
if (subjectUserIdInCert !== req.subjectUserId) {
throw new CaServiceError(
`Issued certificate mosaic_subject_user_id mismatch: expected ${req.subjectUserId}, got ${subjectUserIdInCert}`,
'The Step-CA issued a certificate with a different subject user ID than requested. This may indicate a provisioner misconfiguration or a MITM.',
undefined,
'OID_MISMATCH',
);
}
this.logger.log(`Certificate issued — serial=${serialNumber} grantId=${req.grantId}`);
const result = new IssuedCertDto();
result.certPem = response.crt;
result.certChainPem = certChainPem;
result.serialNumber = serialNumber;
return result;
}
}