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:
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -254,4 +254,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'Passender',
|
||||
'Passendes',
|
||||
'Passende',
|
||||
// quick-261009-ikt Task 5: PFX und Konvertieren
|
||||
'passwortgeschützte',
|
||||
'AES',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user