feat(cert-manager): alle Ausgabeformate, Konvertieren, Bundle und PFX

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:46:43 +02:00
parent fcac0a3bfd
commit 65a1dca80f
18 changed files with 1877 additions and 47 deletions
@@ -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(<CertManagerPage />);
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(<CertManagerPage />);
fireEvent.click(screen.getByRole('button', { name: tab }));
@@ -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<typeof import('../actions')>();
return {
...actual,
buildOutput: (...args: unknown[]) => mockBuild(...args),
downloadBase64: (...args: unknown[]) => mockDownload(...args),
};
});
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
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(<ConvertTab workspace={workspace([leaf, key, csr])} />);
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(<ConvertTab workspace={workspace([leaf, key, csr])} />);
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(<ConvertTab workspace={workspace([leaf, key])} />);
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(<ConvertTab workspace={workspace([key])} />);
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(<ConvertTab workspace={workspace([csr])} />);
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(<ConvertTab workspace={workspace([key])} />);
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(<ConvertTab workspace={workspace([])} />);
expect(screen.getByText(/noch nichts erkannt/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Herunterladen' })).toBeNull();
});
});
@@ -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.<labelKey>. */
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<string | null>(null);
const [formats, setFormats] = useState<Record<string, string>>({});
const [protect, setProtect] = useState(false);
const [keyPassword, setKeyPassword] = useState('');
const [busy, setBusy] = useState(false);
const [errorKey, setErrorKey] = useState<string | null>(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' ? (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
) : null;
}
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('convert.nothingFound')}</p>;
}
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<typeof buildOutput>[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 (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">{t('convert.intro')}</p>
<div className="flex flex-wrap items-center gap-2">
<label htmlFor="cert-convert-item" className="text-sm text-foreground">
{t('convert.item')}
</label>
<select
id="cert-convert-item"
value={item.id}
onChange={(e) => setSelectedId(e.target.value)}
className="min-w-64 max-w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{certs.length > 0 && (
<optgroup label={t('convert.groupCertificates')}>
{certs.map((c) => (
<option key={c.id} value={c.id}>
{`${nameOf(c)} (${t(`roles.${c.role}`)})`}
</option>
))}
</optgroup>
)}
{keys.length > 0 && (
<optgroup label={t('convert.groupKeys')}>
{keys.map((k) => (
<option key={k.id} value={k.id}>
{`${t('roles.privateKey')} (${keyDescription(k)})`}
</option>
))}
</optgroup>
)}
{csrs.length > 0 && (
<optgroup label={t('convert.groupCsrs')}>
{csrs.map((r) => (
<option key={r.id} value={r.id}>
{`${nameOf(r)} (${t('roles.csr')})`}
</option>
))}
</optgroup>
)}
</select>
</div>
<div className="flex flex-wrap items-center gap-2">
<label htmlFor="cert-convert-format" className="text-sm text-foreground">
{t('convert.format')}
</label>
<select
id="cert-convert-format"
value={format}
onChange={(e) => setFormats((f) => ({ ...f, [item.id]: e.target.value }))}
className="rounded border border-border bg-background px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{options.map((o) => (
<option key={o.id} value={o.id}>
{t(`convert.formats.${o.labelKey}`)}
</option>
))}
</select>
</div>
{isKey && (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">{t('convert.keyNote')}</p>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={protect}
onChange={(e) => {
setProtect(e.target.checked);
if (!e.target.checked) setKeyPassword('');
}}
/>
{t('convert.protectKey')}
</label>
{protect && (
<PasswordInput
label={t('convert.keyPassword')}
value={keyPassword}
onChange={setKeyPassword}
/>
)}
</div>
)}
<button type="button" disabled={busy || !ready} onClick={convert} className="btn btn-primary">
{busy ? t('convert.busy') : t('convert.download')}
</button>
{errorKey && (
<p
role="alert"
className="rounded-lg border border-border bg-muted p-3 text-sm text-foreground"
>
{t(`errors.${errorKey}`)}
</p>
)}
</div>
);
}
@@ -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<ChainInfo> & Pick<ChainInfo, 'headId' | 'path'>): 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(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
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(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
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(<MergeTab workspace={workspace([keyed, inter, root, key], [completeChain])} />);
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(<MergeTab workspace={workspace([keyed, inter, root, key], [completeChain])} />);
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(<MergeTab workspace={workspace([keyed, inter, root, key], [completeChain])} />);
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(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
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(
<MergeTab workspace={workspace([keyed, inter, root, key], [completeChain])} />,
);
fireEvent.change(screen.getByLabelText('PFX-Passwort'), { target: { value: 'Neu-Pass-2026' } });
expect(container.textContent).not.toContain('Neu-Pass-2026');
});
});
@@ -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<string | null>(null);
const [includeRoot, setIncludeRoot] = useState(false);
const [format, setFormat] = useState<ChainFormat>('pem');
const [pfxPassword, setPfxPassword] = useState('');
const [pfxRepeat, setPfxRepeat] = useState('');
const [pfxEncryption, setPfxEncryption] = useState<PfxEncryption>('compat');
const [busy, setBusy] = useState<BuildContent | null>(null);
const [errorKey, setErrorKey] = useState<string | null>(null);
const certs = (workspace.analysis?.items ?? []).filter(
(i): i is CertItem => i.kind === 'certificate',
);
const 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) {
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<label htmlFor="cert-merge-format" className="text-sm text-foreground">
{t('merge.formatLabel')}
</label>
<select
id="cert-merge-format"
value={format}
onChange={(e) => setFormat(e.target.value as ChainFormat)}
className="rounded border border-border bg-background px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{CHAIN_FORMATS.map((f) => (
<option key={f} value={f}>
{t(`merge.formats.${f}`)}
</option>
))}
</select>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
@@ -138,6 +183,42 @@ export function MergeTab({ workspace }: MergeTabProps) {
<p className="text-xs text-muted-foreground">{t('merge.chainNeedsIssuer')}</p>
)}
<section className="space-y-2 border-t border-border pt-4">
<h3 className="text-sm font-semibold text-foreground">{t('merge.bundleTitle')}</h3>
<p className="text-xs text-muted-foreground">{t('merge.bundleHint')}</p>
<button
type="button"
disabled={busy !== null || !key}
onClick={() => download('leafKey')}
className="btn btn-secondary"
>
{busy === 'leafKey' ? t('merge.busy') : t('merge.downloadBundle')}
</button>
{!key && <p className="text-xs text-muted-foreground">{t('merge.bundleNeedsKey')}</p>}
</section>
<section className="space-y-3 border-t border-border pt-4">
<h3 className="text-sm font-semibold text-foreground">{t('merge.pfxTitle')}</h3>
<p className="text-xs text-muted-foreground">{t('merge.pfxIntro')}</p>
<PfxOptions
password={pfxPassword}
repeat={pfxRepeat}
encryption={pfxEncryption}
onPassword={setPfxPassword}
onRepeat={setPfxRepeat}
onEncryption={setPfxEncryption}
/>
{!key && <p className="text-xs text-muted-foreground">{t('merge.pfxNoKey')}</p>}
<button
type="button"
disabled={busy !== null || !pfxPasswordReady(pfxPassword, pfxRepeat)}
onClick={() => download('pfx')}
className="btn btn-secondary"
>
{busy === 'pfx' ? t('merge.busy') : t('merge.downloadPfx')}
</button>
</section>
{errorKey && (
<p
role="alert"
@@ -0,0 +1,65 @@
'use client';
import { useTranslations } from 'next-intl';
import { useId } from 'react';
import { PasswordInput } from './PasswordInput';
export type PfxEncryption = 'compat' | 'modern';
interface PfxOptionsProps {
password: string;
repeat: string;
encryption: PfxEncryption;
onPassword: (value: string) => 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 (
<div className="space-y-3">
<PasswordInput label={t('pfx.password')} value={password} onChange={onPassword} />
<PasswordInput label={t('pfx.repeat')} value={repeat} onChange={onRepeat} />
{mismatch && (
<p role="alert" className="text-sm text-foreground">
{t('pfx.mismatch')}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<label htmlFor={selectId} className="text-sm text-foreground">
{t('pfx.encryption')}
</label>
<select
id={selectId}
value={encryption}
onChange={(e) => onEncryption(e.target.value as PfxEncryption)}
className="rounded border border-border bg-background px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="compat">{t('pfx.compat')}</option>
<option value="modern">{t('pfx.modern')}</option>
</select>
</div>
<p className="text-xs text-muted-foreground">{t('pfx.modernHint')}</p>
</div>
);
}
@@ -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' && <AnalyzeTab workspace={workspace} />}
{activeTab === 'split' && <SplitTab workspace={workspace} />}
{activeTab === 'merge' && <MergeTab workspace={workspace} />}
{activeTab === 'convert' && <ConvertTab workspace={workspace} />}
</div>
)}
</div>