From 9f5c28c0ce176fbf811e8d5e03f8506fe2424635 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 21 Apr 2026 21:54:17 -0500 Subject: [PATCH 1/2] feat(federation): Step-CA client service for grant certs (FED-M2-04) - 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 --- apps/gateway/src/app.module.ts | 2 + apps/gateway/src/federation/ca.dto.ts | 56 +++ .../gateway/src/federation/ca.service.spec.ts | 360 ++++++++++++++ apps/gateway/src/federation/ca.service.ts | 439 ++++++++++++++++++ .../src/federation/federation.module.ts | 8 + docs/federation/SETUP.md | 65 +++ infra/step-ca/init.sh | 48 +- infra/step-ca/templates/federation.tpl | 64 +-- 8 files changed, 1005 insertions(+), 37 deletions(-) create mode 100644 apps/gateway/src/federation/ca.dto.ts create mode 100644 apps/gateway/src/federation/ca.service.spec.ts create mode 100644 apps/gateway/src/federation/ca.service.ts create mode 100644 apps/gateway/src/federation/federation.module.ts 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": { -- 2.49.1 From 7524d6e91998a8e8a79a3a997f39b64e1577f946 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 21 Apr 2026 22:24:42 -0500 Subject: [PATCH 2/2] fix(federation): address #494 review findings (FED-M2-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/gateway/package.json | 2 + apps/gateway/src/federation/ca.dto.ts | 11 +- .../gateway/src/federation/ca.service.spec.ts | 269 ++++++++++- apps/gateway/src/federation/ca.service.ts | 444 +++++++++++++----- infra/step-ca/templates/federation.tpl | 4 +- pnpm-lock.yaml | 267 ++++++++--- 6 files changed, 768 insertions(+), 229 deletions(-) diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 7a0fe78..79824f3 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -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", diff --git a/apps/gateway/src/federation/ca.dto.ts b/apps/gateway/src/federation/ca.dto.ts index c4593d2..005e3fd 100644 --- a/apps/gateway/src/federation/ca.dto.ts +++ b/apps/gateway/src/federation/ca.dto.ts @@ -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 { diff --git a/apps/gateway/src/federation/ca.service.spec.ts b/apps/gateway/src/federation/ca.service.spec.ts index 6755d83..11dc5f8 100644 --- a/apps/gateway/src/federation/ca.service.spec.ts +++ b/apps/gateway/src/federation/ca.service.spec.ts @@ -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 { + 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 { + // 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 | 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(), + 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; 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 | 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 | undefined; + + const mockReq = { + write: vi.fn((data: string) => { + capturedBody = JSON.parse(data) as Record; + }), + 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; + expect(validity['duration']).toBe('900s'); + }); }); diff --git a/apps/gateway/src/federation/ca.service.ts b/apps/gateway/src/federation/ca.service.ts index 86f26ba..9e0d296 100644 --- a/apps/gateway/src/federation/ca.service.ts +++ b/apps/gateway/src/federation/ca.service.ts @@ -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 { @@ -236,7 +170,7 @@ function httpsPost(url: string, body: unknown, agent: https.Agent): Promise { + 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 { + if (this.cachedPrivateKey !== null) return this.cachedPrivateKey; + try { + const key = await importJWK(this.jwk, this.jwtAlg); + // importJWK returns KeyLike (crypto.KeyObject | Uint8Array) — in Node.js it's KeyObject + this.cachedPrivateKey = key as unknown as crypto.KeyObject; + return this.cachedPrivateKey; + } catch (err) { + throw new CaServiceError( + 'Failed to import STEP_CA_PROVISIONER_KEY_JSON as a cryptographic key', + 'Ensure STEP_CA_PROVISIONER_KEY_JSON contains a valid JWK private key (EC P-256/P-384 or RSA).', + err, + ); + } + } + + /** + * Build the JWK-provisioner OTT signed with the provisioner private key. + * Algorithm is derived from the JWK kty/crv fields. + */ + private async buildOtt(params: { + csrPem: string; + grantId: string; + subjectUserId: string; + ttlSeconds: number; + csrCn: string; + }): Promise { + const { csrPem, grantId, subjectUserId, ttlSeconds, csrCn } = params; + + // Validate UUID shape for grant id and subject user id + if (!UUID_RE.test(grantId)) { + throw new CaServiceError( + `grantId is not a valid UUID: ${grantId}`, + 'Provide a valid UUID (RFC 4122) for grantId.', + undefined, + 'INVALID_GRANT_ID', + ); + } + if (!UUID_RE.test(subjectUserId)) { + throw new CaServiceError( + `subjectUserId is not a valid UUID: ${subjectUserId}`, + 'Provide a valid UUID (RFC 4122) for subjectUserId.', + undefined, + 'INVALID_GRANT_ID', + ); + } + + const sha = csrFingerprint(csrPem); + const now = Math.floor(Date.now() / 1000); + const privateKey = await this.getPrivateKey(); + + const ott = await new SignJWT({ + iss: this.kid, + sub: csrCn, // M1: set sub to identity from CSR CN + aud: [`${this.caUrl}/1.0/sign`], + iat: now, + nbf: now - 30, // 30 s clock-skew tolerance + exp: now + Math.min(ttlSeconds, 3600), // OTT validity ≤ 1 h + jti: crypto.randomUUID(), // M2: unique token ID + // step.sha is the canonical field name used in the template — M3: keep only step.sha + step: { sha }, + // Mosaic custom claims consumed by federation.tpl + mosaic_grant_id: grantId, + mosaic_subject_user_id: subjectUserId, + }) + .setProtectedHeader({ alg: this.jwtAlg, typ: 'JWT', kid: this.kid }) + .sign(privateKey); + + return ott; + } + + /** + * Validate a PEM-encoded CSR using @peculiar/x509. + * Verifies the self-signature, key type/size, and signature algorithm. + * Optionally verifies that the CSR's SANs match the expected set. + * + * Throws CaServiceError with code 'INVALID_CSR' on failure. + */ + private async validateCsr(pem: string, expectedSans?: string[]): Promise { + let csr: Pkcs10CertificateRequest; + try { + csr = new Pkcs10CertificateRequest(pem); + } catch (err) { + throw new CaServiceError( + 'Failed to parse CSR PEM as a valid PKCS#10 certificate request', + 'Provide a valid PEM-encoded PKCS#10 CSR.', + err, + 'INVALID_CSR', + ); + } + + // Verify self-signature + let valid: boolean; + try { + valid = await csr.verify(); + } catch (err) { + throw new CaServiceError( + 'CSR signature verification threw an error', + 'The CSR self-signature could not be verified. Ensure the CSR is properly formed.', + err, + 'INVALID_CSR', + ); + } + if (!valid) { + throw new CaServiceError( + 'CSR self-signature is invalid', + 'The CSR must be self-signed with the corresponding private key.', + undefined, + 'INVALID_CSR', + ); + } + + // Validate signature algorithm — reject MD5 and SHA-1 + // signatureAlgorithm is HashedAlgorithm which extends Algorithm. + // Cast through unknown to access .name and .hash.name without DOM lib globals. + const sigAlgAny = csr.signatureAlgorithm as unknown as { + name?: string; + hash?: { name?: string }; + }; + const sigAlgName = (sigAlgAny.name ?? '').toLowerCase(); + const hashName = (sigAlgAny.hash?.name ?? '').toLowerCase(); + if ( + sigAlgName.includes('md5') || + sigAlgName.includes('sha1') || + hashName === 'sha-1' || + hashName === 'sha1' + ) { + throw new CaServiceError( + `CSR uses a forbidden signature algorithm: ${sigAlgAny.name ?? 'unknown'}`, + 'Use SHA-256 or stronger. MD5 and SHA-1 are not permitted.', + undefined, + 'INVALID_CSR', + ); + } + + // Validate public key algorithm and strength via the algorithm descriptor on the key. + // csr.publicKey.algorithm is type Algorithm (WebCrypto) — use name-based checks. + // We cast to an extended interface to access curve/modulus info without DOM globals. + const pubKeyAlgo = csr.publicKey.algorithm as { + name: string; + namedCurve?: string; + modulusLength?: number; + }; + const keyAlgoName = pubKeyAlgo.name; + + if (keyAlgoName === 'RSASSA-PKCS1-v1_5' || keyAlgoName === 'RSA-PSS') { + const modulusLength = pubKeyAlgo.modulusLength ?? 0; + if (modulusLength < 2048) { + throw new CaServiceError( + `CSR RSA key is too short: ${modulusLength} bits (minimum 2048)`, + 'Use an RSA key of at least 2048 bits.', + undefined, + 'INVALID_CSR', + ); + } + } else if (keyAlgoName === 'ECDSA') { + const namedCurve = pubKeyAlgo.namedCurve ?? ''; + const allowedCurves = new Set(['P-256', 'P-384']); + if (!allowedCurves.has(namedCurve)) { + throw new CaServiceError( + `CSR EC key uses disallowed curve: ${namedCurve}`, + 'Use EC P-256 or P-384. Other curves are not permitted.', + undefined, + 'INVALID_CSR', + ); + } + } else if (keyAlgoName === 'Ed25519') { + // Ed25519 is explicitly allowed + } else { + throw new CaServiceError( + `CSR uses unsupported key algorithm: ${keyAlgoName}`, + 'Use EC (P-256/P-384), Ed25519, or RSA (≥2048 bit) keys.', + undefined, + 'INVALID_CSR', + ); + } + + // Extract SANs if expectedSans provided + if (expectedSans && expectedSans.length > 0) { + // Get SANs from CSR extensions + const sanExtension = csr.extensions?.find( + (ext) => ext.type === '2.5.29.17', // Subject Alternative Name OID + ); + const csrSans: string[] = []; + if (sanExtension) { + // Parse the raw SAN extension — store as stringified for comparison + // @peculiar/x509 exposes SANs through the parsed extension + const sanExt = sanExtension as { names?: Array<{ type: string; value: string }> }; + if (sanExt.names) { + for (const name of sanExt.names) { + csrSans.push(name.value); + } + } + } + + const csrSanSet = new Set(csrSans); + const expectedSanSet = new Set(expectedSans); + const missing = expectedSans.filter((s) => !csrSanSet.has(s)); + const extra = csrSans.filter((s) => !expectedSanSet.has(s)); + + if (missing.length > 0 || extra.length > 0) { + throw new CaServiceError( + `CSR SANs do not match expected set. Missing: [${missing.join(', ')}], Extra: [${extra.join(', ')}]`, + 'The CSR must include exactly the SANs specified in the issuance request.', + undefined, + 'INVALID_CSR', + ); + } + } + + // Return the CN from the CSR subject for use as JWT sub + const cn = csr.subjectName.getField('CN')?.[0] ?? ''; + return cn; + } + /** * Submit a CSR to step-ca and return the issued certificate. * @@ -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 { + // 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`, }, }; diff --git a/infra/step-ca/templates/federation.tpl b/infra/step-ca/templates/federation.tpl index 2e42f32..d571091 100644 --- a/infra/step-ca/templates/federation.tpl +++ b/infra/step-ca/templates/federation.tpl @@ -39,12 +39,12 @@ { "id": "1.3.6.1.4.1.99999.1", "critical": false, - "value": "{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}" + "value": "{{ printf "\x0c%c%s" (len .Token.mosaic_grant_id) .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 }}" + "value": "{{ printf "\x0c%c%s" (len .Token.mosaic_subject_user_id) .Token.mosaic_subject_user_id | b64enc }}" } ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c25f346..5a1e638 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: '@opentelemetry/semantic-conventions': specifier: ^1.40.0 version: 1.40.0 + '@peculiar/x509': + specifier: ^2.0.0 + version: 2.0.0 '@sinclair/typebox': specifier: ^0.34.48 version: 0.34.48 @@ -155,6 +158,9 @@ importers: ioredis: specifier: ^5.10.0 version: 5.10.0 + jose: + specifier: ^6.2.2 + version: 6.2.2 node-cron: specifier: ^4.2.1 version: 4.2.1 @@ -704,10 +710,10 @@ importers: dependencies: '@mariozechner/pi-agent-core': specifier: ^0.63.1 - version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@mariozechner/pi-ai': specifier: ^0.63.1 - version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) + version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@sinclair/typebox': specifier: ^0.34.41 version: 0.34.48 @@ -3060,6 +3066,40 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@peculiar/asn1-cms@2.6.1': + resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==} + + '@peculiar/asn1-csr@2.6.1': + resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==} + + '@peculiar/asn1-ecc@2.6.1': + resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==} + + '@peculiar/asn1-pfx@2.6.1': + resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==} + + '@peculiar/asn1-pkcs8@2.6.1': + resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==} + + '@peculiar/asn1-pkcs9@2.6.1': + resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==} + + '@peculiar/asn1-rsa@2.6.1': + resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/asn1-x509-attr@2.6.1': + resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==} + + '@peculiar/asn1-x509@2.6.1': + resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==} + + '@peculiar/x509@2.0.0': + resolution: {integrity: sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==} + engines: {node: '>=20.0.0'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -3955,6 +3995,10 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -5326,6 +5370,9 @@ packages: jose@6.2.1: resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -6264,6 +6311,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + qrcode-terminal@0.12.0: resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} hasBin: true @@ -6832,6 +6886,9 @@ packages: ts-mixer@6.0.4: resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -6844,6 +6901,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -7265,12 +7326,6 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@anthropic-ai/sdk@0.73.0(zod@3.25.76)': - dependencies: - json-schema-to-ts: 3.1.1 - optionalDependencies: - zod: 3.25.76 - '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -7765,49 +7820,49 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)': + '@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)': dependencies: '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 '@standard-schema/spec': 1.1.0 better-call: 1.3.2(zod@4.3.6) - jose: 6.2.1 + jose: 6.2.2 kysely: 0.28.11 nanostores: 1.1.1 zod: 4.3.6 - '@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))': + '@better-auth/drizzle-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 optionalDependencies: drizzle-orm: 0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8) - '@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)': + '@better-auth/kysely-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11)': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 kysely: 0.28.11 - '@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/memory-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))': + '@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7))': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 mongodb: 7.1.0(socks@2.8.7) - '@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))': + '@better-auth/telemetry@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))': dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 @@ -8612,18 +8667,6 @@ snapshots: - ws - zod - '@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8672,30 +8715,6 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)': - dependencies: - '@anthropic-ai/sdk': 0.73.0(zod@3.25.76) - '@aws-sdk/client-bedrock-runtime': 3.1008.0 - '@google/genai': 1.45.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) - '@mistralai/mistralai': 1.14.1 - '@sinclair/typebox': 0.34.48 - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - chalk: 5.6.2 - openai: 6.26.0(ws@8.20.0)(zod@3.25.76) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - undici: 7.24.3 - zod-to-json-schema: 3.25.1(zod@3.25.76) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -8862,7 +8881,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) hono: 4.12.8 - jose: 6.2.1 + jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -9992,6 +10011,95 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@peculiar/asn1-cms@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pfx': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.6.0': + dependencies: + asn1js: 3.0.10 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.10 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/x509@2.0.0': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-csr': 2.6.1 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-pkcs9': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + pvtsutils: 1.3.6 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -10948,6 +11056,12 @@ snapshots: asap@2.0.6: {} + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + assertion-error@2.0.1: {} ast-types@0.13.4: @@ -10992,20 +11106,20 @@ snapshots: better-auth@1.5.5(better-sqlite3@12.8.0)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8))(mongodb@7.1.0(socks@2.8.7))(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)): dependencies: - '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1) - '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)) - '@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) - '@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7)) - '@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)) + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1) + '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.2.17)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.8.0)(kysely@0.28.11)(postgres@3.4.8)) + '@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.11) + '@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0(socks@2.8.7)) + '@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.1.1)) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 better-call: 1.3.2(zod@4.3.6) defu: 6.1.4 - jose: 6.2.1 + jose: 6.2.2 kysely: 0.28.11 nanostores: 1.1.1 zod: 4.3.6 @@ -12448,6 +12562,8 @@ snapshots: jose@6.2.1: {} + jose@6.2.2: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -13191,11 +13307,6 @@ snapshots: dependencies: mimic-function: 5.0.1 - openai@6.26.0(ws@8.20.0)(zod@3.25.76): - optionalDependencies: - ws: 8.20.0 - zod: 3.25.76 - openai@6.26.0(ws@8.20.0)(zod@4.3.6): optionalDependencies: ws: 8.20.0 @@ -13548,6 +13659,12 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + qrcode-terminal@0.12.0: {} qs@6.15.0: @@ -14231,6 +14348,8 @@ snapshots: ts-mixer@6.0.4: {} + tslib@1.14.1: {} + tslib@2.8.1: {} tslog@4.10.2: {} @@ -14242,6 +14361,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 -- 2.49.1