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' }]); expect(result.ignored).toEqual([{ file: 6, path: 'readme.txt', reason: 'unknown' }]);
}); });
it('Ketten und gesperrte Eintraege sind in diesem Stand leer', () => { it('liefert zwei Ketten: RSA unvollstaendig (Luecke nach der CA), EC vollstaendig mit Wurzel', () => {
expect(result.chains).toEqual([]); 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([]); expect(result.locked).toEqual([]);
}); });
+5 -2
View File
@@ -1,3 +1,4 @@
import { buildChains } from './cert-chain';
import { detectBlob } from './cert-model'; import { detectBlob } from './cert-model';
import type { import type {
AnalysisResult, AnalysisResult,
@@ -11,7 +12,7 @@ import type {
/** /**
* Fassade der Analyse (quick-261009-ikt, D-15): alle hochgeladenen Dateien erkennen, * Fassade der Analyse (quick-261009-ikt, D-15): alle hochgeladenen Dateien erkennen,
* gleiche Teile zusammenfassen und ordnen. Zustandslos; nichts wird gespeichert. * 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 { export interface AnalyzeFile {
@@ -71,5 +72,7 @@ export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = []
locked.push(...result.locked); 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'); 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( const handlers = Object.getOwnPropertyNames(CertManagerController.prototype).filter(
(n) => n !== 'constructor', (n) => n !== 'constructor',
); );
expect(handlers).toEqual(['analyze']); expect(handlers).toEqual(['analyze', 'build']);
const handler = CertManagerController.prototype.analyze; for (const [name, path] of [
expect(Reflect.getMetadata('path', handler)).toBe('analyze'); ['analyze', 'analyze'],
expect(Reflect.getMetadata('method', handler)).toBe(RequestMethod.POST); ['build', 'build'],
expect(Reflect.getMetadata('__httpCode__', handler)).toBe(200); ] 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', () => { 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 { FilesInterceptor } from '@nestjs/platform-express';
import type { UploadedFileLike } from '../auth/types/auth-user'; import type { UploadedFileLike } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard'; import { UseModule } from '../module-registry/module.guard';
import { analyzeWorkingSet } from './cert-analyze'; 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. */ /** 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_FILES = 30;
@@ -28,7 +30,7 @@ export function repairFileName(name: string): string {
* *
* Routen (alle POST, 200): * Routen (alle POST, 200):
* - analyze Task 1 mehrere Dateien (multipart) erkennen und zusammenfassen * - 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 * - fetch-issuer Task 7 fehlendes Zwischenzertifikat nur auf Knopfdruck holen
*/ */
@Controller('modules/cert-manager') @Controller('modules/cert-manager')
@@ -51,4 +53,10 @@ export class CertManagerController {
files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })), 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 { createHash, type KeyObject, X509Certificate } from 'node:crypto';
import { safeBaseName } from './cert-output'; import { safeBaseName } from './cert-names';
import type { import type {
AnyItem, AnyItem,
CertItem, 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 { 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', () => { describe('safeBaseName', () => {
it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => { it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => {
@@ -16,3 +41,102 @@ describe('safeBaseName', () => {
expect(safeBaseName('...', 'ersatz')).toBe('ersatz'); 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). * Ausgabe-Bausteine des Zertifikat-Managers (quick-261009-ikt, D-19).
* Task 1: nur der Dateiname-Helfer; die Ausgabeformate (build) kommen in den folgenden Tasks. * 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 { safeBaseName };
export function safeBaseName(raw: string, fallback: string): string {
const cleaned = raw const PEM_MIME = 'application/x-pem-file';
.replace(/^\*\./, 'wildcard.')
.replace(/[^A-Za-z0-9._-]+/g, '_') function parseCertificate(pem: unknown): CertItem {
.replace(/^[._]+/, '') if (typeof pem !== 'string' || pem.trim() === '') {
.slice(0, 80); certError('notACertificate', 400, 'Value is not a certificate');
return cleaned || fallback; }
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;
}
+9
View File
@@ -3,6 +3,11 @@ import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import {
CERT_BUILD_ROUTE,
certBuildBodyErrors,
certBuildJsonBody,
} from './cert-manager/cert-json-body';
import { requestLogMiddleware } from './common/request-log'; import { requestLogMiddleware } from './common/request-log';
import { formatAppVersionLine } from './health/app-version'; import { formatAppVersionLine } from './health/app-version';
@@ -16,6 +21,10 @@ async function bootstrap() {
// Cookie parser for JWT httpOnly cookies // Cookie parser for JWT httpOnly cookies
app.use(cookieParser()); app.use(cookieParser());
// Eigene JSON-Grenze (512 KiB) nur fuer POST build des Zertifikat-Managers, vor Nests globalem Leser
// (quick-261009-ikt D-26)
app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors);
// Global validation pipe with whitelist and transform // Global validation pipe with whitelist and transform
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
@@ -124,6 +124,38 @@ export interface AnalysisResult {
ignored: IgnoredEntry[]; ignored: IgnoredEntry[];
} }
export type BuildContent = 'leaf' | 'fullchain' | 'chain' | 'leafKey' | 'pfx' | 'key' | 'csr';
/** Anfrage fuer POST build (D-19); die API baut die Reihenfolge immer selbst aus den gesendeten Zertifikaten. */
export interface BuildInput {
content: BuildContent;
format?: string;
certPem?: string;
poolPems?: string[];
keyPem?: string;
csrPem?: string;
includeRoot?: boolean;
includeChain?: boolean;
password?: string;
pfxEncryption?: 'compat' | 'modern';
template?: string;
baseName?: string;
}
export interface BuildFile {
filename: string;
/** Base64 */
content: string;
mimeType: string;
}
export interface BuildResult {
files: BuildFile[];
chainComplete: boolean;
missingIssuerCn: string | null;
snippet?: string | null;
}
/** Fehler der API mit Statuscode und (falls vorhanden) dem Fehlercode aus `{ code, message }`. */ /** Fehler der API mit Statuscode und (falls vorhanden) dem Fehlercode aus `{ code, message }`. */
export class CertManagerRequestError extends Error { export class CertManagerRequestError extends Error {
constructor( constructor(
@@ -183,6 +215,18 @@ export async function analyzeWorkingSet(entries: WorkingEntry[]): Promise<Analys
return (await response.json()) as AnalysisResult; return (await response.json()) as AnalysisResult;
} }
/** POST /modules/cert-manager/build: JSON, Antwort mit fertigen Dateien (Base64). Passwoerter nur im Koerper. */
export async function buildOutput(input: BuildInput): Promise<BuildResult> {
const response = await fetch(`${API_URL}/modules/cert-manager/build`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
credentials: 'include',
});
if (!response.ok) throw await errorFromResponse(response);
return (await response.json()) as BuildResult;
}
/** Laedt eine Base64-Datei als Browser-Download herunter. Der Dateiname enthaelt nie ein Passwort. */ /** Laedt eine Base64-Datei als Browser-Download herunter. Der Dateiname enthaelt nie ein Passwort. */
export function downloadBase64(filename: string, content: string, mimeType: string): void { export function downloadBase64(filename: string, content: string, mimeType: string): void {
const bytes = atob(content); const bytes = atob(content);
@@ -1,4 +1,11 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'; import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -36,13 +43,14 @@ beforeEach(() => {
afterEach(cleanup); afterEach(cleanup);
describe('CertManagerPage', () => { describe('CertManagerPage', () => {
it('zeigt Titel und den einen Reiter „Dateien“ als aktiven Reiter', () => { it('zeigt Titel und die Reiter „Dateien“ und „Zusammenführen“, „Dateien“ ist aktiv', () => {
render(<CertManagerPage />); render(<CertManagerPage />);
expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument();
const tabs = screen.getAllByRole('button').filter((b) => b.hasAttribute('aria-current')); const nav = screen.getByRole('navigation');
expect(tabs).toHaveLength(1); const tabs = within(nav).getAllByRole('button');
expect(tabs[0]).toHaveTextContent('Dateien'); expect(tabs.map((b) => b.textContent)).toEqual(['Dateien', 'Zusammenführen']);
expect(tabs[0]).toHaveAttribute('aria-current', 'page'); expect(tabs[0]).toHaveAttribute('aria-current', 'page');
expect(tabs[1]).not.toHaveAttribute('aria-current');
// Startansicht ist der Dateien-Reiter // Startansicht ist der Dateien-Reiter
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument(); expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
}); });
@@ -67,4 +75,24 @@ describe('CertManagerPage', () => {
render(<CertManagerPage />); render(<CertManagerPage />);
expect(mockAnalyze).not.toHaveBeenCalled(); expect(mockAnalyze).not.toHaveBeenCalled();
}); });
it('Zusammenführen mit leerem Arbeitsbereich: Hinweis und Knopf zum Reiter „Dateien“', () => {
render(<CertManagerPage />);
fireEvent.click(screen.getByRole('button', { name: 'Zusammenführen' }));
expect(screen.getByText(/Noch keine Dateien\. Laden Sie Ihre Zertifikate/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Zum Reiter „Dateien“' }));
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
});
it('mit Dateien zeigt Zusammenführen die Grundlage, und die Liste bleibt beim Reiterwechsel', async () => {
render(<CertManagerPage />);
fireEvent.change(screen.getByTestId('cert-file-input'), {
target: { files: [new File(['a'], 'a.pem', { lastModified: 1 })] },
});
await waitFor(() => expect(mockAnalyze).toHaveBeenCalled());
fireEvent.click(screen.getByRole('button', { name: 'Zusammenführen' }));
expect(screen.getByText('Grundlage: 1 Datei aus dem Reiter „Dateien“.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Dateien (1)' }));
expect(screen.getByText('a.pem')).toBeInTheDocument();
});
}); });
@@ -0,0 +1,81 @@
'use client';
import { useFormatter, useTranslations } from 'next-intl';
import type { CertItem, ChainInfo } from '../actions';
import { ROLE_STYLES } from './FilesTab';
interface ChainViewProps {
chain: ChainInfo;
certs: CertItem[];
}
/**
* Eine Kette in Reihenfolge: Serverzertifikat zuerst, dann jeder Aussteller. Eine duenne Linie
* verbindet die Schritte. Fehlt ein Aussteller, steht darunter, was fehlt (D-18).
*/
export function ChainView({ chain, certs }: ChainViewProps) {
const t = useTranslations('certManager');
const format = useFormatter();
const steps = chain.path
.map((id) => certs.find((c) => c.id === id))
.filter((c): c is CertItem => c !== undefined);
return (
<div className="space-y-2">
<ol className="ml-2 space-y-0 border-l border-border">
{steps.map((cert) => (
<li key={cert.id} className="relative space-y-0.5 py-2 pl-4">
<span
aria-hidden="true"
className="absolute -left-[5px] top-3.5 size-2.5 rounded-full border border-border bg-card"
/>
<div className="flex flex-wrap items-center gap-2">
<span
className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[cert.role]}`}
>
{t(`roles.${cert.role}`)}
</span>
<span className="break-all text-sm font-medium text-foreground">
{cert.cn || cert.baseName}
</span>
{cert.isExpired && (
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-300">
{t('files.expired')}
</span>
)}
</div>
<p className="flex flex-wrap gap-x-4 text-xs text-muted-foreground">
<span>
{t('chain.issuedBy', { name: cert.issuerCn || t('chain.unknownIssuer') })}
</span>
<span>
{t('chain.validUntil', {
date: format.dateTime(new Date(cert.notAfter), { dateStyle: 'medium' }),
})}
</span>
</p>
</li>
))}
</ol>
{chain.gap?.kind === 'afterLeaf' && (
<div
role="alert"
className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200"
>
<p className="font-medium">
{t('chain.gapAfterLeaf', {
name: chain.gap.missingIssuerCn || t('chain.unknownIssuer'),
})}
</p>
<p className="mt-1 text-xs">{t('chain.gapAfterLeafHint')}</p>
</div>
)}
{chain.gap?.kind === 'afterCa' && (
<p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
{t('chain.gapAfterCa', { name: chain.gap.missingIssuerCn || t('chain.unknownIssuer') })}
</p>
)}
</div>
);
}
@@ -0,0 +1,20 @@
'use client';
import { useTranslations } from 'next-intl';
interface EmptyWorkspaceProps {
onGoToFiles: () => void;
}
/** Hinweis in allen Reitern ausser „Dateien“, solange der Arbeitsbereich leer ist (D-23). */
export function EmptyWorkspace({ onGoToFiles }: EmptyWorkspaceProps) {
const t = useTranslations('certManager');
return (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">{t('workspace.empty')}</p>
<button type="button" onClick={onGoToFiles} className="btn btn-secondary">
{t('workspace.goToFiles')}
</button>
</div>
);
}
@@ -0,0 +1,219 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
import type { AnalysisResult, CertItem, ChainInfo } from '../actions';
import { CertManagerRequestError } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { MergeTab } from './MergeTab';
const mockBuild = vi.fn();
const mockDownload = vi.fn();
vi.mock('../actions', async (importOriginal) => {
const actual = await importOriginal<typeof import('../actions')>();
return {
...actual,
buildOutput: (...args: unknown[]) => mockBuild(...args),
downloadBase64: (...args: unknown[]) => mockDownload(...args),
};
});
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
function cert(id: string, cn: string, role: CertItem['role'], issuerCn: string): CertItem {
return {
id,
kind: 'certificate',
role,
sources: [{ file: 0, path: `${id}.pem` }],
pem: `PEM-${id}`,
baseName: cn.replace(/\s/g, '_'),
cn,
organization: '',
issuerCn,
issuerOrganization: '',
notBefore: '2026-01-01T00:00:00.000Z',
notAfter: '2126-01-01T00:00:00.000Z',
isExpired: false,
daysLeft: 36000,
san: [],
keyType: 'RSA',
keyBits: 2048,
curve: null,
serialNumber: '01',
sha256: '',
sha1: '',
isCa: role !== 'end-entity',
selfSigned: role === 'root',
aiaIssuerUrls: [],
keyId: null,
csrIds: [],
};
}
const leaf = cert('c-leaf', 'www.example.test', 'end-entity', 'Test Inter');
const inter = cert('c-inter', 'Test Inter', 'intermediate', 'Test Root');
const root = cert('c-root', 'Test Root', 'root', 'Test Root');
const leaf2 = cert('c-leaf2', 'api.example.test', 'end-entity', 'Test Inter');
function chain(over: Partial<ChainInfo> & Pick<ChainInfo, 'headId' | 'path'>): ChainInfo {
return { rootId: null, complete: false, gap: null, alternatives: 0, ...over };
}
function workspace(items: CertItem[], chains: ChainInfo[]): CertWorkspace {
const analysis: AnalysisResult = { items, chains, locked: [], ignored: [] };
return {
entries: [],
analysis,
analysisIds: [],
status: 'idle',
errorKey: null,
addFiles: () => [],
remove: () => {},
clear: () => {},
retry: () => {},
};
}
const completeChain = chain({
headId: 'c-leaf',
path: ['c-leaf', 'c-inter', 'c-root'],
rootId: 'c-root',
complete: true,
});
beforeEach(() => {
mockBuild.mockReset();
mockDownload.mockReset();
mockBuild.mockResolvedValue({
files: [
{ filename: 'www.example.test-fullchain.pem', content: 'QQ==', mimeType: 'text/plain' },
],
chainComplete: true,
missingIssuerCn: null,
});
});
afterEach(cleanup);
describe('MergeTab', () => {
it('zeigt die Kette in Reihenfolge mit Rollen', () => {
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
const steps = screen.getAllByRole('listitem');
expect(steps).toHaveLength(3);
expect(steps[0]).toHaveTextContent('Serverzertifikat');
expect(steps[0]).toHaveTextContent('www.example.test');
expect(steps[1]).toHaveTextContent('Zwischenzertifikat');
expect(steps[2]).toHaveTextContent('Stammzertifikat');
});
it('bei mehreren Kettenkoepfen: Auswahl per Radio, die Kette wechselt', () => {
const second = chain({ headId: 'c-leaf2', path: ['c-leaf2', 'c-inter'] });
render(<MergeTab workspace={workspace([leaf, leaf2, inter], [completeChain, second])} />);
const radios = screen.getAllByRole('radio');
expect(radios).toHaveLength(2);
expect(radios[0]).toBeChecked();
fireEvent.click(radios[1]);
expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('api.example.test');
});
it('Root-Zertifikat mitnehmen ist zunaechst nicht angehakt', () => {
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
expect(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' })).not.toBeChecked();
});
it('ohne Wurzel im Pfad ist das Haekchen gesperrt und der Grund steht da', () => {
const noRoot = chain({
headId: 'c-leaf',
path: ['c-leaf', 'c-inter'],
gap: { certId: 'c-inter', kind: 'afterCa', missingIssuerCn: 'Test Root', aiaUrls: [] },
});
render(<MergeTab workspace={workspace([leaf, inter], [noRoot])} />);
expect(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' })).toBeDisabled();
expect(screen.getByText(/nicht in Ihren Dateien/)).toBeInTheDocument();
});
it('Fullchain herunterladen: ein Aufruf von buildOutput ohne Wurzel, dann der Download', async () => {
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
fireEvent.click(screen.getByRole('button', { name: 'Fullchain herunterladen' }));
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
expect(mockBuild).toHaveBeenCalledTimes(1);
expect(mockBuild).toHaveBeenCalledWith({
content: 'fullchain',
format: 'pem',
certPem: 'PEM-c-leaf',
poolPems: ['PEM-c-inter', 'PEM-c-root'],
includeRoot: false,
baseName: 'www.example.test',
});
expect(mockDownload).toHaveBeenCalledWith(
'www.example.test-fullchain.pem',
'QQ==',
'text/plain',
);
});
it('mit angehaktem Root wird includeRoot gesendet', async () => {
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
fireEvent.click(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' }));
fireEvent.click(screen.getByRole('button', { name: 'Fullchain herunterladen' }));
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
expect(mockBuild.mock.calls[0][0]).toMatchObject({ content: 'fullchain', includeRoot: true });
});
it('Nur Kette herunterladen sendet den Inhalt chain', async () => {
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
fireEvent.click(screen.getByRole('button', { name: 'Nur Kette herunterladen' }));
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
expect(mockBuild.mock.calls[0][0]).toMatchObject({ content: 'chain', includeRoot: false });
});
it('Luecke nach dem Server: „Zwischenzertifikat fehlt“ mit dem Namen', () => {
const gap = chain({
headId: 'c-leaf',
path: ['c-leaf'],
gap: { certId: 'c-leaf', kind: 'afterLeaf', missingIssuerCn: 'Test Inter', aiaUrls: [] },
});
render(<MergeTab workspace={workspace([leaf], [gap])} />);
expect(screen.getByRole('alert')).toHaveTextContent('Zwischenzertifikat fehlt: „Test Inter“');
});
it('Luecke nach einer CA: ruhiger Hinweis, kein Warnfeld', () => {
const gap = chain({
headId: 'c-leaf',
path: ['c-leaf', 'c-inter'],
gap: { certId: 'c-inter', kind: 'afterCa', missingIssuerCn: 'Test Root', aiaUrls: [] },
});
render(<MergeTab workspace={workspace([leaf, inter], [gap])} />);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByText(/meist das Stammzertifikat „Test Root“/)).toBeInTheDocument();
});
it('ein Fehlercode der API zeigt den deutschen Text', async () => {
mockBuild.mockRejectedValueOnce(new CertManagerRequestError(400, 'noChain'));
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
fireEvent.click(screen.getByRole('button', { name: 'Nur Kette herunterladen' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Es gibt keine Kette');
expect(mockDownload).not.toHaveBeenCalled();
});
it('413 ohne Code zeigt den Text zu tooLarge', async () => {
mockBuild.mockRejectedValueOnce(new CertManagerRequestError(413, null));
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
fireEvent.click(screen.getByRole('button', { name: 'Fullchain herunterladen' }));
expect(await screen.findByRole('alert')).toHaveTextContent('zusammen zu groß');
});
it('ohne erkannte Zertifikate steht ein Hinweis statt der Knoepfe', () => {
render(<MergeTab workspace={workspace([], [])} />);
expect(screen.getByText(/noch kein Zertifikat erkannt/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Fullchain herunterladen' })).toBeNull();
});
});
@@ -0,0 +1,151 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import {
type BuildContent,
buildOutput,
type CertItem,
certErrorKey,
downloadBase64,
} from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { ChainView } from './ChainView';
interface MergeTabProps {
workspace: CertWorkspace;
}
/**
* Reiter „Zusammenführen“: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst
* (die API prueft Aussteller und Signatur) und liefert Fullchain oder Nur Kette. Das
* Stammzertifikat kommt nur mit, wenn es angehakt ist (D-02, Vorgabe aus).
*/
export function MergeTab({ workspace }: MergeTabProps) {
const t = useTranslations('certManager');
const [selectedHead, setSelectedHead] = useState<string | null>(null);
const [includeRoot, setIncludeRoot] = useState(false);
const [busy, setBusy] = useState<BuildContent | null>(null);
const [errorKey, setErrorKey] = useState<string | null>(null);
const certs = (workspace.analysis?.items ?? []).filter(
(i): i is CertItem => i.kind === 'certificate',
);
const chains = workspace.analysis?.chains ?? [];
if (workspace.status === 'analyzing' && !workspace.analysis) {
return (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
);
}
if (chains.length === 0) {
return <p className="text-sm text-muted-foreground">{t('merge.noCertificates')}</p>;
}
const chain = chains.find((c) => c.headId === selectedHead) ?? chains[0];
const head = certs.find((c) => c.id === chain.headId);
const members = chain.path
.map((id) => certs.find((c) => c.id === id))
.filter((c): c is CertItem => c !== undefined);
const hasRoot = chain.rootId !== null;
const issuersShown = members.filter(
(c) => c.id !== chain.headId && (includeRoot || c.id !== chain.rootId),
);
const download = async (content: 'fullchain' | 'chain') => {
if (!head) return;
setBusy(content);
setErrorKey(null);
try {
const result = await buildOutput({
content,
format: 'pem',
certPem: head.pem,
poolPems: members.filter((c) => c.id !== head.id).map((c) => c.pem),
includeRoot,
baseName: head.baseName,
});
for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType);
} catch (error) {
setErrorKey(certErrorKey(error));
} finally {
setBusy(null);
}
};
return (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">{t('merge.intro')}</p>
{chains.length > 1 && (
<fieldset className="space-y-1">
<legend className="text-sm font-semibold text-foreground">{t('merge.chooseHead')}</legend>
{chains.map((c) => {
const cert = certs.find((x) => x.id === c.headId);
return (
<label key={c.headId} className="flex items-center gap-2 text-sm text-foreground">
<input
type="radio"
name="cert-chain-head"
checked={c.headId === chain.headId}
onChange={() => setSelectedHead(c.headId)}
/>
<span className="break-all">{cert?.cn || cert?.baseName}</span>
</label>
);
})}
</fieldset>
)}
<ChainView chain={chain} certs={certs} />
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={includeRoot && hasRoot}
disabled={!hasRoot}
onChange={(e) => setIncludeRoot(e.target.checked)}
/>
{t('merge.includeRoot')}
</label>
{!hasRoot && (
<p className="pl-6 text-xs text-muted-foreground">{t('merge.noRootAvailable')}</p>
)}
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
disabled={busy !== null}
onClick={() => download('fullchain')}
className="btn btn-primary"
>
{busy === 'fullchain' ? t('merge.busy') : t('merge.downloadFullchain')}
</button>
<button
type="button"
disabled={busy !== null || issuersShown.length === 0}
onClick={() => download('chain')}
className="btn btn-secondary"
>
{busy === 'chain' ? t('merge.busy') : t('merge.downloadChain')}
</button>
</div>
{issuersShown.length === 0 && (
<p className="text-xs text-muted-foreground">{t('merge.chainNeedsIssuer')}</p>
)}
{errorKey && (
<p
role="alert"
className="rounded-lg border border-border bg-muted p-3 text-sm text-foreground"
>
{t(`errors.${errorKey}`)}
</p>
)}
</div>
);
}
@@ -4,14 +4,16 @@ import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { TabBar } from '@/components/accounting/tab-bar'; import { TabBar } from '@/components/accounting/tab-bar';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { EmptyWorkspace } from './components/EmptyWorkspace';
import { FilesTab } from './components/FilesTab'; import { FilesTab } from './components/FilesTab';
import { MergeTab } from './components/MergeTab';
import { useCertWorkspace } from './use-cert-workspace'; import { useCertWorkspace } from './use-cert-workspace';
type TabId = 'files'; type TabId = 'files' | 'merge';
/** /**
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt). * CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
* Der erste Reiter „Dateien“ sammelt alles; die weiteren Reiter (ab Task 2) arbeiten auf dieser Liste. * Der erste Reiter „Dateien“ sammelt alles; die weiteren Reiter arbeiten auf dieser Liste.
* Die Liste lebt nur im Arbeitsspeicher dieses Browserfensters; Reiterwechsel behaelt sie. * Die Liste lebt nur im Arbeitsspeicher dieses Browserfensters; Reiterwechsel behaelt sie.
*/ */
export default function CertManagerPage() { export default function CertManagerPage() {
@@ -25,6 +27,7 @@ export default function CertManagerPage() {
id: 'files', id: 'files',
label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'), label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'),
}, },
{ id: 'merge', label: t('tabs.merge') },
]; ];
return ( return (
@@ -33,6 +36,15 @@ export default function CertManagerPage() {
<TabBar tabs={tabs} active={activeTab} onChange={setActiveTab} /> <TabBar tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="rounded-lg bg-card p-6 shadow-sm dark:border dark:border-border"> <div className="rounded-lg bg-card p-6 shadow-sm dark:border dark:border-border">
{activeTab === 'files' && <FilesTab workspace={workspace} />} {activeTab === 'files' && <FilesTab workspace={workspace} />}
{activeTab !== 'files' && count === 0 && (
<EmptyWorkspace onGoToFiles={() => setActiveTab('files')} />
)}
{activeTab !== 'files' && count > 0 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('workspace.basis', { count })}</p>
{activeTab === 'merge' && <MergeTab workspace={workspace} />}
</div>
)}
</div> </div>
</div> </div>
); );
+26 -1
View File
@@ -1253,7 +1253,8 @@
"description": "Zertifikate, Zwischenzertifikate, Schlüssel und Anfragen sammeln, prüfen und in das gewünschte Format bringen.", "description": "Zertifikate, Zwischenzertifikate, Schlüssel und Anfragen sammeln, prüfen und in das gewünschte Format bringen.",
"tabs": { "tabs": {
"files": "Dateien", "files": "Dateien",
"filesWithCount": "Dateien ({count})" "filesWithCount": "Dateien ({count})",
"merge": "Zusammenführen"
}, },
"roles": { "roles": {
"end-entity": "Serverzertifikat", "end-entity": "Serverzertifikat",
@@ -1311,6 +1312,30 @@
"aiaNotIssuer": "Die Adresse lieferte kein passendes Zertifikat des Ausstellers.", "aiaNotIssuer": "Die Adresse lieferte kein passendes Zertifikat des Ausstellers.",
"aiaUnreachable": "Der Server des Ausstellers ist nicht erreichbar. Laden Sie das Zertifikat selbst herunter und fügen Sie es hinzu.", "aiaUnreachable": "Der Server des Ausstellers ist nicht erreichbar. Laden Sie das Zertifikat selbst herunter und fügen Sie es hinzu.",
"aiaTooLarge": "Die Antwort des Ausstellers ist zu groß und wurde verworfen." "aiaTooLarge": "Die Antwort des Ausstellers ist zu groß und wurde verworfen."
},
"workspace": {
"empty": "Noch keine Dateien. Laden Sie Ihre Zertifikate im Reiter „Dateien“ hoch.",
"goToFiles": "Zum Reiter „Dateien“",
"basis": "{count, plural, one {Grundlage: 1 Datei aus dem Reiter „Dateien“.} other {Grundlage: # Dateien aus dem Reiter „Dateien“.}}"
},
"merge": {
"intro": "Tessera ordnet Serverzertifikat, Zwischenzertifikate und Stammzertifikat selbst und prüft dabei, dass jedes Zertifikat wirklich vom nächsten ausgestellt wurde. Die Reihenfolge Ihrer Dateien spielt keine Rolle.",
"noCertificates": "In den Dateien wurde noch kein Zertifikat erkannt. Fügen Sie im Reiter „Dateien“ ein Zertifikat hinzu.",
"chooseHead": "Für welches Zertifikat soll die Kette gebaut werden?",
"includeRoot": "Root-Zertifikat mitnehmen",
"noRootAvailable": "Das Stammzertifikat ist nicht in Ihren Dateien. Die meisten Server brauchen es auch nicht.",
"downloadFullchain": "Fullchain herunterladen",
"downloadChain": "Nur Kette herunterladen",
"busy": "Wird erstellt …",
"chainNeedsIssuer": "Für „Nur Kette“ fehlt ein Zwischenzertifikat. Fügen Sie es im Reiter „Dateien“ hinzu."
},
"chain": {
"issuedBy": "Ausgestellt von {name}",
"validUntil": "Gültig bis {date}",
"unknownIssuer": "unbekannt",
"gapAfterLeaf": "Zwischenzertifikat fehlt: „{name}“",
"gapAfterLeafHint": "Ohne dieses Zertifikat vertrauen manche Geräte dem Server nicht. Fügen Sie es im Reiter „Dateien“ hinzu.",
"gapAfterCa": "Darüber fehlt noch ein Zertifikat, meist das Stammzertifikat „{name}“. Für eine Fullchain ohne Stammzertifikat wird es nicht gebraucht."
} }
}, },
"tenderRadar": { "tenderRadar": {
+26 -1
View File
@@ -1253,7 +1253,8 @@
"description": "Collect, check and convert certificates, intermediate certificates, keys and requests into the format you need.", "description": "Collect, check and convert certificates, intermediate certificates, keys and requests into the format you need.",
"tabs": { "tabs": {
"files": "Files", "files": "Files",
"filesWithCount": "Files ({count})" "filesWithCount": "Files ({count})",
"merge": "Merge"
}, },
"roles": { "roles": {
"end-entity": "Server certificate", "end-entity": "Server certificate",
@@ -1311,6 +1312,30 @@
"aiaNotIssuer": "The address did not deliver a matching certificate of the issuer.", "aiaNotIssuer": "The address did not deliver a matching certificate of the issuer.",
"aiaUnreachable": "The issuer's server cannot be reached. Download the certificate yourself and add it.", "aiaUnreachable": "The issuer's server cannot be reached. Download the certificate yourself and add it.",
"aiaTooLarge": "The issuer's answer is too large and was discarded." "aiaTooLarge": "The issuer's answer is too large and was discarded."
},
"workspace": {
"empty": "No files yet. Upload your certificates in the “Files” tab.",
"goToFiles": "Go to the “Files” tab",
"basis": "{count, plural, one {Based on 1 file from the “Files” tab.} other {Based on # files from the “Files” tab.}}"
},
"merge": {
"intro": "Tessera orders the server certificate, intermediate certificates and root certificate itself and checks that each certificate was really issued by the next one. The order of your files does not matter.",
"noCertificates": "No certificate has been recognised in the files yet. Add a certificate in the “Files” tab.",
"chooseHead": "For which certificate should the chain be built?",
"includeRoot": "Include root certificate",
"noRootAvailable": "The root certificate is not among your files. Most servers do not need it anyway.",
"downloadFullchain": "Download fullchain",
"downloadChain": "Download chain only",
"busy": "Creating …",
"chainNeedsIssuer": "An intermediate certificate is missing for “chain only”. Add it in the “Files” tab."
},
"chain": {
"issuedBy": "Issued by {name}",
"validUntil": "Valid until {date}",
"unknownIssuer": "unknown",
"gapAfterLeaf": "Intermediate certificate missing: “{name}”",
"gapAfterLeafHint": "Without this certificate some devices will not trust the server. Add it in the “Files” tab.",
"gapAfterCa": "A certificate above this one is missing, usually the root certificate “{name}”. A fullchain without the root does not need it."
} }
}, },
"tenderRadar": { "tenderRadar": {
@@ -249,4 +249,5 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'passenden', 'passenden',
'passendes', 'passendes',
'Ausstellers', 'Ausstellers',
'vertrauen',
]; ];