fix(federation): address #494 review findings (FED-M2-04)
H1: Replace HS256/HMAC signing with real JWK signing (ES256/RS256/ES384)
via jose SignJWT. Algorithm derived from JWK kty/crv. Provisioner
password dropped as signing input; kept only as optional env var for
PBES2-decrypt path at startup.
H2: Clamp cert TTL to 900s (15 min) in both DTO validator and issueCert().
Default changed to 300s (5 min). @Max reduced to 15*60.
H3: Real CSR validation via @peculiar/x509: parse PEM, verify self-
signature, reject weak keys (RSA<2048, bad EC curves), reject MD5/SHA-1.
New validateCsr() throws CaServiceError code INVALID_CSR on failure.
H4: Replace hardcoded \x24 DER length in federation.tpl with dynamic
printf "%c" (len ...) encoding. Add UUID-shape validation for grantId
and subjectUserId in buildOtt() with code INVALID_GRANT_ID.
H5: Load JWK into KeyObject once (lazy, cached). provisionerKeyJson raw
string not stored as class field. provisionerPassword not stored.
M1: Set JWT sub to CSR CN (extracted via @peculiar/x509) instead of URL.
M2: Add jti: crypto.randomUUID() to OTT claims.
M3: Drop top-level sha claim; keep only step.sha.
M4: extractSerial() throws CaServiceError code CERT_PARSE instead of
returning 'unknown' on failure.
M5: Set timeout: 5000 on https.RequestOptions + req.setTimeout(5000).
M6: OTT signature verified with jose.jwtVerify in tests. Added real P-256
CSR test via @peculiar/x509 generator. Added provisionerPassword
leak-check test.
M7: Constructor validates STEP_CA_URL must be https://.
Verification: typecheck ✓, 385 tests pass (16 new), lint ✓, format ✓.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,7 @@
|
||||
"@opentelemetry/sdk-metrics": "^2.6.0",
|
||||
"@opentelemetry/sdk-node": "^0.213.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@peculiar/x509": "^2.0.0",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"better-auth": "^1.5.5",
|
||||
"bullmq": "^5.71.0",
|
||||
@@ -64,6 +65,7 @@
|
||||
"dotenv": "^17.3.1",
|
||||
"fastify": "^5.0.0",
|
||||
"ioredis": "^5.10.0",
|
||||
"jose": "^6.2.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"openai": "^6.32.0",
|
||||
"postgres": "^3.4.8",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* IssuedCertDto — output from CaService.issueCert()
|
||||
*/
|
||||
|
||||
import { IsInt, IsNotEmpty, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
export class IssueCertRequestDto {
|
||||
/**
|
||||
@@ -32,13 +32,14 @@ export class IssueCertRequestDto {
|
||||
|
||||
/**
|
||||
* Requested certificate validity in seconds.
|
||||
* Capped at the step-ca provisioner policy ceiling.
|
||||
* Defaults to 86 400 s (24 h) when omitted by callers.
|
||||
* Hard cap: 900 s (15 minutes). Default: 300 s (5 minutes).
|
||||
* The service will always clamp to 900 s regardless of this value.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(60)
|
||||
@Max(365 * 24 * 3600)
|
||||
ttlSeconds!: number;
|
||||
@Max(15 * 60)
|
||||
ttlSeconds: number = 300;
|
||||
}
|
||||
|
||||
export class IssuedCertDto {
|
||||
|
||||
@@ -7,15 +7,22 @@
|
||||
* - 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
|
||||
* - Malformed CSR: throws before HTTP call (INVALID_CSR)
|
||||
* - 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
|
||||
* verified with jose.jwtVerify (real signature check)
|
||||
* - CaServiceError: has cause + remediation properties
|
||||
* - Missing crt in response: throws CaServiceError
|
||||
* - Real CSR validation: valid P-256 CSR passes; malformed CSR fails with INVALID_CSR
|
||||
* - provisionerPassword never appears in CaServiceError messages
|
||||
* - HTTPS-only enforcement: http:// URL throws in constructor
|
||||
*/
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { jwtVerify, exportJWK, generateKeyPair } from 'jose';
|
||||
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock node:https BEFORE importing CaService so the mock is in place when
|
||||
@@ -46,36 +53,82 @@ vi.mock('node:fs', () => {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Minimal self-signed certificate PEM produced by openssl for testing.
|
||||
// Serial 01, RSA 512 bit (invalid for production, fine for unit tests).
|
||||
// Real self-signed EC P-256 certificate generated with openssl for testing.
|
||||
// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout /dev/null \
|
||||
// -out /dev/stdout -subj "/CN=test" -days 1
|
||||
const FAKE_CERT_PEM = `-----BEGIN CERTIFICATE-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA
|
||||
-----END CERTIFICATE-----\n`;
|
||||
MIIBdDCCARmgAwIBAgIUM+iUJSayN+PwXkyVN6qwSY7sr6gwCgYIKoZIzj0EAwIw
|
||||
DzENMAsGA1UEAwwEdGVzdDAeFw0yNjA0MjIwMzE5MTlaFw0yNjA0MjMwMzE5MTla
|
||||
MA8xDTALBgNVBAMMBHRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR21kHL
|
||||
n1GmFQ4TEBw3EA53pD+2McIBf5WcoHE+x0eMz5DpRKJe0ksHwOVN5Yev5d57kb+4
|
||||
MvG1LhbHCB/uQo8So1MwUTAdBgNVHQ4EFgQUPq0pdIGiQ7pLBRXICS8GTliCrLsw
|
||||
HwYDVR0jBBgwFoAUPq0pdIGiQ7pLBRXICS8GTliCrLswDwYDVR0TAQH/BAUwAwEB
|
||||
/zAKBggqhkjOPQQDAgNJADBGAiEAypJqyC6S77aQ3eEXokM6sgAsD7Oa3tJbCbVm
|
||||
zG3uJb0CIQC1w+GE+Ad0OTR5Quja46R1RjOo8ydpzZ7Fh4rouAiwEw==
|
||||
-----END CERTIFICATE-----
|
||||
`;
|
||||
|
||||
const FAKE_CSR_PEM = `-----BEGIN CERTIFICATE REQUEST-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA
|
||||
-----END CERTIFICATE REQUEST-----\n`;
|
||||
|
||||
const FAKE_CA_PEM = `-----BEGIN CERTIFICATE-----
|
||||
CAROOT000000000000000000000000000000000000000000000000AAAA
|
||||
-----END CERTIFICATE-----\n`;
|
||||
// Use a second copy of the same cert for the CA field in tests.
|
||||
const FAKE_CA_PEM = FAKE_CERT_PEM;
|
||||
|
||||
const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
|
||||
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5fg9-cc7e-7cc0ce491b22';
|
||||
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5f09-cc7e-7cc0ce491b22';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate a real EC P-256 key pair and CSR for integration-style tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// We generate this once at module level so it's available to all tests.
|
||||
// The key pair and CSR PEM are populated asynchronously in the test that needs them.
|
||||
|
||||
let realCsrPem: string;
|
||||
|
||||
async function generateRealCsr(): Promise<string> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('ES256');
|
||||
// Export public key JWK for potential verification (not used here but confirms key is exportable)
|
||||
await exportJWK(publicKey);
|
||||
|
||||
// Use @peculiar/x509 to build a proper CSR
|
||||
const csr = await Pkcs10CertificateRequestGenerator.create({
|
||||
name: 'CN=test.federation.local',
|
||||
signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' },
|
||||
keys: { privateKey, publicKey },
|
||||
});
|
||||
|
||||
return csr.toString('pem');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup env before importing service
|
||||
// We use an EC P-256 key pair here so the JWK-based signing works.
|
||||
// The key pair is generated once and stored in module-level vars.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const JWK_KEY = JSON.stringify({
|
||||
kty: 'oct',
|
||||
kid: 'test-kid',
|
||||
k: 'dGVzdC1zZWNyZXQ=', // base64url("test-secret")
|
||||
});
|
||||
// Real EC P-256 test JWK (test-only, never used in production).
|
||||
// Generated with node webcrypto for use in unit tests.
|
||||
const TEST_EC_PRIVATE_JWK = {
|
||||
key_ops: ['sign'],
|
||||
ext: true,
|
||||
kty: 'EC',
|
||||
x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
|
||||
y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
|
||||
crv: 'P-256',
|
||||
d: 'TM6N79w1HE-PiML5Td4mbXfJaLHEaZrVyVrrwlJv7q8',
|
||||
kid: 'test-ec-kid',
|
||||
};
|
||||
|
||||
const TEST_EC_PUBLIC_JWK = {
|
||||
key_ops: ['verify'],
|
||||
ext: true,
|
||||
kty: 'EC',
|
||||
x: 'Xq2RjZctcPcUMU14qfjs3MtZTmFk8z1lFGQyypgXZOU',
|
||||
y: 't8w9Cbt4RVmR47Wnb_i5cLwefEnMcvwse049zu9Rl_E',
|
||||
crv: 'P-256',
|
||||
kid: 'test-ec-kid',
|
||||
};
|
||||
|
||||
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_PROVISIONER_KEY_JSON'] = JSON.stringify(TEST_EC_PRIVATE_JWK);
|
||||
process.env['STEP_CA_ROOT_CERT_PATH'] = '/fake/root.pem';
|
||||
|
||||
// Import AFTER env is set and mocks are registered
|
||||
@@ -92,6 +145,7 @@ function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): vo
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
on: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
};
|
||||
|
||||
(httpsModule.request as unknown as Mock).mockImplementation(
|
||||
@@ -146,20 +200,56 @@ describe('CaService', () => {
|
||||
});
|
||||
|
||||
function makeReq(overrides: Partial<IssueCertRequestDto> = {}): IssueCertRequestDto {
|
||||
// Use a real CSR if available; fall back to a minimal placeholder
|
||||
const defaultCsr = realCsrPem ?? makeFakeCsr();
|
||||
return {
|
||||
csrPem: FAKE_CSR_PEM,
|
||||
csrPem: defaultCsr,
|
||||
grantId: GRANT_ID,
|
||||
subjectUserId: SUBJECT_USER_ID,
|
||||
ttlSeconds: 86400,
|
||||
ttlSeconds: 300,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeCsr(): string {
|
||||
// A structurally valid-looking CSR header/footer (body will fail crypto verify)
|
||||
return `-----BEGIN CERTIFICATE REQUEST-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0000000000000000AAAA\n-----END CERTIFICATE REQUEST-----\n`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Real CSR generation — runs once and populates realCsrPem
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('generates a real P-256 CSR that passes validateCsr', async () => {
|
||||
realCsrPem = await generateRealCsr();
|
||||
expect(realCsrPem).toMatch(/BEGIN CERTIFICATE REQUEST/);
|
||||
|
||||
// Now test that the service's validateCsr accepts it.
|
||||
// We call it indirectly via issueCert with a successful mock.
|
||||
makeHttpsMock(200, { crt: FAKE_CERT_PEM, certChain: [FAKE_CERT_PEM, FAKE_CA_PEM] });
|
||||
const result = await service.issueCert(makeReq({ csrPem: realCsrPem }));
|
||||
expect(result.certPem).toBe(FAKE_CERT_PEM);
|
||||
});
|
||||
|
||||
it('throws INVALID_CSR for a malformed PEM-shaped CSR', async () => {
|
||||
const malformedCsr =
|
||||
'-----BEGIN CERTIFICATE REQUEST-----\nTm90QVJlYWxDU1I=\n-----END CERTIFICATE REQUEST-----\n';
|
||||
|
||||
await expect(service.issueCert(makeReq({ csrPem: malformedCsr }))).rejects.toSatisfy(
|
||||
(err: unknown) => {
|
||||
if (!(err instanceof CaServiceError)) return false;
|
||||
expect(err.code).toBe('INVALID_CSR');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Happy path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns IssuedCertDto on success (certChain present)', async () => {
|
||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||
makeHttpsMock(200, {
|
||||
crt: FAKE_CERT_PEM,
|
||||
certChain: [FAKE_CERT_PEM, FAKE_CA_PEM],
|
||||
@@ -178,6 +268,7 @@ describe('CaService', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('builds certChainPem from crt+ca when certChain is absent', async () => {
|
||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||
makeHttpsMock(200, {
|
||||
crt: FAKE_CERT_PEM,
|
||||
ca: FAKE_CA_PEM,
|
||||
@@ -195,6 +286,7 @@ describe('CaService', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('falls back to certPem alone when certChain and ca are absent', async () => {
|
||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||
makeHttpsMock(200, { crt: FAKE_CERT_PEM });
|
||||
|
||||
const result = await service.issueCert(makeReq());
|
||||
@@ -208,6 +300,7 @@ describe('CaService', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws CaServiceError on HTTP 401', async () => {
|
||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||
makeHttpsMock(401, { message: 'Unauthorized' });
|
||||
|
||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||
@@ -223,6 +316,7 @@ describe('CaService', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws CaServiceError on HTTP 422', async () => {
|
||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||
makeHttpsMock(422, { message: 'Unprocessable Entity' });
|
||||
|
||||
await expect(service.issueCert(makeReq())).rejects.toBeInstanceOf(CaServiceError);
|
||||
@@ -247,6 +341,7 @@ describe('CaService', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws CaServiceError when step-ca returns non-JSON', async () => {
|
||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||
makeHttpsMock(200, 'this is not json');
|
||||
|
||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||
@@ -261,6 +356,7 @@ describe('CaService', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws CaServiceError on HTTPS connection error', async () => {
|
||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||
makeHttpsMock(0, undefined, 'connect ECONNREFUSED 127.0.0.1:9000');
|
||||
|
||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||
@@ -273,18 +369,21 @@ describe('CaService', () => {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JWT custom claims: mosaic_grant_id and mosaic_subject_user_id
|
||||
// Verified with jose.jwtVerify for real signature verification (M6)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('includes mosaic_grant_id and mosaic_subject_user_id in the OTT payload', async () => {
|
||||
it('OTT contains mosaic_grant_id, mosaic_subject_user_id, and jti; signature verifies with jose', async () => {
|
||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||
|
||||
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(),
|
||||
setTimeout: vi.fn(),
|
||||
};
|
||||
|
||||
(httpsModule.request as unknown as Mock).mockImplementation(
|
||||
@@ -311,21 +410,35 @@ describe('CaService', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await service.issueCert(makeReq());
|
||||
await service.issueCert(makeReq({ csrPem: realCsrPem }));
|
||||
|
||||
expect(capturedBody).toBeDefined();
|
||||
const ott = capturedBody!['ott'] as string;
|
||||
expect(typeof ott).toBe('string');
|
||||
|
||||
// Decode JWT payload (second segment)
|
||||
// Verify JWT structure
|
||||
const parts = ott.split('.');
|
||||
expect(parts).toHaveLength(3);
|
||||
|
||||
// Decode payload without signature check first
|
||||
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);
|
||||
expect(typeof payload['jti']).toBe('string'); // M2: jti present
|
||||
expect(payload['jti']).toMatch(/^[0-9a-f-]{36}$/); // UUID format
|
||||
|
||||
// M3: top-level sha should NOT be present; step.sha should be present
|
||||
expect(payload['sha']).toBeUndefined();
|
||||
const step = payload['step'] as Record<string, unknown> | undefined;
|
||||
expect(step?.['sha']).toBeDefined();
|
||||
|
||||
// M6: Verify signature with jose.jwtVerify using the public key
|
||||
const { importJWK: importJose } = await import('jose');
|
||||
const publicKey = await importJose(TEST_EC_PUBLIC_JWK, 'ES256');
|
||||
const verified = await jwtVerify(ott, publicKey);
|
||||
expect(verified.payload['mosaic_grant_id']).toBe(GRANT_ID);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -349,6 +462,7 @@ describe('CaService', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws CaServiceError when response is missing the crt field', async () => {
|
||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||
makeHttpsMock(200, { ca: FAKE_CA_PEM });
|
||||
|
||||
await expect(service.issueCert(makeReq())).rejects.toSatisfy((err: unknown) => {
|
||||
@@ -357,4 +471,107 @@ describe('CaService', () => {
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// M6: provisionerPassword must never appear in CaServiceError messages
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('provisionerPassword does not appear in any CaServiceError message', async () => {
|
||||
// Temporarily set a recognizable password to test against
|
||||
const originalPassword = process.env['STEP_CA_PROVISIONER_PASSWORD'];
|
||||
process.env['STEP_CA_PROVISIONER_PASSWORD'] = 'super-secret-password-12345';
|
||||
|
||||
// Generate a bad CSR to trigger an error path
|
||||
const caughtErrors: CaServiceError[] = [];
|
||||
try {
|
||||
await service.issueCert(makeReq({ csrPem: 'not-a-csr' }));
|
||||
} catch (err) {
|
||||
if (err instanceof CaServiceError) {
|
||||
caughtErrors.push(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Also try HTTP 401 path
|
||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||
makeHttpsMock(401, { message: 'Unauthorized' });
|
||||
try {
|
||||
await service.issueCert(makeReq({ csrPem: realCsrPem }));
|
||||
} catch (err) {
|
||||
if (err instanceof CaServiceError) {
|
||||
caughtErrors.push(err);
|
||||
}
|
||||
}
|
||||
|
||||
for (const err of caughtErrors) {
|
||||
expect(err.message).not.toContain('super-secret-password-12345');
|
||||
if (err.remediation) {
|
||||
expect(err.remediation).not.toContain('super-secret-password-12345');
|
||||
}
|
||||
}
|
||||
|
||||
process.env['STEP_CA_PROVISIONER_PASSWORD'] = originalPassword;
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// M7: HTTPS-only enforcement in constructor
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws in constructor if STEP_CA_URL uses http://', () => {
|
||||
const originalUrl = process.env['STEP_CA_URL'];
|
||||
process.env['STEP_CA_URL'] = 'http://step-ca:9000';
|
||||
|
||||
expect(() => new CaService()).toThrow(CaServiceError);
|
||||
|
||||
process.env['STEP_CA_URL'] = originalUrl;
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TTL clamp: ttlSeconds is clamped to 900 s (15 min) maximum
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('clamps ttlSeconds to 900 s regardless of input', async () => {
|
||||
if (!realCsrPem) realCsrPem = await generateRealCsr();
|
||||
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
|
||||
const mockReq = {
|
||||
write: vi.fn((data: string) => {
|
||||
capturedBody = JSON.parse(data) as Record<string, unknown>;
|
||||
}),
|
||||
end: vi.fn(),
|
||||
on: vi.fn(),
|
||||
setTimeout: 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;
|
||||
},
|
||||
);
|
||||
|
||||
// Request 86400 s — should be clamped to 900
|
||||
await service.issueCert(makeReq({ ttlSeconds: 86400 }));
|
||||
|
||||
expect(capturedBody).toBeDefined();
|
||||
const validity = capturedBody!['validity'] as Record<string, unknown>;
|
||||
expect(validity['duration']).toBe('900s');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* 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.
|
||||
* 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
|
||||
@@ -13,10 +13,12 @@
|
||||
*
|
||||
* 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_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
|
||||
@@ -32,6 +34,8 @@ 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';
|
||||
|
||||
@@ -42,12 +46,14 @@ import { IssuedCertDto } from './ca.dto.js';
|
||||
export class CaServiceError extends Error {
|
||||
readonly cause: unknown;
|
||||
readonly remediation: string;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(message: string, remediation: string, cause?: unknown) {
|
||||
constructor(message: string, remediation: string, cause?: unknown, code?: string) {
|
||||
super(message);
|
||||
this.name = 'CaServiceError';
|
||||
this.cause = cause;
|
||||
this.remediation = remediation;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,12 +86,24 @@ interface JwkKey {
|
||||
// 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;
|
||||
|
||||
/**
|
||||
* Base64url-encode a Buffer or string (no padding).
|
||||
* Derive the JWT algorithm string from a JWK's kty/crv fields.
|
||||
* EC P-256 → ES256, EC P-384 → ES384, RSA → RS256.
|
||||
*/
|
||||
function b64url(input: Buffer | string): string {
|
||||
const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input;
|
||||
return buf.toString('base64url');
|
||||
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.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,91 +138,6 @@ function csrFingerprint(csrPem: string): string {
|
||||
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.
|
||||
@@ -224,6 +157,7 @@ function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<Step
|
||||
'Content-Length': Buffer.byteLength(bodyStr),
|
||||
},
|
||||
agent,
|
||||
timeout: 5000,
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
@@ -236,7 +170,7 @@ function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<Step
|
||||
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.',
|
||||
'Check STEP_CA_PROVISIONER_KEY_JSON. Ensure the mosaic-fed provisioner is configured in the CA.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -270,6 +204,10 @@ function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<Step
|
||||
});
|
||||
});
|
||||
|
||||
req.setTimeout(5000, () => {
|
||||
req.destroy(new Error('Request timed out after 5000ms'));
|
||||
});
|
||||
|
||||
req.on('error', (err: Error) => {
|
||||
reject(
|
||||
new CaServiceError(
|
||||
@@ -287,15 +225,21 @@ function httpsPost(url: string, body: unknown, agent: https.Agent): Promise<Step
|
||||
|
||||
/**
|
||||
* Extract a decimal serial number from a PEM certificate.
|
||||
* Returns the hex serial if conversion is not possible.
|
||||
* Throws CaServiceError on failure — never silently returns 'unknown'.
|
||||
*/
|
||||
function extractSerial(certPem: string): string {
|
||||
let cert: crypto.X509Certificate;
|
||||
try {
|
||||
const cert = new crypto.X509Certificate(certPem);
|
||||
return cert.serialNumber;
|
||||
} catch {
|
||||
return 'unknown';
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -307,14 +251,15 @@ 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;
|
||||
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 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'];
|
||||
|
||||
@@ -324,12 +269,25 @@ export class CaService {
|
||||
'Set STEP_CA_URL to the base URL of the step-ca instance, e.g. https://step-ca:9000',
|
||||
);
|
||||
}
|
||||
if (!provisionerPassword) {
|
||||
|
||||
// Enforce HTTPS-only URL
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(caUrl);
|
||||
} catch (err) {
|
||||
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.',
|
||||
`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',
|
||||
@@ -343,9 +301,32 @@ export class CaService {
|
||||
);
|
||||
}
|
||||
|
||||
// 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.provisionerPassword = provisionerPassword;
|
||||
this.provisionerKeyJson = provisionerKeyJson;
|
||||
this.rootCertPath = rootCertPath;
|
||||
|
||||
// Read the root cert and pin it for all HTTPS connections.
|
||||
@@ -368,6 +349,225 @@ export class CaService {
|
||||
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.
|
||||
*
|
||||
@@ -375,26 +575,22 @@ export class CaService {
|
||||
* 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=${req.ttlSeconds}s`,
|
||||
`issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${ttl}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-----.',
|
||||
);
|
||||
}
|
||||
// Validate CSR — real cryptographic validation (H3)
|
||||
const csrCn = await this.validateCsr(req.csrPem);
|
||||
|
||||
const ott = buildOtt({
|
||||
caUrl: this.caUrl,
|
||||
provisionerPassword: this.provisionerPassword,
|
||||
provisionerKeyJson: this.provisionerKeyJson,
|
||||
const ott = await this.buildOtt({
|
||||
csrPem: req.csrPem,
|
||||
grantId: req.grantId,
|
||||
subjectUserId: req.subjectUserId,
|
||||
ttlSeconds: req.ttlSeconds,
|
||||
ttlSeconds: ttl,
|
||||
csrCn,
|
||||
});
|
||||
|
||||
const signUrl = `${this.caUrl}/1.0/sign`;
|
||||
@@ -402,7 +598,7 @@ export class CaService {
|
||||
csr: req.csrPem,
|
||||
ott,
|
||||
validity: {
|
||||
duration: `${req.ttlSeconds}s`,
|
||||
duration: `${ttl}s`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user