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
+14 -2
View File
@@ -43,8 +43,20 @@ describe('analyzeWorkingSet', () => {
expect(result.ignored).toEqual([{ file: 6, path: 'readme.txt', reason: 'unknown' }]);
});
it('Ketten und gesperrte Eintraege sind in diesem Stand leer', () => {
expect(result.chains).toEqual([]);
it('liefert zwei Ketten: RSA unvollstaendig (Luecke nach der CA), EC vollstaendig mit Wurzel', () => {
const certs = result.items.filter((i): i is CertItem => i.kind === 'certificate');
expect(result.chains).toHaveLength(2);
const byHead = (cn: string) =>
result.chains.find((c) => certs.find((x) => x.id === c.headId)?.cn === cn);
const rsa = byHead('www.example.test');
expect(rsa?.complete).toBe(false);
expect(rsa?.gap).toMatchObject({ kind: 'afterCa', missingIssuerCn: 'Tessera Test Root RSA' });
const ec = byHead('ec.example.test');
expect(ec?.complete).toBe(true);
expect(certs.find((x) => x.id === ec?.rootId)?.cn).toBe('Tessera Test Root EC');
});
it('gesperrte Eintraege sind in diesem Stand leer', () => {
expect(result.locked).toEqual([]);
});
+5 -2
View File
@@ -1,3 +1,4 @@
import { buildChains } from './cert-chain';
import { detectBlob } from './cert-model';
import type {
AnalysisResult,
@@ -11,7 +12,7 @@ import type {
/**
* Fassade der Analyse (quick-261009-ikt, D-15): alle hochgeladenen Dateien erkennen,
* gleiche Teile zusammenfassen und ordnen. Zustandslos; nichts wird gespeichert.
* Ketten (Task 2), Schluessel/CSR-Zuordnung und gesperrte Container (Task 4) folgen.
* Ketten ab Task 2; Schluessel/CSR-Zuordnung und gesperrte Container (Task 4) folgen.
*/
export interface AnalyzeFile {
@@ -71,5 +72,7 @@ export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = []
locked.push(...result.locked);
});
return { items: orderItems([...byId.values()]), chains: [], locked, ignored };
const items = orderItems([...byId.values()]);
const certs = items.filter((i): i is CertItem => i.kind === 'certificate');
return { items, chains: buildChains(certs).chains, locked, ignored };
}
@@ -0,0 +1,152 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { buildChains } from './cert-chain';
import { detectBlob } from './cert-model';
import type { CertItem } from './cert-types';
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
function load(...names: string[]): CertItem[] {
const items: CertItem[] = [];
names.forEach((name, file) => {
const result = detectBlob(fx(name), { file, path: name, passwords: [] });
for (const item of result.items) if (item.kind === 'certificate') items.push(item);
});
return items;
}
const cnOf = (certs: CertItem[], id: string) => certs.find((c) => c.id === id)?.cn;
const pathCns = (certs: CertItem[], path: string[]) => path.map((id) => cnOf(certs, id));
describe('buildChains', () => {
it('Server + Zwischenzertifikat ohne Wurzel: unvollstaendig, Luecke nach der CA', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter.pem');
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
expect(pathCns(certs, chains[0].path)).toEqual(['www.example.test', 'Tessera Test Inter RSA']);
expect(chains[0].complete).toBe(false);
expect(chains[0].rootId).toBeNull();
expect(chains[0].gap).toMatchObject({
kind: 'afterCa',
missingIssuerCn: 'Tessera Test Root RSA',
});
});
it('mit Wurzel: vollstaendig, rootId gesetzt, keine Luecke', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem');
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
expect(chains[0].complete).toBe(true);
expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA');
expect(chains[0].gap).toBeNull();
expect(chains[0].path).toHaveLength(3);
});
it('nur das Serverzertifikat: Luecke nach dem Server mit Adresse des Ausstellers', () => {
const certs = load('rsa-leaf.pem');
const { chains } = buildChains(certs);
expect(chains[0].gap).toMatchObject({
kind: 'afterLeaf',
missingIssuerCn: 'Tessera Test Inter RSA',
aiaUrls: ['http://pki.example.test/rsa-inter.cer'],
});
});
it('gleichnamige CA mit anderem Schluessel (Attrappe) wird nie genommen', () => {
const certs = load(
'rsa-leaf-noaki.pem',
'rsa-inter-decoy.pem',
'rsa-inter.pem',
'rsa-root.pem',
);
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
const names = chains[0].path.map((id) => certs.find((c) => c.id === id));
expect(names[1]?.sources[0].path).toBe('rsa-inter.pem');
expect(chains[0].complete).toBe(true);
});
it('nur die Attrappe vorhanden: die Signaturpruefung lehnt sie ab, Luecke nach dem Server', () => {
const certs = load('rsa-leaf-noaki.pem', 'rsa-inter-decoy.pem', 'rsa-root.pem');
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
expect(chains[0].path).toHaveLength(1);
expect(chains[0].gap?.kind).toBe('afterLeaf');
});
it('kreuzsigniertes Zwischenzertifikat fuehrt zur zweiten Wurzel', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter-cross.pem', 'rsa-root2.pem');
const { chains } = buildChains(certs);
expect(chains[0].complete).toBe(true);
expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA 2');
});
it('beide Varianten vorhanden: Hauptkette ueber rsa-inter und rsa-root, mindestens eine Alternative', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-inter-cross.pem', 'rsa-root.pem');
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
expect(chains[0].complete).toBe(true);
expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA');
expect(chains[0].alternatives).toBeGreaterThanOrEqual(1);
});
it('abgelaufenes Zwischenzertifikat wird nicht als Hauptkette genommen', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter-expired.pem', 'rsa-inter.pem', 'rsa-root.pem');
const { chains } = buildChains(certs);
const middle = certs.find((c) => c.id === chains[0].path[1]);
expect(middle?.isExpired).toBe(false);
expect(middle?.sources[0].path).toBe('rsa-inter.pem');
expect(chains[0].alternatives).toBeGreaterThanOrEqual(1);
});
it('die Reihenfolge der Eingabe aendert die Hauptkette nicht', () => {
const names = ['rsa-leaf.pem', 'rsa-inter-expired.pem', 'rsa-inter.pem', 'rsa-root.pem'];
const forward = load(...names);
const reversed = load(...[...names].reverse());
const a = buildChains(forward).chains[0];
const b = buildChains(reversed).chains[0];
expect(a.path).toEqual(b.path);
expect(a.alternatives).toBe(b.alternatives);
});
it('Zwischenzertifikat + Wurzel ohne Serverzertifikat: eine Kette mit dem Zwischenzertifikat als Kopf', () => {
const certs = load('rsa-inter.pem', 'rsa-root.pem');
const { chains } = buildChains(certs);
expect(chains).toHaveLength(1);
expect(cnOf(certs, chains[0].headId)).toBe('Tessera Test Inter RSA');
expect(chains[0].complete).toBe(true);
});
it('selbstsigniertes Serverzertifikat: Kette aus ihm selbst, vollstaendig, ohne Wurzel', () => {
const certs = load('selfsigned-leaf.pem');
const { chains } = buildChains(certs);
expect(chains[0].path).toEqual([certs[0].id]);
expect(chains[0].complete).toBe(true);
expect(chains[0].rootId).toBeNull();
expect(chains[0].gap).toBeNull();
});
it('EC-Kette wird genauso gebaut', () => {
const certs = load('ec-leaf.pem', 'ec-inter.pem', 'ec-root.pem');
const { chains } = buildChains(certs);
expect(pathCns(certs, chains[0].path)).toEqual([
'ec.example.test',
'Tessera Test Inter EC',
'Tessera Test Root EC',
]);
expect(chains[0].complete).toBe(true);
});
it('mit vorgegebenem Kopf wird genau diese Kette gebaut', () => {
const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem');
const inter = certs.find((c) => c.role === 'intermediate') as CertItem;
const { chains } = buildChains(certs, [inter.id]);
expect(chains).toHaveLength(1);
expect(chains[0].headId).toBe(inter.id);
});
it('leere Menge ergibt keine Ketten', () => {
expect(buildChains([])).toEqual({ chains: [] });
});
});
+148
View File
@@ -0,0 +1,148 @@
import { X509Certificate } from 'node:crypto';
import type { CertItem, ChainGap, ChainInfo } from './cert-types';
/**
* Kettenbau des Zertifikat-Managers (quick-261009-ikt, D-18). Reine Funktionen, kein Netz.
*
* Aussteller eines Zertifikats C ist jedes andere Zertifikat I der Menge mit
* `C.checkIssued(I) && C.verify(I.publicKey)`: checkIssued vergleicht Namen, Schluesselkennungen
* und Schluesselverwendung, die echte Signaturpruefung entscheidet. Nur beides zusammen
* schuetzt vor einer gleichnamigen CA mit anderem Schluessel. Ein Abgleich nur ueber Namen
* waere falsch (kreuzsignierte und neu ausgestellte Zwischenzertifikate tragen denselben Namen).
*/
const MAX_DEPTH = 10;
const MAX_PATHS = 200;
const MAX_ALTERNATIVES = 10;
interface Node {
item: CertItem;
x: X509Certificate;
/** Menge der Aussteller, die beide Pruefungen bestehen (Indizes in `nodes`) */
issuers: number[];
}
function toNodes(certs: CertItem[]): Node[] {
const nodes: Node[] = [];
const seen = new Set<string>();
for (const item of certs) {
if (seen.has(item.id)) continue;
seen.add(item.id);
try {
nodes.push({ item, x: new X509Certificate(item.pem), issuers: [] });
} catch {
// kein lesbares Zertifikat: gehoert nicht in eine Kette
}
}
nodes.forEach((child, ci) => {
nodes.forEach((candidate, ii) => {
if (ci === ii) return;
try {
if (child.x.checkIssued(candidate.x) && child.x.verify(candidate.x.publicKey)) {
child.issuers.push(ii);
}
} catch {
// nicht pruefbar (z. B. unbekannter Schluesseltyp): kein Aussteller
}
});
});
return nodes;
}
interface FoundPath {
indices: number[];
/** endet an einem selbstsignierten Zertifikat */
complete: boolean;
}
function findPaths(nodes: Node[], start: number): FoundPath[] {
const found: FoundPath[] = [];
const walk = (indices: number[]) => {
if (found.length >= MAX_PATHS) return;
const last = nodes[indices[indices.length - 1]];
if (last.item.selfSigned) {
found.push({ indices: [...indices], complete: true });
return;
}
const next = last.issuers.filter((i) => !indices.includes(i));
if (next.length === 0 || indices.length >= MAX_DEPTH) {
found.push({ indices: [...indices], complete: false });
return;
}
for (const i of next) walk([...indices, i]);
};
walk([start]);
return found;
}
function notCurrentlyValid(item: CertItem, now: number): boolean {
return Date.parse(item.notBefore) > now || Date.parse(item.notAfter) < now;
}
function rank(nodes: Node[], paths: FoundPath[], now: number): FoundPath[] {
const score = (p: FoundPath) => ({
complete: p.complete ? 0 : 1,
invalid: p.indices.filter((i) => notCurrentlyValid(nodes[i].item, now)).length,
length: p.indices.length,
firstIssuerEnd: p.indices.length > 1 ? Date.parse(nodes[p.indices[1]].item.notAfter) : 0,
key: p.indices.map((i) => nodes[i].item.sha256).join('|'),
});
const scored = paths.map((p) => ({ p, s: score(p) }));
scored.sort(
(a, b) =>
a.s.complete - b.s.complete ||
a.s.invalid - b.s.invalid ||
a.s.length - b.s.length ||
b.s.firstIssuerEnd - a.s.firstIssuerEnd ||
(a.s.key < b.s.key ? -1 : a.s.key > b.s.key ? 1 : 0),
);
return scored.map((e) => e.p);
}
function defaultHeads(nodes: Node[]): number[] {
const endEntities = nodes.flatMap((n, i) => (n.item.role === 'end-entity' ? [i] : []));
if (endEntities.length > 0) return endEntities;
const issuing = new Set<number>();
for (const n of nodes) for (const i of n.issuers) issuing.add(i);
return nodes.flatMap((_, i) => (issuing.has(i) ? [] : [i]));
}
function chainOf(nodes: Node[], head: number, now: number): ChainInfo {
const ranked = rank(nodes, findPaths(nodes, head), now);
const primary = ranked[0];
const last = nodes[primary.indices[primary.indices.length - 1]].item;
let gap: ChainGap | null = null;
if (!primary.complete) {
gap = {
certId: last.id,
kind: primary.indices.length === 1 && last.role === 'end-entity' ? 'afterLeaf' : 'afterCa',
missingIssuerCn: last.issuerCn,
aiaUrls: [...last.aiaIssuerUrls],
};
}
return {
headId: nodes[head].item.id,
path: primary.indices.map((i) => nodes[i].item.id),
rootId: primary.complete && last.role === 'root' ? last.id : null,
complete: primary.complete,
gap,
alternatives: Math.min(ranked.length - 1, MAX_ALTERNATIVES),
};
}
/**
* Baut je Kopf eine Kette (Kopf zuerst, dann jeder Aussteller). Standardkoepfe: jedes Serverzertifikat,
* sonst jedes Zertifikat, das kein anderes ausgestellt hat. `headIds` waehlt eigene Koepfe.
* Das Ergebnis haengt nicht von der Reihenfolge der Eingabe ab.
*/
export function buildChains(certs: CertItem[], headIds?: string[]): { chains: ChainInfo[] } {
const nodes = toNodes(certs);
const heads = headIds
? headIds.flatMap((id) => {
const index = nodes.findIndex((n) => n.item.id === id);
return index < 0 ? [] : [index];
})
: defaultHeads(nodes);
const now = Date.now();
return { chains: heads.map((h) => chainOf(nodes, h, now)) };
}
@@ -0,0 +1,116 @@
import { PassThrough } from 'node:stream';
import { describe, expect, it, vi } from 'vitest';
import {
CERT_BUILD_JSON_LIMIT,
CERT_BUILD_ROUTE,
certBuildBodyErrors,
certBuildJsonBody,
} from './cert-json-body';
import {
CERT_BASENAME_MAX,
CERT_PASSWORD_MAX,
CERT_PEM_MAX,
CERT_POOL_MAX,
} from './dto/cert-build.dto';
/** PEM-aehnlicher Text: eine Zeile je 64 Zeichen, genau `chars` Zeichen lang. */
function pemLike(chars: number): string {
let out = '';
while (out.length < chars) out += `${'A'.repeat(64)}\n`;
return out.slice(0, chars);
}
interface FakeReq extends PassThrough {
headers: Record<string, string>;
method: string;
body?: unknown;
}
function fakeRequest(raw: string): FakeReq {
const req = new PassThrough() as FakeReq;
req.headers = {
'content-type': 'application/json',
'content-length': String(Buffer.byteLength(raw)),
};
req.method = 'POST';
req.end(raw);
return req;
}
function runParser(raw: string): Promise<{ req: FakeReq; error: unknown }> {
const req = fakeRequest(raw);
return new Promise((resolve) => {
certBuildJsonBody(req as never, {} as never, (error?: unknown) => resolve({ req, error }));
});
}
function fakeResponse() {
const res = {
statusCode: 0,
payload: undefined as unknown,
status(code: number) {
res.statusCode = code;
return res;
},
json(body: unknown) {
res.payload = body;
return res;
},
};
return res;
}
describe('cert-json-body (D-26)', () => {
it('traegt den Namen certBuildJsonBody, nie jsonParser oder urlencodedParser', () => {
expect(certBuildJsonBody.name).toBe('certBuildJsonBody');
expect(CERT_BUILD_ROUTE).toBe('/modules/cert-manager/build');
expect(CERT_BUILD_JSON_LIMIT).toBe(512 * 1024);
});
it('die groesste Anfrage innerhalb der DTO-Grenzen bleibt unter dem Grenzwert und wird gelesen', async () => {
const body = {
content: 'fullchain',
certPem: pemLike(CERT_PEM_MAX),
poolPems: Array.from({ length: CERT_POOL_MAX }, () => pemLike(CERT_PEM_MAX)),
keyPem: pemLike(CERT_PEM_MAX),
csrPem: pemLike(CERT_PEM_MAX),
password: 'p'.repeat(CERT_PASSWORD_MAX),
baseName: 'b'.repeat(CERT_BASENAME_MAX),
};
const raw = JSON.stringify(body);
expect(Buffer.byteLength(raw)).toBeLessThan(CERT_BUILD_JSON_LIMIT);
const { req, error } = await runParser(raw);
expect(error).toBeUndefined();
expect((req.body as typeof body).poolPems).toHaveLength(CERT_POOL_MAX);
});
it('eine Anfrage ueber 512 KiB wird mit 413 und Code tooLarge beantwortet', async () => {
const { error } = await runParser(JSON.stringify({ content: 'x'.repeat(600 * 1024) }));
const res = fakeResponse();
const next = vi.fn();
certBuildBodyErrors(error, {} as never, res as never, next);
expect(res.statusCode).toBe(413);
expect(res.payload).toMatchObject({ code: 'tooLarge' });
expect(typeof (res.payload as { message: string }).message).toBe('string');
expect(next).not.toHaveBeenCalled();
});
it('fehlerhaftes JSON ergibt 400 mit Code invalidInput', async () => {
const { error } = await runParser('{"content": ');
const res = fakeResponse();
const next = vi.fn();
certBuildBodyErrors(error, {} as never, res as never, next);
expect(res.statusCode).toBe(400);
expect(res.payload).toMatchObject({ code: 'invalidInput' });
expect(next).not.toHaveBeenCalled();
});
it('jeden anderen Fehler reicht die Funktion unveraendert weiter', () => {
const other = new Error('boom');
const res = fakeResponse();
const next = vi.fn();
certBuildBodyErrors(other, {} as never, res as never, next);
expect(next).toHaveBeenCalledWith(other);
expect(res.statusCode).toBe(0);
});
});
@@ -0,0 +1,59 @@
import { createRequire } from 'node:module';
import type { NextFunction, Request, Response } from 'express';
/**
* Eigener JSON-Leser fuer POST build (quick-261009-ikt, D-26).
*
* Nests Standardgrenze fuer JSON ist 100 kB. Eine Anfrage innerhalb der DTO-Grenzen
* (cert-build.dto.ts) kann aber bis etwa 384 kB gross werden: viele Zertifikate im Pool, dazu
* Schluessel und Anfrage. Darum bekommt genau diese Route 512 KiB; jede andere Route behaelt
* die 100 kB. Eine groessere Anfrage wird mit 413 und dem Code tooLarge beantwortet (statt mit
* einer Antwort ohne Code), fehlerhaftes JSON mit 400 und dem Code invalidInput.
*
* Registriert wird der Leser in main.ts mit app.use(CERT_BUILD_ROUTE, ...) vor app.listen.
* Wichtig:
* - Die Funktion darf NICHT jsonParser heissen. Nests Express-Adapter ueberspringt seinen
* globalen JSON-Leser fuer ALLE Routen, sobald eine Schicht mit diesem Namen existiert; dann
* wuerde auch die Anmeldung kein JSON mehr lesen. Darum der Name certBuildJsonBody.
* - `express` ist vom API-Paket aus nicht direkt aufloesbar. Es wird ueber @nestjs/platform-express
* geladen, also aus derselben Kopie, die Nest selbst benutzt. Ein neues Paket kommt nicht hinzu.
* - body-parser lehnt eine Anfrage ab, deren Koerper schon gelesen ist; Nests globaler Leser liest
* den Koerper daher kein zweites Mal.
*/
export const CERT_BUILD_ROUTE = '/modules/cert-manager/build';
export const CERT_BUILD_JSON_LIMIT = 512 * 1024;
const expressFromNest = createRequire(
createRequire(__filename).resolve('@nestjs/platform-express'),
)('express') as typeof import('express');
const parseJson = expressFromNest.json({ limit: CERT_BUILD_JSON_LIMIT });
export function certBuildJsonBody(req: Request, res: Response, next: NextFunction): void {
parseJson(req, res, next);
}
interface BodyParserError {
type?: string;
status?: number;
}
/** Fehler des Lesers: zu gross -> 413 tooLarge, kein gueltiges JSON -> 400 invalidInput, sonst weiter. */
export function certBuildBodyErrors(
error: unknown,
_req: Request,
res: Response,
next: NextFunction,
): void {
const e = (error ?? {}) as BodyParserError;
if (e.type === 'entity.too.large') {
res.status(413).json({ code: 'tooLarge', message: 'Request body exceeds 512 KiB' });
return;
}
if (e.type === 'entity.parse.failed') {
res.status(400).json({ code: 'invalidInput', message: 'Request body is not valid JSON' });
return;
}
next(error);
}
@@ -32,15 +32,32 @@ describe('CertManagerController', () => {
expect(Reflect.getMetadata(MODULE_SLUG_KEY, CertManagerController)).toBe('cert-manager');
});
it('bietet in diesem Stand genau den Handler analyze (POST analyze, Code 200)', () => {
it('bietet in diesem Stand genau die Handler analyze und build (POST, Code 200)', () => {
const handlers = Object.getOwnPropertyNames(CertManagerController.prototype).filter(
(n) => n !== 'constructor',
);
expect(handlers).toEqual(['analyze']);
const handler = CertManagerController.prototype.analyze;
expect(Reflect.getMetadata('path', handler)).toBe('analyze');
expect(Reflect.getMetadata('method', handler)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata('__httpCode__', handler)).toBe(200);
expect(handlers).toEqual(['analyze', 'build']);
for (const [name, path] of [
['analyze', 'analyze'],
['build', 'build'],
] as const) {
const handler = CertManagerController.prototype[name];
expect(Reflect.getMetadata('path', handler)).toBe(path);
expect(Reflect.getMetadata('method', handler)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata('__httpCode__', handler)).toBe(200);
}
});
it('build baut die Fullchain aus den gesendeten Zertifikaten', () => {
const text = (n: string) => fx(n).toString('utf8');
const result = controller.build({
content: 'fullchain',
format: 'pem',
certPem: text('ec-leaf.pem'),
poolPems: [text('ec-inter.pem'), text('ec-root.pem')],
});
expect(result.files[0].filename).toBe('ec.example.test-fullchain.pem');
expect(result.chainComplete).toBe(true);
});
it('ohne Dateien: 400 invalidInput', () => {
@@ -1,9 +1,11 @@
import { Controller, HttpCode, Post, UploadedFiles, UseInterceptors } from '@nestjs/common';
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 { type AnalysisResult, certError } from './cert-types';
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;
@@ -28,7 +30,7 @@ export function repairFileName(name: string): string {
*
* Routen (alle POST, 200):
* - analyze Task 1 mehrere Dateien (multipart) erkennen und zusammenfassen
* - build Task 2 Ausgabe bauen (JSON), ab Task 5/6 erweitert
* - 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')
@@ -51,4 +53,10 @@ export class CertManagerController {
files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })),
);
}
@Post('build')
@HttpCode(200)
build(@Body() dto: BuildOutputDto): BuildResult {
return buildOutput(dto);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createHash, type KeyObject, X509Certificate } from 'node:crypto';
import { safeBaseName } from './cert-output';
import { safeBaseName } from './cert-names';
import type {
AnyItem,
CertItem,
+14
View File
@@ -0,0 +1,14 @@
/**
* Dateinamen-Helfer des Zertifikat-Managers. Eigene Datei, damit cert-model.ts und cert-output.ts
* einander nicht gegenseitig einbinden muessen.
*/
/** 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;
}
+125 -1
View File
@@ -1,5 +1,30 @@
import { X509Certificate } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { safeBaseName } from './cert-output';
import { buildOutput, safeBaseName } from './cert-output';
const fxText = (name: string) => readFileSync(join(__dirname, '__fixtures__', name), 'utf8');
function decode(file: { content: string }): string {
return Buffer.from(file.content, 'base64').toString('utf8');
}
function subjects(pem: string): string[] {
return (pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? []).map(
(block) => String(new X509Certificate(block).toLegacyObject().subject.CN),
);
}
function codeOf(fn: () => unknown): { status: number; code: string } {
try {
fn();
} catch (error) {
const e = error as { getStatus(): number; getResponse(): { code: string } };
return { status: e.getStatus(), code: e.getResponse().code };
}
throw new Error('expected a throw');
}
describe('safeBaseName', () => {
it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => {
@@ -16,3 +41,102 @@ describe('safeBaseName', () => {
expect(safeBaseName('...', 'ersatz')).toBe('ersatz');
});
});
describe('buildOutput fullchain und chain', () => {
const ecLeaf = fxText('ec-leaf.pem');
// absichtlich falsche Reihenfolge plus ein fremdes Zwischenzertifikat
const pool = [fxText('ec-root.pem'), fxText('ec-inter.pem'), fxText('rsa-inter.pem')];
it('Fullchain: Server zuerst, dann Zwischenzertifikat, ohne Wurzel und ohne Fremdes', () => {
const r = buildOutput({ content: 'fullchain', format: 'pem', certPem: ecLeaf, poolPems: pool });
expect(r.files).toHaveLength(1);
expect(r.files[0].filename).toBe('ec.example.test-fullchain.pem');
expect(r.files[0].mimeType).toBe('application/x-pem-file');
expect(subjects(decode(r.files[0]))).toEqual(['ec.example.test', 'Tessera Test Inter EC']);
expect(decode(r.files[0]).endsWith('\n')).toBe(true);
expect(r.chainComplete).toBe(true);
expect(r.missingIssuerCn).toBeNull();
});
it('Fullchain mit Wurzel: drei Bloecke, Wurzel zuletzt', () => {
const r = buildOutput({
content: 'fullchain',
format: 'pem',
certPem: ecLeaf,
poolPems: pool,
includeRoot: true,
});
expect(subjects(decode(r.files[0]))).toEqual([
'ec.example.test',
'Tessera Test Inter EC',
'Tessera Test Root EC',
]);
});
it('Nur Kette: nur das Zwischenzertifikat, mit Wurzel beide', () => {
const only = buildOutput({ content: 'chain', format: 'pem', certPem: ecLeaf, poolPems: pool });
expect(only.files[0].filename).toBe('ec.example.test-chain.pem');
expect(subjects(decode(only.files[0]))).toEqual(['Tessera Test Inter EC']);
const withRoot = buildOutput({
content: 'chain',
format: 'pem',
certPem: ecLeaf,
poolPems: pool,
includeRoot: true,
});
expect(subjects(decode(withRoot.files[0]))).toEqual([
'Tessera Test Inter EC',
'Tessera Test Root EC',
]);
});
it('Nur Kette ohne Zwischenzertifikat: 400 noChain', () => {
expect(
codeOf(() =>
buildOutput({ content: 'chain', format: 'pem', certPem: fxText('rsa-leaf.pem') }),
),
).toEqual({ status: 400, code: 'noChain' });
});
it('kein Zertifikat in certPem oder im Pool: 400 notACertificate', () => {
expect(
codeOf(() =>
buildOutput({ content: 'fullchain', format: 'pem', certPem: 'kein Zertifikat' }),
),
).toEqual({ status: 400, code: 'notACertificate' });
expect(
codeOf(() =>
buildOutput({ content: 'fullchain', format: 'pem', certPem: ecLeaf, poolPems: ['x'] }),
),
).toEqual({ status: 400, code: 'notACertificate' });
});
it('ohne certPem: 400 invalidInput', () => {
expect(codeOf(() => buildOutput({ content: 'fullchain', format: 'pem' }))).toEqual({
status: 400,
code: 'invalidInput',
});
});
it('Luecke wird gemeldet: Server + Zwischenzertifikat ohne Wurzel', () => {
const r = buildOutput({
content: 'fullchain',
format: 'pem',
certPem: fxText('rsa-leaf.pem'),
poolPems: [fxText('rsa-inter.pem')],
});
expect(r.chainComplete).toBe(false);
expect(r.missingIssuerCn).toBe('Tessera Test Root RSA');
expect(subjects(decode(r.files[0]))).toEqual(['www.example.test', 'Tessera Test Inter RSA']);
});
it('Dateiname aus baseName, bereinigt', () => {
const r = buildOutput({
content: 'fullchain',
format: 'pem',
certPem: ecLeaf,
baseName: '../mein Server',
});
expect(r.files[0].filename).toBe('mein_Server-fullchain.pem');
});
});
+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,
};
}
@@ -0,0 +1,58 @@
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
/**
* Anfrage fuer POST build (quick-261009-ikt, D-19). Die Obergrenzen stehen hier als Konstanten,
* weil cert-json-body.ts daraus die groesste gueltige Anfrage berechnet (D-26):
*
* certPem 16 384 + 20 x poolPems 16 384 + keyPem 16 384 + csrPem 16 384 Zeichen
* + password 256 + baseName 120 + JSON-Maskierung der Zeilenumbrueche (etwa +1,6 %)
* = rund 384 kB, also deutlich unter dem Grenzwert von 512 KiB.
*
* Task 2 kennt nur Fullchain und Nur Kette als PEM; weitere Inhalte, Formate und Felder
* (Schluessel, Passwort, Vorlage) folgen in Task 5 und 6.
*/
export const CERT_PEM_MAX = 16_384;
export const CERT_POOL_MAX = 20;
export const CERT_PASSWORD_MAX = 256;
export const CERT_BASENAME_MAX = 120;
export const BUILD_CONTENTS = ['fullchain', 'chain'] as const;
export const BUILD_FORMATS = ['pem'] as const;
export class BuildOutputDto {
@IsIn(BUILD_CONTENTS)
content!: (typeof BUILD_CONTENTS)[number];
@IsOptional()
@IsIn(BUILD_FORMATS)
format?: (typeof BUILD_FORMATS)[number];
@IsOptional()
@IsString()
@MaxLength(CERT_PEM_MAX)
certPem?: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(CERT_POOL_MAX)
@IsString({ each: true })
@MaxLength(CERT_PEM_MAX, { each: true })
poolPems?: string[];
@IsOptional()
@IsBoolean()
includeRoot?: boolean;
@IsOptional()
@IsString()
@MaxLength(CERT_BASENAME_MAX)
baseName?: string;
}