From 65a1dca80f065915370c10235967f0f278ac87b5 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 9 Oct 2026 15:46:43 +0200 Subject: [PATCH] feat(cert-manager): alle Ausgabeformate, Konvertieren, Bundle und PFX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build liefert jeden Inhalt in jedem Format: Einzelzertifikat (PEM, DER, PKCS#7 .p7b/.p7c), Fullchain und Nur Kette auch als PKCS#7, Zertifikat und Schlüssel in einer PEM-Datei, PFX, Schlüssel (PKCS#8, klassisch, DER, optional mit Passwort) und CSR (PEM, DER) - PKCS#7 von Hand aus ASN.1 gebaut, jedes Zertifikat mit seinen eigenen Bytes (RSA und EC) - PFX schreiben für RSA und EC über einen begrenzten, im finally zurückgesetzten forge-Austausch; Kompatibel (3DES/SHA-1, Vorgabe) oder Modern (AES-256) - Schlüssel und Passwörter nur in der Anfrage, nie in Antwort, Fehlertext oder Log; falscher Schlüssel: keyMismatch, kein Schlüssel: keyMissing, ohne Passwort: passwordRequired - Reiter Konvertieren für Zertifikat, Schlüssel und Anfrage; Zusammenführen mit Kettenformat, Zertifikat und Schlüssel sowie PFX (Passwort doppelt, Verschlüsselung wählbar) Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/api/src/cert-manager/cert-keys.spec.ts | 76 +++- apps/api/src/cert-manager/cert-keys.ts | 45 +- .../cert-manager.controller.spec.ts | 55 ++- apps/api/src/cert-manager/cert-output.spec.ts | 392 +++++++++++++++++- apps/api/src/cert-manager/cert-output.ts | 213 +++++++++- apps/api/src/cert-manager/cert-pkcs12.spec.ts | 188 ++++++++- apps/api/src/cert-manager/cert-pkcs12.ts | 72 ++++ .../src/cert-manager/dto/cert-build.dto.ts | 48 ++- .../cert-manager/cert-manager.test.tsx | 4 +- .../components/ConvertTab.test.tsx | 212 ++++++++++ .../cert-manager/components/ConvertTab.tsx | 218 ++++++++++ .../cert-manager/components/MergeTab.test.tsx | 127 +++++- .../cert-manager/components/MergeTab.tsx | 95 ++++- .../cert-manager/components/PfxOptions.tsx | 65 +++ .../(portal)/modules/cert-manager/page.tsx | 5 +- apps/web/src/messages/de.json | 53 ++- apps/web/src/messages/en.json | 53 ++- apps/web/src/messages/umlaut-dictionary.ts | 3 + 18 files changed, 1877 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/PfxOptions.tsx diff --git a/apps/api/src/cert-manager/cert-keys.spec.ts b/apps/api/src/cert-manager/cert-keys.spec.ts index a4ee82e..181a223 100644 --- a/apps/api/src/cert-manager/cert-keys.spec.ts +++ b/apps/api/src/cert-manager/cert-keys.spec.ts @@ -1,8 +1,8 @@ -import { createPrivateKey, createPublicKey } from 'node:crypto'; +import { createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { candidatePasswords, keyIdOf, MAX_PASSWORDS } from './cert-keys'; +import { candidatePasswords, exportKey, keyIdOf, MAX_PASSWORDS } from './cert-keys'; import { detectBlob } from './cert-model'; import type { KeyItem } from './cert-types'; @@ -167,3 +167,75 @@ describe('candidatePasswords', () => { expect(out[0]).toBe('eigen'); }); }); + +describe('exportKey', () => { + const rsa = () => createPrivateKey(fx('rsa-leaf-key.pem')); + const ec = () => createPrivateKey(fx('ec-leaf-key.pem')); + const text = (b: Buffer) => b.toString('utf8'); + const sameKey = (a: ReturnType, b: ReturnType) => + a.export({ type: 'pkcs8', format: 'der' }).equals(b.export({ type: 'pkcs8', format: 'der' })); + + it('PKCS#8 ohne Passwort: PRIVATE KEY, gleicher Schluessel', () => { + const out = text(exportKey(rsa(), 'pkcs8')); + expect(out).toContain('-----BEGIN PRIVATE KEY-----'); + expect(sameKey(createPrivateKey(out), rsa())).toBe(true); + expect(text(exportKey(ec(), 'pkcs8'))).toContain('-----BEGIN PRIVATE KEY-----'); + }); + + it('PKCS#8 mit Passwort: ENCRYPTED PRIVATE KEY, nur mit dem Passwort zu oeffnen', () => { + for (const key of [rsa(), ec()]) { + const out = text(exportKey(key, 'pkcs8', PASSWORD)); + expect(out).toContain('-----BEGIN ENCRYPTED PRIVATE KEY-----'); + expect(sameKey(createPrivateKey({ key: out, passphrase: PASSWORD }), key)).toBe(true); + expect(() => createPrivateKey({ key: out, passphrase: 'falsch' })).toThrow(); + } + }); + + it('klassisch: RSA als RSA PRIVATE KEY, EC als EC PRIVATE KEY', () => { + expect(text(exportKey(rsa(), 'traditional'))).toContain('-----BEGIN RSA PRIVATE KEY-----'); + expect(text(exportKey(ec(), 'traditional'))).toContain('-----BEGIN EC PRIVATE KEY-----'); + }); + + it('klassisch mit Passwort: Proc-Type 4,ENCRYPTED, mit dem Passwort zu oeffnen', () => { + for (const key of [rsa(), ec()]) { + const out = text(exportKey(key, 'traditional', PASSWORD)); + expect(out).toContain('Proc-Type: 4,ENCRYPTED'); + expect(sameKey(createPrivateKey({ key: out, passphrase: PASSWORD }), key)).toBe(true); + } + }); + + it('PKCS#8 als DER; mit Passwort verschluesselt und nur damit zu oeffnen', () => { + const plain = exportKey(ec(), 'pkcs8-der'); + expect(sameKey(createPrivateKey({ key: plain, format: 'der', type: 'pkcs8' }), ec())).toBe( + true, + ); + const enc = exportKey(ec(), 'pkcs8-der', PASSWORD); + expect( + sameKey( + createPrivateKey({ key: enc, format: 'der', type: 'pkcs8', passphrase: PASSWORD }), + ec(), + ), + ).toBe(true); + expect(() => + createPrivateKey({ key: enc, format: 'der', type: 'pkcs8', passphrase: 'falsch' }), + ).toThrow(); + }); + + it('klassisch fuer einen Ed25519-Schluessel: formatNotPossible', () => { + const { privateKey } = generateKeyPairSync('ed25519'); + expect(text(exportKey(privateKey, 'pkcs8'))).toContain('PRIVATE KEY'); + try { + exportKey(privateKey, 'traditional'); + throw new Error('expected a throw'); + } catch (error) { + const e = error as { getStatus(): number; getResponse(): { code: string } }; + expect(e.getStatus()).toBe(400); + expect(e.getResponse().code).toBe('formatNotPossible'); + } + }); + + it('das Passwort steht nie in der Ausgabe', () => { + expect(text(exportKey(rsa(), 'pkcs8', PASSWORD))).not.toContain(PASSWORD); + expect(text(exportKey(rsa(), 'traditional', PASSWORD))).not.toContain(PASSWORD); + }); +}); diff --git a/apps/api/src/cert-manager/cert-keys.ts b/apps/api/src/cert-manager/cert-keys.ts index df60a8a..ab6f0f0 100644 --- a/apps/api/src/cert-manager/cert-keys.ts +++ b/apps/api/src/cert-manager/cert-keys.ts @@ -1,6 +1,12 @@ import { createHash, createPrivateKey, createPublicKey, type KeyObject } from 'node:crypto'; import * as forge from 'node-forge'; -import type { IgnoredEntry, ItemSource, KeyItem, LockedEntry } from './cert-types'; +import { + certError, + type IgnoredEntry, + type ItemSource, + type KeyItem, + type LockedEntry, +} from './cert-types'; /** * Private Schluessel des Zertifikat-Managers (quick-261009-ikt, D-05, D-11, D-16). @@ -224,3 +230,40 @@ export function keyFromDer(der: Buffer, ctx: KeyContext): KeyDetectResult | null } return null; } + +export type KeyExportFormat = 'pkcs8' | 'traditional' | 'pkcs8-der'; + +const EXPORT_CIPHER = 'aes-256-cbc'; + +/** + * Gibt einen privaten Schluessel in dem gewuenschten Format aus (D-19): PKCS#8 als PEM, klassisch + * (PKCS#1 fuer RSA, SEC1 fuer EC) als PEM oder PKCS#8 als DER. Mit Passwort wird mit AES-256-CBC + * verschluesselt (PKCS#8: ENCRYPTED PRIVATE KEY, klassisch: Proc-Type 4,ENCRYPTED). Die Ausgabe ist + * PEM-Text (UTF-8) oder DER. Klassisch gibt es nur fuer RSA und EC: sonst formatNotPossible. + * Weder der Schluessel noch das Passwort erscheinen in einem Fehlertext. + */ +export function exportKey(key: KeyObject, format: KeyExportFormat, password?: string): Buffer { + const cipher = password ? { cipher: EXPORT_CIPHER, passphrase: password } : {}; + try { + if (format === 'pkcs8') { + return Buffer.from(key.export({ type: 'pkcs8', format: 'pem', ...cipher }) as string, 'utf8'); + } + if (format === 'pkcs8-der') { + return key.export({ type: 'pkcs8', format: 'der', ...cipher }) as Buffer; + } + } catch { + return certError('formatNotPossible', 400, 'Key cannot be exported in this format'); + } + const type = key.asymmetricKeyType; + if (type !== 'rsa' && type !== 'ec') { + return certError('formatNotPossible', 400, 'Traditional format needs an RSA or EC key'); + } + try { + return Buffer.from( + key.export({ type: type === 'rsa' ? 'pkcs1' : 'sec1', format: 'pem', ...cipher }) as string, + 'utf8', + ); + } catch { + return certError('formatNotPossible', 400, 'Key cannot be exported in this format'); + } +} diff --git a/apps/api/src/cert-manager/cert-manager.controller.spec.ts b/apps/api/src/cert-manager/cert-manager.controller.spec.ts index 6bcee5b..5ec1934 100644 --- a/apps/api/src/cert-manager/cert-manager.controller.spec.ts +++ b/apps/api/src/cert-manager/cert-manager.controller.spec.ts @@ -1,10 +1,11 @@ import 'reflect-metadata'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { RequestMethod } from '@nestjs/common'; +import { RequestMethod, ValidationPipe } from '@nestjs/common'; import { describe, expect, it } from 'vitest'; import { MODULE_SLUG_KEY } from '../module-registry/module.guard'; import { CertManagerController, parsePasswords, repairFileName } from './cert-manager.controller'; +import { BuildOutputDto, CERT_PEM_MAX } from './dto/cert-build.dto'; const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); const upload = (name: string, buffer: Buffer = fx(name)) => ({ @@ -140,3 +141,55 @@ describe('repairFileName', () => { expect(repairFileName('M\xfcller.pem')).toBe('M\xfcller.pem'); }); }); + +describe('build: Anfrage und Pruefung', () => { + const controller = new CertManagerController(); + const pipe = new ValidationPipe({ whitelist: true, transform: true }); + const validate = (body: object) => + pipe.transform(body, { type: 'body', metatype: BuildOutputDto }) as Promise; + const text = (n: string) => fx(n).toString('utf8'); + + it('PFX mit Schluessel, Passwort und Verschluesselung geht durch die Pruefung und liefert eine Datei', async () => { + const dto = await validate({ + content: 'pfx', + certPem: text('ec-leaf.pem'), + poolPems: [text('ec-inter.pem')], + keyPem: text('ec-leaf-key.pem'), + password: 'Neu-Pass-2026', + pfxEncryption: 'modern', + includeChain: true, + baseName: 'server', + }); + const result = controller.build(dto); + expect(result.files[0].filename).toBe('server.pfx'); + expect(JSON.stringify(result)).not.toContain('Neu-Pass-2026'); + }); + + it('Schluessel- und Anfrage-Ausgabe brauchen kein Zertifikat', async () => { + const key = controller.build( + await validate({ content: 'key', format: 'traditional', keyPem: text('rsa-leaf-key.pem') }), + ); + expect(key.files[0].filename).toBe('schluessel.rsa.key'); + const csr = controller.build( + await validate({ content: 'csr', format: 'der', csrPem: text('rsa-leaf.csr') }), + ); + expect(csr.files[0].filename.endsWith('.csr.der')).toBe(true); + }); + + it.each([ + ['unbekannter Inhalt', { content: 'zip' }], + ['unbekanntes Format', { content: 'leaf', format: 'p12' }], + ['unbekannte Verschluesselung', { content: 'pfx', pfxEncryption: 'rc2' }], + ['includeChain keine Wahrheitsangabe', { content: 'leaf', includeChain: 'ja' }], + ['zu langer Schluessel', { content: 'key', keyPem: 'k'.repeat(CERT_PEM_MAX + 1) }], + ['zu lange Anfrage', { content: 'csr', csrPem: 'r'.repeat(CERT_PEM_MAX + 1) }], + ['zu langes Passwort', { content: 'pfx', password: 'p'.repeat(257) }], + ])('%s wird von der Pruefung abgewiesen', async (_name, body) => { + await expect(validate(body)).rejects.toMatchObject({ status: 400 }); + }); + + it('unbekannte Felder (etwa eine Adresse) werden entfernt', async () => { + const dto = await validate({ content: 'leaf', url: 'http://10.0.0.1/', certPem: 'x' }); + expect((dto as unknown as Record).url).toBeUndefined(); + }); +}); diff --git a/apps/api/src/cert-manager/cert-output.spec.ts b/apps/api/src/cert-manager/cert-output.spec.ts index c778716..40b3b72 100644 --- a/apps/api/src/cert-manager/cert-output.spec.ts +++ b/apps/api/src/cert-manager/cert-output.spec.ts @@ -1,8 +1,11 @@ -import { X509Certificate } from 'node:crypto'; +import { createPrivateKey, generateKeyPairSync, X509Certificate } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { detectBlob } from './cert-model'; import { buildOutput, safeBaseName } from './cert-output'; +import { readPkcs12 } from './cert-pkcs12'; +import type { CertItem, CsrItem } from './cert-types'; const fxText = (name: string) => readFileSync(join(__dirname, '__fixtures__', name), 'utf8'); @@ -140,3 +143,390 @@ describe('buildOutput fullchain und chain', () => { expect(r.files[0].filename).toBe('mein_Server-fullchain.pem'); }); }); + +const bytes = (file: { content: string }) => Buffer.from(file.content, 'base64'); +const NEW_PASSWORD = 'Neu-Pass-2026'; +const PASSWORD = 'Test-Pass-123'; + +/** Zertifikate aus PKCS#7-Bytes (ueber den Parser der Anwendung, also derselbe Weg wie beim Hochladen). */ +function p7Subjects(data: Buffer): string[] { + const r = detectBlob(data, { file: 0, path: 'x.p7', passwords: [] }); + return r.items.filter((i): i is CertItem => i.kind === 'certificate').map((c) => c.cn); +} + +describe('buildOutput: einzelnes Zertifikat', () => { + const rsaLeaf = fxText('rsa-leaf.pem'); + + it('PEM: .crt mit genau dem Zertifikat', () => { + const r = buildOutput({ content: 'leaf', format: 'pem', certPem: rsaLeaf }); + expect(r.files[0].filename).toBe('www.example.test.crt'); + expect(r.files[0].mimeType).toBe('application/x-pem-file'); + expect(subjects(decode(r.files[0]))).toEqual(['www.example.test']); + }); + + it('DER: .cer, von X509Certificate gelesen gleich dem Eingang', () => { + const r = buildOutput({ content: 'leaf', format: 'der', certPem: rsaLeaf }); + expect(r.files[0].filename).toBe('www.example.test.cer'); + expect(r.files[0].mimeType).toBe('application/pkix-cert'); + expect(new X509Certificate(bytes(r.files[0])).fingerprint256).toBe( + new X509Certificate(rsaLeaf).fingerprint256, + ); + }); + + it.each([ + ['p7b', 'www.example.test.p7b', '-----BEGIN PKCS7-----'], + ['p7c', 'www.example.test.p7c', null], + ])('PKCS#7 %s: nur das Serverzertifikat', (format, filename, header) => { + const r = buildOutput({ content: 'leaf', format, certPem: rsaLeaf }); + expect(r.files[0].filename).toBe(filename); + expect(r.files[0].mimeType).toBe('application/x-pkcs7-certificates'); + if (header) expect(decode(r.files[0])).toContain(header); + else expect(bytes(r.files[0])[0]).toBe(0x30); + expect(p7Subjects(bytes(r.files[0]))).toEqual(['www.example.test']); + }); + + it('ohne format gilt PEM', () => { + expect(buildOutput({ content: 'leaf', certPem: rsaLeaf }).files[0].filename).toBe( + 'www.example.test.crt', + ); + }); +}); + +describe('buildOutput: Fullchain und Kette als PKCS#7', () => { + const cases = [ + [ + 'EC', + 'ec-leaf.pem', + ['ec-root.pem', 'ec-inter.pem'], + ['Tessera Test Inter EC', 'Tessera Test Root EC'], + 'ec.example.test', + ], + [ + 'RSA', + 'rsa-leaf.pem', + ['rsa-root.pem', 'rsa-inter.pem'], + ['Tessera Test Inter RSA', 'Tessera Test Root RSA'], + 'www.example.test', + ], + ] as const; + + it.each( + cases, + )('%s: Fullchain p7b/p7c in Kettenreihenfolge, Wurzel nur mit includeRoot', (_t, leaf, pool, issuers, cn) => { + const common = { certPem: fxText(leaf), poolPems: pool.map(fxText) }; + for (const format of ['p7b', 'p7c']) { + const without = buildOutput({ content: 'fullchain', format, ...common }); + expect(without.files[0].filename).toBe(`${cn}-fullchain.${format}`); + expect(p7Subjects(bytes(without.files[0]))).toEqual([cn, issuers[0]]); + const withRoot = buildOutput({ content: 'fullchain', format, includeRoot: true, ...common }); + expect(p7Subjects(bytes(withRoot.files[0]))).toEqual([cn, ...issuers]); + } + }); + + it.each(cases)('%s: Nur Kette p7b/p7c ohne Serverzertifikat', (_t, leaf, pool, issuers, cn) => { + const common = { certPem: fxText(leaf), poolPems: pool.map(fxText) }; + for (const format of ['p7b', 'p7c']) { + const only = buildOutput({ content: 'chain', format, ...common }); + expect(only.files[0].filename).toBe(`${cn}-chain.${format}`); + expect(p7Subjects(bytes(only.files[0]))).toEqual([issuers[0]]); + const withRoot = buildOutput({ content: 'chain', format, includeRoot: true, ...common }); + expect(p7Subjects(bytes(withRoot.files[0]))).toEqual(issuers); + } + }); + + it('die Zertifikate im PKCS#7 sind byte-genau die Originale', () => { + const r = buildOutput({ + content: 'fullchain', + format: 'p7c', + certPem: fxText('ec-leaf.pem'), + poolPems: [fxText('ec-inter.pem')], + }); + const found = detectBlob(bytes(r.files[0]), { file: 0, path: 'x', passwords: [] }).items.map( + (i) => (i as CertItem).sha256, + ); + expect(found).toEqual([ + new X509Certificate(fxText('ec-leaf.pem')).fingerprint256, + new X509Certificate(fxText('ec-inter.pem')).fingerprint256, + ]); + }); +}); + +describe('buildOutput: Zertifikat und Schluessel (leafKey)', () => { + const chain = { + certPem: fxText('ec-leaf.pem'), + poolPems: [fxText('ec-inter.pem'), fxText('ec-root.pem')], + keyPem: fxText('ec-leaf-key.pem'), + }; + + it('Server, Zwischenzertifikat, Schluessel zuletzt; ohne Wurzel', () => { + const r = buildOutput({ content: 'leafKey', ...chain }); + expect(r.files[0].filename).toBe('ec.example.test-bundle.pem'); + const text = decode(r.files[0]); + expect(subjects(text)).toEqual(['ec.example.test', 'Tessera Test Inter EC']); + expect(text.trimEnd().endsWith('-----END PRIVATE KEY-----')).toBe(true); + expect(text.indexOf('BEGIN PRIVATE KEY')).toBeGreaterThan(text.lastIndexOf('END CERTIFICATE')); + expect( + new X509Certificate(fxText('ec-leaf.pem')).checkPrivateKey( + createPrivateKey(text.slice(text.indexOf('-----BEGIN PRIVATE KEY'))), + ), + ).toBe(true); + }); + + it('mit includeRoot kommt die Wurzel vor dem Schluessel', () => { + const r = buildOutput({ content: 'leafKey', includeRoot: true, ...chain }); + expect(subjects(decode(r.files[0]))).toEqual([ + 'ec.example.test', + 'Tessera Test Inter EC', + 'Tessera Test Root EC', + ]); + }); + + it('includeChain false: nur Serverzertifikat und Schluessel', () => { + const r = buildOutput({ content: 'leafKey', includeChain: false, ...chain }); + expect(subjects(decode(r.files[0]))).toEqual(['ec.example.test']); + }); + + it('Schluessel eines anderen Zertifikats: 400 keyMismatch', () => { + expect( + codeOf(() => + buildOutput({ content: 'leafKey', ...chain, keyPem: fxText('rsa-leaf-key.pem') }), + ), + ).toEqual({ status: 400, code: 'keyMismatch' }); + }); + + it('ohne Schluessel: 400 keyMissing', () => { + expect(codeOf(() => buildOutput({ content: 'leafKey', certPem: chain.certPem }))).toEqual({ + status: 400, + code: 'keyMissing', + }); + }); + + it('kein lesbarer Schluessel: 400 invalidInput, ohne Schluesseltext in der Meldung', () => { + try { + buildOutput({ content: 'leafKey', certPem: chain.certPem, keyPem: 'GEHEIM-KEIN-SCHLUESSEL' }); + throw new Error('expected a throw'); + } catch (error) { + const e = error as { getStatus(): number; getResponse(): { code: string; message: string } }; + expect(e.getStatus()).toBe(400); + expect(e.getResponse().code).toBe('invalidInput'); + expect(JSON.stringify(e.getResponse())).not.toContain('GEHEIM'); + } + }); +}); + +describe('buildOutput: PFX', () => { + const rsa = { + certPem: fxText('rsa-leaf.pem'), + poolPems: [fxText('rsa-inter.pem'), fxText('rsa-root.pem')], + keyPem: fxText('rsa-leaf-key.pem'), + }; + const ec = { + certPem: fxText('ec-leaf.pem'), + poolPems: [fxText('ec-inter.pem'), fxText('ec-root.pem')], + keyPem: fxText('ec-leaf-key.pem'), + }; + + it('ohne Passwort: 400 passwordRequired', () => { + expect(codeOf(() => buildOutput({ content: 'pfx', ...rsa }))).toEqual({ + status: 400, + code: 'passwordRequired', + }); + expect(codeOf(() => buildOutput({ content: 'pfx', password: '', ...rsa }))).toEqual({ + status: 400, + code: 'passwordRequired', + }); + }); + + it.each([ + ['RSA', rsa, 'www.example.test'], + ['EC', ec, 'ec.example.test'], + ])('%s: modern, .pfx, mit dem Passwort wieder lesbar (Server, Zwischen, Schluessel)', (_t, set, cn) => { + const r = buildOutput({ + content: 'pfx', + password: NEW_PASSWORD, + pfxEncryption: 'modern', + ...set, + }); + expect(r.files[0].filename).toBe(`${cn}.pfx`); + expect(r.files[0].mimeType).toBe('application/x-pkcs12'); + const read = readPkcs12(bytes(r.files[0]), [], NEW_PASSWORD); + expect(read.ok).toBe(true); + if (!read.ok) return; + const cns = read.contents.certDers.map((d) => + String(new X509Certificate(d).toLegacyObject().subject.CN), + ); + expect(cns).toHaveLength(2); // ohne Wurzel + expect(cns[0]).toBe(cn); + expect(new X509Certificate(set.certPem).checkPrivateKey(read.contents.keys[0].key)).toBe(true); + }); + + it('mit includeRoot kommt die Wurzel dazu; Vorgabe ist kompatibel', () => { + const r = buildOutput({ content: 'pfx', password: NEW_PASSWORD, includeRoot: true, ...rsa }); + const read = readPkcs12(bytes(r.files[0]), [], NEW_PASSWORD); + expect(read.ok && read.contents.certDers).toHaveLength(3); + // 3DES-OID im Container (Kennung als DER-Bytes) + const oid3des = Buffer.from('2a864886f70d010c0103', 'hex'); + expect(bytes(r.files[0]).includes(oid3des)).toBe(true); + }); + + it('ohne Schluessel: nur Zertifikate', () => { + const r = buildOutput({ + content: 'pfx', + password: NEW_PASSWORD, + certPem: rsa.certPem, + poolPems: rsa.poolPems, + }); + const read = readPkcs12(bytes(r.files[0]), [], NEW_PASSWORD); + expect(read.ok && read.contents.keys).toHaveLength(0); + expect(read.ok && read.contents.certDers).toHaveLength(2); + }); + + it('Schluessel eines anderen Zertifikats: 400 keyMismatch', () => { + expect( + codeOf(() => + buildOutput({ content: 'pfx', password: NEW_PASSWORD, ...rsa, keyPem: ec.keyPem }), + ), + ).toEqual({ status: 400, code: 'keyMismatch' }); + }); + + it('das Passwort steht nirgends in der Antwort', () => { + const r = buildOutput({ content: 'pfx', password: NEW_PASSWORD, ...rsa }); + expect(JSON.stringify(r)).not.toContain(NEW_PASSWORD); + }); +}); + +describe('buildOutput: Schluessel und Anfrage exportieren', () => { + const rsaKey = fxText('rsa-leaf-key.pem'); + const ecKey = fxText('ec-leaf-key.pem'); + + it('PKCS#8: .key, mit Passwort ENCRYPTED PRIVATE KEY', () => { + const plain = buildOutput({ content: 'key', format: 'pkcs8', keyPem: rsaKey }); + expect(plain.files[0].filename).toBe('schluessel.key'); + expect(decode(plain.files[0])).toContain('-----BEGIN PRIVATE KEY-----'); + const enc = buildOutput({ + content: 'key', + format: 'pkcs8', + keyPem: rsaKey, + password: PASSWORD, + }); + expect(decode(enc.files[0])).toContain('-----BEGIN ENCRYPTED PRIVATE KEY-----'); + expect(createPrivateKey({ key: decode(enc.files[0]), passphrase: PASSWORD })).toBeTruthy(); + expect(JSON.stringify(enc)).not.toContain(PASSWORD); + }); + + it('klassisch: .rsa.key und .ec.key', () => { + const rsa = buildOutput({ + content: 'key', + format: 'traditional', + keyPem: rsaKey, + baseName: 'srv', + }); + expect(rsa.files[0].filename).toBe('srv.rsa.key'); + expect(decode(rsa.files[0])).toContain('BEGIN RSA PRIVATE KEY'); + const ec = buildOutput({ + content: 'key', + format: 'traditional', + keyPem: ecKey, + baseName: 'srv', + }); + expect(ec.files[0].filename).toBe('srv.ec.key'); + expect(decode(ec.files[0])).toContain('BEGIN EC PRIVATE KEY'); + const enc = buildOutput({ + content: 'key', + format: 'traditional', + keyPem: ecKey, + password: PASSWORD, + }); + expect(decode(enc.files[0])).toContain('Proc-Type: 4,ENCRYPTED'); + }); + + it('DER: .key.der, mit Passwort verschluesselt', () => { + const plain = buildOutput({ content: 'key', format: 'pkcs8-der', keyPem: ecKey }); + expect(plain.files[0].filename).toBe('schluessel.key.der'); + expect(plain.files[0].mimeType).toBe('application/octet-stream'); + expect( + createPrivateKey({ key: bytes(plain.files[0]), format: 'der', type: 'pkcs8' }), + ).toBeTruthy(); + const enc = buildOutput({ + content: 'key', + format: 'pkcs8-der', + keyPem: ecKey, + password: PASSWORD, + }); + expect(() => + createPrivateKey({ key: bytes(enc.files[0]), format: 'der', type: 'pkcs8' }), + ).toThrow(); + expect( + createPrivateKey({ + key: bytes(enc.files[0]), + format: 'der', + type: 'pkcs8', + passphrase: PASSWORD, + }), + ).toBeTruthy(); + }); + + it('ohne Schluessel: 400 keyMissing; klassisch fuer Ed25519: formatNotPossible', () => { + expect(codeOf(() => buildOutput({ content: 'key', format: 'pkcs8' }))).toEqual({ + status: 400, + code: 'keyMissing', + }); + const ed = generateKeyPairSync('ed25519').privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; + expect( + codeOf(() => buildOutput({ content: 'key', format: 'traditional', keyPem: ed })), + ).toEqual({ + status: 400, + code: 'formatNotPossible', + }); + }); + + it('CSR: PEM .csr und DER .csr.der', () => { + const csrPem = fxText('ec-leaf.csr'); + const pem = buildOutput({ content: 'csr', format: 'pem', csrPem }); + expect(pem.files[0].filename).toBe('ec.example.test.csr'); + expect(pem.files[0].mimeType).toBe('application/pkcs10'); + expect(decode(pem.files[0])).toContain('-----BEGIN CERTIFICATE REQUEST-----'); + const der = buildOutput({ content: 'csr', format: 'der', csrPem, baseName: 'srv' }); + expect(der.files[0].filename).toBe('srv.csr.der'); + const read = detectBlob(bytes(der.files[0]), { file: 0, path: 'x', passwords: [] }); + expect(read.items[0].kind).toBe('csr'); + expect((read.items[0] as CsrItem).keyType).toBe('EC'); + }); + + it('CSR ohne csrPem: 400 invalidInput; Text, der keine Anfrage ist: 400 invalidInput', () => { + expect(codeOf(() => buildOutput({ content: 'csr', format: 'pem' }))).toEqual({ + status: 400, + code: 'invalidInput', + }); + expect(codeOf(() => buildOutput({ content: 'csr', format: 'pem', csrPem: 'x' }))).toEqual({ + status: 400, + code: 'invalidInput', + }); + }); +}); + +describe('buildOutput: ungueltige Paare', () => { + it.each([ + ['leaf', 'p12'], + ['fullchain', 'der'], + ['key', 'pem'], + ['csr', 'p7b'], + ['leafKey', 'der'], + ['unbekannt', 'pem'], + ])('%s mit Format %s: 400 invalidInput', (content, format) => { + expect( + codeOf(() => + buildOutput({ + content: content as never, + format, + certPem: fxText('rsa-leaf.pem'), + keyPem: fxText('rsa-leaf-key.pem'), + csrPem: fxText('rsa-leaf.csr'), + }), + ), + ).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 d944d53..01732b6 100644 --- a/apps/api/src/cert-manager/cert-output.ts +++ b/apps/api/src/cert-manager/cert-output.ts @@ -1,25 +1,50 @@ -import { X509Certificate } from 'node:crypto'; +import { createPrivateKey, type KeyObject, X509Certificate } from 'node:crypto'; +import * as forge from 'node-forge'; import { buildChains } from './cert-chain'; +import { csrItemFromDer } from './cert-csr'; +import { exportKey, type KeyExportFormat } from './cert-keys'; import { certItemFromDer } from './cert-model'; import { safeBaseName } from './cert-names'; +import { writePkcs12 } from './cert-pkcs12'; import { + type BuildContent, type BuildFile, type BuildInput, type BuildResult, type CertItem, + type CsrItem, certError, } from './cert-types'; /** * Ausgabe-Bausteine des Zertifikat-Managers (quick-261009-ikt, D-19). - * Task 2: Fullchain und Nur Kette als PEM. Die weiteren Inhalte und Formate (Task 5) kommen in - * dieselbe Funktion. Die Reihenfolge baut die API immer selbst aus den gesendeten Zertifikaten - * (buildChains); eine vom Browser mitgeschickte Reihenfolge wird nie uebernommen. + * Jeder Inhalt in jedem Format laeuft durch die eine Funktion buildOutput. Die Reihenfolge baut die API + * immer selbst aus den gesendeten Zertifikaten (buildChains); eine vom Browser mitgeschickte Reihenfolge + * wird nie uebernommen. Schluessel und Passwoerter werden nur verarbeitet, nie gespeichert oder + * protokolliert, und erscheinen in keiner Fehlermeldung. */ export { safeBaseName }; -const PEM_MIME = 'application/x-pem-file'; +const MIME = { + pem: 'application/x-pem-file', + der: 'application/pkix-cert', + pkcs7: 'application/x-pkcs7-certificates', + pfx: 'application/x-pkcs12', + binary: 'application/octet-stream', + csr: 'application/pkcs10', +} as const; + +/** Erlaubte Formate je Inhalt; das erste ist die Vorgabe. */ +const FORMATS: Record = { + leaf: ['pem', 'der', 'p7b', 'p7c'], + fullchain: ['pem', 'p7b', 'p7c'], + chain: ['pem', 'p7b', 'p7c'], + leafKey: ['pem'], + pfx: ['pfx'], + key: ['pkcs8', 'traditional', 'pkcs8-der'], + csr: ['pem', 'der'], +}; function parseCertificate(pem: unknown): CertItem { if (typeof pem !== 'string' || pem.trim() === '') { @@ -32,21 +57,104 @@ function parseCertificate(pem: unknown): CertItem { } } +function parseKey(pem: string | undefined): KeyObject { + if (typeof pem !== 'string' || pem.trim() === '') { + certError('keyMissing', 400, 'A private key is required'); + } + try { + return createPrivateKey(pem); + } catch { + return certError('invalidInput', 400, 'keyPem is not a readable private key'); + } +} + +const CSR_BLOCK = + /-----BEGIN (?:NEW )?CERTIFICATE REQUEST-----([\s\S]*?)-----END (?:NEW )?CERTIFICATE REQUEST-----/; + +function parseCsr(pem: string | undefined): { der: Buffer; item: CsrItem } { + const body = typeof pem === 'string' ? CSR_BLOCK.exec(pem)?.[1] : undefined; + if (!body) certError('invalidInput', 400, 'csrPem is not a certificate request'); + try { + const der = Buffer.from(body.replace(/\s+/g, ''), 'base64'); + return { der, item: csrItemFromDer(der, { file: 0, path: '' }) }; + } catch { + return certError('invalidInput', 400, 'csrPem is not a certificate request'); + } +} + +function pemBlock(label: string, der: Buffer): string { + const lines = der.toString('base64').match(/.{1,64}/g) ?? []; + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`; +} + /** PEM-Bloecke in Kettenreihenfolge, jeder genau einmal mit abschliessendem Zeilenumbruch. */ function joinPem(certs: CertItem[]): string { return certs.map((c) => `${c.pem.trim()}\n`).join(''); } -function pemFile(filename: string, certs: CertItem[]): BuildFile { - return { - filename, - content: Buffer.from(joinPem(certs), 'utf8').toString('base64'), - mimeType: PEM_MIME, - }; +function derOf(cert: CertItem): Buffer { + return new X509Certificate(cert.pem).raw; +} + +const { asn1 } = forge; + +function sequence(value: forge.asn1.Asn1[]): forge.asn1.Asn1 { + return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, value); +} + +function oid(value: string): forge.asn1.Asn1 { + return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.OID, false, asn1.oidToDer(value).getBytes()); +} + +/** + * PKCS#7 „certs only“ (signedData ohne Inhalt und ohne Unterschriften, wie .p7b/.p7c von CAs), von Hand + * aus ASN.1 gebaut: ContentInfo { signedData, [0] { version 1, leere Verfahrensliste, data, [0] Zertifikate, + * leere Unterschriftenliste } }. Jedes Zertifikat geht mit seinen eigenen Bytes hinein; forges + * Zertifikatobjekte (nur RSA) kommen nie vor. + */ +function pkcs7Der(certs: CertItem[]): Buffer { + const options = { decodeBitStrings: false } as unknown as boolean; + const certAsn1 = certs.map((c) => + asn1.fromDer(forge.util.createBuffer(derOf(c).toString('binary')), options), + ); + const signedData = sequence([ + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, asn1.integerToDer(1).getBytes()), + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SET, true, []), + sequence([oid('1.2.840.113549.1.7.1')]), + asn1.create(asn1.Class.CONTEXT_SPECIFIC, 0, true, certAsn1), + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SET, true, []), + ]); + const contentInfo = sequence([ + oid('1.2.840.113549.1.7.2'), + asn1.create(asn1.Class.CONTEXT_SPECIFIC, 0, true, [signedData]), + ]); + return Buffer.from(asn1.toDer(contentInfo).getBytes(), 'binary'); +} + +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 }; +} + +/** Zertifikatsliste als PEM, PKCS#7 PEM (.p7b) oder PKCS#7 DER (.p7c) unter dem Namen `.`. */ +function certListFile(stem: string, pemName: string, format: string, certs: CertItem[]): BuildFile { + if (format === 'p7b') { + return file(`${stem}.p7b`, pemBlock('PKCS7', pkcs7Der(certs)), MIME.pkcs7); + } + if (format === 'p7c') return file(`${stem}.p7c`, pkcs7Der(certs), MIME.pkcs7); + return file(pemName, joinPem(certs), MIME.pem); } /** Baut die gewuenschte Ausgabe. Wirft Nest-Ausnahmen mit Code (D-24); nie mit Passwort oder Schluessel im Text. */ export function buildOutput(input: BuildInput): BuildResult { + const allowed = FORMATS[input.content]; + if (!allowed) certError('invalidInput', 400, 'Unknown content'); + const format = input.format ?? allowed[0]; + if (!allowed.includes(format)) certError('invalidInput', 400, 'Format is not available here'); + + if (input.content === 'key') return buildKey(input, format as KeyExportFormat); + if (input.content === 'csr') return buildCsr(input, format); + if (!input.certPem) certError('invalidInput', 400, 'certPem is required'); const head = parseCertificate(input.certPem); const pool = (input.poolPems ?? []).map(parseCertificate); @@ -60,18 +168,48 @@ export function buildOutput(input: BuildInput): BuildResult { const path = chain.path.map((id) => byId.get(id) as CertItem); const includeRoot = input.includeRoot === true; const withoutRoot = path.filter((c) => !(c.role === 'root' && c.id !== head.id)); + const shown = includeRoot ? path : withoutRoot; const base = safeBaseName(input.baseName ?? '', head.baseName); let files: BuildFile[]; - if (input.content === 'fullchain') { - files = [pemFile(`${base}-fullchain.pem`, includeRoot ? path : withoutRoot)]; - } else if (input.content === 'chain') { - const issuers = (includeRoot ? path : withoutRoot).filter((c) => c.id !== head.id); - if (issuers.length === 0) - certError('noChain', 400, 'No intermediate or root certificate available'); - files = [pemFile(`${base}-chain.pem`, issuers)]; - } else { - return certError('formatNotPossible', 400, 'Output not available'); + switch (input.content) { + case 'leaf': + if (format === 'der') files = [file(`${base}.cer`, derOf(head), MIME.der)]; + else files = [certListFile(base, `${base}.crt`, format, [head])]; + break; + case 'fullchain': + files = [certListFile(`${base}-fullchain`, `${base}-fullchain.pem`, format, shown)]; + break; + case 'chain': { + const issuers = shown.filter((c) => c.id !== head.id); + if (issuers.length === 0) { + certError('noChain', 400, 'No intermediate or root certificate available'); + } + files = [certListFile(`${base}-chain`, `${base}-chain.pem`, format, issuers)]; + break; + } + case 'leafKey': { + const key = matchingKey(head, input.keyPem); + const certs = input.includeChain === false ? [head] : shown; + const keyPem = key.export({ type: 'pkcs8', format: 'pem' }) as string; + files = [file(`${base}-bundle.pem`, joinPem(certs) + keyPem, MIME.pem)]; + break; + } + case 'pfx': { + if (!input.password) certError('passwordRequired', 400, 'A password is required'); + const key = input.keyPem ? matchingKey(head, input.keyPem) : null; + const der = writePkcs12({ + keyObject: key, + certDers: shown.map(derOf), + password: input.password, + profile: input.pfxEncryption === 'modern' ? 'modern' : 'compat', + friendlyName: base, + }); + files = [file(`${base}.pfx`, der, MIME.pfx)]; + break; + } + default: + return certError('invalidInput', 400, 'Unknown content'); } return { @@ -80,3 +218,38 @@ export function buildOutput(input: BuildInput): BuildResult { missingIssuerCn: chain.gap?.missingIssuerCn ?? null, }; } + +/** Liest den Schluessel und prueft, dass er zum Serverzertifikat gehoert (sonst keyMismatch). */ +function matchingKey(head: CertItem, keyPem: string | undefined): KeyObject { + const key = parseKey(keyPem); + if (!new X509Certificate(head.pem).checkPrivateKey(key)) { + certError('keyMismatch', 400, 'The key does not belong to the certificate'); + } + return key; +} + +function buildKey(input: BuildInput, format: KeyExportFormat): BuildResult { + const key = parseKey(input.keyPem); + const base = safeBaseName(input.baseName ?? '', 'schluessel'); + const data = exportKey(key, format, input.password || undefined); + let filename: string; + let mime: string = MIME.pem; + if (format === 'pkcs8') filename = `${base}.key`; + else if (format === 'traditional') { + filename = key.asymmetricKeyType === 'rsa' ? `${base}.rsa.key` : `${base}.ec.key`; + } else { + filename = `${base}.key.der`; + mime = MIME.binary; + } + return { files: [file(filename, data, mime)], chainComplete: true, missingIssuerCn: null }; +} + +function buildCsr(input: BuildInput, format: string): BuildResult { + const { der, item } = parseCsr(input.csrPem); + const base = safeBaseName(input.baseName ?? '', item.baseName); + const out = + format === 'der' + ? file(`${base}.csr.der`, der, MIME.csr) + : file(`${base}.csr`, item.pem, MIME.csr); + return { files: [out], chainComplete: true, missingIssuerCn: null }; +} diff --git a/apps/api/src/cert-manager/cert-pkcs12.spec.ts b/apps/api/src/cert-manager/cert-pkcs12.spec.ts index 77fea55..7eadd20 100644 --- a/apps/api/src/cert-manager/cert-pkcs12.spec.ts +++ b/apps/api/src/cert-manager/cert-pkcs12.spec.ts @@ -1,10 +1,11 @@ -import { X509Certificate } from 'node:crypto'; +import { createPrivateKey, X509Certificate } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import * as forge from 'node-forge'; +import { describe, expect, it, vi } from 'vitest'; import { candidatePasswords, keyIdOf } from './cert-keys'; import { detectBlob } from './cert-model'; -import { isPkcs12Der, readPkcs12 } from './cert-pkcs12'; +import { isPkcs12Der, readPkcs12, writePkcs12 } from './cert-pkcs12'; import type { CertItem, KeyItem } from './cert-types'; const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); @@ -110,3 +111,184 @@ describe('detectBlob: PKCS#12', () => { expect(JSON.stringify(detect('rsa-modern.pfx', PASSWORD))).not.toContain(PASSWORD); }); }); + +describe('writePkcs12', () => { + const NEW_PASSWORD = 'Neu-Pass-2026'; + const OID_3DES = '1.2.840.113549.1.12.1.3'; + const OID_PBES2 = '1.2.840.113549.1.5.13'; + const OID_AES256 = '2.16.840.1.101.3.4.1.42'; + const OID_LOCAL_KEY_ID = '1.2.840.113549.1.9.21'; + + const asn1Of = (der: Buffer) => + forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), { + strict: false, + } as unknown as boolean); + const kids = (n: forge.asn1.Asn1) => n.value as forge.asn1.Asn1[]; + const oidOf = (n: forge.asn1.Asn1) => forge.asn1.derToOid(n.value as string); + + /** Alle SafeBags im Container (Zertifikate unverschluesselt, Schluessel im selben Aufbau). */ + function safeBags(der: Buffer): forge.asn1.Asn1[] { + const authSafe = kids(kids(asn1Of(der))[1])[1]; // ContentInfo -> [0] explicit + const inner = kids(authSafe)[0]; // OCTET STRING mit AuthenticatedSafe + const contentInfos = kids(forge.asn1.fromDer(forge.util.createBuffer(inner.value as string))); + const bags: forge.asn1.Asn1[] = []; + for (const ci of contentInfos) { + const octets = kids(kids(ci)[1])[0].value as string; + bags.push(...kids(forge.asn1.fromDer(forge.util.createBuffer(octets)))); + } + return bags; + } + + const keyBagOf = (der: Buffer) => + safeBags(der).find( + (b) => oidOf(kids(b)[0]) === forge.pki.oids.pkcs8ShroudedKeyBag, + ) as forge.asn1.Asn1; + + function localKeyId(bag: forge.asn1.Asn1): string { + const attrs = kids(bag)[2]; + for (const attr of kids(attrs)) { + if (oidOf(kids(attr)[0]) === OID_LOCAL_KEY_ID) { + return Buffer.from(kids(kids(attr)[1])[0].value as string, 'binary').toString('hex'); + } + } + return ''; + } + + const certDers = (names: string[]) => names.map((n) => new X509Certificate(fx(n)).raw); + + it.each([ + ['rsa', 'rsa-leaf-key.pem', ['rsa-leaf.pem', 'rsa-inter.pem']], + ['ec', 'ec-leaf-key.pem', ['ec-leaf.pem', 'ec-inter.pem']], + ] as const)('%s: Round-Trip ueber readPkcs12 in beiden Profilen', (_type, keyFile, certFiles) => { + for (const profile of ['compat', 'modern'] as const) { + const der = writePkcs12({ + keyObject: createPrivateKey(fx(keyFile)), + certDers: certDers([...certFiles]), + password: NEW_PASSWORD, + profile, + friendlyName: 'server', + }); + expect(isPkcs12Der(der)).toBe(true); + const read = readPkcs12(der, [], NEW_PASSWORD); + expect(read.ok).toBe(true); + if (!read.ok) return; + const fingerprints = read.contents.certDers.map((d) => new X509Certificate(d).fingerprint256); + expect(fingerprints).toEqual(certFiles.map(sha)); // Serverzertifikat zuerst + expect(read.contents.keys).toHaveLength(1); + expect(new X509Certificate(fx(certFiles[0])).checkPrivateKey(read.contents.keys[0].key)).toBe( + true, + ); + // falsches Passwort oeffnet nicht + expect(readPkcs12(der, [], 'falsch')).toMatchObject({ ok: false, reason: 'passwordWrong' }); + } + }); + + it('kompatibel: Schluesselbeutel mit pbeWithSHA1And3-KeyTripleDES-CBC', () => { + const der = writePkcs12({ + keyObject: createPrivateKey(fx('ec-leaf-key.pem')), + certDers: certDers(['ec-leaf.pem']), + password: NEW_PASSWORD, + profile: 'compat', + friendlyName: 'server', + }); + const algorithm = kids(kids(kids(keyBagOf(der))[1])[0])[0]; + expect(oidOf(kids(algorithm)[0])).toBe(OID_3DES); + }); + + it('modern: Schluesselbeutel mit PBES2 und AES-256-CBC', () => { + const der = writePkcs12({ + keyObject: createPrivateKey(fx('rsa-leaf-key.pem')), + certDers: certDers(['rsa-leaf.pem']), + password: NEW_PASSWORD, + profile: 'modern', + friendlyName: 'server', + }); + const algorithm = kids(kids(kids(keyBagOf(der))[1])[0])[0]; + expect(oidOf(kids(algorithm)[0])).toBe(OID_PBES2); + // PBES2-Parameter: { keyDerivation, encryptionScheme { AES-256-CBC, iv } } + const encryptionScheme = kids(kids(algorithm)[1])[1]; + expect(oidOf(kids(encryptionScheme)[0])).toBe(OID_AES256); + }); + + it('Schluesselbeutel und Serverzertifikat tragen dieselbe localKeyId', () => { + const der = writePkcs12({ + keyObject: createPrivateKey(fx('ec-leaf-key.pem')), + certDers: certDers(['ec-leaf.pem', 'ec-inter.pem']), + password: NEW_PASSWORD, + profile: 'compat', + friendlyName: 'server', + }); + const bags = safeBags(der); + const certBags = bags.filter((b) => oidOf(kids(b)[0]) === forge.pki.oids.certBag); + expect(certBags).toHaveLength(2); + const keyId = localKeyId(keyBagOf(der)); + expect(keyId).toMatch(/^[0-9a-f]{40}$/); + expect(localKeyId(certBags[0])).toBe(keyId); + }); + + it('ohne Schluessel: nur Zertifikate', () => { + const der = writePkcs12({ + keyObject: null, + certDers: certDers(['rsa-leaf.pem', 'rsa-inter.pem']), + password: NEW_PASSWORD, + profile: 'compat', + friendlyName: 'server', + }); + const read = readPkcs12(der, [], NEW_PASSWORD); + expect(read.ok && read.contents.certDers).toHaveLength(2); + expect(read.ok && read.contents.keys).toHaveLength(0); + }); + + it('forge ist danach unveraendert (nach Erfolg und nach einem Fehler mittendrin)', () => { + const pki = forge.pki as unknown as Record; + const before = { + privateKeyToAsn1: pki.privateKeyToAsn1, + wrapRsaPrivateKey: pki.wrapRsaPrivateKey, + certificateToAsn1: pki.certificateToAsn1, + }; + writePkcs12({ + keyObject: createPrivateKey(fx('rsa-leaf-key.pem')), + certDers: certDers(['rsa-leaf.pem']), + password: NEW_PASSWORD, + profile: 'compat', + friendlyName: 'server', + }); + expect(pki.privateKeyToAsn1).toBe(before.privateKeyToAsn1); + expect(pki.wrapRsaPrivateKey).toBe(before.wrapRsaPrivateKey); + expect(pki.certificateToAsn1).toBe(before.certificateToAsn1); + // forge scheitert mitten im Schreiben, waehrend die Durchreicher eingesetzt sind + let seenDuring: unknown; + const spy = vi.spyOn(forge.pkcs12, 'toPkcs12Asn1').mockImplementation(() => { + seenDuring = pki.certificateToAsn1; + throw new Error('boom'); + }); + try { + expect(() => + writePkcs12({ + keyObject: null, + certDers: certDers(['rsa-leaf.pem']), + password: NEW_PASSWORD, + profile: 'compat', + friendlyName: 'server', + }), + ).toThrow('boom'); + } finally { + spy.mockRestore(); + } + expect(seenDuring).not.toBe(before.certificateToAsn1); // der Durchreicher war wirklich eingesetzt + expect(pki.privateKeyToAsn1).toBe(before.privateKeyToAsn1); + expect(pki.wrapRsaPrivateKey).toBe(before.wrapRsaPrivateKey); + expect(pki.certificateToAsn1).toBe(before.certificateToAsn1); + }); + + it('das Passwort steht nicht im Container', () => { + const der = writePkcs12({ + keyObject: createPrivateKey(fx('rsa-leaf-key.pem')), + certDers: certDers(['rsa-leaf.pem']), + password: NEW_PASSWORD, + profile: 'modern', + friendlyName: 'server', + }); + expect(der.includes(Buffer.from(NEW_PASSWORD))).toBe(false); + }); +}); diff --git a/apps/api/src/cert-manager/cert-pkcs12.ts b/apps/api/src/cert-manager/cert-pkcs12.ts index 7ebe9b3..9582e00 100644 --- a/apps/api/src/cert-manager/cert-pkcs12.ts +++ b/apps/api/src/cert-manager/cert-pkcs12.ts @@ -122,3 +122,75 @@ export function readPkcs12(der: Buffer, passwords: string[], ownPassword = ''): if (!passwordProblem) throw lastError; return { ok: false, reason: ownPassword ? 'passwordWrong' : 'passwordNeeded' }; } + +export type Pkcs12Profile = 'compat' | 'modern'; + +export interface WritePkcs12Options { + /** Schluessel zum Serverzertifikat; ohne Schluessel entsteht ein Container nur mit Zertifikaten */ + keyObject: KeyObject | null; + /** Zertifikate als DER, Serverzertifikat zuerst */ + certDers: Buffer[]; + password: string; + /** compat = 3DES/SHA-1 (auch aeltere Windows-Server), modern = AES-256 */ + profile: Pkcs12Profile; + friendlyName: string; +} + +/** Fuer BMPString (UTF-16, big endian) als forge-Bytefolge. */ +function bmpString(text: string): string { + return Buffer.from(text, 'utf16le').swap16().toString('binary'); +} + +/** + * Schreibt einen PKCS#12-Container (D-07, D-20). + * + * forge schreibt von sich aus nur RSA: `toPkcs12Asn1` ruft `pki.privateKeyToAsn1`, + * `pki.wrapRsaPrivateKey` und `pki.certificateToAsn1` auf RSA-Objekten auf. Damit auch EC-Zertifikate + * und -Schluessel hinein koennen, werden diese drei Funktionen fuer die Dauer EINES synchronen + * Aufrufs durch Durchreicher ersetzt: sie geben das vorbereitete ASN.1 (Zertifikat aus dem DER, + * Schluessel als PKCS#8 aus node:crypto) unveraendert zurueck. Die Verschluesselung (PBE, MAC) + * macht weiterhin forge selbst. Die Originale stehen im `finally` wieder an ihrem Platz; weil Node + * einfaedig ist und der Aufruf synchron laeuft, kann kein anderer Aufrufer den Austausch sehen. + * + * compat = `3des` (pbeWithSHA1And3-KeyTripleDES-CBC, HMAC-SHA1), Windows Server 2016 und aelter lesen es. + * modern = `aes256` (PBES2/PBKDF2/AES-256-CBC fuer den Schluessel; forge bleibt beim SHA-1-MAC). + */ +export function writePkcs12(options: WritePkcs12Options): Buffer { + if (!options.password) throw new Error('A password is required'); + const parse = (der: Buffer) => + forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), { + decodeBitStrings: false, + } as unknown as boolean); + const keyAsn1 = options.keyObject + ? parse(options.keyObject.export({ type: 'pkcs8', format: 'der' })) + : null; + const certs = options.certDers.map((der) => ({ asn1: parse(der) })); + + // `forge.pki` ist ein gewoehnliches, beschreibbares Objekt; die Typen kennen die Zuweisung nicht. + const pki = forge.pki as unknown as Record; + const original = { + privateKeyToAsn1: pki.privateKeyToAsn1, + wrapRsaPrivateKey: pki.wrapRsaPrivateKey, + certificateToAsn1: pki.certificateToAsn1, + }; + try { + pki.privateKeyToAsn1 = (wrapped: { asn1: forge.asn1.Asn1 }) => wrapped.asn1; + pki.wrapRsaPrivateKey = (asn1: forge.asn1.Asn1) => asn1; + pki.certificateToAsn1 = (wrapped: { asn1: forge.asn1.Asn1 }) => wrapped.asn1; + const p12 = forge.pkcs12.toPkcs12Asn1( + (keyAsn1 ? { asn1: keyAsn1 } : null) as unknown as forge.pki.rsa.PrivateKey | null, + certs as unknown as forge.pki.Certificate[], + options.password, + { + algorithm: options.profile === 'modern' ? 'aes256' : '3des', + friendlyName: bmpString(options.friendlyName), + generateLocalKeyId: true, + }, + ); + return toBuffer(p12); + } finally { + pki.privateKeyToAsn1 = original.privateKeyToAsn1; + pki.wrapRsaPrivateKey = original.wrapRsaPrivateKey; + pki.certificateToAsn1 = original.certificateToAsn1; + } +} 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 1bad171..7c75428 100644 --- a/apps/api/src/cert-manager/dto/cert-build.dto.ts +++ b/apps/api/src/cert-manager/dto/cert-build.dto.ts @@ -16,16 +16,33 @@ import { * + password 256 + baseName 120 + JSON-Maskierung der Zeilenumbrueche (etwa +1,6 %) * = rund 384 kB, also deutlich unter dem Grenzwert von 512 KiB. * - * Task 2 kennt nur Fullchain und Nur Kette als PEM; weitere Inhalte, Formate und Felder - * (Schluessel, Passwort, Vorlage) folgen in Task 5 und 6. + * Stand Task 5: alle Inhalte und Formate aus D-19; die Vorlage (template) folgt in Task 6. */ export const CERT_PEM_MAX = 16_384; export const CERT_POOL_MAX = 20; export const CERT_PASSWORD_MAX = 256; export const CERT_BASENAME_MAX = 120; -export const BUILD_CONTENTS = ['fullchain', 'chain'] as const; -export const BUILD_FORMATS = ['pem'] as const; +export const BUILD_CONTENTS = [ + 'leaf', + 'fullchain', + 'chain', + 'leafKey', + 'pfx', + 'key', + 'csr', +] as const; +export const BUILD_FORMATS = [ + 'pem', + 'der', + 'p7b', + 'p7c', + 'pfx', + 'pkcs8', + 'traditional', + 'pkcs8-der', +] as const; +export const BUILD_PFX_ENCRYPTIONS = ['compat', 'modern'] as const; export class BuildOutputDto { @IsIn(BUILD_CONTENTS) @@ -47,10 +64,33 @@ export class BuildOutputDto { @MaxLength(CERT_PEM_MAX, { each: true }) poolPems?: string[]; + @IsOptional() + @IsString() + @MaxLength(CERT_PEM_MAX) + keyPem?: string; + + @IsOptional() + @IsString() + @MaxLength(CERT_PEM_MAX) + csrPem?: string; + @IsOptional() @IsBoolean() includeRoot?: boolean; + @IsOptional() + @IsBoolean() + includeChain?: boolean; + + @IsOptional() + @IsString() + @MaxLength(CERT_PASSWORD_MAX) + password?: string; + + @IsOptional() + @IsIn(BUILD_PFX_ENCRYPTIONS) + pfxEncryption?: (typeof BUILD_PFX_ENCRYPTIONS)[number]; + @IsOptional() @IsString() @MaxLength(CERT_BASENAME_MAX) 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 c255d1e..a49d76a 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', () => { + it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen, Konvertieren', () => { render(); expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument(); const nav = screen.getByRole('navigation'); @@ -53,6 +53,7 @@ describe('CertManagerPage', () => { 'Analysieren', 'Aufteilen', 'Zusammenführen', + 'Konvertieren', ]); expect(tabs[0]).toHaveAttribute('aria-current', 'page'); for (const tab of tabs.slice(1)) expect(tab).not.toHaveAttribute('aria-current'); @@ -92,6 +93,7 @@ describe('CertManagerPage', () => { it.each([ 'Analysieren', 'Aufteilen', + 'Konvertieren', ])('%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/ConvertTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx new file mode 100644 index 0000000..a750387 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.test.tsx @@ -0,0 +1,212 @@ +import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import de from '@/messages/de.json'; +import type { AnalysisResult, AnyItem, CertItem, CsrItem, KeyItem } from '../actions'; +import { CertManagerRequestError } from '../actions'; +import type { CertWorkspace } from '../use-cert-workspace'; +import { ConvertTab } from './ConvertTab'; + +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} + , + ); +} + +const leaf: CertItem = { + id: 'c-leaf', + kind: 'certificate', + role: 'end-entity', + sources: [{ file: 0, path: 'leaf.pem' }], + pem: 'PEM-LEAF', + baseName: 'www.example.test', + cn: 'www.example.test', + organization: '', + issuerCn: 'Test Inter', + 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: false, + selfSigned: false, + aiaIssuerUrls: [], + keyId: 'k-1', + csrIds: [], +}; +const key: KeyItem = { + id: 'k-1', + kind: 'privateKey', + sources: [{ file: 1, path: 'leaf-key.pem' }], + pem: 'PEM-KEY', + baseName: 'schluessel', + keyType: 'RSA', + keyBits: 2048, + curve: null, + wasEncrypted: false, + certIds: ['c-leaf'], +}; +const csr: CsrItem = { + id: 'r-1', + kind: 'csr', + sources: [{ file: 2, path: 'leaf.csr' }], + pem: 'PEM-CSR', + baseName: 'anfrage-www', + cn: 'www.example.test', + organization: '', + san: [], + keyType: 'RSA', + keyBits: 2048, + curve: null, + keyId: 'k-1', + certIds: ['c-leaf'], +}; + +function workspace(items: AnyItem[]): CertWorkspace { + const analysis: AnalysisResult = { items, chains: [], locked: [], ignored: [] }; + return { + entries: [], + analysis, + analysisIds: [], + status: 'idle', + errorKey: null, + addFiles: () => [], + addText: () => null, + setPassword: () => {}, + remove: () => {}, + clear: () => {}, + retry: () => {}, + }; +} + +beforeEach(() => { + mockBuild.mockReset(); + mockDownload.mockReset(); + mockBuild.mockResolvedValue({ + files: [{ filename: 'out.cer', content: 'QQ==', mimeType: 'application/pkix-cert' }], + chainComplete: true, + missingIssuerCn: null, + }); +}); +afterEach(cleanup); + +const select = (name: string) => screen.getByRole('combobox', { name }); +const optionNames = (name: string) => + Array.from(select(name).querySelectorAll('option')).map((o) => o.textContent); + +describe('ConvertTab', () => { + it('fasst die Teile nach Art zusammen: Zertifikate, Schluessel, Anfragen', () => { + render(); + const groups = Array.from( + select('Was soll umgewandelt werden?').querySelectorAll('optgroup'), + ).map((g) => g.label); + expect(groups).toEqual(['Zertifikate', 'Private Schlüssel', 'Zertifikatsanfragen']); + }); + + it('Zertifikat: vier Formate, Download mit genau Inhalt, Format und PEM', async () => { + render(); + expect(optionNames('Zielformat')).toEqual([ + 'PEM (.crt)', + 'DER (.cer)', + 'PKCS#7 (.p7b)', + 'PKCS#7, binär (.p7c)', + ]); + fireEvent.change(select('Zielformat'), { target: { value: 'der' } }); + fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' })); + await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1)); + expect(mockBuild).toHaveBeenCalledWith({ content: 'leaf', format: 'der', certPem: 'PEM-LEAF' }); + expect(mockDownload).toHaveBeenCalledWith('out.cer', 'QQ==', 'application/pkix-cert'); + }); + + it('Schluessel: drei Formate, kein Passwort ohne Haken, Dateiname nach dem Zertifikat', async () => { + render(); + fireEvent.change(select('Was soll umgewandelt werden?'), { target: { value: 'k-1' } }); + expect(optionNames('Zielformat')).toEqual([ + 'PKCS#8 (.key)', + 'Traditionell (PKCS#1 für RSA, SEC1 für EC)', + 'PKCS#8, binär (.key.der)', + ]); + fireEvent.change(select('Zielformat'), { target: { value: 'traditional' } }); + fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' })); + await waitFor(() => expect(mockBuild).toHaveBeenCalled()); + expect(mockBuild).toHaveBeenCalledWith({ + content: 'key', + format: 'traditional', + keyPem: 'PEM-KEY', + baseName: 'www.example.test', + }); + expect(mockBuild.mock.calls[0][0]).not.toHaveProperty('password'); + }); + + it('Schluessel mit Passwort schuetzen: ohne Passwort gesperrt, mit Passwort gesendet', async () => { + render(); + fireEvent.click(screen.getByRole('checkbox', { name: 'Schlüssel mit Passwort schützen' })); + const button = screen.getByRole('button', { name: 'Herunterladen' }); + expect(button).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Passwort für den Schlüssel'), { + target: { value: 'Neu-Pass-2026' }, + }); + expect(button).toBeEnabled(); + fireEvent.click(button); + await waitFor(() => expect(mockBuild).toHaveBeenCalled()); + expect(mockBuild).toHaveBeenCalledWith({ + content: 'key', + format: 'pkcs8', + keyPem: 'PEM-KEY', + baseName: undefined, + password: 'Neu-Pass-2026', + }); + }); + + it('Anfrage: PEM und DER, die Anfrage geht in csrPem', async () => { + render(); + expect(optionNames('Zielformat')).toEqual(['PEM (.csr)', 'DER (.csr.der)']); + fireEvent.change(select('Zielformat'), { target: { value: 'der' } }); + fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' })); + await waitFor(() => expect(mockBuild).toHaveBeenCalled()); + expect(mockBuild).toHaveBeenCalledWith({ + content: 'csr', + format: 'der', + csrPem: 'PEM-CSR', + baseName: 'anfrage-www', + }); + }); + + it('ein Fehlercode der API zeigt den deutschen Text und laedt nichts herunter', async () => { + mockBuild.mockRejectedValueOnce(new CertManagerRequestError(400, 'formatNotPossible')); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Dieses Format ist für den Schlüsseltyp nicht möglich', + ); + expect(mockDownload).not.toHaveBeenCalled(); + }); + + it('ohne erkannte Teile steht ein Hinweis', () => { + render(); + expect(screen.getByText(/noch nichts erkannt/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Herunterladen' })).toBeNull(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx new file mode 100644 index 0000000..bd30c85 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { + type AnyItem, + buildOutput, + type CertItem, + type CsrItem, + certErrorKey, + downloadBase64, + type KeyItem, +} from '../actions'; +import type { CertWorkspace } from '../use-cert-workspace'; +import { PasswordInput } from './PasswordInput'; + +interface ConvertTabProps { + workspace: CertWorkspace; +} + +/** Formate je Art; die Kennung geht unveraendert an die API, der Text steht unter convert.formats.. */ +const FORMATS = { + certificate: [ + { id: 'pem', labelKey: 'certPem' }, + { id: 'der', labelKey: 'certDer' }, + { id: 'p7b', labelKey: 'p7b' }, + { id: 'p7c', labelKey: 'p7c' }, + ], + privateKey: [ + { id: 'pkcs8', labelKey: 'pkcs8' }, + { id: 'traditional', labelKey: 'traditional' }, + { id: 'pkcs8-der', labelKey: 'pkcs8Der' }, + ], + csr: [ + { id: 'pem', labelKey: 'csrPem' }, + { id: 'der', labelKey: 'csrDer' }, + ], +} as const; + +function nameOf(item: AnyItem): string { + return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName; +} + +/** + * Reiter „Konvertieren“: ein einzelnes Teil des Arbeitsbereichs (Zertifikat, privater Schluessel oder + * Zertifikatsanfrage) in ein anderes Format umwandeln. Die API baut die Datei; der Schluessel wird nur + * fuer diese Anfrage gesendet und nirgends gespeichert. Das Passwort wird nur gesendet, wenn der + * Schluessel damit geschuetzt werden soll. + */ +export function ConvertTab({ workspace }: ConvertTabProps) { + const t = useTranslations('certManager'); + const [selectedId, setSelectedId] = useState(null); + const [formats, setFormats] = useState>({}); + const [protect, setProtect] = useState(false); + const [keyPassword, setKeyPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [errorKey, setErrorKey] = useState(null); + + const items = workspace.analysis?.items ?? []; + const certs = items.filter((i): i is CertItem => i.kind === 'certificate'); + const keys = items.filter((i): i is KeyItem => i.kind === 'privateKey'); + const csrs = items.filter((i): i is CsrItem => i.kind === 'csr'); + + if (!workspace.analysis) { + return workspace.status === 'analyzing' ? ( +

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

+ ) : null; + } + if (items.length === 0) { + return

{t('convert.nothingFound')}

; + } + + const item = items.find((i) => i.id === selectedId) ?? items[0]; + const options = FORMATS[item.kind]; + const format = options.find((o) => o.id === formats[item.id])?.id ?? options[0].id; + const isKey = item.kind === 'privateKey'; + const needsPassword = isKey && protect; + const ready = !needsPassword || keyPassword !== ''; + + const keyDescription = (key: KeyItem) => + key.keyType === 'RSA' + ? t('analyze.keyRsa', { bits: key.keyBits ?? 0 }) + : key.keyType === 'EC' + ? t('analyze.keyEc', { curve: key.curve ?? '' }) + : key.keyType; + + const convert = async () => { + setBusy(true); + setErrorKey(null); + try { + let request: Parameters[0]; + if (item.kind === 'certificate') { + request = { content: 'leaf', format, certPem: item.pem }; + } else if (item.kind === 'csr') { + request = { content: 'csr', format, csrPem: item.pem, baseName: item.baseName }; + } else { + // Dateiname nach dem zugehoerigen Zertifikat, wenn es eines gibt + const owner = certs.find((c) => item.certIds.includes(c.id)); + request = { + content: 'key', + format, + keyPem: item.pem, + ...(owner ? { baseName: owner.baseName } : {}), + ...(protect ? { password: keyPassword } : {}), + }; + } + const result = await buildOutput(request); + for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType); + } catch (error) { + setErrorKey(certErrorKey(error)); + } finally { + setBusy(false); + } + }; + + return ( +
+

{t('convert.intro')}

+ +
+ + +
+ +
+ + +
+ + {isKey && ( +
+

{t('convert.keyNote')}

+ + {protect && ( + + )} +
+ )} + + + + {errorKey && ( +

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

+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx index 30d435d..a7ae816 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx @@ -3,7 +3,7 @@ import { NextIntlClientProvider } from 'next-intl'; import type { ReactElement } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import de from '@/messages/de.json'; -import type { AnalysisResult, CertItem, ChainInfo } from '../actions'; +import type { AnalysisResult, CertItem, ChainInfo, KeyItem } from '../actions'; import { CertManagerRequestError } from '../actions'; import type { CertWorkspace } from '../use-cert-workspace'; import { MergeTab } from './MergeTab'; @@ -68,7 +68,7 @@ function chain(over: Partial & Pick): C return { rootId: null, complete: false, gap: null, alternatives: 0, ...over }; } -function workspace(items: CertItem[], chains: ChainInfo[]): CertWorkspace { +function workspace(items: (CertItem | KeyItem)[], chains: ChainInfo[]): CertWorkspace { const analysis: AnalysisResult = { items, chains, locked: [], ignored: [] }; return { entries: [], @@ -218,4 +218,127 @@ describe('MergeTab', () => { expect(screen.getByText(/noch kein Zertifikat erkannt/)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Fullchain herunterladen' })).toBeNull(); }); + + it('Format der Kette: PEM, PKCS#7 (.p7b) und PKCS#7 binaer (.p7c)', async () => { + render(); + const select = screen.getByRole('combobox', { name: 'Format der Kette' }); + expect(Array.from(select.querySelectorAll('option')).map((o) => o.textContent)).toEqual([ + 'PEM (.pem)', + 'PKCS#7 (.p7b)', + 'PKCS#7, binär (.p7c)', + ]); + fireEvent.change(select, { target: { value: 'p7c' } }); + fireEvent.click(screen.getByRole('button', { name: 'Nur Kette herunterladen' })); + await waitFor(() => expect(mockBuild).toHaveBeenCalled()); + expect(mockBuild.mock.calls[0][0]).toMatchObject({ content: 'chain', format: 'p7c' }); + }); +}); + +describe('MergeTab: Zertifikat und Schluessel, PFX', () => { + 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 keyed = { ...leaf, keyId: 'k-leaf' }; + + it('ohne passenden Schluessel ist der Bundle-Knopf gesperrt und der Grund steht da', () => { + render(); + expect( + screen.getByRole('button', { name: 'Zertifikat und Schlüssel (eine PEM-Datei)' }), + ).toBeDisabled(); + expect(screen.getByText(/kein passender privater Schlüssel/)).toBeInTheDocument(); + }); + + it('mit Schluessel: ein Aufruf mit leafKey und dem Schluessel', async () => { + render(); + const button = screen.getByRole('button', { + name: 'Zertifikat und Schlüssel (eine PEM-Datei)', + }); + expect(button).toBeEnabled(); + fireEvent.click(button); + await waitFor(() => expect(mockBuild).toHaveBeenCalled()); + expect(mockBuild).toHaveBeenCalledWith({ + content: 'leafKey', + format: 'pem', + certPem: 'PEM-c-leaf', + poolPems: ['PEM-c-inter', 'PEM-c-root'], + includeRoot: false, + baseName: 'www.example.test', + keyPem: 'PEM-KEY', + }); + }); + + it('PFX: Standard ist Kompatibel, ohne Passwort gesperrt, bei ungleicher Wiederholung gesperrt', () => { + render(); + const encryption = screen.getByRole('combobox', { name: 'Verschlüsselung' }); + expect((encryption as HTMLSelectElement).value).toBe('compat'); + expect(Array.from(encryption.querySelectorAll('option')).map((o) => o.textContent)).toEqual([ + 'Kompatibel (auch ältere Windows-Server)', + 'Modern (AES-256)', + ]); + expect(screen.getByText(/Windows Server 2016/)).toBeInTheDocument(); + const download = screen.getByRole('button', { name: 'PFX-Datei herunterladen' }); + expect(download).toBeDisabled(); + fireEvent.change(screen.getByLabelText('PFX-Passwort'), { target: { value: 'Neu-Pass-2026' } }); + fireEvent.change(screen.getByLabelText('Passwort wiederholen'), { + target: { value: 'anders' }, + }); + expect(screen.getByText('Die beiden Passwörter stimmen nicht überein.')).toBeInTheDocument(); + expect(download).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Passwort wiederholen'), { + target: { value: 'Neu-Pass-2026' }, + }); + expect(download).toBeEnabled(); + }); + + it('PFX herunterladen: Inhalt pfx mit Schluessel, Passwort und gewaehlter Verschluesselung', async () => { + render(); + fireEvent.change(screen.getByRole('combobox', { name: 'Verschlüsselung' }), { + target: { value: 'modern' }, + }); + fireEvent.change(screen.getByLabelText('PFX-Passwort'), { target: { value: 'Neu-Pass-2026' } }); + fireEvent.change(screen.getByLabelText('Passwort wiederholen'), { + target: { value: 'Neu-Pass-2026' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'PFX-Datei herunterladen' })); + await waitFor(() => expect(mockBuild).toHaveBeenCalled()); + expect(mockBuild).toHaveBeenCalledWith({ + content: 'pfx', + certPem: 'PEM-c-leaf', + poolPems: ['PEM-c-inter', 'PEM-c-root'], + includeRoot: false, + baseName: 'www.example.test', + keyPem: 'PEM-KEY', + password: 'Neu-Pass-2026', + pfxEncryption: 'modern', + }); + expect(mockDownload).toHaveBeenCalledTimes(1); + }); + + it('PFX ohne Schluessel: der Hinweis steht da und keyPem fehlt in der Anfrage', async () => { + render(); + expect(screen.getByText(/enthält die PFX-Datei nur die Zertifikate/)).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('PFX-Passwort'), { target: { value: 'pw' } }); + fireEvent.change(screen.getByLabelText('Passwort wiederholen'), { target: { value: 'pw' } }); + fireEvent.click(screen.getByRole('button', { name: 'PFX-Datei herunterladen' })); + await waitFor(() => expect(mockBuild).toHaveBeenCalled()); + expect(mockBuild.mock.calls[0][0]).not.toHaveProperty('keyPem'); + expect(mockBuild.mock.calls[0][0]).toMatchObject({ content: 'pfx', pfxEncryption: 'compat' }); + }); + + it('das Passwort erscheint nirgends im dargestellten Text', () => { + const { container } = render( + , + ); + fireEvent.change(screen.getByLabelText('PFX-Passwort'), { target: { value: 'Neu-Pass-2026' } }); + expect(container.textContent).not.toContain('Neu-Pass-2026'); + }); }); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx index c83e2eb..610d13f 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx @@ -8,9 +8,14 @@ import { type CertItem, certErrorKey, downloadBase64, + type KeyItem, } from '../actions'; import type { CertWorkspace } from '../use-cert-workspace'; import { ChainView } from './ChainView'; +import { type PfxEncryption, PfxOptions, pfxPasswordReady } from './PfxOptions'; + +const CHAIN_FORMATS = ['pem', 'p7b', 'p7c'] as const; +type ChainFormat = (typeof CHAIN_FORMATS)[number]; interface MergeTabProps { workspace: CertWorkspace; @@ -18,19 +23,28 @@ interface MergeTabProps { /** * Reiter „Zusammenführen“: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst - * (die API prueft Aussteller und Signatur) und liefert Fullchain oder Nur Kette. Das - * Stammzertifikat kommt nur mit, wenn es angehakt ist (D-02, Vorgabe aus). + * (die API prueft Aussteller und Signatur) und liefert Fullchain oder Nur Kette (PEM, .p7b, .p7c), + * Zertifikat und Schluessel in einer PEM-Datei oder eine PFX-Datei. Das Stammzertifikat kommt nur + * mit, wenn es angehakt ist (D-02, Vorgabe aus). Die PFX-Passwoerter bleiben im Zustand dieses + * Reiters und gehen nur in die Anfrage. */ export function MergeTab({ workspace }: MergeTabProps) { const t = useTranslations('certManager'); const [selectedHead, setSelectedHead] = useState(null); const [includeRoot, setIncludeRoot] = useState(false); + const [format, setFormat] = useState('pem'); + const [pfxPassword, setPfxPassword] = useState(''); + const [pfxRepeat, setPfxRepeat] = useState(''); + const [pfxEncryption, setPfxEncryption] = useState('compat'); const [busy, setBusy] = useState(null); const [errorKey, setErrorKey] = useState(null); 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) { @@ -54,19 +68,32 @@ export function MergeTab({ workspace }: MergeTabProps) { (c) => c.id !== chain.headId && (includeRoot || c.id !== chain.rootId), ); - const download = async (content: 'fullchain' | 'chain') => { + const key = head?.keyId ? keys.find((k) => k.id === head.keyId) : undefined; + + const download = async (content: BuildContent) => { if (!head) return; setBusy(content); setErrorKey(null); try { - const result = await buildOutput({ - content, - format: 'pem', + const base = { certPem: head.pem, poolPems: members.filter((c) => c.id !== head.id).map((c) => c.pem), includeRoot, baseName: head.baseName, - }); + }; + const result = await buildOutput( + content === 'leafKey' + ? { content, format: 'pem', ...base, keyPem: key?.pem } + : content === 'pfx' + ? { + content, + ...base, + ...(key ? { keyPem: key.pem } : {}), + password: pfxPassword, + pfxEncryption, + } + : { content, format, ...base }, + ); for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType); } catch (error) { setErrorKey(certErrorKey(error)); @@ -116,6 +143,24 @@ export function MergeTab({ workspace }: MergeTabProps) { )} +
+ + +
+
+ {!key &&

{t('merge.bundleNeedsKey')}

} + + +
+

{t('merge.pfxTitle')}

+

{t('merge.pfxIntro')}

+ + {!key &&

{t('merge.pfxNoKey')}

} + +
+ {errorKey && (

void; + onRepeat: (value: string) => void; + onEncryption: (value: PfxEncryption) => void; +} + +/** Die PFX-Datei darf erst erzeugt werden, wenn ein Passwort gesetzt ist und beide Eingaben gleich sind. */ +export function pfxPasswordReady(password: string, repeat: string): boolean { + return password !== '' && password === repeat; +} + +/** + * Passwort (zweimal) und Verschluesselung fuer PFX-Dateien (D-07). Vorgabe ist „Kompatibel“ + * (3DES/SHA-1): Windows Server 2016 und aeltere koennen AES-256 oft nicht oeffnen. + * Das Passwort bleibt im Zustand des Reiters, wird nirgends gespeichert und nur in der Anfrage gesendet. + */ +export function PfxOptions({ + password, + repeat, + encryption, + onPassword, + onRepeat, + onEncryption, +}: PfxOptionsProps) { + const t = useTranslations('certManager'); + const selectId = useId(); + const mismatch = repeat !== '' && password !== repeat; + return ( +

+ + + {mismatch && ( +

+ {t('pfx.mismatch')} +

+ )} +
+ + +
+

{t('pfx.modernHint')}

+
+ ); +} 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 c3a4bd2..942733a 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/page.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/page.tsx @@ -5,13 +5,14 @@ import { useState } from 'react'; import { TabBar } from '@/components/accounting/tab-bar'; import { PageHeader } from '@/components/layout/page-header'; import { AnalyzeTab } from './components/AnalyzeTab'; +import { ConvertTab } from './components/ConvertTab'; import { EmptyWorkspace } from './components/EmptyWorkspace'; import { FilesTab } from './components/FilesTab'; import { MergeTab } from './components/MergeTab'; import { SplitTab } from './components/SplitTab'; import { useCertWorkspace } from './use-cert-workspace'; -type TabId = 'files' | 'analyze' | 'split' | 'merge'; +type TabId = 'files' | 'analyze' | 'split' | 'merge' | 'convert'; /** * CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt). @@ -32,6 +33,7 @@ export default function CertManagerPage() { { id: 'analyze', label: t('tabs.analyze') }, { id: 'split', label: t('tabs.split') }, { id: 'merge', label: t('tabs.merge') }, + { id: 'convert', label: t('tabs.convert') }, ]; return ( @@ -49,6 +51,7 @@ export default function CertManagerPage() { {activeTab === 'analyze' && } {activeTab === 'split' && } {activeTab === 'merge' && } + {activeTab === 'convert' && }
)} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index c18264f..95db708 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -1256,7 +1256,8 @@ "filesWithCount": "Dateien ({count})", "analyze": "Analysieren", "split": "Aufteilen", - "merge": "Zusammenführen" + "merge": "Zusammenführen", + "convert": "Konvertieren" }, "roles": { "end-entity": "Serverzertifikat", @@ -1398,7 +1399,21 @@ "downloadFullchain": "Fullchain herunterladen", "downloadChain": "Nur Kette herunterladen", "busy": "Wird erstellt …", - "chainNeedsIssuer": "Für „Nur Kette“ fehlt ein Zwischenzertifikat. Fügen Sie es im Reiter „Dateien“ hinzu." + "chainNeedsIssuer": "Für „Nur Kette“ fehlt ein Zwischenzertifikat. Fügen Sie es im Reiter „Dateien“ hinzu.", + "formatLabel": "Format der Kette", + "formats": { + "pem": "PEM (.pem)", + "p7b": "PKCS#7 (.p7b)", + "p7c": "PKCS#7, binär (.p7c)" + }, + "bundleTitle": "Zertifikat und Schlüssel", + "bundleHint": "Eine einzige PEM-Datei mit Serverzertifikat, Zwischenzertifikaten und dem privaten Schlüssel. Bewahren Sie sie sicher auf.", + "downloadBundle": "Zertifikat und Schlüssel (eine PEM-Datei)", + "bundleNeedsKey": "Dazu liegt noch kein passender privater Schlüssel vor. Fügen Sie ihn im Reiter „Dateien“ hinzu.", + "pfxTitle": "PFX-Datei", + "pfxIntro": "Eine passwortgeschützte Datei mit Zertifikaten und Schlüssel, wie sie Windows, IIS und viele Geräte erwarten.", + "pfxNoKey": "Ohne passenden Schlüssel enthält die PFX-Datei nur die Zertifikate.", + "downloadPfx": "PFX-Datei herunterladen" }, "chain": { "issuedBy": "Ausgestellt von {name}", @@ -1411,6 +1426,40 @@ "passwordInput": { "show": "Passwort anzeigen", "hide": "Passwort verbergen" + }, + "pfx": { + "password": "PFX-Passwort", + "repeat": "Passwort wiederholen", + "mismatch": "Die beiden Passwörter stimmen nicht überein.", + "encryption": "Verschlüsselung", + "compat": "Kompatibel (auch ältere Windows-Server)", + "modern": "Modern (AES-256)", + "modernHint": "Ältere Windows-Server, etwa Windows Server 2016, können die moderne Verschlüsselung oft nicht öffnen. Im Zweifel wählen Sie „Kompatibel“." + }, + "convert": { + "intro": "Wandeln Sie ein einzelnes Teil Ihrer Dateien in ein anderes Format um.", + "nothingFound": "Es wurde noch nichts erkannt, das sich umwandeln ließe.", + "item": "Was soll umgewandelt werden?", + "format": "Zielformat", + "groupCertificates": "Zertifikate", + "groupKeys": "Private Schlüssel", + "groupCsrs": "Zertifikatsanfragen", + "formats": { + "certPem": "PEM (.crt)", + "certDer": "DER (.cer)", + "p7b": "PKCS#7 (.p7b)", + "p7c": "PKCS#7, binär (.p7c)", + "pkcs8": "PKCS#8 (.key)", + "traditional": "Traditionell (PKCS#1 für RSA, SEC1 für EC)", + "pkcs8Der": "PKCS#8, binär (.key.der)", + "csrPem": "PEM (.csr)", + "csrDer": "DER (.csr.der)" + }, + "keyNote": "Der Schlüssel wird nur für die Umwandlung an Tessera gesendet und nirgends gespeichert.", + "protectKey": "Schlüssel mit Passwort schützen", + "keyPassword": "Passwort für den Schlüssel", + "download": "Herunterladen", + "busy": "Wird erstellt …" } }, "tenderRadar": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index fe884a6..2b3eb0c 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -1256,7 +1256,8 @@ "filesWithCount": "Files ({count})", "analyze": "Analyze", "split": "Split", - "merge": "Merge" + "merge": "Merge", + "convert": "Convert" }, "roles": { "end-entity": "Server certificate", @@ -1398,7 +1399,21 @@ "downloadFullchain": "Download fullchain", "downloadChain": "Download chain only", "busy": "Creating …", - "chainNeedsIssuer": "An intermediate certificate is missing for “chain only”. Add it in the “Files” tab." + "chainNeedsIssuer": "An intermediate certificate is missing for “chain only”. Add it in the “Files” tab.", + "formatLabel": "Chain format", + "formats": { + "pem": "PEM (.pem)", + "p7b": "PKCS#7 (.p7b)", + "p7c": "PKCS#7, binary (.p7c)" + }, + "bundleTitle": "Certificate and key", + "bundleHint": "A single PEM file with the server certificate, the intermediate certificates and the private key. Keep it safe.", + "downloadBundle": "Certificate and key (one PEM file)", + "bundleNeedsKey": "There is no matching private key yet. Add it in the “Files” tab.", + "pfxTitle": "PFX file", + "pfxIntro": "A password-protected file with certificates and key, as Windows, IIS and many devices expect it.", + "pfxNoKey": "Without a matching key the PFX file contains only the certificates.", + "downloadPfx": "Download PFX file" }, "chain": { "issuedBy": "Issued by {name}", @@ -1411,6 +1426,40 @@ "passwordInput": { "show": "Show password", "hide": "Hide password" + }, + "pfx": { + "password": "PFX password", + "repeat": "Repeat password", + "mismatch": "The two passwords do not match.", + "encryption": "Encryption", + "compat": "Compatible (also older Windows servers)", + "modern": "Modern (AES-256)", + "modernHint": "Older Windows servers, such as Windows Server 2016, often cannot open the modern encryption. When in doubt, choose “Compatible”." + }, + "convert": { + "intro": "Convert a single item of your files into another format.", + "nothingFound": "Nothing has been recognised yet that could be converted.", + "item": "What should be converted?", + "format": "Target format", + "groupCertificates": "Certificates", + "groupKeys": "Private keys", + "groupCsrs": "Certificate requests", + "formats": { + "certPem": "PEM (.crt)", + "certDer": "DER (.cer)", + "p7b": "PKCS#7 (.p7b)", + "p7c": "PKCS#7, binary (.p7c)", + "pkcs8": "PKCS#8 (.key)", + "traditional": "Traditional (PKCS#1 for RSA, SEC1 for EC)", + "pkcs8Der": "PKCS#8, binary (.key.der)", + "csrPem": "PEM (.csr)", + "csrDer": "DER (.csr.der)" + }, + "keyNote": "The key is sent to Tessera only for the conversion and is not stored anywhere.", + "protectKey": "Protect the key with a password", + "keyPassword": "Password for the key", + "download": "Download", + "busy": "Creating …" } }, "tenderRadar": { diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index dd93280..9ef2127 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -254,4 +254,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ 'Passender', 'Passendes', 'Passende', + // quick-261009-ikt Task 5: PFX und Konvertieren + 'passwortgeschützte', + 'AES', ];