diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index 3c7a833..dc9a6e3 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -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: [ diff --git a/apps/gateway/src/federation/ca.dto.ts b/apps/gateway/src/federation/ca.dto.ts new file mode 100644 index 0000000..c4593d2 --- /dev/null +++ b/apps/gateway/src/federation/ca.dto.ts @@ -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; +} diff --git a/apps/gateway/src/federation/ca.service.spec.ts b/apps/gateway/src/federation/ca.service.spec.ts new file mode 100644 index 0000000..6755d83 --- /dev/null +++ b/apps/gateway/src/federation/ca.service.spec.ts @@ -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 { + 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 | undefined; + + // Override the mock to capture the request body + const mockReq = { + write: vi.fn((data: string) => { + capturedBody = JSON.parse(data) as Record; + }), + 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; + + 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; + }); + }); +}); diff --git a/apps/gateway/src/federation/ca.service.ts b/apps/gateway/src/federation/ca.service.ts new file mode 100644 index 0000000..86f26ba --- /dev/null +++ b/apps/gateway/src/federation/ca.service.ts @@ -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 { + 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 { + 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; + } +} diff --git a/apps/gateway/src/federation/federation.module.ts b/apps/gateway/src/federation/federation.module.ts new file mode 100644 index 0000000..b512633 --- /dev/null +++ b/apps/gateway/src/federation/federation.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CaService } from './ca.service.js'; + +@Module({ + providers: [CaService], + exports: [CaService], +}) +export class FederationModule {} diff --git a/docs/federation/SETUP.md b/docs/federation/SETUP.md index 38ec2e7..12ab930 100644 --- a/docs/federation/SETUP.md +++ b/docs/federation/SETUP.md @@ -123,3 +123,68 @@ If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop Federation peer private keys (`federation_peers.client_key_pem`) are sealed at rest using AES-256-GCM with a key derived from `BETTER_AUTH_SECRET` via SHA-256. If `BETTER_AUTH_SECRET` is rotated, all sealed `client_key_pem` values in the database become unreadable and must be re-sealed with the new key before rotation completes. The full key rotation procedure (decrypt all rows with old key, re-encrypt with new key, atomically swap the secret) is out of scope for M2. Operators must not rotate `BETTER_AUTH_SECRET` without a migration plan for all sealed federation peer keys. + +## OID Assignments — Mosaic Internal OID Arc + +Mosaic uses the private enterprise arc `1.3.6.1.4.1.99999` for custom X.509 +certificate extensions in federation grant certificates. + +**IMPORTANT:** This is a development/internal OID arc. Before deploying to a +production environment accessible by external parties, register a proper IANA +Private Enterprise Number (PEN) at +and update these assignments accordingly. + +### Assigned OIDs + +| OID | Symbolic name | Description | +| --------------------- | --------------------------------- | --------------------------------------------------------- | +| `1.3.6.1.4.1.99999.1` | `mosaic.federation.grantId` | UUID of the `federation_grants` row authorising this cert | +| `1.3.6.1.4.1.99999.2` | `mosaic.federation.subjectUserId` | UUID of the local user on whose behalf the cert is issued | + +### Encoding + +Each extension value is DER-encoded as an ASN.1 **UTF8String**: + +``` +Tag 0x0C (UTF8String) +Length 0x24 (36 decimal — fixed length of a UUID string) +Value <36 ASCII bytes of the UUID> +``` + +The step-ca X.509 template at `infra/step-ca/templates/federation.tpl` +produces this encoding via the Go template expression: + +``` +{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }} +``` + +The resulting base64 value is passed as the `value` field of the extension +object in the template JSON. + +### CA Environment Variables + +The `CaService` (`apps/gateway/src/federation/ca.service.ts`) requires the +following environment variables at gateway startup: + +| Variable | Required | Description | +| ------------------------------ | -------- | -------------------------------------------------------------------- | +| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` | +| `STEP_CA_PROVISIONER_PASSWORD` | Yes | JWK provisioner password for the `mosaic-fed` provisioner | +| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK (public + private) for the `mosaic-fed` provisioner | +| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the step-ca root CA certificate PEM file | + +Set these variables in your environment or secret manager before starting +the gateway. In the federated Docker Compose stack they are expected to be +injected via Docker secrets and environment variable overrides. + +### Fail-loud contract + +The CA service (and the X.509 template) are designed to fail loudly if the +custom OIDs cannot be embedded: + +- The template produces a malformed extension value (zero-length UTF8String + body) when the JWT claims `mosaic_grant_id` or `mosaic_subject_user_id` are + absent. step-ca rejects the CSR rather than issuing a cert without the OIDs. +- `CaService.issueCert()` throws a `CaServiceError` on every error path with + a human-readable `remediation` string. It never silently returns a cert that + may be missing the required extensions. diff --git a/infra/step-ca/init.sh b/infra/step-ca/init.sh index c363ffc..f43d6a3 100755 --- a/infra/step-ca/init.sh +++ b/infra/step-ca/init.sh @@ -6,6 +6,10 @@ # On the first run (no /home/step/config/ca.json present) this script: # 1. Initialises Step-CA with a JWK provisioner named "mosaic-fed". # 2. Writes the CA configuration to the persistent volume at /home/step. +# 3. Copies the federation X.509 template into the CA config directory. +# 4. Patches the mosaic-fed provisioner entry in ca.json to reference the +# template via options.x509.templateFile (using jq — must be installed +# in the container image). # # On subsequent runs (config already exists) this script skips init and # starts the CA directly. @@ -18,15 +22,16 @@ # Prod: mounted from a Docker secret at /run/secrets/ca_password. # # OID template: -# infra/step-ca/templates/federation.tpl is copied into the CA config -# directory so the JWK provisioner can reference it. The template -# skeleton is wired in M2-04 when the CA service lands the SAN-bearing -# CSR work. +# infra/step-ca/templates/federation.tpl emits custom OID extensions: +# 1.3.6.1.4.1.99999.1 — mosaic_grant_id +# 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id set -e CA_CONFIG="/home/step/config/ca.json" PASSWORD_FILE="/run/secrets/ca_password" +TEMPLATE_SRC="/etc/step-ca-templates/federation.tpl" +TEMPLATE_DEST="/home/step/templates/federation.tpl" if [ ! -f "${CA_CONFIG}" ]; then echo "[step-ca init] First boot detected — initialising Mosaic Federation CA..." @@ -43,12 +48,37 @@ if [ ! -f "${CA_CONFIG}" ]; then echo "[step-ca init] CA initialised." - # Copy the X.509 template into the Step-CA config directory so the - # provisioner can reference it in M2-04. - if [ -f "/etc/step-ca-templates/federation.tpl" ]; then + # Copy the X.509 template into the Step-CA config directory. + if [ -f "${TEMPLATE_SRC}" ]; then mkdir -p /home/step/templates - cp /etc/step-ca-templates/federation.tpl /home/step/templates/federation.tpl - echo "[step-ca init] Federation X.509 template copied to /home/step/templates/." + cp "${TEMPLATE_SRC}" "${TEMPLATE_DEST}" + echo "[step-ca init] Federation X.509 template copied to ${TEMPLATE_DEST}." + else + echo "[step-ca init] WARNING: Template source ${TEMPLATE_SRC} not found — skipping copy." + fi + + # Wire the template into the mosaic-fed provisioner via jq. + # This is idempotent: the block only runs once (first boot). + # + # jq filter: find the provisioner entry with name "mosaic-fed" and set + # .options.x509.templateFile to the absolute path of the template. + # All other provisioners and config keys are left unchanged. + if [ -f "${TEMPLATE_DEST}" ] && command -v jq > /dev/null 2>&1; then + echo "[step-ca init] Patching mosaic-fed provisioner with X.509 template..." + TEMP_CONFIG="${CA_CONFIG}.tmp" + jq --arg tpl "${TEMPLATE_DEST}" ' + .authority.provisioners |= map( + if .name == "mosaic-fed" then + .options.x509.templateFile = $tpl + else + . + end + ) + ' "${CA_CONFIG}" > "${TEMP_CONFIG}" && mv "${TEMP_CONFIG}" "${CA_CONFIG}" + echo "[step-ca init] Provisioner patched." + elif ! command -v jq > /dev/null 2>&1; then + echo "[step-ca init] WARNING: jq not found — skipping provisioner template patch." + echo "[step-ca init] Install jq in the step-ca image to enable automatic template wiring." fi echo "[step-ca init] Startup complete." diff --git a/infra/step-ca/templates/federation.tpl b/infra/step-ca/templates/federation.tpl index 0e2f132..2e42f32 100644 --- a/infra/step-ca/templates/federation.tpl +++ b/infra/step-ca/templates/federation.tpl @@ -5,41 +5,49 @@ {{- /* Mosaic Federation X.509 Certificate Template ============================================ - This template is used by the "mosaic-fed" JWK provisioner to sign - federation client certificates. + Provisioner: mosaic-fed (JWK) + Implemented: FED-M2-04 - Custom OID extensions (per PRD §6): - 1.3.6.1.4.1.99999.1 — mosaic.federation.grantId (UUID string) - 1.3.6.1.4.1.99999.2 — mosaic.federation.subjectUserId (UUID string) + This template emits two custom OID extensions carrying Mosaic federation + identifiers. The OTT token (built by CaService.buildOtt) MUST include the + claims `mosaic_grant_id` and `mosaic_subject_user_id` as top-level JWT + claims. step-ca exposes them under `.Token.` in this template. - TODO (M2-04): Wire actual OID extensions below once the CA service - (apps/gateway/src/federation/ca.service.ts) lands the SAN-bearing CSR - work and the template can be exercised end-to-end. + OID Registry (Mosaic Internal Arc — 1.3.6.1.4.1.99999): + 1.3.6.1.4.1.99999.1 mosaic_grant_id (UUID, 36 ASCII chars) + 1.3.6.1.4.1.99999.2 mosaic_subject_user_id (UUID, 36 ASCII chars) + + DER encoding for each extension value (ASN.1 UTF8String): + Tag = 0x0C (UTF8String) + Length = 0x24 (decimal 36 — the fixed length of a UUID string) + Value = 36 ASCII bytes of the UUID + + The `printf` below builds the raw TLV bytes then base64-encodes them. + step-ca expects the `value` field to be base64-encoded raw DER bytes. + + Fail-loud contract: + If either claim is missing from the token the printf will produce a + zero-length UUID field, making the extension malformed. step-ca will + reject the certificate rather than issuing one without the required OIDs. + Silent OID stripping is NEVER tolerated. Step-CA template reference: https://smallstep.com/docs/step-ca/templates - - Expected final shape of the extensions block (placeholder — not yet - activated): - - "extensions": [ - { - "id": "1.3.6.1.4.1.99999.1", - "critical": false, - "value": {{ toJson (first .Token.mosaic_grant_id) }} - }, - { - "id": "1.3.6.1.4.1.99999.2", - "critical": false, - "value": {{ toJson (first .Token.mosaic_subject_user_id) }} - } - ], - - The provisioner must pass these values in the ACME/JWK token payload - (token claims `mosaic_grant_id` and `mosaic_subject_user_id`) when - submitting the CSR. M2-04 owns that work. */ -}} + "extensions": [ + { + "id": "1.3.6.1.4.1.99999.1", + "critical": false, + "value": "{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}" + }, + { + "id": "1.3.6.1.4.1.99999.2", + "critical": false, + "value": "{{ printf "\x0c\x24%s" .Token.mosaic_subject_user_id | b64enc }}" + } + ], + "keyUsage": ["digitalSignature"], "extKeyUsage": ["clientAuth"], "basicConstraints": {