From c07b0cfaf03210e0a3c131b207f65db3c116b695 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 1 Oct 2026 15:23:34 +0200 Subject: [PATCH] feat(cert-manager): Zertifikatspaket hochladen, erkennen und in jedem Format herunterladen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer erster Reiter Übersicht: mehrere Dateien oder die ZIP vom Aussteller auf einmal ablegen. Der Server erkennt jedes Teil (Server-, Zwischen-, Stammzertifikat, privater Schlüssel, CSR, auch aus PFX/P7B), fasst Duplikate zusammen, ordnet Schlüssel/CSR dem Zertifikat zu und baut die Kette. Unter jedem Teil stehen Download-Knöpfe für alle passenden Formate (crt, cer, Fullchain, p7b, pfx mit Schlüssel und Kette; key PKCS#8/PKCS#1/ DER; csr PEM/DER). Geschützte PFX lassen sich mit Passwort entsperren. Neue Endpunkte POST analyze (Dateien/ZIP, Begrenzung Anzahl und Größe vor dem Entpacken) und POST export (JSON, zustandslos). Modultexte siezen jetzt durchgehend; Gültigkeit in UTC wie im Zertifikat. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/api/src/cert-manager/cert-bundle.spec.ts | 268 +++++++ apps/api/src/cert-manager/cert-bundle.ts | 655 ++++++++++++++++++ .../cert-manager/cert-manager.controller.ts | 52 ++ .../(portal)/modules/cert-manager/actions.ts | 78 +++ .../cert-manager/cert-manager.test.tsx | 10 +- .../components/OverviewTab.test.tsx | 202 ++++++ .../cert-manager/components/OverviewTab.tsx | 355 ++++++++++ .../(portal)/modules/cert-manager/page.tsx | 86 ++- apps/web/src/messages/de.json | 80 ++- apps/web/src/messages/en.json | 70 +- apps/web/src/messages/umlaut-dictionary.ts | 7 + 11 files changed, 1811 insertions(+), 52 deletions(-) create mode 100644 apps/api/src/cert-manager/cert-bundle.spec.ts create mode 100644 apps/api/src/cert-manager/cert-bundle.ts create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx diff --git a/apps/api/src/cert-manager/cert-bundle.spec.ts b/apps/api/src/cert-manager/cert-bundle.spec.ts new file mode 100644 index 0000000..bcb9d46 --- /dev/null +++ b/apps/api/src/cert-manager/cert-bundle.spec.ts @@ -0,0 +1,268 @@ +import AdmZip from 'adm-zip'; +import * as forge from 'node-forge'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { analyzeBundle, exportBundleItem, safeBaseName } from './cert-bundle'; + +/** + * cert-bundle.spec (quick-261001-l4q) — Zertifikatspaket wie vom Aussteller: + * Stamm -> Zwischen -> Server, dazu Schluessel, CSR und PFX, als ZIP. + * Alles hier erzeugt (keine echten Kundendaten im Repo). + */ + +interface Pki { + rootPem: string; + interPem: string; + leafPem: string; + keyPem: string; + csrPem: string; + pfx: Buffer; + leafModulus: string; +} + +let pki: Pki; + +function makeCert( + subjectCn: string, + pub: forge.pki.PublicKey, + signer: forge.pki.PrivateKey, + issuer: forge.pki.CertificateField[] | null, + ca: boolean, + serial: string, +): forge.pki.Certificate { + const cert = forge.pki.createCertificate(); + cert.publicKey = pub; + cert.serialNumber = serial; + cert.validity.notBefore = new Date(Date.now() - 86_400_000); + cert.validity.notAfter = new Date(Date.now() + 90 * 86_400_000); + const subject = [{ name: 'commonName', value: subjectCn }]; + cert.setSubject(subject); + cert.setIssuer(issuer ?? subject); + const ext: object[] = [{ name: 'basicConstraints', cA: ca }]; + if (!ca) ext.push({ name: 'subjectAltName', altNames: [{ type: 2, value: subjectCn }] }); + cert.setExtensions(ext); + cert.sign(signer as forge.pki.rsa.PrivateKey, forge.md.sha256.create()); + return cert; +} + +beforeAll(() => { + const rootKeys = forge.pki.rsa.generateKeyPair(1024); + const interKeys = forge.pki.rsa.generateKeyPair(1024); + const leafKeys = forge.pki.rsa.generateKeyPair(1024); + const root = makeCert('Test Root CA', rootKeys.publicKey, rootKeys.privateKey, null, true, '01'); + const inter = makeCert( + 'Test Intermediate CA', + interKeys.publicKey, + rootKeys.privateKey, + root.subject.attributes, + true, + '02', + ); + const leaf = makeCert( + 'www.example.test', + leafKeys.publicKey, + interKeys.privateKey, + inter.subject.attributes, + false, + '03', + ); + + const csr = forge.pki.createCertificationRequest(); + csr.publicKey = leafKeys.publicKey; + csr.setSubject([{ name: 'commonName', value: 'www.example.test' }]); + csr.sign(leafKeys.privateKey, forge.md.sha256.create()); + + const p12 = forge.pkcs12.toPkcs12Asn1(leafKeys.privateKey, [leaf, inter], 'geheim', { + algorithm: '3des', + }); + + pki = { + rootPem: forge.pki.certificateToPem(root), + interPem: forge.pki.certificateToPem(inter), + leafPem: forge.pki.certificateToPem(leaf), + keyPem: forge.pki.privateKeyInfoToPem( + forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(leafKeys.privateKey)), + ), + csrPem: forge.pki.certificationRequestToPem(csr), + pfx: Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary'), + leafModulus: leafKeys.publicKey.n.toString(16), + }; +}, 60_000); + +function issuerZip(): Buffer { + const zip = new AdmZip(); + zip.addFile('www.example.test/www.example.test.pem', Buffer.from(pki.leafPem + pki.interPem)); + zip.addFile('www.example.test/www.example.test.key', Buffer.from(pki.keyPem)); + zip.addFile('www.example.test/www.example.test.csr', Buffer.from(pki.csrPem)); + zip.addFile('www.example.test/www.example.test.pfx', pki.pfx); + zip.addFile('www.example.test/.dnstxtrecord', Buffer.from('_dnsauth abc123')); + return zip.toBuffer(); +} + +describe('analyzeBundle', () => { + it('ZIP vom Aussteller: erkennt jedes Teil, fasst PEM/PFX zusammen, ordnet Schluessel und Kette zu', () => { + const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim'); + + const kinds = r.items.map((i) => (i.kind === 'certificate' ? i.role : i.kind)); + expect(kinds).toEqual(['end-entity', 'intermediate', 'privateKey', 'csr']); + + const [leaf, inter, key, csr] = r.items; + expect(leaf.cn).toBe('www.example.test'); + expect(leaf.san).toEqual(['www.example.test']); + // in PEM UND PFX enthalten -> ein Eintrag mit beiden Quellen + expect(leaf.sources.sort()).toEqual(['www.example.test.pem', 'www.example.test.pfx']); + expect(leaf.chainIds).toEqual([inter.id]); + expect(leaf.matchId).toBe(key.id); + expect(key.matchId).toBe(leaf.id); + expect(key.sources.sort()).toEqual(['www.example.test.key', 'www.example.test.pfx']); + expect(csr.matchId).toBe(leaf.id); + expect(csr.cn).toBe('www.example.test'); + expect(leaf.baseName).toBe('www.example.test'); + + expect(r.locked).toEqual([]); + expect(r.ignored).toEqual(['.dnstxtrecord']); + }); + + it('PFX ohne passendes Passwort wird als gesperrt gemeldet, der Rest trotzdem erkannt', () => { + const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'falsch'); + expect(r.locked).toEqual(['www.example.test.pfx']); + expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(2); + }); + + it('Stammzertifikat wird als root erkannt und an die Kette gehaengt', () => { + const r = analyzeBundle( + [ + { + originalname: 'chain.pem', + buffer: Buffer.from(pki.leafPem + pki.interPem + pki.rootPem), + }, + ], + '', + ); + expect(r.items.map((i) => i.role)).toEqual(['end-entity', 'intermediate', 'root']); + expect(r.items[0].chainIds).toHaveLength(2); + }); + + it('ohne Dateien -> 400', () => { + expect(() => analyzeBundle([], '')).toThrow(/No files/); + }); + + it('ZIP mit zu vielen Dateien -> 400', () => { + const zip = new AdmZip(); + for (let i = 0; i < 101; i++) zip.addFile(`f${i}.txt`, Buffer.from('x')); + expect(() => + analyzeBundle([{ originalname: 'gross.zip', buffer: zip.toBuffer() }], ''), + ).toThrow(/too many files/); + }); +}); + +describe('exportBundleItem', () => { + function bundle() { + const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim'); + const byId = Object.fromEntries(r.items.map((i) => [i.id, i])); + return { items: r.items, byId }; + } + const decode = (b64: string) => Buffer.from(b64, 'base64'); + + it('Zertifikat in jedem Format liest sich wieder ein', () => { + const { items, byId } = bundle(); + const leaf = items[0]; + const chain = leaf.chainIds.map((id) => byId[id].pem); + const keyPem = byId[leaf.matchId!].pem; + const base = { kind: leaf.kind, pem: leaf.pem, baseName: leaf.baseName, chain, keyPem }; + + const crt = exportBundleItem({ ...base, format: 'crt' }); + expect(crt.filename).toBe('www.example.test.crt'); + expect( + forge.pki.certificateFromPem(decode(crt.content).toString()).subject.getField('CN').value, + ).toBe('www.example.test'); + + const cer = exportBundleItem({ ...base, format: 'cer' }); + expect(cer.filename).toBe('www.example.test.cer'); + forge.pki.certificateFromAsn1(forge.asn1.fromDer(decode(cer.content).toString('binary'))); + + const full = exportBundleItem({ ...base, format: 'fullchain' }); + expect( + decode(full.content) + .toString() + .match(/BEGIN CERTIFICATE/g), + ).toHaveLength(2); + + const p7b = exportBundleItem({ ...base, format: 'p7b' }); + const p7 = forge.pkcs7.messageFromPem(decode(p7b.content).toString()); + expect('certificates' in p7 ? p7.certificates : []).toHaveLength(2); + + const pfx = exportBundleItem({ ...base, format: 'pfx', password: 'neu' }); + expect(pfx.filename).toBe('www.example.test.pfx'); + const p12 = forge.pkcs12.pkcs12FromAsn1( + forge.asn1.fromDer(decode(pfx.content).toString('binary')), + 'neu', + ); + expect(p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag]).toHaveLength( + 2, + ); + const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[ + forge.pki.oids.pkcs8ShroudedKeyBag + ]![0]; + expect((keyBag.key as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(pki.leafModulus); + }); + + it('PFX ohne Passwort -> 400', () => { + const { items } = bundle(); + expect(() => + exportBundleItem({ kind: 'certificate', pem: items[0].pem, format: 'pfx' }), + ).toThrow(/password is required/); + }); + + it('Schluessel als PKCS#8, PKCS#1 und DER', () => { + const { items } = bundle(); + const key = items.find((i) => i.kind === 'privateKey')!; + const base = { kind: key.kind, pem: key.pem, baseName: key.baseName }; + expect(decode(exportBundleItem({ ...base, format: 'key' }).content).toString()).toContain( + 'BEGIN PRIVATE KEY', + ); + const rsa = exportBundleItem({ ...base, format: 'key-rsa' }); + expect(rsa.filename).toBe('www.example.test.rsa.key'); + expect(decode(rsa.content).toString()).toContain('BEGIN RSA PRIVATE KEY'); + const der = exportBundleItem({ ...base, format: 'key-der' }); + const info = forge.asn1.fromDer(decode(der.content).toString('binary')); + expect((forge.pki.privateKeyFromAsn1(info) as forge.pki.rsa.PrivateKey).n.toString(16)).toBe( + pki.leafModulus, + ); + }); + + it('CSR als PEM und DER', () => { + const { items } = bundle(); + const csr = items.find((i) => i.kind === 'csr')!; + const der = exportBundleItem({ + kind: 'csr', + pem: csr.pem, + baseName: csr.baseName, + format: 'csr-der', + }); + expect(der.filename).toBe('www.example.test.csr.der'); + forge.pki.certificationRequestFromAsn1( + forge.asn1.fromDer(decode(der.content).toString('binary')), + ); + }); + + it('unpassendes Format -> 400', () => { + const { items } = bundle(); + expect(() => + exportBundleItem({ kind: 'csr', pem: items[3].pem, format: 'pfx', password: 'x' }), + ).toThrow(); + expect(() => + exportBundleItem({ kind: 'privateKey', pem: 'kein pem', format: 'key-rsa' }), + ).toThrow(/Failed to export/); + }); +}); + +describe('safeBaseName', () => { + it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => { + expect(safeBaseName('*.example.de', 'x')).toBe('wildcard.example.de'); + expect(safeBaseName('Encryption Everywhere DV TLS CA - G1', 'x')).toBe( + 'Encryption_Everywhere_DV_TLS_CA_-_G1', + ); + expect(safeBaseName('../../etc/passwd', 'x')).toBe('etc_passwd'); + expect(safeBaseName('', 'fallback')).toBe('fallback'); + }); +}); diff --git a/apps/api/src/cert-manager/cert-bundle.ts b/apps/api/src/cert-manager/cert-bundle.ts new file mode 100644 index 0000000..0362e1e --- /dev/null +++ b/apps/api/src/cert-manager/cert-bundle.ts @@ -0,0 +1,655 @@ +import { BadRequestException } from '@nestjs/common'; +import AdmZip from 'adm-zip'; +import * as forge from 'node-forge'; +import type { UploadedFileLike } from '../auth/types/auth-user'; + +/** + * Zertifikatspaket (quick-261001-l4q): alles, was ein Aussteller liefert — + * Zertifikat mit Kette (.pem/.crt/.cer/.p7b), privater Schluessel (.key), + * Zertifikatsanfrage (.csr), PFX/P12, gern als ZIP — auf einmal hochladen, + * erkennen, was was ist, und jedes Teil in jedem passenden Format + * herunterladen. + * + * Zustandslos wie der Rest des Moduls: `analyzeBundle` gibt je Teil den + * kanonischen PEM-Text zurueck, `exportBundleItem` baut daraus die Datei. + * Nichts wird gespeichert, Passwoerter werden nie protokolliert. + */ + +type BundleFile = Pick; + +export type BundleCertRole = 'end-entity' | 'intermediate' | 'root'; +export type BundleItemKind = 'certificate' | 'privateKey' | 'csr'; + +export interface BundleItem { + id: string; + kind: BundleItemKind; + /** Nur bei Zertifikaten. */ + role?: BundleCertRole; + /** Dateien, in denen dieses Teil gefunden wurde (Duplikate zusammengefasst). */ + sources: string[]; + /** Kanonischer PEM-Text — Grundlage fuer jeden Export. */ + pem: string; + /** Vorschlag fuer den Dateinamen ohne Endung, aus dem CN abgeleitet. */ + baseName: string; + cn: string; + organization: string; + issuerCn: string; + notBefore: string | null; + notAfter: string | null; + isExpired: boolean | null; + daysLeft: number | null; + san: string[]; + keyType: string; + keyBits: number; + serialNumber: string; + sha256: string; + /** Zertifikat: id des passenden Schluessels; Schluessel/CSR: id des passenden Zertifikats. */ + matchId: string | null; + /** Zertifikat: ids der Kette darueber (Aussteller, dessen Aussteller ...). */ + chainIds: string[]; + /** Formate, die `exportBundleItem` fuer dieses Teil liefern kann. */ + formats: BundleExportFormat[]; +} + +export interface BundleAnalysis { + items: BundleItem[]; + /** PFX/P12 oder verschluesselte Schluessel, die ohne (richtiges) Passwort nicht lesbar sind. */ + locked: string[]; + /** Dateien ohne erkennbares Zertifikat, Schluessel oder CSR. */ + ignored: string[]; +} + +export type BundleExportFormat = + | 'crt' + | 'cer' + | 'fullchain' + | 'p7b' + | 'pfx' + | 'key' + | 'key-rsa' + | 'key-der' + | 'csr' + | 'csr-der'; + +export interface BundleExportInput { + kind: BundleItemKind; + pem: string; + format: BundleExportFormat; + baseName?: string; + /** Zertifikat: PEMs der Kette darueber (fuer Fullchain/P7B/PFX). */ + chain?: string[]; + /** Zertifikat: PEM des passenden privaten Schluessels (fuer PFX). */ + keyPem?: string; + /** PFX: Passwort fuer die neue Datei. */ + password?: string; +} + +export interface BundleExportFile { + filename: string; + /** Base64 */ + content: string; + mimeType: string; +} + +const MAX_ZIP_ENTRIES = 100; +const MAX_ENTRY_BYTES = 5 * 1024 * 1024; + +const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----[\s\S]+?-----END \1-----/g; + +// --------------------------------------------------------------------------- +// Hilfen +// --------------------------------------------------------------------------- + +function binary(buffer: Buffer): forge.util.ByteStringBuffer { + return forge.util.createBuffer(buffer.toString('binary')); +} + +function bytesToBase64(bytes: string): string { + return Buffer.from(forge.util.bytesToHex(bytes), 'hex').toString('base64'); +} + +function textToBase64(text: string): string { + return Buffer.from(text, 'utf-8').toString('base64'); +} + +function sha256Of(cert: forge.pki.Certificate): string { + const md = forge.md.sha256.create(); + md.update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()); + return (md.digest().toHex().match(/.{2}/g) ?? []).join(':').toUpperCase(); +} + +function field(name: forge.pki.Certificate['subject'], short: string): string { + return (name.getField(short)?.value as string | undefined) ?? ''; +} + +/** 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; +} + +function certRole(cert: forge.pki.Certificate): BundleCertRole { + const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null; + if (!bc?.cA) return 'end-entity'; + return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate'; +} + +function publicKeyInfo(key: unknown): { keyType: string; keyBits: number; modulus: string } { + // node-forge liefert RSA-Schluessel mit `n`; EC-Schluessel kennt es nur + // eingeschraenkt (siehe Kommentar in CertManagerService.parseCert). + const k = key as { n?: forge.jsbn.BigInteger }; + if (k?.n) return { keyType: 'RSA', keyBits: k.n.bitLength(), modulus: k.n.toString(16) }; + return { keyType: 'EC', keyBits: 0, modulus: '' }; +} + +function sanOf(extensions: unknown[] | undefined): string[] { + const ext = (extensions ?? []).find((e) => (e as { name?: string }).name === 'subjectAltName') as + | { altNames?: { type: number; value?: string; ip?: string }[] } + | undefined; + return (ext?.altNames ?? []).map((n) => + n.type === 2 ? (n.value ?? '') : `IP:${n.ip ?? n.value ?? ''}`, + ); +} + +// --------------------------------------------------------------------------- +// Einsammeln: Dateien (inkl. ZIP) -> rohe Teile +// --------------------------------------------------------------------------- + +interface RawKey { + source: string; + pem: string; + modulus: string; + keyType: string; + keyBits: number; +} + +interface RawCsr { + source: string; + pem: string; +} + +interface Collected { + certs: { source: string; cert: forge.pki.Certificate }[]; + keys: RawKey[]; + csrs: RawCsr[]; + locked: string[]; + ignored: string[]; +} + +function expandZips(files: BundleFile[]): { name: string; buffer: Buffer }[] { + const out: { name: string; buffer: Buffer }[] = []; + for (const file of files) { + if (!file.originalname.toLowerCase().endsWith('.zip')) { + out.push({ name: file.originalname, buffer: file.buffer }); + continue; + } + let zip: AdmZip; + try { + zip = new AdmZip(file.buffer); + } catch { + throw new BadRequestException(`"${file.originalname}" is not a readable ZIP archive`); + } + const entries = zip + .getEntries() + .filter((e) => !e.isDirectory && !e.entryName.startsWith('__MACOSX/')); + if (entries.length > MAX_ZIP_ENTRIES) { + throw new BadRequestException(`"${file.originalname}" contains too many files`); + } + for (const entry of entries) { + // Groesse aus dem Kopf pruefen, BEVOR entpackt wird (Zip-Bombe). + if (entry.header.size > MAX_ENTRY_BYTES) { + throw new BadRequestException( + `"${entry.entryName}" in "${file.originalname}" is too large`, + ); + } + const name = entry.entryName.split('/').pop() ?? entry.entryName; + out.push({ name, buffer: entry.getData() }); + } + } + return out; +} + +function addKey(c: Collected, source: string, privateKey: forge.pki.rsa.PrivateKey): void { + const info = publicKeyInfo(privateKey); + const pem = forge.pki.privateKeyInfoToPem( + forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(privateKey)), + ); + c.keys.push({ source, pem, ...info }); +} + +function collectPemText(c: Collected, source: string, text: string, password: string): boolean { + let found = false; + for (const match of text.matchAll(PEM_BLOCK)) { + const [block, type] = match; + try { + if (type === 'CERTIFICATE' || type === 'TRUSTED CERTIFICATE') { + c.certs.push({ source, cert: forge.pki.certificateFromPem(block) }); + found = true; + } else if (type === 'PRIVATE KEY' || type === 'RSA PRIVATE KEY') { + const key = forge.pki.privateKeyFromPem(block) as forge.pki.rsa.PrivateKey; + addKey(c, source, key); + found = true; + } else if (type === 'ENCRYPTED PRIVATE KEY') { + const key = password ? forge.pki.decryptRsaPrivateKey(block, password) : null; + if (key) addKey(c, source, key as forge.pki.rsa.PrivateKey); + else c.locked.push(source); + found = true; + } else if (type === 'EC PRIVATE KEY') { + // node-forge kann EC nicht umrechnen — Teil bleibt im Original erhalten. + c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 }); + found = true; + } else if (type === 'CERTIFICATE REQUEST' || type === 'NEW CERTIFICATE REQUEST') { + c.csrs.push({ + source, + pem: block.replace(/NEW CERTIFICATE REQUEST/g, 'CERTIFICATE REQUEST'), + }); + found = true; + } else if (type === 'PKCS7') { + const p7 = forge.pkcs7.messageFromPem(block); + for (const cert of 'certificates' in p7 ? p7.certificates : []) + c.certs.push({ source, cert }); + found = true; + } + } catch { + // PKCS#8 mit EC-Schluessel o. ae.: node-forge kann ihn nicht lesen — + // im Original behalten statt zu verwerfen. + if (type === 'PRIVATE KEY') { + c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 }); + found = true; + } + } + } + return found; +} + +function collectPfx(c: Collected, source: string, buffer: Buffer, password: string): void { + let p12: forge.pkcs12.Pkcs12Pfx | null = null; + for (const candidate of password ? [password, ''] : ['']) { + try { + p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(binary(buffer)), candidate); + break; + } catch { + // naechstes Passwort versuchen + } + } + if (!p12) { + c.locked.push(source); + return; + } + for (const bag of p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag] ?? + []) { + if (bag.cert) c.certs.push({ source, cert: bag.cert }); + } + for (const oid of [forge.pki.oids.pkcs8ShroudedKeyBag, forge.pki.oids.keyBag]) { + for (const bag of p12.getBags({ bagType: oid })[oid] ?? []) { + if (bag.key) addKey(c, source, bag.key as forge.pki.rsa.PrivateKey); + } + } +} + +function collectDer(c: Collected, source: string, buffer: Buffer): boolean { + try { + const asn1 = forge.asn1.fromDer(binary(buffer)); + try { + c.certs.push({ source, cert: forge.pki.certificateFromAsn1(asn1) }); + return true; + } catch { + /* kein einzelnes Zertifikat */ + } + try { + const p7 = forge.pkcs7.messageFromAsn1(asn1); + const certs = 'certificates' in p7 ? p7.certificates : []; + for (const cert of certs) c.certs.push({ source, cert }); + if (certs.length > 0) return true; + } catch { + /* kein PKCS#7 */ + } + try { + forge.pki.certificationRequestFromAsn1(asn1); + const body = forge.asn1.toDer(asn1).getBytes(); + c.csrs.push({ source, pem: forge.pem.encode({ type: 'CERTIFICATE REQUEST', body }) }); + return true; + } catch { + /* keine CSR */ + } + } catch { + /* kein DER */ + } + return false; +} + +function collect(files: BundleFile[], password: string): Collected { + const c: Collected = { certs: [], keys: [], csrs: [], locked: [], ignored: [] }; + for (const { name, buffer } of expandZips(files)) { + const ext = name.split('.').pop()?.toLowerCase() ?? ''; + if (ext === 'pfx' || ext === 'p12') { + collectPfx(c, name, buffer, password); + continue; + } + const head = buffer.subarray(0, 4096).toString('latin1'); + const found = head.includes('-----BEGIN') + ? collectPemText(c, name, buffer.toString('utf-8'), password) + : collectDer(c, name, buffer); + if (!found) c.ignored.push(name); + } + return c; +} + +// --------------------------------------------------------------------------- +// analyzeBundle +// --------------------------------------------------------------------------- + +const CERT_FORMATS: BundleExportFormat[] = ['crt', 'cer', 'fullchain', 'p7b', 'pfx']; + +export function analyzeBundle(files: BundleFile[], password = ''): BundleAnalysis { + if (files.length === 0) throw new BadRequestException('No files provided'); + const c = collect(files, password); + + // Zertifikate nach Fingerabdruck zusammenfassen (PEM und PFX enthalten oft dieselben). + const certMap = new Map }>(); + for (const { source, cert } of c.certs) { + const fp = sha256Of(cert); + const entry = certMap.get(fp) ?? { cert, sources: new Set() }; + entry.sources.add(source); + certMap.set(fp, entry); + } + + const now = Date.now(); + const certItems: BundleItem[] = [...certMap.entries()].map(([fp, { cert, sources }]) => { + const info = publicKeyInfo(cert.publicKey); + const cn = field(cert.subject, 'CN'); + const notAfter = cert.validity.notAfter; + const role = certRole(cert); + return { + id: `cert-${fp.replace(/:/g, '').slice(0, 16).toLowerCase()}`, + kind: 'certificate', + role, + sources: [...sources], + pem: forge.pki.certificateToPem(cert), + baseName: safeBaseName(cn, role === 'end-entity' ? 'zertifikat' : 'ca'), + cn, + organization: field(cert.subject, 'O'), + issuerCn: field(cert.issuer, 'CN'), + notBefore: cert.validity.notBefore.toISOString(), + notAfter: notAfter.toISOString(), + isExpired: notAfter.getTime() < now, + daysLeft: Math.ceil((notAfter.getTime() - now) / 86_400_000), + san: sanOf(cert.extensions), + keyType: info.keyType, + keyBits: info.keyBits, + serialNumber: cert.serialNumber, + sha256: fp, + matchId: null, + chainIds: [], + formats: CERT_FORMATS, + // nur intern fuer Kette/Zuordnung, wird unten entfernt + _cert: cert, + _modulus: info.modulus, + } as BundleItem & { _cert: forge.pki.Certificate; _modulus: string }; + }); + + // Kette: zu jedem Zertifikat den Aussteller im Paket suchen. + type Internal = BundleItem & { _cert: forge.pki.Certificate; _modulus: string }; + const internals = certItems as Internal[]; + for (const item of internals) { + let current = item._cert; + const seen = new Set([item.id]); + for (let depth = 0; depth < 10; depth++) { + if (current.subject.hash === current.issuer.hash) break; + const issuer = internals.find( + (o) => !seen.has(o.id) && o._cert.subject.hash === current.issuer.hash, + ); + if (!issuer) break; + item.chainIds.push(issuer.id); + seen.add(issuer.id); + current = issuer._cert; + } + } + + // Schluessel: Duplikate zusammenfassen, dem Zertifikat zuordnen. + const keyMap = new Map }>(); + for (const key of c.keys) { + const id = key.modulus || key.pem; + const entry = keyMap.get(id) ?? { key, sources: new Set() }; + entry.sources.add(key.source); + keyMap.set(id, entry); + } + const keyItems: BundleItem[] = [...keyMap.values()].map(({ key, sources }, i) => { + const cert = key.modulus ? internals.find((o) => o._modulus === key.modulus) : undefined; + const id = `key-${i + 1}`; + if (cert) cert.matchId = id; + const isRsa = key.keyType === 'RSA'; + return { + id, + kind: 'privateKey', + sources: [...sources], + pem: key.pem, + baseName: + cert?.baseName ?? + safeBaseName( + sources + .values() + .next() + .value?.replace(/\.[^.]+$/, '') ?? '', + 'schluessel', + ), + cn: cert?.cn ?? '', + organization: '', + issuerCn: '', + notBefore: null, + notAfter: null, + isExpired: null, + daysLeft: null, + san: [], + keyType: key.keyType, + keyBits: key.keyBits, + serialNumber: '', + sha256: '', + matchId: cert?.id ?? null, + chainIds: [], + formats: isRsa ? ['key', 'key-rsa', 'key-der'] : ['key'], + }; + }); + + // CSRs: Duplikate zusammenfassen, Details lesen, dem Zertifikat zuordnen. + const csrMap = new Map }>(); + for (const csr of c.csrs) { + const norm = csr.pem.replace(/\s+/g, ''); + const entry = csrMap.get(norm) ?? { pem: csr.pem, sources: new Set() }; + entry.sources.add(csr.source); + csrMap.set(norm, entry); + } + const csrItems: BundleItem[] = [...csrMap.values()].map(({ pem, sources }, i) => { + let cn = ''; + let organization = ''; + let info = { keyType: '', keyBits: 0, modulus: '' }; + let san: string[] = []; + try { + const csr = forge.pki.certificationRequestFromPem(pem); + cn = field(csr.subject as forge.pki.Certificate['subject'], 'CN'); + organization = field(csr.subject as forge.pki.Certificate['subject'], 'O'); + info = publicKeyInfo(csr.publicKey); + const ext = csr.getAttribute({ name: 'extensionRequest' }) as { + extensions?: unknown[]; + } | null; + san = sanOf(ext?.extensions); + } catch { + // EC-CSR: node-forge liest sie nicht — Teil bleibt trotzdem herunterladbar. + } + const cert = info.modulus ? internals.find((o) => o._modulus === info.modulus) : undefined; + return { + id: `csr-${i + 1}`, + kind: 'csr', + sources: [...sources], + pem, + baseName: cert?.baseName ?? safeBaseName(cn, 'anfrage'), + cn, + organization, + issuerCn: '', + notBefore: null, + notAfter: null, + isExpired: null, + daysLeft: null, + san, + keyType: info.keyType, + keyBits: info.keyBits, + serialNumber: '', + sha256: '', + matchId: cert?.id ?? null, + chainIds: [], + formats: ['csr', 'csr-der'], + }; + }); + + // Reihenfolge: Serverzertifikat(e), Zwischen-, Stammzertifikate, Schluessel, CSR. + const roleOrder: Record = { 'end-entity': 0, intermediate: 1, root: 2 }; + internals.sort( + (a, b) => + roleOrder[a.role ?? 'end-entity'] - roleOrder[b.role ?? 'end-entity'] || + b.chainIds.length - a.chainIds.length, + ); + const certsClean: BundleItem[] = internals.map(({ _cert, _modulus, ...rest }) => rest); + + return { + items: [...certsClean, ...keyItems, ...csrItems], + locked: [...new Set(c.locked)], + ignored: [...new Set(c.ignored)], + }; +} + +// --------------------------------------------------------------------------- +// exportBundleItem +// --------------------------------------------------------------------------- + +const MIME = { + pem: 'application/x-pem-file', + der: 'application/x-x509-ca-cert', + p7b: 'application/x-pkcs7-certificates', + pfx: 'application/x-pkcs12', + key: 'application/x-pem-file', + octet: 'application/octet-stream', +} as const; + +function pemBody(pem: string): string { + const [msg] = forge.pem.decode(pem); + if (!msg) throw new Error('no PEM block'); + return msg.body; +} + +export function exportBundleItem(input: BundleExportInput): BundleExportFile { + const { kind, pem, format, chain = [], keyPem, password } = input; + const base = safeBaseName( + input.baseName ?? '', + kind === 'csr' ? 'anfrage' : kind === 'privateKey' ? 'schluessel' : 'zertifikat', + ); + + if (!pem || typeof pem !== 'string') throw new BadRequestException('No PEM provided'); + if (format === 'pfx' && (!password || password.trim() === '')) { + throw new BadRequestException('A password is required for PFX output'); + } + + try { + if (kind === 'certificate') { + const cert = forge.pki.certificateFromPem(pem); + const chainCerts = chain.map((p) => forge.pki.certificateFromPem(p)); + switch (format) { + case 'crt': + return { + filename: `${base}.crt`, + content: textToBase64(forge.pki.certificateToPem(cert)), + mimeType: MIME.pem, + }; + case 'cer': + return { + filename: `${base}.cer`, + content: bytesToBase64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()), + mimeType: MIME.der, + }; + case 'fullchain': { + const text = [cert, ...chainCerts].map((x) => forge.pki.certificateToPem(x)).join(''); + return { + filename: `${base}-fullchain.pem`, + content: textToBase64(text), + mimeType: MIME.pem, + }; + } + case 'p7b': { + const p7 = forge.pkcs7.createSignedData(); + for (const x of [cert, ...chainCerts]) p7.addCertificate(x); + const text = forge.pem.encode({ + type: 'PKCS7', + body: forge.asn1.toDer(p7.toAsn1()).getBytes(), + }); + return { filename: `${base}.p7b`, content: textToBase64(text), mimeType: MIME.p7b }; + } + case 'pfx': { + const key = keyPem + ? (forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey) + : null; + const p12 = forge.pkcs12.toPkcs12Asn1( + // null = reines Zertifikatsbuendel ohne Schluessel (siehe + // CertManagerService.mergeCerts). + key, + [cert, ...chainCerts], + password as string, // oben geprueft: PFX verlangt ein Passwort + { algorithm: '3des', friendlyName: input.baseName || undefined }, + ); + return { + filename: `${base}.pfx`, + content: bytesToBase64(forge.asn1.toDer(p12).getBytes()), + mimeType: MIME.pfx, + }; + } + } + } else if (kind === 'privateKey') { + switch (format) { + case 'key': + return { + filename: `${base}.key`, + content: textToBase64(`${pem.trim()}\n`), + mimeType: MIME.key, + }; + case 'key-rsa': { + const key = forge.pki.privateKeyFromPem(pem); + return { + filename: `${base}.rsa.key`, + content: textToBase64(forge.pki.privateKeyToPem(key)), + mimeType: MIME.key, + }; + } + case 'key-der': { + const key = forge.pki.privateKeyFromPem(pem); + const info = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(key)); + return { + filename: `${base}.key.der`, + content: bytesToBase64(forge.asn1.toDer(info).getBytes()), + mimeType: MIME.octet, + }; + } + } + } else if (kind === 'csr') { + switch (format) { + case 'csr': + return { + filename: `${base}.csr`, + content: textToBase64(`${pem.trim()}\n`), + mimeType: MIME.pem, + }; + case 'csr-der': + return { + filename: `${base}.csr.der`, + content: bytesToBase64(pemBody(pem)), + mimeType: MIME.octet, + }; + } + } + } catch { + // Passwort und Schluessel nie protokollieren oder zurueckgeben. + throw new BadRequestException(`Failed to export ${kind} as ${format}`); + } + throw new BadRequestException(`Unsupported format "${format}" for ${kind}`); +} diff --git a/apps/api/src/cert-manager/cert-manager.controller.ts b/apps/api/src/cert-manager/cert-manager.controller.ts index dcb97d9..e9d37e4 100644 --- a/apps/api/src/cert-manager/cert-manager.controller.ts +++ b/apps/api/src/cert-manager/cert-manager.controller.ts @@ -10,6 +10,12 @@ import { import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { UseModule } from '../module-registry/module.guard'; import type { UploadedFileLike } from '../auth/types/auth-user'; +import { + analyzeBundle, + type BundleExportFormat, + type BundleItemKind, + exportBundleItem, +} from './cert-bundle'; import { CertManagerService } from './cert-manager.service'; /** @@ -118,4 +124,50 @@ export class CertManagerController { } return this.certManagerService.convertCert({ file, pemText, targetFormat, password }); } + + /** + * POST /modules/cert-manager/analyze (quick-261001-l4q) + * Zertifikatspaket: mehrere Dateien oder ZIP hochladen, jedes Teil erkennen + * (Server-/Zwischen-/Stammzertifikat, privater Schluessel, CSR), Duplikate + * zusammenfassen, Schluessel und Kette zuordnen. + * + * T-09-03: 20 Dateien, je 5 MB; ZIP-Inhalt zusaetzlich begrenzt (cert-bundle.ts). + * T-09-02: password is never passed to the logger + */ + @Post('analyze') + @UseInterceptors( + FilesInterceptor('files', 20, { + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ) + async analyze( + @UploadedFiles() files: UploadedFileLike[] | undefined, + @Body('password') password?: string, + ) { + return analyzeBundle(files ?? [], password ?? ''); + } + + /** + * POST /modules/cert-manager/export (quick-261001-l4q) + * Ein Teil aus `analyze` (PEM) in das gewuenschte Format bringen. + * JSON-Body; PFX verlangt ein Passwort fuer die neue Datei. + */ + @Post('export') + async export( + @Body('kind') kind: BundleItemKind, + @Body('pem') pem: string, + @Body('format') format: BundleExportFormat, + @Body('baseName') baseName?: string, + @Body('chain') chain?: string[], + @Body('keyPem') keyPem?: string, + @Body('password') password?: string, + ) { + if ( + chain !== undefined && + (!Array.isArray(chain) || chain.some((c) => typeof c !== 'string')) + ) { + throw new BadRequestException('chain must be a list of PEM strings'); + } + return exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password }); + } } 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 6a475e0..12a6029 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts +++ b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts @@ -208,3 +208,81 @@ export async function postForm( return response.json(); } + +// --------------------------------------------------------------------------- +// Zertifikatspaket (quick-261001-l4q) — spiegelt apps/api/src/cert-manager/cert-bundle.ts +// --------------------------------------------------------------------------- + +export type BundleItemKind = 'certificate' | 'privateKey' | 'csr'; +export type BundleExportFormat = + | 'crt' + | 'cer' + | 'fullchain' + | 'p7b' + | 'pfx' + | 'key' + | 'key-rsa' + | 'key-der' + | 'csr' + | 'csr-der'; + +export interface BundleItem { + id: string; + kind: BundleItemKind; + role?: CertRole; + sources: string[]; + pem: string; + baseName: string; + cn: string; + organization: string; + issuerCn: string; + notBefore: string | null; + notAfter: string | null; + isExpired: boolean | null; + daysLeft: number | null; + san: string[]; + keyType: string; + keyBits: number; + serialNumber: string; + sha256: string; + matchId: string | null; + chainIds: string[]; + formats: BundleExportFormat[]; +} + +export interface BundleAnalysis { + items: BundleItem[]; + locked: string[]; + ignored: string[]; +} + +/** Mehrere Dateien (auch ZIP) analysieren — POST /modules/cert-manager/analyze. */ +export async function analyzeBundleAction(files: File[], password?: string): Promise { + const form = new FormData(); + for (const file of files) form.append('files', file); + if (password) form.append('password', password); + return postForm('analyze', form) as Promise; +} + +/** Ein Teil des Pakets in ein Format bringen — POST /modules/cert-manager/export (JSON). */ +export async function exportBundleItemAction(input: { + kind: BundleItemKind; + pem: string; + format: BundleExportFormat; + baseName: string; + chain?: string[]; + keyPem?: string; + password?: string; +}): Promise { + const response = await fetch(`${API_URL}/modules/cert-manager/export`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + credentials: 'include', + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`${response.status} ${body}`.trim()); + } + return response.json() as Promise; +} 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 dfacb06..620a4a9 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 @@ -7,6 +7,7 @@ vi.mock('next-intl', () => ({ const certManager: Record = { 'title': 'Zertifikat-Manager', 'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.', + 'tabs.overview': 'Uebersicht', 'tabs.inspect': 'Analysieren', 'tabs.split': 'Aufteilen', 'tabs.merge': 'Zusammenfuehren', @@ -76,8 +77,15 @@ describe('CertManagerPage shell', () => { expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument(); }); - it('shows Inspect tab empty state on initial render', () => { + it('startet mit der Uebersicht ohne Einzeldatei-Eingabe (quick-261001-l4q)', () => { render(); + expect(screen.getByText('dropTitle')).toBeInTheDocument(); + expect(screen.queryByText('Datei hierher ziehen oder klicken')).not.toBeInTheDocument(); + }); + + it('shows Inspect tab empty state when Inspect tab is active', () => { + render(); + fireEvent.click(screen.getAllByText('Analysieren')[0]); expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument(); expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument(); }); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.test.tsx new file mode 100644 index 0000000..cd63b56 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.test.tsx @@ -0,0 +1,202 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => + values ? `${key} ${JSON.stringify(values)}` : key, +})); + +vi.mock('../actions', () => ({ + analyzeBundleAction: vi.fn(), + exportBundleItemAction: vi.fn(), + downloadBase64: vi.fn(), +})); + +import { + analyzeBundleAction, + type BundleItem, + downloadBase64, + exportBundleItemAction, +} from '../actions'; +import { OverviewTab } from './OverviewTab'; + +const mockAnalyze = analyzeBundleAction as ReturnType; +const mockExport = exportBundleItemAction as ReturnType; +const mockDownload = downloadBase64 as ReturnType; + +function item(over: Partial): BundleItem { + return { + id: 'x', + kind: 'certificate', + sources: ['a.pem'], + pem: 'PEM', + baseName: 'www.example.test', + cn: 'www.example.test', + organization: '', + issuerCn: '', + notBefore: null, + notAfter: null, + isExpired: null, + daysLeft: null, + san: [], + keyType: 'RSA', + keyBits: 2048, + serialNumber: '', + sha256: '', + matchId: null, + chainIds: [], + formats: [], + ...over, + }; +} + +const LEAF = item({ + id: 'leaf', + role: 'end-entity', + pem: 'LEAF-PEM', + issuerCn: 'Test Intermediate CA', + notBefore: '2026-01-01T00:00:00.000Z', + notAfter: '2027-01-01T00:00:00.000Z', + isExpired: false, + daysLeft: 200, + san: ['www.example.test', 'example.test'], + matchId: 'key', + chainIds: ['inter'], + formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'], +}); +const INTER = item({ + id: 'inter', + role: 'intermediate', + pem: 'INTER-PEM', + cn: 'Test Intermediate CA', + isExpired: false, + daysLeft: 900, + formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'], +}); +const KEY = item({ + id: 'key', + kind: 'privateKey', + pem: 'KEY-PEM', + matchId: 'leaf', + formats: ['key', 'key-rsa', 'key-der'], +}); +const CSR = item({ + id: 'csr', + kind: 'csr', + pem: 'CSR-PEM', + matchId: 'leaf', + formats: ['csr', 'csr-der'], +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function upload(files: File[]) { + render(); + fireEvent.change(screen.getByTestId('overview-file-input'), { target: { files } }); + await waitFor(() => expect(mockAnalyze).toHaveBeenCalled()); +} + +describe('OverviewTab', () => { + it('analysiert die abgelegten Dateien und zeigt je Teil Typ und Download-Knoepfe', async () => { + mockAnalyze.mockResolvedValue({ + items: [LEAF, INTER, KEY, CSR], + locked: [], + ignored: ['.dnstxtrecord'], + }); + const zip = new File(['zip'], 'paket.zip'); + await upload([zip]); + + expect(mockAnalyze).toHaveBeenCalledWith([zip], undefined); + const cards = await screen.findAllByTestId('bundle-item'); + expect(cards).toHaveLength(4); + expect(within(cards[0]).getByText('type.end-entity')).toBeInTheDocument(); + expect(within(cards[1]).getByText('type.intermediate')).toBeInTheDocument(); + expect(within(cards[2]).getByText('type.privateKey')).toBeInTheDocument(); + expect(within(cards[3]).getByText('type.csr')).toBeInTheDocument(); + // Zuordnung und Gueltigkeit + expect(within(cards[0]).getByText('www.example.test, example.test')).toBeInTheDocument(); + expect(within(cards[0]).getByText('valid')).toBeInTheDocument(); + expect(within(cards[2]).getByText(/type\.end-entity/)).toBeInTheDocument(); + // alle Formate als Knoepfe + expect( + within(cards[0]) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['format.crt', 'format.cer', 'format.fullchain', 'format.p7b', 'format.pfx']); + expect(within(cards[2]).getAllByRole('button')).toHaveLength(3); + expect(screen.getByText(/ignored/)).toBeInTheDocument(); + }); + + it('Download schickt Kette und Schluessel mit und loest die Datei aus', async () => { + mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] }); + mockExport.mockResolvedValue({ + filename: 'www.example.test-fullchain.pem', + content: 'Zm9v', + mimeType: 'x', + }); + await upload([new File(['x'], 'a.pem')]); + const [leafCard] = await screen.findAllByTestId('bundle-item'); + + fireEvent.click(within(leafCard).getByText('format.fullchain')); + await waitFor(() => + expect(mockDownload).toHaveBeenCalledWith('www.example.test-fullchain.pem', 'Zm9v', 'x'), + ); + expect(mockExport).toHaveBeenCalledWith({ + kind: 'certificate', + pem: 'LEAF-PEM', + format: 'fullchain', + baseName: 'www.example.test', + chain: ['INTER-PEM'], + keyPem: 'KEY-PEM', + password: undefined, + }); + }); + + it('PFX fragt erst ein Passwort ab', async () => { + mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] }); + mockExport.mockResolvedValue({ + filename: 'www.example.test.pfx', + content: 'Zm9v', + mimeType: 'x', + }); + await upload([new File(['x'], 'a.pem')]); + const [leafCard] = await screen.findAllByTestId('bundle-item'); + + fireEvent.click(within(leafCard).getByText('format.pfx')); + expect(mockExport).not.toHaveBeenCalled(); + expect(within(leafCard).getByText('pfxWithKey')).toBeInTheDocument(); + const download = within(leafCard).getByText('pfxDownload'); + expect(download).toBeDisabled(); + + fireEvent.change(within(leafCard).getByLabelText('pfxPassword'), { + target: { value: 'geheim' }, + }); + fireEvent.click(download); + await waitFor(() => + expect(mockExport).toHaveBeenCalledWith( + expect.objectContaining({ format: 'pfx', password: 'geheim' }), + ), + ); + }); + + it('geschuetzte PFX: Passwort eingeben und erneut pruefen', async () => { + mockAnalyze.mockResolvedValueOnce({ items: [LEAF], locked: ['a.pfx'], ignored: [] }); + const pfx = new File(['x'], 'a.pfx'); + await upload([pfx]); + + fireEvent.change(await screen.findByLabelText('lockedPassword'), { target: { value: 'pw' } }); + mockAnalyze.mockResolvedValueOnce({ items: [LEAF, KEY], locked: [], ignored: [] }); + fireEvent.click(screen.getByText('unlock')); + await waitFor(() => expect(mockAnalyze).toHaveBeenLastCalledWith([pfx], 'pw')); + expect(await screen.findAllByTestId('bundle-item')).toHaveLength(2); + }); + + it('Fehler beim Pruefen wird angezeigt', async () => { + mockAnalyze.mockRejectedValue(new Error('400')); + await upload([new File(['x'], 'a.txt')]); + expect(await screen.findByText('error')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx new file mode 100644 index 0000000..e3eef62 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx @@ -0,0 +1,355 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useRef, useState } from 'react'; +import { + analyzeBundleAction, + type BundleAnalysis, + type BundleExportFormat, + type BundleItem, + downloadBase64, + exportBundleItemAction, +} from '../actions'; + +/** + * OverviewTab (quick-261001-l4q) — Zertifikatspaket vom Aussteller auf einmal + * hochladen (mehrere Dateien oder ZIP), sehen, welche Datei was ist, und jedes + * Teil in jedem passenden Format herunterladen. Die Erkennung macht der Server + * (POST analyze), der Export ebenfalls (POST export) — beides zustandslos. + * + * T-09-02: Passwoerter leben nur im lokalen Zustand, nie in URL oder Log. + */ + +const ACCEPT = '.zip,.pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c,.key,.csr'; + +const ROLE_STYLES: Record = { + 'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', + intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', + privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300', + csr: 'bg-muted text-muted-foreground', +}; + +function typeKey(item: BundleItem): string { + return item.kind === 'certificate' ? (item.role ?? 'end-entity') : item.kind; +} + +/** In UTC wie im Zertifikat: „bis 23:59:59 UTC“ ist bei uns schon der Folgetag — der Aussteller nennt aber dieses Datum. */ +function formatDate(iso: string | null): string { + if (!iso) return ''; + return new Date(iso).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + timeZone: 'UTC', + }); +} + +export function OverviewTab() { + const t = useTranslations('certManager.overview'); + const inputRef = useRef(null); + const [files, setFiles] = useState([]); + const [password, setPassword] = useState(''); + const [isDragOver, setIsDragOver] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + async function analyze(next: File[], pw: string) { + if (next.length === 0) { + setResult(null); + return; + } + setLoading(true); + setError(null); + try { + setResult(await analyzeBundleAction(next, pw || undefined)); + } catch { + setResult(null); + setError(t('error')); + } finally { + setLoading(false); + } + } + + function addFiles(list: FileList | null) { + const added = Array.from(list ?? []); + if (added.length === 0) return; + const next = [...files, ...added]; + setFiles(next); + void analyze(next, password); + } + + function removeFile(index: number) { + const next = files.filter((_, i) => i !== index); + setFiles(next); + void analyze(next, password); + } + + function reset() { + setFiles([]); + setPassword(''); + setResult(null); + setError(null); + } + + const byId = Object.fromEntries((result?.items ?? []).map((i) => [i.id, i])); + + return ( +
+ { + addFiles(e.target.files); + e.target.value = ''; + }} + /> + + + {files.length > 0 && ( +
+ {files.map((file, i) => ( + + {file.name} + + + ))} + +
+ )} + + {loading &&

{t('analyzing')}

} + {error &&

{error}

} + + {result && result.locked.length > 0 && ( +
+

+ {t('locked', { files: result.locked.join(', ') })} +

+
+ setPassword(e.target.value)} + placeholder={t('lockedPassword')} + aria-label={t('lockedPassword')} + autoComplete="off" + className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm" + /> + +
+
+ )} + + {result && result.items.length === 0 && !loading && ( +

{t('nothingFound')}

+ )} + + {result && result.items.length > 0 && ( +
    + {result.items.map((item) => ( + + ))} +
+ )} + + {result && result.ignored.length > 0 && ( +

+ {t('ignored', { files: result.ignored.join(', ') })} +

+ )} +
+ ); +} + +function BundleItemCard({ item, byId }: { item: BundleItem; byId: Record }) { + const t = useTranslations('certManager.overview'); + const [pfxOpen, setPfxOpen] = useState(false); + const [pfxPassword, setPfxPassword] = useState(''); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + const match = item.matchId ? byId[item.matchId] : undefined; + const chain = item.chainIds.map((id) => byId[id]).filter(Boolean); + + async function download(format: BundleExportFormat, password?: string) { + setBusy(format); + setError(null); + try { + const file = await exportBundleItemAction({ + kind: item.kind, + pem: item.pem, + format, + baseName: item.baseName, + chain: item.kind === 'certificate' ? chain.map((c) => c.pem) : undefined, + keyPem: item.kind === 'certificate' && match ? match.pem : undefined, + password, + }); + downloadBase64(file.filename, file.content, file.mimeType); + if (format === 'pfx') { + setPfxOpen(false); + setPfxPassword(''); + } + } catch { + setError(t('exportError')); + } finally { + setBusy(null); + } + } + + const status = + item.isExpired === null + ? null + : item.isExpired + ? { cls: 'bg-status-down/15 text-status-down-fg', text: t('expired') } + : (item.daysLeft ?? 0) <= 30 + ? { + cls: 'bg-status-warn/15 text-status-warn-fg', + text: t('expiresSoon', { days: item.daysLeft ?? 0 }), + } + : { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('valid') }; + + return ( +
  • +
    + + {t(`type.${typeKey(item)}`)} + + {item.cn || item.baseName} + {status && ( + + {status.text} + + )} +
    +

    {t(`explain.${typeKey(item)}`)}

    + +
    + {item.kind === 'certificate' && ( + <> +
    {t('issuer')}
    +
    {item.issuerCn}
    +
    {t('validity')}
    +
    + {formatDate(item.notBefore)} – {formatDate(item.notAfter)} +
    + + )} + {item.san.length > 0 && ( + <> +
    {t('names')}
    +
    {item.san.join(', ')}
    + + )} + {item.keyType && ( + <> +
    {t('key')}
    +
    {item.keyBits > 0 ? `${item.keyType} ${item.keyBits} Bit` : item.keyType}
    + + )} + {match && ( + <> +
    {t('belongsTo')}
    +
    + {t(`type.${typeKey(match)}`)} {match.cn} +
    + + )} +
    {t('source')}
    +
    {item.sources.join(', ')}
    +
    + + {item.kind === 'privateKey' && ( +

    {t('keySecret')}

    + )} + +
    + {item.formats.map((format) => ( + + ))} +
    + + {pfxOpen && ( +
    +

    + {match ? t('pfxWithKey') : t('pfxWithoutKey')} +

    +
    + setPfxPassword(e.target.value)} + placeholder={t('pfxPassword')} + aria-label={t('pfxPassword')} + autoComplete="new-password" + className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm" + /> + +
    +
    + )} + + {error &&

    {error}

    } +
  • + ); +} 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 43bc964..d18fdcc 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/page.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/page.tsx @@ -9,10 +9,11 @@ import { InspectTab } from './components/InspectTab'; import { SplitTab } from './components/SplitTab'; import { MergeTab } from './components/MergeTab'; import { ConvertTab } from './components/ConvertTab'; +import { OverviewTab } from './components/OverviewTab'; -type TabId = 'inspect' | 'split' | 'merge' | 'convert'; +type TabId = 'overview' | 'inspect' | 'split' | 'merge' | 'convert'; -const TABS: TabId[] = ['inspect', 'split', 'merge', 'convert']; +const TABS: TabId[] = ['overview', 'inspect', 'split', 'merge', 'convert']; /** Returns true if the selected file is a PFX/P12 (requires decryption password) */ function isPfxFile(file: File | null): boolean { @@ -23,6 +24,7 @@ function isPfxFile(file: File | null): boolean { /** * CertManagerPage — tab-based shell for certificate operations. + * quick-261001-l4q: erster Reiter „Übersicht“ fuer ganze Zertifikatspakete. * Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card. * T-09-02: password lives in local React state only; never logged or placed in URLs. * T-09-04: all API calls via postForm() which sends credentials:'include'. @@ -34,7 +36,7 @@ function isPfxFile(file: File | null): boolean { export default function CertManagerPage() { const t = useTranslations('certManager'); - const [activeTab, setActiveTab] = useState('inspect'); + const [activeTab, setActiveTab] = useState('overview'); const [file, setFile] = useState(null); const [pemText, setPemText] = useState(''); const [password, setPassword] = useState(''); @@ -75,17 +77,14 @@ export default function CertManagerPage() { const renderActiveTab = () => { switch (activeTab) { + case 'overview': + return ; case 'inspect': return ; case 'split': return ; case 'merge': - return ( - - ); + return ; case 'convert': return ( - {/* Shared input card */} -
    - {/* DropZone */} - - - {/* ODER divider */} -
    -
    - {t('or')} -
    -
    - - {/* PEM textarea */} -