feat(federation): Step-CA client service for grant certs (FED-M2-04)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

- Add CaService (@Injectable) that POSTs CSRs to step-ca /1.0/sign over
  HTTPS with a pinned CA root cert; builds HS256 OTT with custom claims
  mosaic_grant_id and mosaic_subject_user_id plus step.sha CSR fingerprint
- Add CaServiceError with cause + remediation for fail-loud contract
- Add IssueCertRequestDto and IssuedCertDto with class-validator decorators
- Add FederationModule exporting CaService; wire into AppModule
- Replace federation.tpl TODO placeholder with real step-ca Go template
  emitting OID 1.3.6.1.4.1.99999.1 (grantId) and .2 (subjectUserId) as
  DER UTF8String extensions (tag 0x0C, length 0x24, base64-encoded value)
- Update infra/step-ca/init.sh to patch mosaic-fed provisioner config with
  templateFile path via jq on first boot (idempotent)
- Append OID assignment registry and CA env var table to docs/federation/SETUP.md
- 11 unit tests pass: happy path, certChain fallbacks, HTTP 401/4xx, malformed
  CSR (no HTTP call), non-JSON response, connection error, JWT claim assertions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jarvis
2026-04-21 21:54:17 -05:00
committed by Jarvis
parent f2cda52e1a
commit 79442a8e8e
9 changed files with 1019 additions and 41 deletions

View File

