diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5aecc53..172b310 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -12,6 +12,7 @@ import { MailModule } from './mail/mail.module'; import { CalendarModule } from './calendar/calendar.module'; import { DashboardModule } from './dashboard/dashboard.module'; import { DkvModule } from './dkv/dkv.module'; +import { CertManagerModule } from './cert-manager/cert-manager.module'; import { DomaincheckModule } from './domaincheck/domaincheck.module'; import { ModuleRegistryModule } from './module-registry/module-registry.module'; import { PrismaModule } from './prisma/prisma.module'; @@ -33,6 +34,7 @@ import { UserModule } from './user/user.module'; LdapModule, ModuleRegistryModule, DomaincheckModule, + CertManagerModule, DashboardModule, CalendarModule, SettingsModule, diff --git a/apps/api/src/cert-manager/cert-manager.controller.ts b/apps/api/src/cert-manager/cert-manager.controller.ts new file mode 100644 index 0000000..edfd7eb --- /dev/null +++ b/apps/api/src/cert-manager/cert-manager.controller.ts @@ -0,0 +1,114 @@ +import { + BadRequestException, + Body, + Controller, + Post, + UploadedFile, + UploadedFiles, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; +import { UseModule } from '../module-registry/module.guard'; +import { CertManagerService } from './cert-manager.service'; + +/** + * CertManagerController — 4 POST endpoints for certificate operations. + * + * All routes are protected by: + * - Global JwtAuthGuard (authentication) + * - Global TenantGuard (tenant context) + * - @UseModule('cert-manager') ModuleGuard (module activation check) + * + * File size limit: 5 MB per file (T-09-03 — DoS mitigation). + * Password parameter is never passed to a logger (T-09-02 — InfoDisc mitigation). + */ +@Controller('modules/cert-manager') +@UseModule('cert-manager') +export class CertManagerController { + constructor(private readonly certManagerService: CertManagerService) {} + + /** + * POST /modules/cert-manager/parse + * Inspect a single certificate: subject, issuer, validity, SANs, fingerprints. + * Accepts multipart file upload OR JSON body with pemText. + */ + @Post('parse') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ) + async parseCert( + @UploadedFile() file: any, + @Body('password') password?: string, + @Body('pemText') pemText?: string, + ) { + if (!file && !pemText) { + throw new BadRequestException('No file or PEM text provided'); + } + return this.certManagerService.parseCert({ file, pemText, password }); + } + + /** + * POST /modules/cert-manager/split + * Split a fullchain.pem or P7B bundle into individual certificates. + */ + @Post('split') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ) + async splitCerts( + @UploadedFile() file: any, + @Body('password') password?: string, + ) { + if (!file) { + throw new BadRequestException('No file provided'); + } + return this.certManagerService.splitCerts({ file, password }); + } + + /** + * POST /modules/cert-manager/merge + * Merge multiple certificates into a PEM chain or PFX bundle. + * Uses FilesInterceptor (plural) to accept multiple files with field name "files". + */ + @Post('merge') + @UseInterceptors( + FilesInterceptor('files', 20, { + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ) + async mergeCerts( + @UploadedFiles() files: any[], + @Body('outputFormat') outputFormat: string, + @Body('password') password?: string, + ) { + if (!files || files.length < 2) { + throw new BadRequestException('At least 2 files required for merge'); + } + return this.certManagerService.mergeCerts({ files, outputFormat, password }); + } + + /** + * POST /modules/cert-manager/convert + * Convert a certificate between PEM, DER, PFX/P12, P7B, CRT/CER formats. + */ + @Post('convert') + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ) + async convertCert( + @UploadedFile() file: any, + @Body('targetFormat') targetFormat: string, + @Body('password') password?: string, + ) { + if (!file) { + throw new BadRequestException('No file provided'); + } + return this.certManagerService.convertCert({ file, targetFormat, password }); + } +} diff --git a/apps/api/src/cert-manager/cert-manager.module.ts b/apps/api/src/cert-manager/cert-manager.module.ts new file mode 100644 index 0000000..f793e4a --- /dev/null +++ b/apps/api/src/cert-manager/cert-manager.module.ts @@ -0,0 +1,41 @@ +import { Logger, Module, OnModuleInit } from '@nestjs/common'; +import { ModuleRegistryModule } from '../module-registry/module-registry.module'; +import { ModuleRegistryService } from '../module-registry/module-registry.service'; +import { CertManagerController } from './cert-manager.controller'; +import { seedCertManagerModule } from './cert-manager.seed'; +import { CertManagerService } from './cert-manager.service'; + +/** + * NestJS module for the Cert Manager feature. + * + * Provides server-side certificate inspection, splitting, merging, + * and format conversion using node-forge (pure JS, no native bindings). + * + * Seeds itself into the module registry on application startup via + * OnModuleInit lifecycle hook (CERT-06). + * + * After seeding: an admin must activate the module per-tenant via the + * Marketplace UI before endpoints become accessible (ModuleGuard checks + * TenantModuleActivation, not isSystem flag — RESEARCH.md Pitfall 2). + */ +@Module({ + imports: [ModuleRegistryModule], + controllers: [CertManagerController], + providers: [CertManagerService], +}) +export class CertManagerModule implements OnModuleInit { + private readonly logger = new Logger(CertManagerModule.name); + + constructor( + private readonly moduleRegistryService: ModuleRegistryService, + ) {} + + async onModuleInit(): Promise { + try { + await seedCertManagerModule(this.moduleRegistryService); + this.logger.log('Cert-Manager module seeded in registry'); + } catch (error) { + this.logger.error('Failed to seed cert-manager module', error); + } + } +} diff --git a/apps/api/src/cert-manager/cert-manager.seed.ts b/apps/api/src/cert-manager/cert-manager.seed.ts new file mode 100644 index 0000000..dbbf03e --- /dev/null +++ b/apps/api/src/cert-manager/cert-manager.seed.ts @@ -0,0 +1,26 @@ +import { ModuleRegistryService } from '../module-registry/module-registry.service'; + +/** + * Seeds the cert-manager module into the module registry. + * + * Called during CertManagerModule initialization to ensure the + * "cert-manager" module record exists in the database (CERT-06). + * + * isSystem: true registers the module in the registry but does NOT + * auto-activate it per tenant. Admin must activate via Marketplace UI. + */ +export async function seedCertManagerModule( + moduleRegistryService: ModuleRegistryService, +): Promise { + await moduleRegistryService.seedModule({ + slug: 'cert-manager', + name: 'Cert Manager', + version: '1.0.0', + category: 'security-tools', + description: { + de: 'Zertifikate analysieren, konvertieren und verwalten', + en: 'Inspect, convert and manage certificates', + }, + isSystem: true, + }); +} diff --git a/apps/api/src/cert-manager/cert-manager.service.ts b/apps/api/src/cert-manager/cert-manager.service.ts new file mode 100644 index 0000000..3ce7225 --- /dev/null +++ b/apps/api/src/cert-manager/cert-manager.service.ts @@ -0,0 +1,122 @@ +import { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common'; +import * as forge from 'node-forge'; + +/** + * CertManagerService — server-side certificate operations. + * + * All cryptographic processing is ephemeral (upload → process → return). + * No data is persisted to disk or database. + * + * SECURITY NOTES: + * - Binary buffers MUST use toString('binary') for forge (never 'utf-8' — Pitfall 1) + * - Password parameters are never passed to the logger + * - All forge operations wrapped in try/catch → BadRequestException + */ +@Injectable() +export class CertManagerService { + private readonly logger = new Logger(CertManagerService.name); + + // --------------------------------------------------------------------------- + // Shared helpers (used by all operation methods) + // --------------------------------------------------------------------------- + + /** + * Detect the format of a certificate file from extension + content sniff. + * .cer is ambiguous — resolved by inspecting the first bytes of the buffer. + */ + detectFormat( + filename: string, + buffer: Buffer, + ): 'pem' | 'der' | 'pfx' | 'p7b' { + const ext = filename.split('.').pop()?.toLowerCase() ?? ''; + const isPemContent = buffer.slice(0, 27).toString('ascii').includes('-----BEGIN'); + + if (ext === 'pfx' || ext === 'p12') return 'pfx'; + if (ext === 'p7b' || ext === 'p7c') return 'p7b'; + if (ext === 'der') return 'der'; + if (ext === 'pem' || ext === 'crt') return 'pem'; + if (ext === 'cer') return isPemContent ? 'pem' : 'der'; // .cer is ambiguous + // Fallback: sniff content + return isPemContent ? 'pem' : 'der'; + } + + /** + * Convert a Node.js Buffer to a forge ByteStringBuffer using 'binary' encoding. + * + * CRITICAL: Always use 'binary' encoding — UTF-8 corrupts DER/PFX/P7B binary data. + * See RESEARCH.md Pitfall 1. + */ + toForgeBuffer(buffer: Buffer): forge.util.ByteStringBuffer { + return forge.util.createBuffer(buffer.toString('binary')); + } + + /** + * Compute SHA-1 or SHA-256 fingerprint of a certificate. + * Hash is computed over the DER-encoded bytes, returned as uppercase colon-joined hex. + */ + getFingerprint(cert: forge.pki.Certificate, algorithm: 'sha1' | 'sha256'): string { + const md = algorithm === 'sha1' ? forge.md.sha1.create() : forge.md.sha256.create(); + const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(); + md.update(der); + return md.digest().toHex().match(/.{2}/g)!.join(':').toUpperCase(); + } + + /** + * Split a PEM string containing one or more concatenated certificates. + * Returns an array of parsed forge Certificate objects. + */ + parsePemChain(pem: string): forge.pki.Certificate[] { + const blocks = + pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []; + return blocks.map((b) => forge.pki.certificateFromPem(b)); + } + + // --------------------------------------------------------------------------- + // Operation method stubs (implemented in later plan slices) + // --------------------------------------------------------------------------- + + async parseCert(_input: { + file?: any; + pemText?: string; + password?: string; + }): Promise { + throw new NotImplementedException('parseCert is not yet implemented'); + } + + async splitCerts(_input: { + file?: any; + password?: string; + }): Promise { + throw new NotImplementedException('splitCerts is not yet implemented'); + } + + async mergeCerts(_input: { + files?: any[]; + outputFormat: string; + password?: string; + }): Promise { + throw new NotImplementedException('mergeCerts is not yet implemented'); + } + + async convertCert(_input: { + file?: any; + targetFormat: string; + password?: string; + }): Promise { + throw new NotImplementedException('convertCert is not yet implemented'); + } + + // --------------------------------------------------------------------------- + // Internal helpers for later slices + // --------------------------------------------------------------------------- + + /** Wrap a node-forge operation and re-throw as BadRequestException on failure */ + protected _parseOrThrow(fn: () => T, errorMsg: string): T { + try { + return fn(); + } catch (_err) { + this.logger.warn(`Cert parse failed: ${errorMsg}`); + throw new BadRequestException(errorMsg); + } + } +} diff --git a/apps/api/src/cert-manager/dto/convert-cert.dto.ts b/apps/api/src/cert-manager/dto/convert-cert.dto.ts new file mode 100644 index 0000000..c157f36 --- /dev/null +++ b/apps/api/src/cert-manager/dto/convert-cert.dto.ts @@ -0,0 +1,8 @@ +/** + * DTO for the cert-manager convert endpoint body fields. + * Used alongside FileInterceptor for single-file upload. + */ +export class ConvertCertDto { + targetFormat!: 'pem' | 'der' | 'pfx' | 'p7b'; + password?: string; +} diff --git a/apps/api/src/cert-manager/dto/merge-certs.dto.ts b/apps/api/src/cert-manager/dto/merge-certs.dto.ts new file mode 100644 index 0000000..776ff41 --- /dev/null +++ b/apps/api/src/cert-manager/dto/merge-certs.dto.ts @@ -0,0 +1,8 @@ +/** + * DTO for the cert-manager merge endpoint body fields. + * Used alongside FilesInterceptor for multi-file upload. + */ +export class MergeCertsDto { + outputFormat!: 'pem' | 'pfx'; + password?: string; +} diff --git a/apps/api/src/cert-manager/dto/parse-cert.dto.ts b/apps/api/src/cert-manager/dto/parse-cert.dto.ts new file mode 100644 index 0000000..342511f --- /dev/null +++ b/apps/api/src/cert-manager/dto/parse-cert.dto.ts @@ -0,0 +1,8 @@ +/** + * DTO for the cert-manager parse endpoint (text paste / JSON body path). + * For file uploads the body fields are extracted via @Body() in the controller. + */ +export class ParseCertDto { + pemText!: string; + password?: string; +}