feat(federation): Step-CA client service for grant certs (FED-M2-04) (#494)
This commit was merged in pull request #494.
This commit is contained in:
635
apps/gateway/src/federation/ca.service.ts
Normal file
635
apps/gateway/src/federation/ca.service.ts
Normal file
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* 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 } 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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user