From 47b26219b2d5aef30e22449e4d459aed8403b4dd Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 9 Oct 2026 15:57:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(cert-manager):=20Vorlagen=20f=C3=BCr=20Zie?= =?UTF-8?q?lsysteme,=20Modulversion=201.2.0=20und=20Anleitungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sieben Vorlagen (Nginx, Apache ab/vor 2.4.8, Windows/IIS, Nginx Proxy Manager, HAProxy, Tomcat) mit Dateien und Einrichtungszeilen - Reiter „Vorlagen“ mit ZIP samt Anleitung, Schnipsel und Kopieren - Modulversion 1.2.0, Modul-Changelog, CHANGELOG und drei Anleitungen Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 + .../cert-manager/cert-manager.changelog.ts | 31 ++ apps/api/src/cert-manager/cert-output.spec.ts | 31 ++ apps/api/src/cert-manager/cert-output.ts | 23 ++ .../src/cert-manager/cert-templates.spec.ts | 261 ++++++++++++++ apps/api/src/cert-manager/cert-templates.ts | 160 +++++++++ apps/api/src/cert-manager/cert-types.ts | 10 +- .../src/cert-manager/dto/cert-build.dto.ts | 8 +- .../(portal)/modules/cert-manager/actions.ts | 22 +- .../cert-manager/cert-manager.test.tsx | 4 +- .../components/TemplatesTab.test.tsx | 298 ++++++++++++++++ .../cert-manager/components/TemplatesTab.tsx | 320 ++++++++++++++++++ .../(portal)/modules/cert-manager/page.tsx | 5 +- apps/web/src/messages/de.json | 84 ++++- apps/web/src/messages/en.json | 84 ++++- apps/web/src/messages/umlaut-dictionary.ts | 6 + docs/anleitung-anwender.md | 16 +- docs/anleitung-betrieb.md | 9 + docs/anleitung-entwicklung.md | 72 ++++ 19 files changed, 1434 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/cert-manager/cert-templates.spec.ts create mode 100644 apps/api/src/cert-manager/cert-templates.ts create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa89be..a38e06b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T - Dateien, Hochladen und Herunterladen: Dateien und ganze Ordner per Ziehen und Ablegen (auch direkt auf einen Ordner) oder über „Hochladen“ hochladen; große Dateien gehen in Stücken hoch. Die Übertragungsleiste am unteren Rand zeigt den Fortschritt jeder Datei, lässt Abbrechen und Wiederholen zu und nennt Fehler in einfachen Worten. Tessera überschreibt nie stillschweigend: Gibt es den Namen schon, wählen Sie „Ersetzen“, „Beide behalten“ oder „Überspringen“. Dateien werden direkt heruntergeladen, Ordner und Mehrfachauswahl als ZIP-Datei. - Dateien, Teilen: Im Zeilenmenü einer Datei oder eines Ordners finden Sie jetzt „Teilen“. Dort suchen Sie Kolleginnen, Kollegen und Gruppen aus Ihrer Nextcloud und geben die Datei zum „Ansehen“ oder „Bearbeiten“ frei; die Berechtigung ändern Sie später, und eine Freigabe nehmen Sie mit „Entfernen“ wieder zurück. Außerdem erstellen Sie öffentliche Links mit „Ansehen“ oder „Bearbeiten“, bei Ordnern auch mit „Nur hochladen“, und kopieren sie mit „Link kopieren“. Passwort und Ablaufdatum richten sich nach den Regeln der Firmen-Nextcloud: Verlangt sie ein Passwort, fragt Tessera danach und kann auf Wunsch eines erzeugen. Tessera speichert das Passwort nie und zeigt es nur, solange der Dialog offen ist. Zum Schutz der Nextcloud legt Tessera höchstens 10 neue Freigaben innerhalb von 10 Minuten an. - Dateien, Übersicht der Freigaben: Die Reiter „Von mir geteilt“ und „Mit mir geteilt“ zeigen alle Freigaben auf einen Blick. Sie öffnen Einträge direkt im zugehörigen Ordner, ändern oder entfernen Ihre eigenen Freigaben, verlassen Freigaben anderer und nehmen offene Freigaben an oder lehnen sie ab. Geteilte Einträge sind in der Dateiliste markiert. Freigaben per E-Mail und an andere Server zeigt Tessera nicht an; sie bleiben der Nextcloud vorbehalten. +- Zertifikat-Manager, ein gemeinsamer Arbeitsbereich: Im ersten Reiter „Dateien“ laden Sie mehrere Dateien nacheinander oder auf einmal hoch, auch ZIP-Dateien vom Zertifikatsaussteller, und können PEM-Text einfügen. Jede Datei bleibt in der Liste, zu jeder steht, was Tessera darin erkannt hat; einzelne Dateien nehmen Sie mit ihrem eigenen Knopf wieder heraus. Passwörter geben Sie je Datei ein. Die Liste, Schlüssel und Passwörter bleiben nur in diesem Browserfenster und sind nach dem Neuladen weg; Tessera speichert sie nirgends. Analysieren, Aufteilen, Zusammenführen, Konvertieren und Vorlagen arbeiten alle mit dieser Liste. Modulversion 1.2.0. +- Zertifikat-Manager, Zusammenführen und Formate: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst und prüft dabei die echte Unterschrift jedes Zertifikats. „Fullchain“ und „Nur Kette“ gibt es als PEM, .p7b und .p7c, dazu Zertifikat und Schlüssel in einer PEM-Datei und PFX-Dateien mit eigenem Passwort, wahlweise „Kompatibel“ (auch für ältere Windows-Server, Vorgabe) oder „Modern“ (AES-256). Das Wurzelzertifikat kommt nur mit, wenn Sie „Root-Zertifikat mitnehmen“ ankreuzen. Fehlt ein Zwischenzertifikat, meldet Tessera es mit Namen. Neu im Reiter „Konvertieren“: jedes erkannte Zertifikat, jeder Schlüssel und jede Anfrage in jedes passende Format, auch Schlüssel mit Passwort. +- Zertifikat-Manager, Vorlagen: Für Nginx, Apache (ab und vor 2.4.8), Windows/IIS, Nginx Proxy Manager, HAProxy und Tomcat erzeugt Tessera mit einem Klick die passenden Dateien samt den Zeilen für die Einrichtung und einer kurzen Anleitung. Dafür braucht es das Zertifikat und den passenden privaten Schlüssel. ### Geändert @@ -29,6 +32,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ### Behoben +- Zertifikat-Manager: Beim Zusammenführen ersetzte eine zweite Datei die erste; jetzt bleiben alle Dateien in der Liste. Zertifikate und Schlüssel mit elliptischen Kurven (EC) wurden bisher nicht erkannt; jetzt funktionieren sie in allen Reitern. - Desktop-App: Dateien lassen sich jetzt auch in der Desktop-App per Ziehen und Ablegen hochladen, zum Beispiel im Modul Dateien oder im Zertifikat-Manager. Bisher übernahm die App das Ablegen selbst, und auf der Seite kam nichts an (Linux und Windows). Dafür ist die neue Version der Desktop-App nötig. - Nextcloud-Status: Das Formular zum Hinzufügen und Bearbeiten einer Cloud zeigte bei manchen Eingaben englische Meldungen (zum Beispiel bei einer Bildadresse mit http://). Alle Hinweise in diesem Formular erscheinen jetzt auf Deutsch (bei englischer Spracheinstellung auf Englisch). diff --git a/apps/api/src/cert-manager/cert-manager.changelog.ts b/apps/api/src/cert-manager/cert-manager.changelog.ts index cdd9d96..2eac893 100644 --- a/apps/api/src/cert-manager/cert-manager.changelog.ts +++ b/apps/api/src/cert-manager/cert-manager.changelog.ts @@ -6,6 +6,37 @@ import type { ModuleChangelog } from '../module-registry/module-changelog'; * docs/anleitung-entwicklung.md, Abschnitt „Modulversion und Modul-Changelog pflegen“. */ export const CERT_MANAGER_CHANGELOG: ModuleChangelog = [ + { + version: '1.2.0', + date: '2026-10-09', + changes: [ + { + kind: 'new', + de: 'Ein gemeinsamer Reiter „Dateien“: Laden Sie mehrere Dateien und ZIP-Dateien auf einmal hoch oder fügen Sie PEM-Text ein. Alle anderen Reiter arbeiten mit dieser Liste.', + en: 'One shared “Files” tab: upload several files and ZIP files at once or paste PEM text. All other tabs work with this list.', + }, + { + kind: 'new', + de: '„Zusammenführen“ ordnet die Kette selbst und liefert Fullchain, nur die Kette, Zertifikat mit Schlüssel oder eine PFX-Datei; das Root-Zertifikat nehmen Sie nur auf Wunsch mit.', + en: '“Merge” orders the chain by itself and delivers a full chain, the chain only, certificate with key or a PFX file; the root certificate is only included on request.', + }, + { + kind: 'new', + de: 'Alle gängigen Formate rein und raus, auch Zertifikate und Schlüssel mit elliptischen Kurven (EC) und verschlüsselte Schlüssel. PFX-Dateien wahlweise kompatibel oder modern verschlüsselt.', + en: 'All common formats in and out, including elliptic-curve (EC) certificates and keys and encrypted keys. PFX files with compatible or modern encryption.', + }, + { + kind: 'new', + de: 'Vorlagen für Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy und Tomcat liefern die passenden Dateien mit einem Klick.', + en: 'Templates for Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy and Tomcat deliver the right files with one click.', + }, + { + kind: 'fixed', + de: 'Beim Zusammenführen ersetzt eine zweite Datei nicht mehr die erste.', + en: 'When merging, a second file no longer replaces the first one.', + }, + ], + }, { version: '1.1.0', date: '2026-10-02', diff --git a/apps/api/src/cert-manager/cert-output.spec.ts b/apps/api/src/cert-manager/cert-output.spec.ts index 40b3b72..a801089 100644 --- a/apps/api/src/cert-manager/cert-output.spec.ts +++ b/apps/api/src/cert-manager/cert-output.spec.ts @@ -530,3 +530,34 @@ describe('buildOutput: ungueltige Paare', () => { ).toEqual({ status: 400, code: 'invalidInput' }); }); }); + +describe('buildOutput: Vorlagen im Ergebnis', () => { + it('liefert bei Vorlagen einen Schnipsel, bei anderen Inhalten keinen', () => { + const tpl = buildOutput({ + content: 'template', + template: 'haproxy', + certPem: fxText('rsa-leaf.pem'), + poolPems: [fxText('rsa-inter.pem')], + keyPem: fxText('rsa-leaf-key.pem'), + }); + expect(tpl.snippet).toBe('bind :443 ssl crt /etc/haproxy/certs/www.example.test.pem'); + expect(tpl.chainComplete).toBe(false); + expect(tpl.missingIssuerCn).toBe('Tessera Test Root RSA'); + const plain = buildOutput({ content: 'leaf', certPem: fxText('rsa-leaf.pem') }); + expect('snippet' in plain).toBe(false); + }); + + it('Vorlage mit Format: 400 invalidInput', () => { + expect( + codeOf(() => + buildOutput({ + content: 'template', + template: 'nginx', + format: 'pem', + certPem: fxText('rsa-leaf.pem'), + keyPem: fxText('rsa-leaf-key.pem'), + }), + ), + ).toEqual({ status: 400, code: 'invalidInput' }); + }); +}); diff --git a/apps/api/src/cert-manager/cert-output.ts b/apps/api/src/cert-manager/cert-output.ts index 01732b6..b88dd98 100644 --- a/apps/api/src/cert-manager/cert-output.ts +++ b/apps/api/src/cert-manager/cert-output.ts @@ -6,6 +6,7 @@ import { exportKey, type KeyExportFormat } from './cert-keys'; import { certItemFromDer } from './cert-model'; import { safeBaseName } from './cert-names'; import { writePkcs12 } from './cert-pkcs12'; +import { buildTemplate, isTemplateId } from './cert-templates'; import { type BuildContent, type BuildFile, @@ -44,6 +45,7 @@ const FORMATS: Record = { pfx: ['pfx'], key: ['pkcs8', 'traditional', 'pkcs8-der'], csr: ['pem', 'der'], + template: ['template'], }; function parseCertificate(pem: unknown): CertItem { @@ -172,6 +174,7 @@ export function buildOutput(input: BuildInput): BuildResult { const base = safeBaseName(input.baseName ?? '', head.baseName); let files: BuildFile[]; + let snippet: string | null = null; switch (input.content) { case 'leaf': if (format === 'der') files = [file(`${base}.cer`, derOf(head), MIME.der)]; @@ -208,6 +211,25 @@ export function buildOutput(input: BuildInput): BuildResult { files = [file(`${base}.pfx`, der, MIME.pfx)]; break; } + case 'template': { + if (!isTemplateId(input.template)) certError('invalidInput', 400, 'Unknown template'); + // Eine Vorlage ohne passenden Schluessel gibt es nicht (templateNeedsKey); ein falscher Schluessel ist keyMismatch. + if (!input.keyPem || input.keyPem.trim() === '') { + certError('templateNeedsKey', 400, 'The template needs the matching private key'); + } + const key = matchingKey(head, input.keyPem); + const built = buildTemplate(input.template, { + head, + shown, + key, + base, + password: input.password, + pfxProfile: input.pfxEncryption === 'modern' ? 'modern' : 'compat', + }); + files = built.files; + snippet = built.snippet; + break; + } default: return certError('invalidInput', 400, 'Unknown content'); } @@ -216,6 +238,7 @@ export function buildOutput(input: BuildInput): BuildResult { files, chainComplete: chain.complete, missingIssuerCn: chain.gap?.missingIssuerCn ?? null, + ...(input.content === 'template' ? { snippet } : {}), }; } diff --git a/apps/api/src/cert-manager/cert-templates.spec.ts b/apps/api/src/cert-manager/cert-templates.spec.ts new file mode 100644 index 0000000..0fed588 --- /dev/null +++ b/apps/api/src/cert-manager/cert-templates.spec.ts @@ -0,0 +1,261 @@ +import { createPrivateKey, X509Certificate } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import * as forge from 'node-forge'; +import { describe, expect, it } from 'vitest'; +import { buildOutput } from './cert-output'; +import { readPkcs12 } from './cert-pkcs12'; +import { TEMPLATE_IDS } from './cert-templates'; +import type { BuildFile, BuildInput } from './cert-types'; + +const fxText = (name: string) => readFileSync(join(__dirname, '__fixtures__', name), 'utf8'); +const bytes = (file: BuildFile) => Buffer.from(file.content, 'base64'); +const text = (file: BuildFile) => bytes(file).toString('utf8'); +const PASSWORD = 'Neu-Pass-2026'; + +const SETS = { + rsa: { + cn: 'www.example.test', + certPem: fxText('rsa-leaf.pem'), + poolPems: [fxText('rsa-root.pem'), fxText('rsa-inter.pem')], + keyPem: fxText('rsa-leaf-key.pem'), + traditionalHeader: '-----BEGIN RSA PRIVATE KEY-----', + }, + ec: { + cn: 'ec.example.test', + certPem: fxText('ec-leaf.pem'), + poolPems: [fxText('ec-root.pem'), fxText('ec-inter.pem')], + keyPem: fxText('ec-leaf-key.pem'), + traditionalHeader: '-----BEGIN EC PRIVATE KEY-----', + }, +} as const; + +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'); +} + +function blocks(pem: string): string[] { + return pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? []; +} + +function cnsOf(pem: string): string[] { + return blocks(pem).map((b) => String(new X509Certificate(b).toLegacyObject().subject.CN)); +} + +function names(files: BuildFile[]): string[] { + return files.map((f) => f.filename); +} + +function template(id: string, set: keyof typeof SETS, extra: Partial = {}) { + const s = SETS[set]; + return buildOutput({ + content: 'template', + template: id, + certPem: s.certPem, + poolPems: [...s.poolPems], + keyPem: s.keyPem, + ...extra, + }); +} + +describe('Vorlagen: Kennungen', () => { + it('kennt die sieben Zielsysteme', () => { + expect([...TEMPLATE_IDS]).toEqual([ + 'nginx', + 'apache', + 'apache-legacy', + 'iis', + 'npm', + 'haproxy', + 'tomcat', + ]); + }); +}); + +describe.each(['rsa', 'ec'] as const)('Vorlagen (%s)', (set) => { + const s = SETS[set]; + + it('nginx: fullchain.pem (Server, Zwischen) und privkey.pem (PKCS#8) plus Schnipsel mit Basisnamen', () => { + const r = template('nginx', set); + expect(names(r.files)).toEqual(['fullchain.pem', 'privkey.pem']); + expect(cnsOf(text(r.files[0]))[0]).toBe(s.cn); + expect(cnsOf(text(r.files[0]))).toHaveLength(2); + expect(text(r.files[1])).toContain('-----BEGIN PRIVATE KEY-----'); + expect(new X509Certificate(s.certPem).checkPrivateKey(createPrivateKey(text(r.files[1])))).toBe( + true, + ); + expect(r.snippet).toBe( + `ssl_certificate /etc/nginx/ssl/${s.cn}/fullchain.pem;\nssl_certificate_key /etc/nginx/ssl/${s.cn}/privkey.pem;`, + ); + expect(r.chainComplete).toBe(true); + }); + + it('nginx mit Wurzel: drei Zertifikate, Wurzel zuletzt', () => { + const r = template('nginx', set, { includeRoot: true }); + expect(cnsOf(text(r.files[0]))).toHaveLength(3); + expect(cnsOf(text(r.files[0]))[2]).toContain('Root'); + }); + + it('apache (ab 2.4.8): fullchain.pem und privkey.pem, SSLCertificateFile und SSLCertificateKeyFile', () => { + const r = template('apache', set); + expect(names(r.files)).toEqual(['fullchain.pem', 'privkey.pem']); + expect(r.snippet).toContain(`SSLCertificateFile /etc/ssl/${s.cn}/fullchain.pem`); + expect(r.snippet).toContain(`SSLCertificateKeyFile /etc/ssl/${s.cn}/privkey.pem`); + expect(r.snippet).not.toContain('SSLCertificateChainFile'); + }); + + it('apache-legacy: cert.pem (nur Server), chain.pem (nur Aussteller), privkey.pem', () => { + const r = template('apache-legacy', set); + expect(names(r.files)).toEqual(['cert.pem', 'chain.pem', 'privkey.pem']); + expect(cnsOf(text(r.files[0]))).toEqual([s.cn]); + expect(cnsOf(text(r.files[1]))).toHaveLength(1); + expect(cnsOf(text(r.files[1]))[0]).toContain('Inter'); + expect(r.snippet).toContain('SSLCertificateChainFile'); + }); + + it('iis: .pfx mit kompatibler Verschluesselung, mit dem Passwort lesbar, Schluessel passt', () => { + const r = template('iis', set, { password: PASSWORD }); + expect(names(r.files)).toEqual([`${s.cn}.pfx`]); + const der = bytes(r.files[0]); + // 3DES-Schluesselbeutel (pbeWithSHA1And3-KeyTripleDES-CBC = 1.2.840.113549.1.12.1.3) + expect(der.includes(Buffer.from('2a864886f70d010c0103', 'hex'))).toBe(true); + const read = readPkcs12(der, [], PASSWORD); + expect(read.ok).toBe(true); + if (!read.ok) return; + expect(new X509Certificate(s.certPem).checkPrivateKey(read.contents.keys[0].key)).toBe(true); + expect(r.snippet).toBe( + `Import-PfxCertificate -FilePath .\\${s.cn}.pfx -CertStoreLocation Cert:\\LocalMachine\\My -Password (Read-Host -AsSecureString)`, + ); + expect(r.snippet).not.toContain(PASSWORD); + }); + + it('npm: certificate.pem nur Server, intermediate.pem nur Zwischen, privkey.pem klassisch, kein Schnipsel', () => { + const r = template('npm', set); + expect(names(r.files)).toEqual(['certificate.pem', 'intermediate.pem', 'privkey.pem']); + expect(cnsOf(text(r.files[0]))).toEqual([s.cn]); + expect(cnsOf(text(r.files[1]))).toHaveLength(1); + expect(cnsOf(text(r.files[1]))[0]).toContain('Inter'); + expect(text(r.files[2]).startsWith(s.traditionalHeader)).toBe(true); + expect(r.snippet).toBeNull(); + const withRoot = template('npm', set, { includeRoot: true }); + expect(cnsOf(text(withRoot.files[1]))).toHaveLength(2); + }); + + it('haproxy: eine Datei mit Server, Zwischen und Schluessel in dieser Reihenfolge', () => { + const r = template('haproxy', set); + expect(names(r.files)).toEqual([`${s.cn}.pem`]); + const pem = text(r.files[0]); + expect(cnsOf(pem)).toHaveLength(2); + expect(cnsOf(pem)[0]).toBe(s.cn); + expect(pem.indexOf('-----BEGIN PRIVATE KEY-----')).toBeGreaterThan( + pem.lastIndexOf('-----END CERTIFICATE-----'), + ); + expect(r.snippet).toBe(`bind :443 ssl crt /etc/haproxy/certs/${s.cn}.pem`); + }); + + it('tomcat: .p12, Anzeigename gleich Basisname, Schnipsel mit IHR-PASSWORT ohne das echte Passwort', () => { + const r = template('tomcat', set, { password: PASSWORD }); + expect(names(r.files)).toEqual([`${s.cn}.p12`]); + const read = readPkcs12(bytes(r.files[0]), [], PASSWORD); + expect(read.ok).toBe(true); + // friendlyName steckt verschluesselt im Container: mit forge und dem Passwort auslesen + const p12 = forge.pkcs12.pkcs12FromAsn1( + forge.asn1.fromDer(forge.util.createBuffer(bytes(r.files[0]).toString('binary'))), + PASSWORD, + ); + const aliases = p12.safeContents.flatMap((c) => + c.safeBags.flatMap((b) => (b.attributes?.friendlyName ?? []) as string[]), + ); + // forge liefert den BMPString roh (UTF-16, big endian) + const decoded = aliases.map((a) => Buffer.from(a, 'binary').swap16().toString('utf16le')); + expect(decoded).toContain(s.cn); + expect(r.snippet).toContain('IHR-PASSWORT'); + expect(r.snippet).toContain(`conf/${s.cn}.p12`); + expect(r.snippet).toContain(`certificateKeyAlias="${s.cn}"`); + expect(r.snippet).not.toContain(PASSWORD); + }); + + it('iis mit Modern: AES-256 im Schluesselbeutel (gewaehlt, nicht Vorgabe)', () => { + const r = template('iis', set, { password: PASSWORD, pfxEncryption: 'modern' }); + const read = readPkcs12(bytes(r.files[0]), [], PASSWORD); + expect(read.ok).toBe(true); + // PBES2 = 1.2.840.113549.1.5.13 + expect(bytes(r.files[0]).includes(Buffer.from('2a864886f70d01050d', 'hex'))).toBe(true); + }); +}); + +describe('Vorlagen: Fehler', () => { + it('ohne Schluessel: 400 templateNeedsKey fuer jede Vorlage', () => { + for (const id of TEMPLATE_IDS) { + expect( + codeOf(() => + buildOutput({ + content: 'template', + template: id, + certPem: SETS.rsa.certPem, + password: PASSWORD, + }), + ), + ).toEqual({ status: 400, code: 'templateNeedsKey' }); + } + }); + + it('falscher Schluessel: 400 keyMismatch', () => { + expect(codeOf(() => template('nginx', 'rsa', { keyPem: SETS.ec.keyPem }))).toEqual({ + status: 400, + code: 'keyMismatch', + }); + }); + + it('iis und tomcat ohne Passwort: 400 passwordRequired', () => { + for (const id of ['iis', 'tomcat']) { + expect(codeOf(() => template(id, 'rsa'))).toEqual({ status: 400, code: 'passwordRequired' }); + expect(codeOf(() => template(id, 'rsa', { password: '' }))).toEqual({ + status: 400, + code: 'passwordRequired', + }); + } + }); + + it('unbekannte oder fehlende Kennung: 400 invalidInput', () => { + expect(codeOf(() => template('weblogic', 'rsa'))).toEqual({ + status: 400, + code: 'invalidInput', + }); + expect( + codeOf(() => + buildOutput({ + content: 'template', + certPem: SETS.rsa.certPem, + keyPem: SETS.rsa.keyPem, + }), + ), + ).toEqual({ status: 400, code: 'invalidInput' }); + }); + + it('Kennung ohne Zertifikat: 400 invalidInput', () => { + expect(codeOf(() => buildOutput({ content: 'template', template: 'nginx' }))).toEqual({ + status: 400, + code: 'invalidInput', + }); + }); + + it('Antwort und Fehler enthalten weder Passwort noch Schluesseltext', () => { + const r = template('tomcat', 'rsa', { password: PASSWORD }); + expect(JSON.stringify(r.snippet)).not.toContain(PASSWORD); + let message = ''; + try { + template('iis', 'rsa', { keyPem: 'kein Schluessel', password: PASSWORD }); + } catch (error) { + message = JSON.stringify((error as { getResponse(): unknown }).getResponse()); + } + expect(message).not.toContain(PASSWORD); + expect(message).not.toContain('kein Schluessel'); + }); +}); diff --git a/apps/api/src/cert-manager/cert-templates.ts b/apps/api/src/cert-manager/cert-templates.ts new file mode 100644 index 0000000..26c3ac4 --- /dev/null +++ b/apps/api/src/cert-manager/cert-templates.ts @@ -0,0 +1,160 @@ +import type { KeyObject } from 'node:crypto'; +import { X509Certificate } from 'node:crypto'; +import { exportKey } from './cert-keys'; +import { type Pkcs12Profile, writePkcs12 } from './cert-pkcs12'; +import { type BuildFile, type CertItem, certError } from './cert-types'; + +/** + * Vorlagen fuer Zielsysteme (quick-261009-ikt, D-04, D-21). Eine Vorlage liefert die fertigen Dateien + * und einen Konfigurationsschnipsel; mehrere Dateien packt der Browser zu einer ZIP-Datei (fflate). + * Alle Vorlagen brauchen das Serverzertifikat und den passenden privaten Schluessel (der Aufrufer + * hat beides schon geprueft). Der Schluessel liegt in den Dateien unverschluesselt, ausser in PFX- + * Dateien (Passwort). Die Wurzel kommt nur mit, wenn `shown` sie enthaelt (Haken „Root-Zertifikat + * mitnehmen“). Das Passwort erscheint nie in einem Schnipsel. + * + * Diese Datei kennt cert-output.ts nicht (sonst entstuende eine Importschleife); die paar Hilfen + * fuer Dateien und PEM-Bloecke stehen deshalb hier noch einmal in Kurzform. + */ + +export const TEMPLATE_IDS = [ + 'nginx', + 'apache', + 'apache-legacy', + 'iis', + 'npm', + 'haproxy', + 'tomcat', +] as const; +export type TemplateId = (typeof TEMPLATE_IDS)[number]; + +export interface TemplateContext { + /** Serverzertifikat */ + head: CertItem; + /** Kette in Reihenfolge: Serverzertifikat zuerst, dann die Aussteller (Wurzel nur auf Wunsch) */ + shown: CertItem[]; + /** der zum Serverzertifikat gehoerende private Schluessel */ + key: KeyObject; + /** sicherer Basisname fuer Dateinamen und Schnipsel */ + base: string; + password?: string; + pfxProfile: Pkcs12Profile; +} + +export interface TemplateResult { + files: BuildFile[]; + snippet: string | null; +} + +const MIME_PEM = 'application/x-pem-file'; +const MIME_PFX = 'application/x-pkcs12'; + +export function isTemplateId(value: unknown): value is TemplateId { + return typeof value === 'string' && (TEMPLATE_IDS as readonly string[]).includes(value); +} + +function file(filename: string, data: Buffer | string, mimeType: string): BuildFile { + const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : data; + return { filename, content: bytes.toString('base64'), mimeType }; +} + +function joinPem(certs: CertItem[]): string { + return certs.map((c) => `${c.pem.trim()}\n`).join(''); +} + +function derOf(cert: CertItem): Buffer { + return new X509Certificate(cert.pem).raw; +} + +function pkcs8Pem(key: KeyObject): string { + return exportKey(key, 'pkcs8').toString('utf8'); +} + +function requirePassword(ctx: TemplateContext): string { + if (!ctx.password) certError('passwordRequired', 400, 'A password is required'); + return ctx.password; +} + +function pfxFile(ctx: TemplateContext, extension: string): BuildFile { + const der = writePkcs12({ + keyObject: ctx.key, + certDers: ctx.shown.map(derOf), + password: requirePassword(ctx), + profile: ctx.pfxProfile, + friendlyName: ctx.base, + }); + return file(`${ctx.base}.${extension}`, der, MIME_PFX); +} + +/** Bauen je Vorlage. Wirft Nest-Ausnahmen mit Code; unbekannte Kennung ergibt invalidInput. */ +export function buildTemplate(id: unknown, ctx: TemplateContext): TemplateResult { + if (!isTemplateId(id)) certError('invalidInput', 400, 'Unknown template'); + const { base } = ctx; + const issuers = ctx.shown.filter((c) => c.id !== ctx.head.id); + + switch (id) { + case 'nginx': + return { + files: [ + file('fullchain.pem', joinPem(ctx.shown), MIME_PEM), + file('privkey.pem', pkcs8Pem(ctx.key), MIME_PEM), + ], + snippet: [ + `ssl_certificate /etc/nginx/ssl/${base}/fullchain.pem;`, + `ssl_certificate_key /etc/nginx/ssl/${base}/privkey.pem;`, + ].join('\n'), + }; + case 'apache': + return { + files: [ + file('fullchain.pem', joinPem(ctx.shown), MIME_PEM), + file('privkey.pem', pkcs8Pem(ctx.key), MIME_PEM), + ], + snippet: [ + `SSLCertificateFile /etc/ssl/${base}/fullchain.pem`, + `SSLCertificateKeyFile /etc/ssl/${base}/privkey.pem`, + ].join('\n'), + }; + case 'apache-legacy': { + // Ohne Aussteller gibt es keine Kettendatei; dann entfallen Datei und Zeile. + const files = [file('cert.pem', joinPem([ctx.head]), MIME_PEM)]; + const lines = [ + `SSLCertificateFile /etc/ssl/${base}/cert.pem`, + `SSLCertificateKeyFile /etc/ssl/${base}/privkey.pem`, + ]; + if (issuers.length > 0) { + files.push(file('chain.pem', joinPem(issuers), MIME_PEM)); + lines.push(`SSLCertificateChainFile /etc/ssl/${base}/chain.pem`); + } + files.push(file('privkey.pem', pkcs8Pem(ctx.key), MIME_PEM)); + return { files, snippet: lines.join('\n') }; + } + case 'iis': + return { + files: [pfxFile(ctx, 'pfx')], + snippet: `Import-PfxCertificate -FilePath .\\${base}.pfx -CertStoreLocation Cert:\\LocalMachine\\My -Password (Read-Host -AsSecureString)`, + }; + case 'npm': { + // Nginx Proxy Manager verlangt bei RSA oft „RSA PRIVATE KEY“ (PKCS#1), bei EC „EC PRIVATE KEY“ (SEC1). + let keyPem: string; + try { + keyPem = exportKey(ctx.key, 'traditional').toString('utf8'); + } catch { + keyPem = pkcs8Pem(ctx.key); + } + const files = [file('certificate.pem', joinPem([ctx.head]), MIME_PEM)]; + if (issuers.length > 0) files.push(file('intermediate.pem', joinPem(issuers), MIME_PEM)); + files.push(file('privkey.pem', keyPem, MIME_PEM)); + return { files, snippet: null }; + } + case 'haproxy': + return { + files: [file(`${base}.pem`, joinPem(ctx.shown) + pkcs8Pem(ctx.key), MIME_PEM)], + snippet: `bind :443 ssl crt /etc/haproxy/certs/${base}.pem`, + }; + case 'tomcat': + return { + files: [pfxFile(ctx, 'p12')], + snippet: ``, + }; + } +} diff --git a/apps/api/src/cert-manager/cert-types.ts b/apps/api/src/cert-manager/cert-types.ts index b5a0056..0891fee 100644 --- a/apps/api/src/cert-manager/cert-types.ts +++ b/apps/api/src/cert-manager/cert-types.ts @@ -129,7 +129,15 @@ export interface AnalysisResult { ignored: IgnoredEntry[]; } -export type BuildContent = 'leaf' | 'fullchain' | 'chain' | 'leafKey' | 'pfx' | 'key' | 'csr'; +export type BuildContent = + | 'leaf' + | 'fullchain' + | 'chain' + | 'leafKey' + | 'pfx' + | 'key' + | 'csr' + | 'template'; export interface BuildInput { content: BuildContent; diff --git a/apps/api/src/cert-manager/dto/cert-build.dto.ts b/apps/api/src/cert-manager/dto/cert-build.dto.ts index 7c75428..429cb5d 100644 --- a/apps/api/src/cert-manager/dto/cert-build.dto.ts +++ b/apps/api/src/cert-manager/dto/cert-build.dto.ts @@ -7,6 +7,7 @@ import { IsString, MaxLength, } from 'class-validator'; +import { TEMPLATE_IDS } from '../cert-templates'; /** * Anfrage fuer POST build (quick-261009-ikt, D-19). Die Obergrenzen stehen hier als Konstanten, @@ -16,7 +17,7 @@ import { * + password 256 + baseName 120 + JSON-Maskierung der Zeilenumbrueche (etwa +1,6 %) * = rund 384 kB, also deutlich unter dem Grenzwert von 512 KiB. * - * Stand Task 5: alle Inhalte und Formate aus D-19; die Vorlage (template) folgt in Task 6. + * Stand Task 6: alle Inhalte und Formate aus D-19 sowie die Vorlage (template, nur eine Kennung). */ export const CERT_PEM_MAX = 16_384; export const CERT_POOL_MAX = 20; @@ -31,6 +32,7 @@ export const BUILD_CONTENTS = [ 'pfx', 'key', 'csr', + 'template', ] as const; export const BUILD_FORMATS = [ 'pem', @@ -91,6 +93,10 @@ export class BuildOutputDto { @IsIn(BUILD_PFX_ENCRYPTIONS) pfxEncryption?: (typeof BUILD_PFX_ENCRYPTIONS)[number]; + @IsOptional() + @IsIn(TEMPLATE_IDS) + template?: (typeof TEMPLATE_IDS)[number]; + @IsOptional() @IsString() @MaxLength(CERT_BASENAME_MAX) diff --git a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts index ee55391..d1df0e6 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts +++ b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts @@ -124,7 +124,25 @@ export interface AnalysisResult { ignored: IgnoredEntry[]; } -export type BuildContent = 'leaf' | 'fullchain' | 'chain' | 'leafKey' | 'pfx' | 'key' | 'csr'; +export type BuildContent = + | 'leaf' + | 'fullchain' + | 'chain' + | 'leafKey' + | 'pfx' + | 'key' + | 'csr' + | 'template'; + +/** Kennungen der Vorlagen (Zielsysteme); die API prueft sie gegen dieselbe Liste. */ +export type TemplateId = + | 'nginx' + | 'apache' + | 'apache-legacy' + | 'iis' + | 'npm' + | 'haproxy' + | 'tomcat'; /** Anfrage fuer POST build (D-19); die API baut die Reihenfolge immer selbst aus den gesendeten Zertifikaten. */ export interface BuildInput { @@ -138,7 +156,7 @@ export interface BuildInput { includeChain?: boolean; password?: string; pfxEncryption?: 'compat' | 'modern'; - template?: string; + template?: TemplateId; baseName?: string; } diff --git a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx index a49d76a..b4cce62 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx @@ -43,7 +43,7 @@ beforeEach(() => { afterEach(cleanup); describe('CertManagerPage', () => { - it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen, Konvertieren', () => { + it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen, Konvertieren, Vorlagen', () => { render(); expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument(); const nav = screen.getByRole('navigation'); @@ -54,6 +54,7 @@ describe('CertManagerPage', () => { 'Aufteilen', 'Zusammenführen', 'Konvertieren', + 'Vorlagen', ]); expect(tabs[0]).toHaveAttribute('aria-current', 'page'); for (const tab of tabs.slice(1)) expect(tab).not.toHaveAttribute('aria-current'); @@ -94,6 +95,7 @@ describe('CertManagerPage', () => { 'Analysieren', 'Aufteilen', 'Konvertieren', + 'Vorlagen', ])('%s mit leerem Arbeitsbereich: Hinweis auf den Reiter „Dateien“', (tab) => { render(); fireEvent.click(screen.getByRole('button', { name: tab })); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.test.tsx new file mode 100644 index 0000000..1b8d298 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.test.tsx @@ -0,0 +1,298 @@ +import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'; +import { strFromU8, unzipSync } from 'fflate'; +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, KeyItem } from '../actions'; +import { CertManagerRequestError } from '../actions'; +import type { CertWorkspace } from '../use-cert-workspace'; +import { TemplatesTab } from './TemplatesTab'; + +const mockBuild = vi.fn(); +const mockDownload = vi.fn(); + +vi.mock('../actions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildOutput: (...args: unknown[]) => mockBuild(...args), + downloadBase64: (...args: unknown[]) => mockDownload(...args), + }; +}); + +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +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 keyedLeaf = { ...leaf, keyId: 'k-leaf' }; +const inter = cert('c-inter', 'Test Inter', 'intermediate', 'Test Root'); +const root = cert('c-root', 'Test Root', 'root', 'Test Root'); +const key: KeyItem = { + id: 'k-leaf', + kind: 'privateKey', + sources: [{ file: 1, path: 'key.pem' }], + pem: 'PEM-KEY', + baseName: 'schluessel', + keyType: 'RSA', + keyBits: 2048, + curve: null, + wasEncrypted: false, + certIds: ['c-leaf'], +}; + +const completeChain: ChainInfo = { + headId: 'c-leaf', + path: ['c-leaf', 'c-inter', 'c-root'], + rootId: 'c-root', + complete: true, + gap: null, + alternatives: 0, +}; + +function workspace(items: (CertItem | KeyItem)[], chains: ChainInfo[]): CertWorkspace { + const analysis: AnalysisResult = { items, chains, locked: [], ignored: [] }; + return { + entries: [], + analysis, + analysisIds: [], + status: 'idle', + errorKey: null, + addFiles: () => [], + addText: () => null, + setPassword: () => {}, + remove: () => {}, + clear: () => {}, + retry: () => {}, + }; +} + +const withKey = () => workspace([keyedLeaf, inter, root, key], [completeChain]); +const b64 = (text: string) => btoa(text); + +function nginxResult() { + return { + files: [ + { filename: 'fullchain.pem', content: b64('FULLCHAIN'), mimeType: 'application/x-pem-file' }, + { filename: 'privkey.pem', content: b64('PRIVKEY'), mimeType: 'application/x-pem-file' }, + ], + chainComplete: true, + missingIssuerCn: null, + snippet: 'ssl_certificate /etc/nginx/ssl/www.example.test/fullchain.pem;', + }; +} + +beforeEach(() => { + mockBuild.mockReset(); + mockDownload.mockReset(); + mockBuild.mockResolvedValue(nginxResult()); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('TemplatesTab', () => { + it('zeigt sieben Karten in fester Reihenfolge', () => { + render(); + const titles = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent); + expect(titles).toEqual([ + 'Nginx', + 'Apache 2.4.8 und neuer', + 'Apache älter als 2.4.8', + 'Windows / IIS', + 'Nginx Proxy Manager', + 'HAProxy', + 'Tomcat / Java', + ]); + expect(screen.getAllByText(/Das erhalten Sie/)).toHaveLength(7); + }); + + it('der Haken „Root-Zertifikat mitnehmen“ ist zunaechst aus', () => { + render(); + expect(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' })).not.toBeChecked(); + }); + + it('ohne passenden Schluessel sind alle Knoepfe gesperrt und der Grund steht da', () => { + render(); + expect(screen.getByText(/noch kein passender privater Schlüssel/)).toBeInTheDocument(); + for (const button of screen.getAllByRole('button', { name: /^Vorlage herunterladen/ })) { + expect(button).toBeDisabled(); + } + }); + + it('ohne erkannte Zertifikate steht ein Hinweis statt der Karten', () => { + render(); + expect(screen.getByText(/noch kein Zertifikat erkannt/)).toBeInTheDocument(); + expect(screen.queryAllByRole('heading', { level: 3 })).toHaveLength(0); + }); + + it('Nginx: eine Anfrage mit Vorlage und Schluessel, Ergebnis als ZIP mit Anleitung', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' })); + await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1)); + expect(mockBuild).toHaveBeenCalledWith({ + content: 'template', + template: 'nginx', + certPem: 'PEM-c-leaf', + poolPems: ['PEM-c-inter', 'PEM-c-root'], + keyPem: 'PEM-KEY', + includeRoot: false, + baseName: 'www.example.test', + }); + const [name, content, mime] = mockDownload.mock.calls[0]; + expect(name).toBe('www.example.test-nginx.zip'); + expect(mime).toBe('application/zip'); + const bytes = Uint8Array.from(atob(content as string), (c) => c.charCodeAt(0)); + const files = unzipSync(bytes); + expect(Object.keys(files).sort()).toEqual(['ANLEITUNG.txt', 'fullchain.pem', 'privkey.pem']); + expect(strFromU8(files['fullchain.pem'])).toBe('FULLCHAIN'); + const guide = strFromU8(files['ANLEITUNG.txt']); + expect(guide).toContain('Anleitung für Nginx'); + expect(guide).toContain('1. Kopieren Sie beide Dateien'); + expect(guide).toContain('ssl_certificate /etc/nginx/ssl/www.example.test/fullchain.pem;'); + }); + + it('mit angehaktem Root wird includeRoot gesendet', async () => { + render(); + fireEvent.click(screen.getByRole('checkbox', { name: 'Root-Zertifikat mitnehmen' })); + fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' })); + await waitFor(() => expect(mockBuild).toHaveBeenCalled()); + expect(mockBuild.mock.calls[0][0]).toMatchObject({ includeRoot: true }); + }); + + it('danach steht der Schnipsel da, und Kopieren schreibt ihn in die Zwischenablage', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' })); + expect(await screen.findByText(/^ssl_certificate \/etc\/nginx/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Kopieren' })); + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + 'ssl_certificate /etc/nginx/ssl/www.example.test/fullchain.pem;', + ), + ); + expect(await screen.findByRole('button', { name: 'Kopiert' })).toBeInTheDocument(); + }); + + it('HAProxy: eine Datei wird direkt geladen, ohne ZIP', async () => { + mockBuild.mockResolvedValueOnce({ + files: [{ filename: 'www.example.test.pem', content: b64('X'), mimeType: 'text/plain' }], + chainComplete: true, + missingIssuerCn: null, + snippet: 'bind :443 ssl crt /etc/haproxy/certs/www.example.test.pem', + }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: HAProxy' })); + await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1)); + expect(mockDownload).toHaveBeenCalledWith('www.example.test.pem', b64('X'), 'text/plain'); + expect(await screen.findByText(/^bind :443 ssl crt/)).toBeInTheDocument(); + }); + + it('Nginx Proxy Manager: ZIP ohne Schnipsel, die Schritte nennen Custom und die Felder', async () => { + mockBuild.mockResolvedValueOnce({ + files: [ + { filename: 'certificate.pem', content: b64('C'), mimeType: 'text/plain' }, + { filename: 'privkey.pem', content: b64('K'), mimeType: 'text/plain' }, + ], + chainComplete: true, + missingIssuerCn: null, + snippet: null, + }); + render(); + expect(screen.getByText(/Wählen Sie „Custom“/)).toBeInTheDocument(); + expect(screen.getByText(/bei „Certificate Key“ die Datei privkey\.pem/)).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx Proxy Manager' }), + ); + await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1)); + expect(mockDownload.mock.calls[0][0]).toBe('www.example.test-npm.zip'); + expect(screen.queryByText('Zeilen für die Einrichtung')).not.toBeInTheDocument(); + }); + + it('IIS: Kompatibel ist vorgewaehlt, ohne Passwort gesperrt, dann Anfrage mit Passwort', async () => { + mockBuild.mockResolvedValueOnce({ + files: [ + { filename: 'www.example.test.pfx', content: b64('P'), mimeType: 'application/x-pkcs12' }, + ], + chainComplete: true, + missingIssuerCn: null, + snippet: 'Import-PfxCertificate -FilePath .\\www.example.test.pfx', + }); + render(); + const encryptions = screen.getAllByRole('combobox', { name: 'Verschlüsselung' }); + expect(encryptions).toHaveLength(2); // IIS und Tomcat + expect((encryptions[0] as HTMLSelectElement).value).toBe('compat'); + const button = screen.getByRole('button', { name: 'Vorlage herunterladen: Windows / IIS' }); + expect(button).toBeDisabled(); + const passwords = screen.getAllByLabelText('PFX-Passwort'); + const repeats = screen.getAllByLabelText('Passwort wiederholen'); + fireEvent.change(passwords[0], { target: { value: 'Neu-Pass-2026' } }); + fireEvent.change(repeats[0], { target: { value: 'Neu-Pass-2026' } }); + expect(button).toBeEnabled(); + // Tomcat bleibt gesperrt: eigenes Passwort je Karte + expect( + screen.getByRole('button', { name: 'Vorlage herunterladen: Tomcat / Java' }), + ).toBeDisabled(); + fireEvent.click(button); + await waitFor(() => expect(mockBuild).toHaveBeenCalled()); + expect(mockBuild.mock.calls[0][0]).toMatchObject({ + content: 'template', + template: 'iis', + password: 'Neu-Pass-2026', + pfxEncryption: 'compat', + }); + expect(mockDownload).toHaveBeenCalledWith( + 'www.example.test.pfx', + b64('P'), + 'application/x-pkcs12', + ); + // das Passwort steht nirgends im dargestellten Text + expect(document.body.textContent).not.toContain('Neu-Pass-2026'); + }); + + it('ein Fehlercode der API zeigt den deutschen Text', async () => { + mockBuild.mockRejectedValueOnce(new CertManagerRequestError(400, 'keyMismatch')); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Vorlage herunterladen: Nginx' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Der Schlüssel gehört nicht'); + expect(mockDownload).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.tsx new file mode 100644 index 0000000..9771dad --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/TemplatesTab.tsx @@ -0,0 +1,320 @@ +'use client'; + +import { strFromU8, strToU8, zipSync } from 'fflate'; +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { + type BuildFile, + buildOutput, + type CertItem, + certErrorKey, + downloadBase64, + type KeyItem, + type TemplateId, +} from '../actions'; +import type { CertWorkspace } from '../use-cert-workspace'; +import { sanitizeZipFilename } from '../zip-filename'; +import { ChainView } from './ChainView'; +import { type PfxEncryption, PfxOptions, pfxPasswordReady } from './PfxOptions'; + +interface TemplatesTabProps { + workspace: CertWorkspace; +} + +/** Reihenfolge der Karten; `messageKey` ist der Zweig unter certManager.templates, `steps` die Zahl der Schritte. */ +const TEMPLATES: { id: TemplateId; messageKey: string; steps: number; pfx: boolean }[] = [ + { id: 'nginx', messageKey: 'nginx', steps: 3, pfx: false }, + { id: 'apache', messageKey: 'apache', steps: 3, pfx: false }, + { id: 'apache-legacy', messageKey: 'apacheLegacy', steps: 3, pfx: false }, + { id: 'iis', messageKey: 'iis', steps: 3, pfx: true }, + { id: 'npm', messageKey: 'npm', steps: 3, pfx: false }, + { id: 'haproxy', messageKey: 'haproxy', steps: 3, pfx: false }, + { id: 'tomcat', messageKey: 'tomcat', steps: 3, pfx: true }, +]; + +interface PfxChoice { + password: string; + encryption: PfxEncryption; +} + +function bytesOf(file: BuildFile): Uint8Array { + const raw = atob(file.content); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); + return bytes; +} + +/** Mehrere Dateien: ZIP aus den Dateien der API plus der Anleitung als Textdatei. */ +function zipBase64(files: BuildFile[], instructionsName: string, instructions: string): string { + const entries: Record = {}; + for (const file of files) entries[file.filename] = bytesOf(file); + entries[instructionsName] = strToU8(instructions); + return btoa(strFromU8(zipSync(entries), true)); +} + +/** + * Reiter „Vorlagen“: ein Klick liefert die Dateien fuer ein Zielsystem (Nginx, Apache, Windows/IIS, + * Nginx Proxy Manager, HAProxy, Tomcat) und zeigt die Zeilen fuer die Einrichtung. Alle Vorlagen + * brauchen das Serverzertifikat und den passenden privaten Schluessel. Die API baut die Dateien + * (und ordnet die Kette selbst); mehrere Dateien packt der Browser mit einer Anleitung in eine ZIP-Datei. + * PFX-Passwoerter bleiben im Zustand der Karte und gehen nur in die Anfrage. + */ +export function TemplatesTab({ workspace }: TemplatesTabProps) { + const t = useTranslations('certManager'); + const [selectedHead, setSelectedHead] = useState(null); + const [includeRoot, setIncludeRoot] = useState(false); + const [busy, setBusy] = useState(null); + const [errorKey, setErrorKey] = useState(null); + const [shown, setShown] = useState<{ id: TemplateId; snippet: string } | null>(null); + const [copied, setCopied] = useState(false); + + const certs = (workspace.analysis?.items ?? []).filter( + (i): i is CertItem => i.kind === 'certificate', + ); + const keys = (workspace.analysis?.items ?? []).filter( + (i): i is KeyItem => i.kind === 'privateKey', + ); + const chains = workspace.analysis?.chains ?? []; + + if (workspace.status === 'analyzing' && !workspace.analysis) { + return ( +

