feat(cert-manager): Reiter „Dateien“ mit mehreren Dateien und ein Parser für RSA und EC – Durchstich
- Ein gemeinsamer Arbeitsbereich im Browser: jede Auswahl hängt an, eine zweite Datei ersetzt nie die erste - Ein Parser (node:crypto) für RSA- und EC-Zertifikate als PEM, DER und TRUSTED CERTIFICATE, Rolle je Datei - Neue Route analyze (mehrere Dateien, 30 x 5 MiB, zusammen 20 MiB); alte Routen, Service und Web-Reiter entfernt - Test-PKI-Fixtures (RSA und EC, Ketten, Schlüssel, CSR, PFX) mit Erzeugungsskript Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,173 +1,54 @@
|
||||
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 { Controller, HttpCode, Post, UploadedFiles, UseInterceptors } from '@nestjs/common';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
import {
|
||||
analyzeBundle,
|
||||
type BundleExportFormat,
|
||||
type BundleItemKind,
|
||||
exportBundleItem,
|
||||
} from './cert-bundle';
|
||||
import { CertManagerService } from './cert-manager.service';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import { analyzeWorkingSet } from './cert-analyze';
|
||||
import { type AnalysisResult, certError } from './cert-types';
|
||||
|
||||
/** Obergrenzen (D-17): je Datei 5 MiB, alle Dateien zusammen 20 MiB, hoechstens 30 Dateien. */
|
||||
export const CERT_MAX_FILES = 30;
|
||||
export const CERT_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
export const CERT_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* CertManagerController — 4 POST endpoints for certificate operations.
|
||||
* multer liest Dateinamen als Latin-1. Waren es UTF-8-Bytes (Umlaute), den Namen zurueckgewinnen;
|
||||
* ist das Ergebnis kein gueltiges UTF-8, bleibt der Name wie er ist.
|
||||
*/
|
||||
export function repairFileName(name: string): string {
|
||||
const utf8 = Buffer.from(name, 'latin1').toString('utf8');
|
||||
return utf8.includes('�') ? name : utf8;
|
||||
}
|
||||
|
||||
/**
|
||||
* CertManagerController: Zertifikat-Manager (quick-261009-ikt, D-14).
|
||||
*
|
||||
* All routes are protected by:
|
||||
* - Global JwtAuthGuard (authentication)
|
||||
* - Global TenantGuard (tenant context)
|
||||
* - @UseModule('cert-manager') ModuleGuard (module activation check)
|
||||
* Zustandslos: nichts wird gespeichert, Passwoerter und Schluessel kommen nie in ein Log
|
||||
* (kein Logger-Aufruf mit Anfrageinhalt; das Request-Log kennt nur Pfad und Status).
|
||||
* Geschuetzt durch den globalen JwtAuthGuard, den TenantGuard und @UseModule('cert-manager').
|
||||
*
|
||||
* 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).
|
||||
* Routen (alle POST, 200):
|
||||
* - analyze Task 1 mehrere Dateien (multipart) erkennen und zusammenfassen
|
||||
* - build Task 2 Ausgabe bauen (JSON), ab Task 5/6 erweitert
|
||||
* - fetch-issuer Task 7 fehlendes Zwischenzertifikat nur auf Knopfdruck holen
|
||||
*/
|
||||
@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: UploadedFileLike | undefined,
|
||||
@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: UploadedFileLike | undefined,
|
||||
@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: UploadedFileLike[],
|
||||
@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, and P7B formats.
|
||||
* Accepts a multipart file upload OR a pemText body field.
|
||||
*
|
||||
* T-09-03: fileSize limit 5 MB (DoS mitigation)
|
||||
* T-09-04: global JwtAuthGuard + @UseModule('cert-manager') ModuleGuard
|
||||
* T-09-02: password is never passed to the logger
|
||||
*/
|
||||
@Post('convert')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async convertCert(
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
@Body('targetFormat') targetFormat: string,
|
||||
@Body('password') password?: string,
|
||||
@Body('pemText') pemText?: string,
|
||||
) {
|
||||
if (!file && !pemText) {
|
||||
throw new BadRequestException('No file or PEM text provided');
|
||||
}
|
||||
return this.certManagerService.convertCert({ file, pemText, targetFormat, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/cert-manager/analyze (quick-261001-l4q)
|
||||
* Zertifikatspaket: mehrere Dateien oder ZIP hochladen, jedes Teil erkennen
|
||||
* (Server-/Zwischen-/Stammzertifikat, privater Schluessel, CSR), Duplikate
|
||||
* zusammenfassen, Schluessel und Kette zuordnen.
|
||||
*
|
||||
* T-09-03: 20 Dateien, je 5 MB; ZIP-Inhalt zusaetzlich begrenzt (cert-bundle.ts).
|
||||
* T-09-02: password is never passed to the logger
|
||||
*/
|
||||
@Post('analyze')
|
||||
@HttpCode(200)
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', 20, {
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
FilesInterceptor('files', CERT_MAX_FILES, { limits: { fileSize: CERT_MAX_FILE_BYTES } }),
|
||||
)
|
||||
async analyze(
|
||||
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
||||
@Body('password') password?: string,
|
||||
) {
|
||||
return analyzeBundle(files ?? [], password ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /modules/cert-manager/export (quick-261001-l4q)
|
||||
* Ein Teil aus `analyze` (PEM) in das gewuenschte Format bringen.
|
||||
* JSON-Body; PFX verlangt ein Passwort fuer die neue Datei.
|
||||
*/
|
||||
@Post('export')
|
||||
async export(
|
||||
@Body('kind') kind: BundleItemKind,
|
||||
@Body('pem') pem: string,
|
||||
@Body('format') format: BundleExportFormat,
|
||||
@Body('baseName') baseName?: string,
|
||||
@Body('chain') chain?: string[],
|
||||
@Body('keyPem') keyPem?: string,
|
||||
@Body('password') password?: string,
|
||||
) {
|
||||
if (
|
||||
chain !== undefined &&
|
||||
(!Array.isArray(chain) || chain.some((c) => typeof c !== 'string'))
|
||||
) {
|
||||
throw new BadRequestException('chain must be a list of PEM strings');
|
||||
analyze(@UploadedFiles() files: UploadedFileLike[] | undefined): AnalysisResult {
|
||||
if (!files || files.length === 0) {
|
||||
certError('invalidInput', 400, 'No files provided');
|
||||
}
|
||||
return exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password });
|
||||
const total = files.reduce((sum, f) => sum + f.buffer.length, 0);
|
||||
if (total > CERT_MAX_TOTAL_BYTES) {
|
||||
certError('tooLarge', 413, 'Files together exceed 20 MiB');
|
||||
}
|
||||
return analyzeWorkingSet(
|
||||
files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user