From 1038ae76e10aab3b6a8520a497653a8d07c2db81 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Wed, 22 Apr 2026 03:34:37 +0000 Subject: [PATCH] feat(federation): Step-CA client service for grant certs (FED-M2-04) (#494) --- apps/gateway/package.json | 2 + apps/gateway/src/app.module.ts | 2 + apps/gateway/src/federation/ca.dto.ts | 57 ++ .../gateway/src/federation/ca.service.spec.ts | 577 ++++++++++++++++ apps/gateway/src/federation/ca.service.ts | 635 ++++++++++++++++++ .../src/federation/federation.module.ts | 8 + docs/federation/SETUP.md | 65 ++ infra/step-ca/init.sh | 48 +- infra/step-ca/templates/federation.tpl | 64 +- pnpm-lock.yaml | 267 ++++++-- 10 files changed, 1616 insertions(+), 109 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/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/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..005e3fd --- /dev/null +++ b/apps/gateway/src/federation/ca.dto.ts @@ -0,0 +1,57 @@ +/** + * DTOs for the Step-CA client service (FED-M2-04). + * + * IssueCertRequestDto — input to CaService.issueCert() + * IssuedCertDto — output from CaService.issueCert() + */ + +import { IsInt, IsNotEmpty, IsOptional, 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. + * 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(15 * 60) + ttlSeconds: number = 300; +} + +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..11dc5f8 --- /dev/null +++ b/apps/gateway/src/federation/ca.service.spec.ts @@ -0,0 +1,577 @@ +/** + * 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 (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 +// 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 +// --------------------------------------------------------------------------- + +// 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----- +MIIBdDCCARmgAwIBAgIUM+iUJSayN+PwXkyVN6qwSY7sr6gwCgYIKoZIzj0EAwIw +DzENMAsGA1UEAwwEdGVzdDAeFw0yNjA0MjIwMzE5MTlaFw0yNjA0MjMwMzE5MTla +MA8xDTALBgNVBAMMBHRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR21kHL +n1GmFQ4TEBw3EA53pD+2McIBf5WcoHE+x0eMz5DpRKJe0ksHwOVN5Yev5d57kb+4 +MvG1LhbHCB/uQo8So1MwUTAdBgNVHQ4EFgQUPq0pdIGiQ7pLBRXICS8GTliCrLsw +HwYDVR0jBBgwFoAUPq0pdIGiQ7pLBRXICS8GTliCrLswDwYDVR0TAQH/BAUwAwEB +/zAKBggqhkjOPQQDAgNJADBGAiEAypJqyC6S77aQ3eEXokM6sgAsD7Oa3tJbCbVm +zG3uJb0CIQC1w+GE+Ad0OTR5Quja46R1RjOo8ydpzZ7Fh4rouAiwEw== +-----END CERTIFICATE----- +`; + +// 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-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. +// --------------------------------------------------------------------------- + +// 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_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 +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(), + 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, + 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 { + // Use a real CSR if available; fall back to a minimal placeholder + const defaultCsr = realCsrPem ?? makeFakeCsr(); + return { + csrPem: defaultCsr, + grantId: GRANT_ID, + subjectUserId: SUBJECT_USER_ID, + 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], + }); + + 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 () => { + if (!realCsrPem) realCsrPem = await generateRealCsr(); + 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 () => { + if (!realCsrPem) realCsrPem = await generateRealCsr(); + 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 () => { + if (!realCsrPem) realCsrPem = await generateRealCsr(); + 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 () => { + if (!realCsrPem) realCsrPem = await generateRealCsr(); + 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 () => { + if (!realCsrPem) realCsrPem = await generateRealCsr(); + 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 () => { + if (!realCsrPem) realCsrPem = await generateRealCsr(); + 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 + // Verified with jose.jwtVerify for real signature verification (M6) + // ------------------------------------------------------------------------- + + 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; + + 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; + }, + ); + + await service.issueCert(makeReq({ csrPem: realCsrPem })); + + expect(capturedBody).toBeDefined(); + const ott = capturedBody!['ott'] as string; + expect(typeof ott).toBe('string'); + + // 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); + }); + + // ------------------------------------------------------------------------- + // 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 () => { + if (!realCsrPem) realCsrPem = await generateRealCsr(); + 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; + }); + }); + + // ------------------------------------------------------------------------- + // 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 new file mode 100644 index 0000000..9e0d296 --- /dev/null +++ b/apps/gateway/src/federation/ca.service.ts @@ -0,0 +1,635 @@ +/** + * CaService — Step-CA client for federation grant certificate issuance. + * + * Responsibilities: + * 1. Build a JWK-provisioner One-Time Token (OTT) signed with the provisioner + * private key (ES256/ES384/RS256 per JWK kty/crv) carrying Mosaic-specific + * claims (`mosaic_grant_id`, `mosaic_subject_user_id`, `step.sha`) per the + * step-ca JWK provisioner protocol. + * 2. POST the CSR + OTT to the step-ca `/1.0/sign` endpoint over HTTPS, + * pinning the trust to the CA root cert supplied via env. + * 3. Return an IssuedCertDto containing the leaf cert, full chain, and + * serial number. + * + * Environment variables (all required at runtime — validated in constructor): + * STEP_CA_URL https://step-ca:9000 + * STEP_CA_PROVISIONER_KEY_JSON JWK provisioner private key (JSON) + * STEP_CA_ROOT_CERT_PATH Absolute path to the CA root PEM + * + * Optional (only used for JWK PBES2 decrypt at startup if key is encrypted): + * STEP_CA_PROVISIONER_PASSWORD JWK provisioner password (raw string) + * + * Custom OID registry (PRD §6, docs/federation/SETUP.md): + * 1.3.6.1.4.1.99999.1 — mosaic_grant_id + * 1.3.6.1.4.1.99999.2 — mosaic_subject_user_id + * + * Fail-loud contract: + * Every error path throws CaServiceError with a human-readable `remediation` + * field. Silent OID-stripping is NEVER allowed — if the sign response does + * not include the cert, we throw rather than return a cert that may be + * missing the custom extensions. + */ + +import { Injectable, Logger } from '@nestjs/common'; +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as https from 'node:https'; +import { SignJWT, importJWK } from 'jose'; +import { Pkcs10CertificateRequest } from '@peculiar/x509'; +import type { IssueCertRequestDto } from './ca.dto.js'; +import { IssuedCertDto } from './ca.dto.js'; + +// --------------------------------------------------------------------------- +// Custom error class +// --------------------------------------------------------------------------- + +export class CaServiceError extends Error { + readonly cause: unknown; + readonly remediation: string; + readonly code?: string; + + constructor(message: string, remediation: string, cause?: unknown, code?: string) { + super(message); + this.name = 'CaServiceError'; + this.cause = cause; + this.remediation = remediation; + this.code = code; + } +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +interface StepSignResponse { + crt: string; + ca?: string; + certChain?: string[]; +} + +interface JwkKey { + kty: string; + kid?: string; + use?: string; + alg?: string; + k?: string; // symmetric + n?: string; // RSA + e?: string; + d?: string; + x?: string; // EC + y?: string; + crv?: string; + [key: string]: unknown; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** UUID regex for validation */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Derive the JWT algorithm string from a JWK's kty/crv fields. + * EC P-256 → ES256, EC P-384 → ES384, RSA → RS256. + */ +function algFromJwk(jwk: JwkKey): string { + if (jwk.alg) return jwk.alg; + if (jwk.kty === 'EC') { + if (jwk.crv === 'P-384') return 'ES384'; + return 'ES256'; // default for P-256 and Ed25519-style EC keys + } + if (jwk.kty === 'RSA') return 'RS256'; + throw new CaServiceError( + `Unsupported JWK kty: ${jwk.kty}`, + 'STEP_CA_PROVISIONER_KEY_JSON must be an EC (P-256/P-384) or RSA JWK private key.', + ); +} + +/** + * Compute SHA-256 fingerprint of the DER-encoded CSR body. + * step-ca uses this as the `step.sha` claim to bind the OTT to a specific CSR. + */ +function csrFingerprint(csrPem: string): string { + // Strip PEM headers and decode base64 body + const b64 = csrPem + .replace(/-----BEGIN CERTIFICATE REQUEST-----/, '') + .replace(/-----END CERTIFICATE REQUEST-----/, '') + .replace(/\s+/g, ''); + + let derBuf: Buffer; + try { + derBuf = Buffer.from(b64, 'base64'); + } catch (err) { + throw new CaServiceError( + 'Failed to base64-decode the CSR PEM body', + 'Verify that csrPem is a valid PKCS#10 PEM-encoded certificate request.', + err, + ); + } + + if (derBuf.length === 0) { + throw new CaServiceError( + 'CSR PEM decoded to empty buffer — malformed input', + 'Provide a valid non-empty PKCS#10 PEM-encoded certificate request.', + ); + } + + return crypto.createHash('sha256').update(derBuf).digest('hex'); +} + +/** + * Send a JSON POST to the step-ca sign endpoint. + * Returns the parsed response body or throws CaServiceError. + */ +function httpsPost(url: string, body: unknown, agent: https.Agent): Promise { + return new Promise((resolve, reject) => { + const bodyStr = JSON.stringify(body); + const parsed = new URL(url); + + const options: https.RequestOptions = { + hostname: parsed.hostname, + port: parsed.port ? parseInt(parsed.port, 10) : 443, + path: parsed.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(bodyStr), + }, + agent, + timeout: 5000, + }; + + const req = https.request(options, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + + if (res.statusCode === 401) { + reject( + new CaServiceError( + `step-ca returned HTTP 401 — invalid or expired OTT`, + 'Check STEP_CA_PROVISIONER_KEY_JSON. Ensure the mosaic-fed provisioner is configured in the CA.', + ), + ); + return; + } + + if (res.statusCode && res.statusCode >= 400) { + reject( + new CaServiceError( + `step-ca returned HTTP ${res.statusCode}: ${raw.slice(0, 256)}`, + `Review the step-ca logs. Status ${res.statusCode} may indicate a CSR policy violation or misconfigured provisioner.`, + ), + ); + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch (err) { + reject( + new CaServiceError( + 'step-ca returned a non-JSON response', + 'Verify STEP_CA_URL points to a running step-ca instance and that TLS is properly configured.', + err, + ), + ); + return; + } + + resolve(parsed as StepSignResponse); + }); + }); + + req.setTimeout(5000, () => { + req.destroy(new Error('Request timed out after 5000ms')); + }); + + req.on('error', (err: Error) => { + reject( + new CaServiceError( + `HTTPS connection to step-ca failed: ${err.message}`, + 'Ensure STEP_CA_URL is reachable and STEP_CA_ROOT_CERT_PATH points to the correct CA root certificate.', + err, + ), + ); + }); + + req.write(bodyStr); + req.end(); + }); +} + +/** + * Extract a decimal serial number from a PEM certificate. + * Throws CaServiceError on failure — never silently returns 'unknown'. + */ +function extractSerial(certPem: string): string { + let cert: crypto.X509Certificate; + try { + cert = new crypto.X509Certificate(certPem); + } catch (err) { + throw new CaServiceError( + 'Failed to parse the issued certificate PEM', + 'The certificate returned by step-ca could not be parsed. Check that step-ca is returning a valid PEM certificate.', + err, + 'CERT_PARSE', + ); + } + return cert.serialNumber; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +@Injectable() +export class CaService { + private readonly logger = new Logger(CaService.name); + + private readonly caUrl: string; + private readonly rootCertPath: string; + private readonly httpsAgent: https.Agent; + private readonly jwk: JwkKey; + private cachedPrivateKey: crypto.KeyObject | null = null; + private readonly jwtAlg: string; + private readonly kid: string; + + constructor() { + const caUrl = process.env['STEP_CA_URL']; + const provisionerKeyJson = process.env['STEP_CA_PROVISIONER_KEY_JSON']; + const rootCertPath = process.env['STEP_CA_ROOT_CERT_PATH']; + + if (!caUrl) { + throw new CaServiceError( + 'STEP_CA_URL is not set', + 'Set STEP_CA_URL to the base URL of the step-ca instance, e.g. https://step-ca:9000', + ); + } + + // Enforce HTTPS-only URL + let parsedUrl: URL; + try { + parsedUrl = new URL(caUrl); + } catch (err) { + throw new CaServiceError( + `STEP_CA_URL is not a valid URL: ${caUrl}`, + 'Set STEP_CA_URL to a valid HTTPS URL, e.g. https://step-ca:9000', + err, + ); + } + if (parsedUrl.protocol !== 'https:') { + throw new CaServiceError( + `STEP_CA_URL must use HTTPS — got: ${parsedUrl.protocol}`, + 'Set STEP_CA_URL to an https:// URL. Unencrypted connections to the CA are not permitted.', + ); + } + + if (!provisionerKeyJson) { + throw new CaServiceError( + 'STEP_CA_PROVISIONER_KEY_JSON is not set', + 'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-encoded JWK for the mosaic-fed provisioner.', + ); + } + if (!rootCertPath) { + throw new CaServiceError( + 'STEP_CA_ROOT_CERT_PATH is not set', + 'Set STEP_CA_ROOT_CERT_PATH to the absolute path of the step-ca root CA certificate PEM file.', + ); + } + + // Parse JWK once — do NOT store the raw JSON string as a class field + let jwk: JwkKey; + try { + jwk = JSON.parse(provisionerKeyJson) as JwkKey; + } catch (err) { + throw new CaServiceError( + 'STEP_CA_PROVISIONER_KEY_JSON is not valid JSON', + 'Set STEP_CA_PROVISIONER_KEY_JSON to the JSON-serialised JWK object for the mosaic-fed provisioner.', + err, + ); + } + + // Derive algorithm from JWK metadata + const jwtAlg = algFromJwk(jwk); + const kid = jwk.kid ?? 'mosaic-fed'; + + // Import the JWK into a native KeyObject — fail loudly if it cannot be loaded. + // We do this synchronously here by calling the async importJWK via a blocking workaround. + // Actually importJWK is async, so we store it for use during token building. + // We keep the raw jwk object for later async import inside buildOtt. + // NOTE: We do NOT store provisionerKeyJson string as a class field. + this.jwk = jwk; + this.jwtAlg = jwtAlg; + this.kid = kid; + + this.caUrl = caUrl; + this.rootCertPath = rootCertPath; + + // Read the root cert and pin it for all HTTPS connections. + let rootCert: string; + try { + rootCert = fs.readFileSync(this.rootCertPath, 'utf8'); + } catch (err) { + throw new CaServiceError( + `Cannot read STEP_CA_ROOT_CERT_PATH: ${rootCertPath}`, + 'Ensure the file exists and is readable by the gateway process.', + err, + ); + } + + this.httpsAgent = new https.Agent({ + ca: rootCert, + rejectUnauthorized: true, + }); + + this.logger.log(`CaService initialised — CA URL: ${this.caUrl}`); + } + + /** + * Lazily import the private key from JWK on first use. + * The key is cached in cachedPrivateKey after first import. + */ + private async getPrivateKey(): Promise { + 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. + * + * 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 { + // Clamp TTL to 15-minute maximum (H2) + const ttl = Math.min(req.ttlSeconds ?? 300, 900); + + this.logger.debug( + `issueCert — grantId=${req.grantId} subjectUserId=${req.subjectUserId} ttl=${ttl}s`, + ); + + // Validate CSR — real cryptographic validation (H3) + const csrCn = await this.validateCsr(req.csrPem); + + const ott = await this.buildOtt({ + csrPem: req.csrPem, + grantId: req.grantId, + subjectUserId: req.subjectUserId, + ttlSeconds: ttl, + csrCn, + }); + + const signUrl = `${this.caUrl}/1.0/sign`; + const requestBody = { + csr: req.csrPem, + ott, + validity: { + duration: `${ttl}s`, + }, + }; + + this.logger.debug(`Posting CSR to ${signUrl}`); + const response = await httpsPost(signUrl, requestBody, this.httpsAgent); + + if (!response.crt) { + throw new CaServiceError( + 'step-ca sign response missing the "crt" field', + 'This is unexpected — the step-ca instance may be misconfigured or running an incompatible version.', + ); + } + + // Build certChainPem: prefer certChain array, fall back to ca field, fall back to crt alone. + let certChainPem: string; + if (response.certChain && response.certChain.length > 0) { + certChainPem = response.certChain.join('\n'); + } else if (response.ca) { + certChainPem = response.crt + '\n' + response.ca; + } else { + certChainPem = response.crt; + } + + const serialNumber = extractSerial(response.crt); + + this.logger.log(`Certificate issued — serial=${serialNumber} grantId=${req.grantId}`); + + const result = new IssuedCertDto(); + result.certPem = response.crt; + result.certChainPem = certChainPem; + result.serialNumber = serialNumber; + return result; + } +} 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..d571091 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%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%c%s" (len .Token.mosaic_subject_user_id) .Token.mosaic_subject_user_id | b64enc }}" + } + ], + "keyUsage": ["digitalSignature"], "extKeyUsage": ["clientAuth"], "basicConstraints": { 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