Files
tessera-ctl/apps/api/src/cert-manager/cert-manager.controller.ts
T
schalli fcac0a3bfd feat(cert-manager): Schlüssel, PFX und CSR erkennen, Passwort je Datei
- Private Schlüssel (PKCS#1, PKCS#8, SEC1; PEM und DER; unverschlüsselt, verschlüsselt, klassisch verschlüsselt) für RSA und EC über node:crypto
- PKCS#12 lesen (OpenSSL 3, kompatibel, RC2; EC-Zertifikate und -Schlüssel), auch ohne Endung und im ZIP
- Zertifikatsanfragen (CSR) als PEM und DER mit Inhaber, SAN und Schlüssel
- Zuordnung von Schlüssel und Anfrage zum Zertifikat (checkPrivateKey, SPKI-Vergleich)
- Feld passwords je Datei, gesperrte Dateien fragen nach dem Passwort; kein Passwort in Antwort oder Log
- Oberfläche: Passwortfeld mit Anzeigen/Verbergen, Schlüssel- und Anfragekarten im Reiter Analysieren

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 15:31:54 +02:00

97 lines
3.8 KiB
TypeScript

import { Body, Controller, HttpCode, Post, UploadedFiles, UseInterceptors } from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import type { UploadedFileLike } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard';
import { analyzeWorkingSet } from './cert-analyze';
import { buildOutput } from './cert-output';
import { type AnalysisResult, type BuildResult, certError } from './cert-types';
import { BuildOutputDto } from './dto/cert-build.dto';
/** 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;
/** Das Feld `passwords`: ein JSON-Array von Zeichenketten, je Datei eines (Task 4). */
export const CERT_MAX_PASSWORDS = 30;
export const CERT_MAX_PASSWORD_CHARS = 1024;
/**
* Liest das multipart-Feld `passwords`. Fehlt es, gibt es keine Passwoerter. Alles andere als ein
* JSON-Array aus hoechstens 30 Zeichenketten zu je hoechstens 1024 Zeichen: 400 invalidInput.
* Der Inhalt erscheint nie in einer Fehlermeldung.
*/
export function parsePasswords(raw: unknown): string[] {
if (raw === undefined || raw === null || raw === '') return [];
let value: unknown = raw;
if (typeof raw === 'string') {
try {
value = JSON.parse(raw);
} catch {
certError('invalidInput', 400, 'passwords must be a JSON array of strings');
}
}
if (
!Array.isArray(value) ||
value.length > CERT_MAX_PASSWORDS ||
value.some((p) => typeof p !== 'string' || p.length > CERT_MAX_PASSWORD_CHARS)
) {
certError('invalidInput', 400, 'passwords must be a JSON array of strings');
}
return value as string[];
}
/**
* 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).
*
* 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').
*
* Routen (alle POST, 200):
* - analyze Task 1 mehrere Dateien (multipart) erkennen und zusammenfassen; Task 4: optionales Feld `passwords`
* - build Task 2 Ausgabe bauen (JSON, eigene Grenze 512 KiB, siehe cert-json-body.ts), 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 {
@Post('analyze')
@HttpCode(200)
@UseInterceptors(
FilesInterceptor('files', CERT_MAX_FILES, { limits: { fileSize: CERT_MAX_FILE_BYTES } }),
)
analyze(
@UploadedFiles() files: UploadedFileLike[] | undefined,
@Body('passwords') rawPasswords?: unknown,
): AnalysisResult {
if (!files || files.length === 0) {
certError('invalidInput', 400, 'No files provided');
}
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');
}
const passwords = parsePasswords(rawPasswords);
return analyzeWorkingSet(
files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })),
passwords,
);
}
@Post('build')
@HttpCode(200)
build(@Body() dto: BuildOutputDto): BuildResult {
return buildOutput(dto);
}
}