@@ -24,6 +24,7 @@ import { GCModule } from './gc/gc.module.js';
import { ReloadModule } from './reload/reload.module.js';
import { WorkspaceModule } from './workspace/workspace.module.js';
import { QueueModule } from './queue/queue.module.js';
import { FederationModule } from './federation/federation.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@Module({
@@ -52,6 +53,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
QueueModule,
ReloadModule,
WorkspaceModule,
FederationModule,
],
controllers: [HealthController],
providers: [

View File

@@ -0,0 +1,56 @@
/**
* DTOs for the Step-CA client service (FED-M2-04).
*
* IssueCertRequestDto — input to CaService.issueCert()
* IssuedCertDto — output from CaService.issueCert()
*/
import { IsInt, IsNotEmpty, IsString, IsUUID, Max, Min } from 'class-validator';
export class IssueCertRequestDto {
/**
* PEM-encoded PKCS#10 Certificate Signing Request.
* The CSR must already include the desired SANs.
*/
@IsString()
@IsNotEmpty()
csrPem!: string;
/**
* UUID of the federation_grants row this certificate is being issued for.
* Embedded as the `mosaic_grant_id` custom OID extension.
*/
@IsUUID()
grantId!: string;
/**
* UUID of the local user on whose behalf the cert is being issued.
* Embedded as the `mosaic_subject_user_id` custom OID extension.
*/
@IsUUID()
subjectUserId!: string;
/**
* Requested certificate validity in seconds.
* Capped at the step-ca provisioner policy ceiling.
* Defaults to 86 400 s (24 h) when omitted by callers.
*/
@IsInt()
@Min(60)
@Max(365 * 24 * 3600)
ttlSeconds!: number;
}
export class IssuedCertDto {
/** PEM-encoded leaf certificate returned by step-ca. */
certPem!: string;
/**
* PEM-encoded full certificate chain (leaf + intermediates + root).
* Falls back to `certPem` when step-ca returns no `certChain` field.
*/
certChainPem!: string;
/** Decimal serial number string of the issued certificate. */
serialNumber!: string;
}

View File

@@ -0,0 +1,360 @@
/**
* Unit tests for CaService — Step-CA client (FED-M2-04).
*
* Coverage:
* - Happy path: returns IssuedCertDto with certPem, certChainPem, serialNumber
* - certChainPem fallback: falls back to certPem when certChain absent
* - certChainPem from ca field: uses crt+ca when certChain absent but ca present
* - HTTP 401: throws CaServiceError with cause + remediation
* - HTTP non-401 error: throws CaServiceError
* - Malformed CSR: throws before HTTP call
* - Non-JSON response: throws CaServiceError
* - HTTPS connection error: throws CaServiceError
* - JWT custom claims: mosaic_grant_id and mosaic_subject_user_id present in OTT payload
* - CaServiceError: has cause + remediation properties
* - Missing crt in response: throws CaServiceError
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
// ---------------------------------------------------------------------------
// Mock node:https BEFORE importing CaService so the mock is in place when
// the module is loaded. Vitest/ESM require vi.mock at the top level.
// ---------------------------------------------------------------------------
vi.mock('node:https', () => {
const mockRequest = vi.fn();
const mockAgent = vi.fn().mockImplementation(() => ({}));
return {
default: { request: mockRequest, Agent: mockAgent },
request: mockRequest,
Agent: mockAgent,
};
});
vi.mock('node:fs', () => {
const mockReadFileSync = vi
.fn()
.mockReturnValue('-----BEGIN CERTIFICATE-----\nFAKEROOT\n-----END CERTIFICATE-----\n');
return {
default: { readFileSync: mockReadFileSync },
readFileSync: mockReadFileSync,
};
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Minimal self-signed certificate PEM produced by openssl for testing.
// Serial 01, RSA 512 bit (invalid for production, fine for unit tests).
const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA
-----END CERTIFICATE-----\n`;
const FAKE_CSR_PEM = `-----BEGIN CERTIFICATE REQUEST-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA
-----END CERTIFICATE REQUEST-----\n`;
const FAKE_CA_PEM = `-----BEGIN CERTIFICATE-----
CAROOT000000000000000000000000000000000000000000000000AAAA
-----END CERTIFICATE-----\n`;
const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5fg9-cc7e-7cc0ce491b22';
// ---------------------------------------------------------------------------
// Setup env before importing service
// ---------------------------------------------------------------------------
const JWK_KEY = JSON.stringify({
kty: 'oct',
kid: 'test-kid',
k: 'dGVzdC1zZWNyZXQ=', // base64url("test-secret")
});
process.env['STEP_CA_URL'] = 'https://step-ca:9000';
process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'test-password';
process.env['STEP_CA_PROVISIONER_KEY_JSON'] = JWK_KEY;
process.env['STEP_CA_ROOT_CERT_PATH'] = '/fake/root.pem';
// Import AFTER env is set and mocks are registered
import * as httpsModule from 'node:https';
import { CaService, CaServiceError } from './ca.service.js';
import type { IssueCertRequestDto } from './ca.dto.js';
// ---------------------------------------------------------------------------
// Helper to build a mock https.request that simulates step-ca
// ---------------------------------------------------------------------------
function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): void {
const mockReq = {
write: vi.fn(),
end: vi.fn(),
on: vi.fn(),
};
(httpsModule.request as unknown as Mock).mockImplementation(
(
_options: unknown,
callback: (res: {
statusCode: number;
on: (event: string, cb: (chunk?: Buffer) => void) => void;
}) => void,
) => {
const mockRes = {
statusCode,
on: (event: string, cb: (chunk?: Buffer) => void) => {
if (event === 'data') {
if (body !== undefined) {
cb(Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)));
}
}
if (event === 'end') {
cb();
}
},
};
if (errorMsg) {
// Simulate a connection error via the req.on('error') handler
mockReq.on.mockImplementation((event: string, cb: (err: Error) => void) => {
if (event === 'error') {
setImmediate(() => cb(new Error(errorMsg)));
}
});
} else {
// Normal flow: call the response callback
setImmediate(() => callback(mockRes));
}
return mockReq;
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('CaService', () => {
let service: CaService;
beforeEach(() => {
vi.clearAllMocks();
service = new CaService();
});
function makeReq(overrides: Partial<IssueCertRequestDto> = {}): IssueCertRequestDto {
return {
csrPem: FAKE_CSR_PEM,
grantId: GRANT_ID,
subjectUserId: SUBJECT_USER_ID,
ttlSeconds: 86400,
...overrides,
};
}
// -------------------------------------------------------------------------
// Happy path
// -------------------------------------------------------------------------
it('returns IssuedCertDto on success (certChain present)', async () => {
makeHttpsMock(200, {
crt: FAKE_CERT_PEM,
certChain: [FAKE_CERT_PEM, FAKE_CA_PEM],
});
const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM);
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
expect(result.certChainPem).toContain(FAKE_CA_PEM);
expect(typeof result.serialNumber).toBe('string');
});
// -------------------------------------------------------------------------
// certChainPem fallback — certChain absent, ca field present
// -------------------------------------------------------------------------
it('builds certChainPem from crt+ca when certChain is absent', async () => {
makeHttpsMock(200, {
crt: FAKE_CERT_PEM,
ca: FAKE_CA_PEM,
});
const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM);
expect(result.certChainPem).toContain(FAKE_CERT_PEM);
expect(result.certChainPem).toContain(FAKE_CA_PEM);
});
// -------------------------------------------------------------------------
// certChainPem fallback — no certChain, no ca field
// -------------------------------------------------------------------------
it('falls back to certPem alone when certChain and ca are absent', async () => {
makeHttpsMock(200, { crt: FAKE_CERT_PEM });
const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM);
expect(result.certChainPem).toBe(FAKE_CERT_PEM);
});
// -------------------------------------------------------------------------
// HTTP 401
// -------------------------------------------------------------------------
it('throws CaServiceError on HTTP 401', async () => {
makeHttpsMock(401, { message: 'Unauthorized' });
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
if (!(err instanceof CaServiceError)) return false;
expect(err.message).toMatch(/401/);
expect(err.remediation).toBeTruthy();
return true;
});
});
// -------------------------------------------------------------------------
// HTTP non-401 error (e.g. 422)
// -------------------------------------------------------------------------
it('throws CaServiceError on HTTP 422', async () => {
makeHttpsMock(422, { message: 'Unprocessable Entity' });
await expect(service.issueCert(makeReq())).rejects.toBeInstanceOf(CaServiceError);
});
// -------------------------------------------------------------------------
// Malformed CSR — throws before HTTP call
// -------------------------------------------------------------------------
it('throws CaServiceError for malformed CSR without making HTTP call', async () => {
const requestSpy = vi.spyOn(httpsModule, 'request');
await expect(service.issueCert(makeReq({ csrPem: 'not-a-valid-csr' }))).rejects.toBeInstanceOf(
CaServiceError,
);
expect(requestSpy).not.toHaveBeenCalled();
});
// -------------------------------------------------------------------------
// Non-JSON response
// -------------------------------------------------------------------------
it('throws CaServiceError when step-ca returns non-JSON', async () => {
makeHttpsMock(200, 'this is not json');
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
if (!(err instanceof CaServiceError)) return false;
expect(err.message).toMatch(/non-JSON/);
return true;
});
});
// -------------------------------------------------------------------------
// HTTPS connection error
// -------------------------------------------------------------------------
it('throws CaServiceError on HTTPS connection error', async () => {
makeHttpsMock(0, undefined, 'connect ECONNREFUSED 127.0.0.1:9000');
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
if (!(err instanceof CaServiceError)) return false;
expect(err.message).toMatch(/HTTPS connection/);
expect(err.cause).toBeInstanceOf(Error);
return true;
});
});
// -------------------------------------------------------------------------
// JWT custom claims: mosaic_grant_id and mosaic_subject_user_id
// -------------------------------------------------------------------------
it('includes mosaic_grant_id and mosaic_subject_user_id in the OTT payload', async () => {
let capturedBody: Record<string, unknown> | undefined;
// Override the mock to capture the request body
const mockReq = {
write: vi.fn((data: string) => {
capturedBody = JSON.parse(data) as Record<string, unknown>;
}),
end: vi.fn(),
on: vi.fn(),
};
(httpsModule.request as unknown as Mock).mockImplementation(
(
_options: unknown,
callback: (res: {
statusCode: number;
on: (event: string, cb: (chunk?: Buffer) => void) => void;
}) => void,
) => {
const mockRes = {
statusCode: 200,
on: (event: string, cb: (chunk?: Buffer) => void) => {
if (event === 'data') {
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM })));
}
if (event === 'end') {
cb();
}
},
};
setImmediate(() => callback(mockRes));
return mockReq;
},
);
await service.issueCert(makeReq());
expect(capturedBody).toBeDefined();
const ott = capturedBody!['ott'] as string;
expect(typeof ott).toBe('string');
// Decode JWT payload (second segment)
const parts = ott.split('.');
expect(parts).toHaveLength(3);
const payloadJson = Buffer.from(parts[1]!, 'base64url').toString('utf8');
const payload = JSON.parse(payloadJson) as Record<string, unknown>;
expect(payload['mosaic_grant_id']).toBe(GRANT_ID);
expect(payload['mosaic_subject_user_id']).toBe(SUBJECT_USER_ID);
});
// -------------------------------------------------------------------------
// CaServiceError has cause + remediation
// -------------------------------------------------------------------------
it('CaServiceError carries cause and remediation', () => {
const cause = new Error('original error');
const err = new CaServiceError('something went wrong', 'fix it like this', cause);
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(CaServiceError);
expect(err.message).toBe('something went wrong');
expect(err.remediation).toBe('fix it like this');
expect(err.cause).toBe(cause);
expect(err.name).toBe('CaServiceError');
});
// -------------------------------------------------------------------------
// Missing crt in response
// -------------------------------------------------------------------------
it('throws CaServiceError when response is missing the crt field', async () => {
makeHttpsMock(200, { ca: FAKE_CA_PEM });
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
if (!(err instanceof CaServiceError)) return false;
expect(err.message).toMatch(/missing the "crt" field/);
return true;
});
});
});

View File

@@ -0,0 +1,439 @@
/**
* CaService — Step-CA client for federation grant certificate issuance.
*
* Responsibilities:
* 1. Build a JWK-provisioner One-Time Token (OTT) signed with HS256
* 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_PASSWORD JWK provisioner password (raw string)
* STEP_CA_PROVISIONER_KEY_JSON JWK provisioner public+private key (JSON)
* STEP_CA_ROOT_CERT_PATH Absolute path to the CA root PEM
*
* 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 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;
constructor(message: string, remediation: string, cause?: unknown) {
super(message);
this.name = 'CaServiceError';
this.cause = cause;
this.remediation = remediation;
}
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
/**
* Base64url-encode a Buffer or string (no padding).
*/
function b64url(input: Buffer | string): string {
const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input;
return buf.toString('base64url');
}
/**
* 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');
}
/**
* Derive a signing key from the JWK provisioner password using PBKDF2
* then sign with HMAC-SHA256 to produce an HS256 JWT.
*
* step-ca JWK provisioner tokens:
* - alg: HS256
* - header.kid: provisioner key ID
* - The key is the raw password bytes (step-ca uses the password directly
* as the HMAC key when the JWK provisioner type is "JWK" with symmetric
* key, or the password-derived key when encrypting the JWK).
*
* Per step-ca source (jose/jwk.go), for a JWK provisioner the OTT is a
* JWT signed with the provisioner's decrypted private key. For HS256 the
* key material is the `k` field of the JWK (symmetric secret), which itself
* was encrypted with the provisioner password. Since we already have the
* raw provisioner password we use it directly as the HMAC key — this mirrors
* what `step ca token` does for symmetric JWK provisioners.
*/
function buildOtt(params: {
caUrl: string;
provisionerPassword: string;
provisionerKeyJson: string;
csrPem: string;
grantId: string;
subjectUserId: string;
ttlSeconds: number;
}): string {
const {
caUrl,
provisionerPassword,
provisionerKeyJson,
csrPem,
grantId,
subjectUserId,
ttlSeconds,
} = params;
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,
);
}
const sha = csrFingerprint(csrPem);
const now = Math.floor(Date.now() / 1000);
const kid = jwk.kid ?? 'mosaic-fed';
const header = {
alg: 'HS256',
typ: 'JWT',
kid,
};
const payload = {
iss: kid,
sub: `${caUrl}/1.0/sign`,
aud: [`${caUrl}/1.0/sign`],
iat: now,
nbf: now - 30, // 30 s clock-skew tolerance
exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h
sha,
// Mosaic custom claims consumed by federation.tpl
mosaic_grant_id: grantId,
mosaic_subject_user_id: subjectUserId,
// step.sha is the canonical field name used in the template
step: { sha },
};
const headerB64 = b64url(JSON.stringify(header));
const payloadB64 = b64url(JSON.stringify(payload));
const signingInput = `${headerB64}.${payloadB64}`;
// Use the provisioner password as the raw HMAC-SHA256 key.
const hmac = crypto.createHmac('sha256', Buffer.from(provisionerPassword, 'utf8'));
hmac.update(signingInput);
const signature = hmac.digest();
return `${signingInput}.${b64url(signature)}`;
}
/**
* 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,
};
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_PASSWORD and 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.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.
* Returns the hex serial if conversion is not possible.
*/
function extractSerial(certPem: string): string {
try {
const cert = new crypto.X509Certificate(certPem);
return cert.serialNumber;
} catch {
return 'unknown';
}
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
@Injectable()
export class CaService {
private readonly logger = new Logger(CaService.name);
private readonly caUrl: string;
private readonly provisionerPassword: string;
private readonly provisionerKeyJson: string;
private readonly rootCertPath: string;
private readonly httpsAgent: https.Agent;
constructor() {
const caUrl = process.env['STEP_CA_URL'];
const provisionerPassword = process.env['STEP_CA_PROVISIONER_PASSWORD'];
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',
);
}
if (!provisionerPassword) {
throw new CaServiceError(
'STEP_CA_PROVISIONER_PASSWORD is not set',
'Set STEP_CA_PROVISIONER_PASSWORD to the JWK provisioner password for the mosaic-fed provisioner.',
);
}
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.',
);
}
this.caUrl = caUrl;
this.provisionerPassword = provisionerPassword;
this.provisionerKeyJson = provisionerKeyJson;
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}`);
}
/**
* 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> {
this.logger.debug(
`issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${req.ttlSeconds}s`,
);
// Validate CSR before making network calls
if (!req.csrPem || !req.csrPem.includes('CERTIFICATE REQUEST')) {
throw new CaServiceError(
'csrPem does not appear to be a valid PKCS#10 PEM',
'Provide a PEM-encoded CSR starting with -----BEGIN CERTIFICATE REQUEST-----.',
);
}
const ott = buildOtt({
caUrl: this.caUrl,
provisionerPassword: this.provisionerPassword,
provisionerKeyJson: this.provisionerKeyJson,
csrPem: req.csrPem,
grantId: req.grantId,
subjectUserId: req.subjectUserId,
ttlSeconds: req.ttlSeconds,
});
const signUrl = `${this.caUrl}/1.0/sign`;
const requestBody = {
csr: req.csrPem,
ott,
validity: {
duration: `${req.ttlSeconds}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;
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { CaService } from './ca.service.js';
@Module({
providers: [CaService],
exports: [CaService],
})
export class FederationModule {}