feat(cert-manager): Zusammenführen mit Fullchain und Nur Kette

- Kettenbau mit Aussteller- und Signaturprüfung (checkIssued plus verify), Wurzel nur auf Wunsch
- build-Route für Fullchain und Nur Kette (PEM), eigene JSON-Grenze 512 KiB mit 413 und Code tooLarge
- Reiter Zusammenführen mit Kettenansicht, Hinweis bei fehlendem Zwischenzertifikat

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:03:23 +02:00
parent 75ea83fc01
commit 30118a2401
24 changed files with 1428 additions and 34 deletions
+78 -10
View File
@@ -1,14 +1,82 @@
import { X509Certificate } from 'node:crypto';
import { buildChains } from './cert-chain';
import { certItemFromDer } from './cert-model';
import { safeBaseName } from './cert-names';
import {
type BuildFile,
type BuildInput,
type BuildResult,
type CertItem,
certError,
} from './cert-types';
/**
* Ausgabe-Bausteine des Zertifikat-Managers (quick-261009-ikt).
* Task 1: nur der Dateiname-Helfer; die Ausgabeformate (build) kommen in den folgenden Tasks.
* Ausgabe-Bausteine des Zertifikat-Managers (quick-261009-ikt, D-19).
* Task 2: Fullchain und Nur Kette als PEM. Die weiteren Inhalte und Formate (Task 5) kommen in
* dieselbe Funktion. Die Reihenfolge baut die API immer selbst aus den gesendeten Zertifikaten
* (buildChains); eine vom Browser mitgeschickte Reihenfolge wird nie uebernommen.
*/
/** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */
export function safeBaseName(raw: string, fallback: string): string {
const cleaned = raw
.replace(/^\*\./, 'wildcard.')
.replace(/[^A-Za-z0-9._-]+/g, '_')
.replace(/^[._]+/, '')
.slice(0, 80);
return cleaned || fallback;
export { safeBaseName };
const PEM_MIME = 'application/x-pem-file';
function parseCertificate(pem: unknown): CertItem {
if (typeof pem !== 'string' || pem.trim() === '') {
certError('notACertificate', 400, 'Value is not a certificate');
}
try {
return certItemFromDer(new X509Certificate(pem).raw, { file: 0, path: '' });
} catch {
return certError('notACertificate', 400, 'Value is not a certificate');
}
}
/** PEM-Bloecke in Kettenreihenfolge, jeder genau einmal mit abschliessendem Zeilenumbruch. */
function joinPem(certs: CertItem[]): string {
return certs.map((c) => `${c.pem.trim()}\n`).join('');
}
function pemFile(filename: string, certs: CertItem[]): BuildFile {
return {
filename,
content: Buffer.from(joinPem(certs), 'utf8').toString('base64'),
mimeType: PEM_MIME,
};
}
/** Baut die gewuenschte Ausgabe. Wirft Nest-Ausnahmen mit Code (D-24); nie mit Passwort oder Schluessel im Text. */
export function buildOutput(input: BuildInput): BuildResult {
if (!input.certPem) certError('invalidInput', 400, 'certPem is required');
const head = parseCertificate(input.certPem);
const pool = (input.poolPems ?? []).map(parseCertificate);
const unique = new Map<string, CertItem>();
for (const c of [head, ...pool]) if (!unique.has(c.id)) unique.set(c.id, c);
const all = [...unique.values()];
const chain = buildChains(all, [head.id]).chains[0];
const byId = new Map(all.map((c) => [c.id, c]));
const path = chain.path.map((id) => byId.get(id) as CertItem);
const includeRoot = input.includeRoot === true;
const withoutRoot = path.filter((c) => !(c.role === 'root' && c.id !== head.id));
const base = safeBaseName(input.baseName ?? '', head.baseName);
let files: BuildFile[];
if (input.content === 'fullchain') {
files = [pemFile(`${base}-fullchain.pem`, includeRoot ? path : withoutRoot)];
} else if (input.content === 'chain') {
const issuers = (includeRoot ? path : withoutRoot).filter((c) => c.id !== head.id);
if (issuers.length === 0)
certError('noChain', 400, 'No intermediate or root certificate available');
files = [pemFile(`${base}-chain.pem`, issuers)];
} else {
return certError('formatNotPossible', 400, 'Output not available');
}
return {
files,
chainComplete: chain.complete,
missingIssuerCn: chain.gap?.missingIssuerCn ?? null,
};
}