+ {t('files.analyzing')} +

+ ); + } + if (chains.length === 0) { + return

{t('merge.noCertificates')}

; + } + + 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 key = head?.keyId ? keys.find((k) => k.id === head.keyId) : undefined; + + const instructionsText = ( + template: (typeof TEMPLATES)[number], + files: BuildFile[], + snippet: string, + ) => { + const lines = [ + t('templates.instructionsHeading', { name: t(`templates.${template.messageKey}.title`) }), + '', + ]; + lines.push(`${t('templates.instructionsFiles')}:`); + for (const file of files) lines.push(`- ${file.filename}`); + lines.push('', `${t('templates.stepsTitle')}:`); + for (let n = 1; n <= template.steps; n++) { + lines.push(`${n}. ${t(`templates.${template.messageKey}.steps.${n}`)}`); + } + if (snippet) lines.push('', `${t('templates.instructionsSnippet')}:`, snippet); + return `${lines.join('\n')}\n`; + }; + + const download = async (template: (typeof TEMPLATES)[number], pfx: PfxChoice | null) => { + if (!head || !key) return; + setBusy(template.id); + setErrorKey(null); + setShown(null); + setCopied(false); + try { + const result = await buildOutput({ + content: 'template', + template: template.id, + certPem: head.pem, + poolPems: members.filter((c) => c.id !== head.id).map((c) => c.pem), + keyPem: key.pem, + includeRoot, + baseName: head.baseName, + ...(pfx ? { password: pfx.password, pfxEncryption: pfx.encryption } : {}), + }); + const snippet = result.snippet ?? ''; + if (result.files.length > 1) { + const zipName = sanitizeZipFilename(`${head.baseName}-${template.id}.zip`); + downloadBase64( + zipName, + zipBase64( + result.files, + t('templates.instructionsFile'), + instructionsText(template, result.files, snippet), + ), + 'application/zip', + ); + } else { + for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType); + } + if (snippet) setShown({ id: template.id, snippet }); + } catch (error) { + setErrorKey(certErrorKey(error)); + } finally { + setBusy(null); + } + }; + + const copy = async (snippet: string) => { + try { + await navigator.clipboard.writeText(snippet); + setCopied(true); + } catch { + setCopied(false); + } + }; + + return ( +
+

{t('templates.intro')}

+ + {chains.length > 1 && ( +
+ + {t('templates.chooseHead')} + + {chains.map((c) => { + const cert = certs.find((x) => x.id === c.headId); + return ( + + ); + })} +
+ )} + + + +
+ + {!hasRoot && ( +

{t('merge.noRootAvailable')}

+ )} +
+ + {!key && ( +

+ {t('templates.needsKey')} +

+ )} + +
    + {TEMPLATES.map((template) => ( + download(template, pfx)} + snippet={shown?.id === template.id ? shown.snippet : null} + copied={copied} + onCopy={copy} + /> + ))} +
