/** * 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 { 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 { 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 { 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 { 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 { // 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; } }