+ + {errorKey && ( +

+ {t(`errors.${errorKey}`)} +

+ )} +
+ ); +} + +interface TemplateCardProps { + template: (typeof TEMPLATES)[number]; + disabled: boolean; + busy: boolean; + onDownload: (pfx: PfxChoice | null) => void; + snippet: string | null; + copied: boolean; + onCopy: (snippet: string) => void; +} + +/** Eine Vorlage: Titel, was geliefert wird, die Schritte, bei PFX-Vorlagen Passwort und Verschluesselung. */ +function TemplateCard({ + template, + disabled, + busy, + onDownload, + snippet, + copied, + onCopy, +}: TemplateCardProps) { + const t = useTranslations('certManager'); + const [password, setPassword] = useState(''); + const [repeat, setRepeat] = useState(''); + const [encryption, setEncryption] = useState('compat'); + const base = `templates.${template.messageKey}`; + const ready = !template.pfx || pfxPasswordReady(password, repeat); + + return ( +
  • +

    {t(`${base}.title`)}

    +

    + {t('templates.delivers')}: + {t(`${base}.delivers`)} +

    +
    +

    {t('templates.stepsTitle')}

    +
      + {Array.from({ length: template.steps }, (_, i) => i + 1).map((n) => ( +
    1. {t(`${base}.steps.${n}`)}
    2. + ))} +
    +
    + {template.pfx && ( + + )} + + {snippet && ( +
    +

    {t('templates.snippetTitle')}

    +
    +            {snippet}
    +          
    +

    {t('templates.snippetHint')}

    + +
    + )} +
  • + ); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/page.tsx b/apps/web/src/app/(portal)/modules/cert-manager/page.tsx index 942733a..de9e0e6 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/page.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/page.tsx @@ -10,9 +10,10 @@ import { EmptyWorkspace } from './components/EmptyWorkspace'; import { FilesTab } from './components/FilesTab'; import { MergeTab } from './components/MergeTab'; import { SplitTab } from './components/SplitTab'; +import { TemplatesTab } from './components/TemplatesTab'; import { useCertWorkspace } from './use-cert-workspace'; -type TabId = 'files' | 'analyze' | 'split' | 'merge' | 'convert'; +type TabId = 'files' | 'analyze' | 'split' | 'merge' | 'convert' | 'templates'; /** * CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt). @@ -34,6 +35,7 @@ export default function CertManagerPage() { { id: 'split', label: t('tabs.split') }, { id: 'merge', label: t('tabs.merge') }, { id: 'convert', label: t('tabs.convert') }, + { id: 'templates', label: t('tabs.templates') }, ]; return ( @@ -52,6 +54,7 @@ export default function CertManagerPage() { {activeTab === 'split' && } {activeTab === 'merge' && } {activeTab === 'convert' && } + {activeTab === 'templates' && } )} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 95db708..e5ced91 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -1257,7 +1257,8 @@ "analyze": "Analysieren", "split": "Aufteilen", "merge": "Zusammenführen", - "convert": "Konvertieren" + "convert": "Konvertieren", + "templates": "Vorlagen" }, "roles": { "end-entity": "Serverzertifikat", @@ -1460,6 +1461,87 @@ "keyPassword": "Passwort für den Schlüssel", "download": "Herunterladen", "busy": "Wird erstellt …" + }, + "templates": { + "intro": "Wählen Sie das System, auf dem das Zertifikat laufen soll. Tessera erstellt mit einem Klick die passenden Dateien und zeigt die Zeilen für die Einrichtung. Für jede Vorlage brauchen Sie das Serverzertifikat und den passenden privaten Schlüssel.", + "chooseHead": "Für welches Zertifikat sollen die Dateien erstellt werden?", + "needsKey": "Für dieses Zertifikat liegt noch kein passender privater Schlüssel vor. Fügen Sie ihn im Reiter „Dateien“ hinzu, dann sind die Vorlagen verfügbar.", + "delivers": "Das erhalten Sie", + "stepsTitle": "So gehen Sie vor", + "download": "Vorlage herunterladen", + "busy": "Wird erstellt …", + "zipHint": "Mehrere Dateien kommen zusammen in einer ZIP-Datei, die auch eine Anleitung enthält.", + "snippetTitle": "Zeilen für die Einrichtung", + "snippetHint": "Passen Sie die Pfade an Ihren Server an.", + "copy": "Kopieren", + "copied": "Kopiert", + "instructionsFile": "ANLEITUNG.txt", + "instructionsHeading": "Anleitung für {name}", + "instructionsFiles": "Enthaltene Dateien", + "instructionsSnippet": "Zeilen für die Einrichtung", + "nginx": { + "title": "Nginx", + "delivers": "fullchain.pem (Serverzertifikat und Zwischenzertifikate) und privkey.pem (privater Schlüssel).", + "steps": { + "1": "Kopieren Sie beide Dateien auf Ihren Server, zum Beispiel in den Ordner /etc/nginx/ssl.", + "2": "Tragen Sie die beiden angezeigten Zeilen im server-Block Ihrer Nginx-Konfiguration ein.", + "3": "Prüfen Sie die Einstellungen mit „nginx -t“ und laden Sie Nginx mit „nginx -s reload“ neu." + } + }, + "apache": { + "title": "Apache 2.4.8 und neuer", + "delivers": "fullchain.pem (Serverzertifikat und Zwischenzertifikate) und privkey.pem (privater Schlüssel).", + "steps": { + "1": "Kopieren Sie beide Dateien auf Ihren Server, zum Beispiel in den Ordner /etc/ssl.", + "2": "Tragen Sie die beiden angezeigten Zeilen im VirtualHost für Port 443 ein.", + "3": "Prüfen Sie die Einstellungen mit „apachectl configtest“ und laden Sie Apache neu." + } + }, + "apacheLegacy": { + "title": "Apache älter als 2.4.8", + "delivers": "cert.pem (Serverzertifikat), chain.pem (Zwischenzertifikate) und privkey.pem (privater Schlüssel).", + "steps": { + "1": "Kopieren Sie die Dateien auf Ihren Server, zum Beispiel in den Ordner /etc/ssl.", + "2": "Tragen Sie die angezeigten Zeilen im VirtualHost für Port 443 ein. Ältere Apache-Versionen brauchen die Kette in einer eigenen Datei.", + "3": "Prüfen Sie die Einstellungen mit „apachectl configtest“ und laden Sie Apache neu." + } + }, + "iis": { + "title": "Windows / IIS", + "delivers": "Eine PFX-Datei mit Zertifikaten und Schlüssel. Vorgewählt ist die kompatible Verschlüsselung, die auch ältere Windows-Server öffnen können.", + "steps": { + "1": "Kopieren Sie die PFX-Datei auf den Windows-Server.", + "2": "Importieren Sie sie per Doppelklick in den Speicher „Lokaler Computer“, Ablage „Eigene Zertifikate“, oder mit der angezeigten PowerShell-Zeile. Das Passwort geben Sie dabei ein.", + "3": "Wählen Sie das Zertifikat im IIS-Manager unter „Bindungen“ der Website aus." + } + }, + "npm": { + "title": "Nginx Proxy Manager", + "delivers": "certificate.pem (Serverzertifikat), intermediate.pem (Zwischenzertifikate, falls vorhanden) und privkey.pem (privater Schlüssel im klassischen Format).", + "steps": { + "1": "Öffnen Sie in Nginx Proxy Manager den Bereich „SSL Certificates“ und klicken Sie auf „Add SSL Certificate“. Wählen Sie „Custom“.", + "2": "Vergeben Sie einen Namen. Wählen Sie bei „Certificate Key“ die Datei privkey.pem, bei „Certificate“ die Datei certificate.pem und bei „Intermediate Certificate“ die Datei intermediate.pem.", + "3": "Speichern Sie und wählen Sie das Zertifikat anschließend beim Proxy-Host im Reiter „SSL“ aus." + } + }, + "haproxy": { + "title": "HAProxy", + "delivers": "Eine einzige PEM-Datei mit Serverzertifikat, Zwischenzertifikaten und privatem Schlüssel.", + "steps": { + "1": "Kopieren Sie die Datei auf Ihren Server, zum Beispiel in den Ordner /etc/haproxy/certs. Schützen Sie sie, denn sie enthält den Schlüssel.", + "2": "Tragen Sie die angezeigte Zeile im frontend-Block Ihrer HAProxy-Konfiguration ein.", + "3": "Laden Sie HAProxy neu, zum Beispiel mit „systemctl reload haproxy“." + } + }, + "tomcat": { + "title": "Tomcat / Java", + "delivers": "Eine Datei im Format PKCS#12 (.p12) mit Zertifikaten und Schlüssel. Vorgewählt ist die kompatible Verschlüsselung.", + "steps": { + "1": "Kopieren Sie die Datei auf Ihren Server, zum Beispiel in den Ordner conf von Tomcat.", + "2": "Tragen Sie die angezeigte Zeile in den SSLHostConfig-Block der server.xml ein und ersetzen Sie IHR-PASSWORT durch das Passwort, das Sie hier vergeben haben.", + "3": "Starten Sie Tomcat neu." + } + } } }, "tenderRadar": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 2b3eb0c..3671587 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -1257,7 +1257,8 @@ "analyze": "Analyze", "split": "Split", "merge": "Merge", - "convert": "Convert" + "convert": "Convert", + "templates": "Templates" }, "roles": { "end-entity": "Server certificate", @@ -1460,6 +1461,87 @@ "keyPassword": "Password for the key", "download": "Download", "busy": "Creating …" + }, + "templates": { + "intro": "Choose the system the certificate is going to run on. Tessera creates the matching files with one click and shows the lines for the setup. Every template needs the server certificate and the matching private key.", + "chooseHead": "For which certificate should the files be created?", + "needsKey": "There is no matching private key for this certificate yet. Add it in the “Files” tab, then the templates become available.", + "delivers": "What you get", + "stepsTitle": "How to proceed", + "download": "Download template", + "busy": "Creating …", + "zipHint": "Several files come together in one ZIP file that also contains instructions.", + "snippetTitle": "Lines for the setup", + "snippetHint": "Adjust the paths to your server.", + "copy": "Copy", + "copied": "Copied", + "instructionsFile": "INSTRUCTIONS.txt", + "instructionsHeading": "Instructions for {name}", + "instructionsFiles": "Included files", + "instructionsSnippet": "Lines for the setup", + "nginx": { + "title": "Nginx", + "delivers": "fullchain.pem (server certificate and intermediate certificates) and privkey.pem (private key).", + "steps": { + "1": "Copy both files to your server, for example to the folder /etc/nginx/ssl.", + "2": "Add the two lines shown to the server block of your Nginx configuration.", + "3": "Check the settings with “nginx -t” and reload Nginx with “nginx -s reload”." + } + }, + "apache": { + "title": "Apache 2.4.8 and newer", + "delivers": "fullchain.pem (server certificate and intermediate certificates) and privkey.pem (private key).", + "steps": { + "1": "Copy both files to your server, for example to the folder /etc/ssl.", + "2": "Add the two lines shown to the VirtualHost for port 443.", + "3": "Check the settings with “apachectl configtest” and reload Apache." + } + }, + "apacheLegacy": { + "title": "Apache older than 2.4.8", + "delivers": "cert.pem (server certificate), chain.pem (intermediate certificates) and privkey.pem (private key).", + "steps": { + "1": "Copy the files to your server, for example to the folder /etc/ssl.", + "2": "Add the lines shown to the VirtualHost for port 443. Older Apache versions need the chain in a file of its own.", + "3": "Check the settings with “apachectl configtest” and reload Apache." + } + }, + "iis": { + "title": "Windows / IIS", + "delivers": "A PFX file with certificates and key. The compatible encryption is preselected; older Windows servers can open it as well.", + "steps": { + "1": "Copy the PFX file to the Windows server.", + "2": "Import it by double-click into the “Local Computer” store, “Personal”, or with the PowerShell line shown. You enter the password there.", + "3": "Select the certificate in IIS Manager under “Bindings” of the website." + } + }, + "npm": { + "title": "Nginx Proxy Manager", + "delivers": "certificate.pem (server certificate), intermediate.pem (intermediate certificates, if any) and privkey.pem (private key in the traditional format).", + "steps": { + "1": "In Nginx Proxy Manager open “SSL Certificates” and click “Add SSL Certificate”. Choose “Custom”.", + "2": "Enter a name. For “Certificate Key” choose the file privkey.pem, for “Certificate” the file certificate.pem and for “Intermediate Certificate” the file intermediate.pem.", + "3": "Save, then select the certificate on the proxy host in the “SSL” tab." + } + }, + "haproxy": { + "title": "HAProxy", + "delivers": "A single PEM file with server certificate, intermediate certificates and private key.", + "steps": { + "1": "Copy the file to your server, for example to the folder /etc/haproxy/certs. Protect it, because it contains the key.", + "2": "Add the line shown to the frontend block of your HAProxy configuration.", + "3": "Reload HAProxy, for example with “systemctl reload haproxy”." + } + }, + "tomcat": { + "title": "Tomcat / Java", + "delivers": "A file in PKCS#12 format (.p12) with certificates and key. The compatible encryption is preselected.", + "steps": { + "1": "Copy the file to your server, for example to the conf folder of Tomcat.", + "2": "Add the line shown to the SSLHostConfig block of server.xml and replace IHR-PASSWORT with the password you set here.", + "3": "Restart Tomcat." + } + } } }, "tenderRadar": { diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index 9ef2127..ab6918c 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -257,4 +257,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ // quick-261009-ikt Task 5: PFX und Konvertieren 'passwortgeschützte', 'AES', + // quick-261009-ikt Task 6: Vorlagen + 'ssl', + 'neuer', + 'klassischen', + 'SSLHostConfig', + 'PASSWORT', ]; diff --git a/docs/anleitung-anwender.md b/docs/anleitung-anwender.md index df040b7..16282bc 100644 --- a/docs/anleitung-anwender.md +++ b/docs/anleitung-anwender.md @@ -149,14 +149,18 @@ Das Modul prüft automatisch ein Postfach auf eingehende DKV-Tankkarten-Rechnung ### Zertifikat-Manager -Ein Werkzeug rund um SSL/TLS-Zertifikate mit vier Reitern: +Der Zertifikat-Manager hilft Ihnen, Zertifikate, Schlüssel und Zertifikatsanfragen zu prüfen, zu ordnen und in das Format zu bringen, das Ihr Server braucht. Er arbeitet mit **einer gemeinsamen Liste von Dateien**: Sie laden Ihre Dateien einmal im ersten Reiter hoch, und alle anderen Reiter arbeiten mit dieser Liste. Es gibt sechs Reiter, in dieser Reihenfolge: **Dateien**, **Analysieren**, **Aufteilen**, **Zusammenführen**, **Konvertieren** und **Vorlagen**. Ist die Liste noch leer, weisen die anderen Reiter freundlich auf den Reiter „Dateien“ hin. Wenn Sie zwischen den Reitern wechseln, bleibt die Liste erhalten. -- **Analysieren** — lädt ein Zertifikat (Datei oder eingefügter PEM-Text) und zeigt dessen Details, inklusive Einordnung als Root-CA, Zwischen-CA oder Endzertifikat. -- **Aufteilen** — zerlegt eine Fullchain- oder P7B-Datei in ihre einzelnen Zertifikate. -- **Zusammenführen** — fügt mehrere einzelne Zertifikate zu einer Kette zusammen. -- **Konvertieren** — wandelt ein Zertifikat in ein anderes Format um. +**Wichtig zu Schlüsseln und Passwörtern:** Die Liste lebt nur im Arbeitsspeicher Ihres Browserfensters. Private Schlüssel und Passwörter werden von Tessera nirgends gespeichert und in keinem Protokoll festgehalten. Laden Sie die Seite neu oder schließen Sie das Fenster, ist die Liste weg, und Sie laden Ihre Dateien bei Bedarf noch einmal hoch. -Unterstützte Dateiformate sind unter anderem `.pem`, `.crt`, `.cer`, `.der`, `.pfx`, `.p12`, `.p7b` und `.p7c`. Passwortgeschützte PFX/P12-Dateien verlangen die Eingabe des zugehörigen Passworts. Ergebnisse lassen sich einzeln oder gesammelt als ZIP herunterladen. +- **Dateien** — Hier sammeln Sie alles. Ziehen Sie Dateien in das Feld oder klicken Sie hinein und wählen Sie Dateien aus, so oft und in beliebiger Menge nacheinander; **jede neue Datei kommt zur Liste hinzu und ersetzt nie eine frühere**. Sie können auch die ZIP-Datei Ihres Zertifikatsausstellers hochladen: Tessera schaut hinein und nennt zu jedem enthaltenen Eintrag, was es darin gefunden hat. Überflüssiges wie Ordner mit dem Namen `__MACOSX` überspringt Tessera still; verschachtelte oder passwortgeschützte ZIP-Dateien nennt es mit dem Grund, warum es sie nicht öffnet. Zusätzlich können Sie PEM-Text einfügen („PEM-Text einfügen“, Text beginnt mit `-----BEGIN`); er erscheint als eigener Eintrag. Die Liste fasst bis zu 30 Einträge, höchstens 5 MB je Datei und zusammen höchstens 10 MB; dieselbe Datei kann nicht zweimal hinzugefügt werden. Zu jedem Eintrag zeigt die Liste, was erkannt wurde: Serverzertifikat, Zwischenzertifikat, Stammzertifikat, privater Schlüssel oder Zertifikatsanfrage. Mit dem Knopf am Eintrag nehmen Sie eine einzelne Datei wieder aus der Liste. Ist eine Datei durch ein Passwort geschützt (PFX-/P12-Datei oder verschlüsselter Schlüssel), erscheint beim Eintrag ein Feld für das Passwort; es gilt nur für diese eine Datei. Hat Tessera zum Serverzertifikat schon einen Schlüssel, erscheint eine passwortgeschützte PFX-Datei nur als ruhiger Hinweis, den Sie nicht beachten müssen. +- **Analysieren** — Zeigt zu jedem erkannten Teil die Einzelheiten: Name, Aussteller, Gültigkeit (mit den verbleibenden Tagen), Schlüsselart (RSA oder EC mit Kurve), die Namen, für die das Zertifikat gilt, sowie Seriennummer und Fingerabdrücke. Außerdem sehen Sie, was zusammengehört: welcher Schlüssel zu welchem Zertifikat passt und welche Zertifikatsanfrage zu welchem Zertifikat gehört. Tessera ordnet nie nach dem Namen zu, sondern prüft den Schlüssel selbst. +- **Aufteilen** — Zerlegt Ihre Dateien in die einzelnen Teile. Jedes Teil laden Sie in seinem natürlichen Format herunter (Zertifikat als `.crt`, Schlüssel als `.key`, Anfrage als `.csr`) oder alle zusammen als ZIP-Datei. +- **Zusammenführen** — Tessera ordnet Serverzertifikat, Zwischenzertifikate und Stammzertifikat **selbst**; die Reihenfolge Ihrer Dateien spielt keine Rolle. Dabei prüft es nicht nur Namen, sondern die echte Unterschrift jedes Zertifikats, sodass ein gleichnamiges, aber falsches Zwischenzertifikat nie verwendet wird. Gibt es mehrere Möglichkeiten, wählt Tessera nachvollziehbar die beste (zum Beispiel nicht abgelaufen). Sie können herunterladen: **Fullchain** (Serverzertifikat plus Zwischenzertifikate), **Nur Kette** (nur die Zwischenzertifikate, zum Beispiel für Systeme, die das Serverzertifikat getrennt wollen), **Zertifikat und Schlüssel** in einer PEM-Datei sowie eine **PFX-Datei**. Fullchain und Nur Kette gibt es als PEM, als `.p7b` oder als binäre `.p7c`. Für die PFX-Datei vergeben Sie ein Passwort (zweimal eingeben) und wählen die Verschlüsselung: **„Kompatibel (auch ältere Windows-Server)“** ist vorgewählt und die sichere Wahl, wenn Sie nicht wissen, wo die Datei eingespielt wird; **„Modern (AES-256)“** ist stärker, wird aber von älteren Systemen wie Windows Server 2016 oft nicht gelesen. Das Häkchen **„Root-Zertifikat mitnehmen“** ist standardmäßig aus, denn die meisten Server und Browser kennen die Wurzel schon und brauchen sie nicht; setzen Sie es nur, wenn ein Gerät es ausdrücklich verlangt (manche Geräte, Java-Anwendungen oder eigene Firmenwurzeln). Fehlt ein Aussteller in Ihrer Liste, sagt Tessera das ausdrücklich: **„Zwischenzertifikat fehlt“** heißt, dass direkt über dem Serverzertifikat das Zwischenzertifikat nicht in der Liste ist; fehlt dagegen nur das Zertifikat ganz oben (meist die Wurzel), erscheint ein ruhiger Hinweis, denn das ist für die meisten Server in Ordnung. Fügen Sie das fehlende Zertifikat im Reiter „Dateien“ hinzu. +- **Konvertieren** — Wandelt ein einzelnes Teil in ein anderes Format um: Zertifikate als PEM, DER, PKCS#7 (`.p7b`, `.p7c`); Schlüssel als PKCS#8, klassisch (PKCS#1 bei RSA, SEC1 bei EC) oder DER, auf Wunsch mit eigenem Passwort verschlüsselt; Zertifikatsanfragen als PEM oder DER. +- **Vorlagen** — Wählen Sie das System, auf dem Ihr Zertifikat laufen soll, und Tessera erzeugt mit einem Klick die passenden Dateien und zeigt die Zeilen für die Einrichtung (mit „Kopieren“). Mehrere Dateien kommen in einer ZIP-Datei, die zusätzlich eine kurze Anleitung enthält. Jede Vorlage braucht das Serverzertifikat **und** den dazu passenden privaten Schlüssel; fehlt der Schlüssel, erklärt Tessera das. Es gibt sieben Vorlagen: **Nginx** (`fullchain.pem` und `privkey.pem`); **Apache 2.4.8 und neuer** (ebenfalls `fullchain.pem` und `privkey.pem`); **Apache älter als 2.4.8** (getrennt `cert.pem`, `chain.pem` und `privkey.pem`); **Windows / IIS** (eine PFX-Datei mit dem Passwort, das Sie vergeben, vorgewählt ist „Kompatibel“); **Nginx Proxy Manager** (`certificate.pem`, `intermediate.pem` und `privkey.pem`; die Anleitung nennt, welche Datei in welches Feld unter „SSL Certificates“, „Add SSL Certificate“, „Custom“ gehört); **HAProxy** (eine einzige PEM-Datei mit Zertifikat, Zwischenzertifikaten und Schlüssel) und **Tomcat / Java** (eine `.p12`-Datei mit dem Passwort, das Sie vergeben, ebenfalls mit vorgewähltem „Kompatibel“). Das Passwort steht nie in den angezeigten Zeilen; dort steht stattdessen `IHR-PASSWORT`. + +Unterstützte Eingaben sind unter anderem `.pem`, `.crt`, `.cer`, `.der`, `.pfx`, `.p12`, `.p7b`, `.p7c`, Schlüssel (`.key`, PKCS#1, PKCS#8 und SEC1, im Text oder binär, mit und ohne Passwort), Zertifikatsanfragen (`.csr`) und ZIP-Dateien. Tessera erkennt Dateien an ihrem Inhalt, nicht an der Endung, sodass auch eine Datei ohne passende Endung geöffnet wird. Zertifikate und Schlüssel mit elliptischen Kurven (EC) werden ebenso verarbeitet wie RSA. ### Domaincheck diff --git a/docs/anleitung-betrieb.md b/docs/anleitung-betrieb.md index 593b48c..74e7067 100644 --- a/docs/anleitung-betrieb.md +++ b/docs/anleitung-betrieb.md @@ -196,6 +196,14 @@ Das Modul „Dateien“ spricht aus dem `api`-Container mit der Nextcloud der In - **Brute-Force-Ausnahme in der Nextcloud:** Tragen Sie die Adresse des Tessera-Servers dort in die Ausnahmeliste ein (Administrationshandbuch, Abschnitt „Dateien: Nextcloud anbinden“). Sonst kann eine Reihe falscher Anmeldungen die Nextcloud für alle Benutzer gleichzeitig sperren. - **Verschlüsselung:** Die gespeicherten App-Passwörter sind mit `TESSERA_ENCRYPTION_KEY` verschlüsselt (Kapitel 2). Ein anderer Schlüssel macht sie unlesbar; die Benutzer müssen sich dann neu verbinden. +### Zertifikat-Manager + +Das Modul „Zertifikat-Manager“ braucht keine Einstellungen und keine eigene Konfiguration. Es speichert nichts: Zertifikate, Schlüssel und Passwörter kommen mit jeder Anfrage aus dem Browser, werden im Arbeitsspeicher des `api`-Containers verarbeitet und nicht in der Datenbank oder in Protokollen abgelegt. Für den Betrieb gilt: + +- **Größe der Anfragen:** Die Dateien gehen über `/api-proxy` an die API. Eine einzelne Analyse schickt höchstens 10 MB (höchstens 30 Dateien, je Datei bis 5 MB). Die Voraussetzung am Proxy ist dieselbe wie im Abschnitt „Dateien (Nextcloud)“: `client_max_body_size` von mindestens `10m`. Beim Herunterladen eines Ergebnisses schickt der Browser nur Zertifikate und höchstens einen Schlüssel als JSON, höchstens 512 KiB je Anfrage (Tessera legt für genau diese Anfrage eine eigene Grenze fest, alle anderen JSON-Anfragen bleiben bei 100 kB). +- **Ausgehender Zugriff:** Der Zertifikat-Manager ruft von sich aus nichts im Internet ab. +- **Rechenaufwand:** Passwortgeschützte PFX-Dateien und verschlüsselte Schlüssel öffnet Tessera mit den eingegebenen Passwörtern (je Datei höchstens zehn verschiedene Versuche); das kostet kurz Rechenzeit, belastet den Server aber nicht dauerhaft. + ## 4. Neue Fassung einspielen Das ist der wichtigste Ablauf im Tagesgeschäft. Zwei Befehle: @@ -765,5 +773,6 @@ Für die Desktop-Auslieferung ist keine neue Pflichtvariable nötig. | `/api-proxy/desktop/update` antwortet dauerhaft `204`, obwohl Pakete da sind | Manifest ohne `signature`/`updateVersion`: Pakete aus einem Bau vor der Update-Funktion oder mit `--no-sign` | Eine Änderung unter `apps/desktop/` pushen bzw. den Tag neu bauen lassen; im CI prüfen, dass die Secrets `TAURI_SIGNING_PRIVATE_KEY`/`_PASSWORD` gesetzt sind (Abschnitt „Updates in der App und der Signierschlüssel"). | | Eine Fehlermeldung aus der Desktop-App nennt als Herkunft „Desktop-App (unbekannt)“ ohne Version, Betreff-Kürzel `[Desktop]` | Der Client ist älter als diese Fassung: er meldet dem Server beim Start nur `desktop=1`, nicht Version, Stand und Betriebssystem (Parameter `dv`, `dc`, `dos`, aus denen `web` das Cookie `tessera_desktop_client` bildet) | Kein Fehler, die Meldung ist trotzdem als Desktop-App erkennbar. Client über „Auf Version … aktualisieren“ im Infobereich oder den Browser-Installer aktualisieren; danach stehen Betriebssystem, Version und Stand in der Meldung. | | Beim Hochladen in „Dateien“ bricht jede größere Datei beim ersten 8-MB-Stück ab (Fehler „Die Verbindung wurde unterbrochen“, im Proxy-Protokoll `413`) | Der Proxy vor Tessera (Nginx Proxy Manager) lässt keine Anfragen über seiner Größengrenze durch (`client_max_body_size`), oder sein Zeitlimit ist zu kurz | Für die Tessera-Adresse `client_max_body_size` auf mindestens `10m` und die Lese-/Sendezeitlimits auf mindestens 120 Sekunden stellen (Kapitel 3, Abschnitt „Dateien (Nextcloud)“). | +| Im Zertifikat-Manager bricht das Hochladen mehrerer Dateien ab (Fehlertext „Die Dateien sind zusammen zu groß“ oder `413` im Proxy-Protokoll) | Der Proxy vor Tessera (Nginx Proxy Manager) lässt Anfragen über seiner Größengrenze nicht durch (`client_max_body_size`); die Analyse schickt bis zu 10 MB | Für die Tessera-Adresse `client_max_body_size` auf mindestens `10m` stellen (Kapitel 3, Abschnitt „Dateien (Nextcloud)“); Tessera selbst erlaubt höchstens 20 MB je Analyse. | | In „Dateien“ tragen öffentliche Links eine interne Adresse (zum Beispiel ein interner Rechnername oder eine IP-Adresse) und lassen sich von außen nicht öffnen | Die Nextcloud baut die Adresse eines Links aus dem Namen, unter dem Tessera sie aufruft; in Tessera ist die interne Adresse der Nextcloud eingetragen | In den Einstellungen des Moduls die von außen erreichbare Adresse der Nextcloud eintragen, oder in der `config.php` der Nextcloud `overwritehost`, `overwriteprotocol` und `overwrite.cli.url` auf die externe Adresse setzen; bereits erstellte Links ändern sich nicht rückwirkend, sie müssen neu erstellt werden. | | In „Dateien“ erscheint „Sie haben in kurzer Zeit viele Freigaben angelegt. Bitte warten Sie einige Minuten.“ | Tessera hat für den Benutzer 10 neue Freigaben innerhalb von 10 Minuten angelegt oder mehr als 40 Versuche in 10 Minuten gezählt (auch abgelehnte) | Einige Minuten abwarten (der Zähler läuft nach 10 Minuten ab); ein Neustart des `api`-Containers setzt den Zähler zurück, ist aber nur im Ausnahmefall nötig. | diff --git a/docs/anleitung-entwicklung.md b/docs/anleitung-entwicklung.md index 2fc6e3b..22f90de 100644 --- a/docs/anleitung-entwicklung.md +++ b/docs/anleitung-entwicklung.md @@ -870,3 +870,75 @@ Nextcloud (`ratelimit.protection.enabled`) nur für den Lauf aus und stellt die `user:setting anna files_sharing default_accept`). Alles setzt der `trap` zurück. Der Zähler von Tessera liegt im Prozess: `all` verbraucht 7 der 10 Plätze, nach jedem `all`-Lauf `docker compose restart api`. + +**Zertifikat-Manager, Arbeitsbereich und Ketten (quick-261009-ikt):** Der Zertifikat-Manager +(`apps/api/src/cert-manager`, `apps/web/src/app/(portal)/modules/cert-manager`) hält **nichts** +auf dem Server. Die Oberfläche führt eine Liste von Dateien nur im Arbeitsspeicher des Browsers +(`use-cert-workspace.ts`, rein mit `working-set.ts`) und schickt bei jeder Änderung die ganze Liste +an `POST analyze` (multipart, höchstens 30 Dateien zu je 5 MiB, zusammen 20 MiB); ältere Antworten +verwirft ein Anfragezähler. Es gibt genau drei POST-Routen, alle ohne Zustand: `analyze`, `build` +(JSON) und, ab dem Abschnitt zum Nachladen fehlender Zertifikate, `fetch-issuer`. Fehler tragen +immer einen Code im Körper (`{ code, message }`, Liste in `cert-types.ts`), den die Oberfläche unter +`certManager.errors.` übersetzt. + +*Ein Parser.* **`node:crypto` entscheidet alles über Zertifikate und Schlüssel** (`X509Certificate`, +`createPrivateKey`, `createPublicKey`, `KeyObject#export`), denn nur so laufen RSA **und** EC durch +denselben Weg. `node-forge` bleibt ausschließlich für PKCS#12 (Lesen und Schreiben) und als +allgemeiner ASN.1-Leser und -Schreiber (PKCS#7 lesen und bauen, Zertifikatsanfragen lesen). Kein +Produktivcode ruft `certificateFromPem`, `certificateFromAsn1`, `certificationRequestFromAsn1`, +`messageFromPem` oder `messageFromAsn1` auf, denn diese forge-Leser können nur RSA; das prüft das +Gate in der Aufgabenkette per `grep`. Die Erkennung (`cert-model.ts`, `detectBlob`) ist eine feste +Stufenfolge (ZIP, PEM-Blöcke, DER-Zertifikat, PKCS#12, PKCS#7, Schlüssel, Anfrage), jede Stufe in +`try/catch`: eine kaputte Datei wird `unknown`, nie ein Fehler der Anfrage. Erkannt wird am Inhalt, +nie an der Dateiendung. + +*Ketten.* `cert-chain.ts` (`buildChains`) nimmt als Aussteller nur Zertifikate, für die +`C.checkIssued(I)` **und** `C.verify(I.publicKey)` gelten. `checkIssued` allein vergleicht nur Namen +und Schlüsselkennungen; ein gleichnamiges Zwischenzertifikat mit anderem Schlüssel (Fixture +`rsa-inter-decoy`) besteht es und würde ohne die Unterschriftsprüfung fälschlich genommen. +Selbstsigniert heißt: Unterschrift mit dem eigenen Schlüssel stimmt und (`checkIssued` gegen sich +selbst oder Aussteller gleich Inhaber). Mehrere Wege rangiert die Funktion fest (endet bei einer +Wurzel der Liste, weniger abgelaufene, kürzer, späteres Ablaufdatum, dann SHA-256), damit das +Ergebnis nie vom Zufall abhängt. Schlüssel und Anfragen ordnet `matchKeys` mit +`checkPrivateKey` bzw. dem Vergleich der öffentlichen Schlüssel zu, nie nach Namen. `build` +baut die Reihenfolge **immer neu** aus den gesendeten Zertifikaten; eine vom Browser mitgeschickte +Reihenfolge gäbe es nicht einmal als Feld. + +*PFX schreiben.* forge schreibt von sich aus nur RSA. `cert-pkcs12.ts` (`writePkcs12`) tauscht +deshalb für die Dauer **eines** synchronen Aufrufs drei Funktionen von `forge.pki` +(`privateKeyToAsn1`, `wrapRsaPrivateKey`, `certificateToAsn1`) gegen Durchreicher aus und stellt +sie im `finally` wieder her; ASN.1 für Zertifikate und Schlüssel kommt aus `node:crypto`, die +Verschlüsselung und den MAC macht weiterhin forge. Das ist sicher, weil Node einfädig ist und der +Aufruf nicht abgibt; das Spec prüft die Wiederherstellung auch nach einem Fehler. Profile: +`compat` (3DES, SHA-1, Vorgabe, lesbar bis Windows Server 2016) und `modern` (AES-256, der MAC bleibt +bei forge SHA-1). Die Vorlagen für IIS und Tomcat nutzen dieselbe Funktion (`cert-templates.ts`); +die Vorlagen für Dateien bauen aus denselben Bausteinen wie `buildOutput` und kennen `cert-output.ts` +bewusst nicht (sonst entstünde eine Importschleife). + +*ZIP-Grenzen.* `zip-expand.ts` prüft **alles vor dem ersten Entpacken** an den Kopfdaten: höchstens +100 Einträge, 1 MiB je Eintrag, Verhältnis entpackt zu gepackt höchstens 100, Summe 20 MiB, nur eine +Ebene (ein ZIP im ZIP wird gemeldet, nicht geöffnet), verschlüsselte Einträge werden gemeldet. +Eintragsnamen dienen nur der Anzeige und werden nie als Dateipfad benutzt. + +*Grenze des Anfragekörpers von `build` (D-26).* Die Express-Voreinstellung von 100 kB reicht für die +größte erlaubte `build`-Anfrage nicht (Zertifikat, 20 Pool-Zertifikate, Schlüssel und Anfrage zu je +16 384 Zeichen ergeben rund 384 kB). Deshalb hat nur diese Route einen eigenen JSON-Leser mit +512 KiB (`cert-json-body.ts`), den `main.ts` per `app.use(CERT_BUILD_ROUTE, certBuildJsonBody, +certBuildBodyErrors)` **vor** `app.listen` einhängt (Nest registriert seine eigenen Leser erst in +`init()`). Zwei Fallen: Erstens liegt `express` nicht in `apps/api/node_modules`, der Leser kommt +deshalb über `createRequire(...)` aus der Kopie, die auch `@nestjs/platform-express` lädt. Zweitens +**darf die Funktion nicht `jsonParser` heißen**: Nests `ExpressAdapter` überspringt seinen eigenen +JSON-Leser für **alle** Routen, sobald schon eine Schicht mit diesem Funktionsnamen im Router liegt +(`isMiddlewareApplied`); deshalb heißt sie `certBuildJsonBody`. Eine größere Anfrage bekommt 413 mit +Code `tooLarge`, kaputtes JSON 400 mit `invalidInput`; alle anderen Routen behalten die 100 kB. Das +Spec `cert-json-body.spec.ts` rechnet die größte DTO-Anfrage aus den Konstanten in +`dto/cert-build.dto.ts` aus und bleibt rot, wenn jemand dort eine Obergrenze erhöht, ohne die +Grenze mitzuziehen. + +*Fixtures.* Die Testdaten liegen unter `apps/api/src/cert-manager/__fixtures__/` und entstehen mit +`make-fixtures.sh` (OpenSSL 3.4 oder neuer; Passwort aller geschützten Dateien `Test-Pass-123`, die +Schlüssel der CAs werden nach der Erzeugung gelöscht). Dateinamen enden **nie** auf `.key`, denn die +`.gitignore` ignoriert `*.key` wegen des Updater-Schlüssels; Schlüssel heißen `-key.pem` oder +`-key-….der`. Die Specs lesen die Dateien mit `readFileSync` und rufen OpenSSL nie auf (die CI hat +es nicht); die Live-Prüfung mit OpenSSL-Gegenprobe (`openssl verify`, `pkcs12 -info`, +`pkcs7 -print_certs`, `pkey`) liegt im Skript `e2e-cert.sh` der Aufgabe.