fix(cert-manager): Reiter zeigen nur noch passende Auswertungen, Aufteilen schützt Schlüssel

- Ausgabe-Reiter bieten nur an, was zur aktuellen Dateiliste passt: nach dem Entfernen
  einer Datei oder einer fehlgeschlagenen Prüfung kommt Hinweis oder Fehler mit
  „Erneut versuchen“ statt der alten Auswertung (WR-02)
- Aufteilen: ein in der Datei geschützter Schlüssel bleibt geschützt (Passwort für die
  Ausgabe, auch in der ZIP); ohne Schutz nur ausdrücklich und mit sichtbarem Hinweis;
  Schlüssel heißen nach ihrem Zertifikat (WR-03, IN-05)
- Meldungen: tooManyItems, aiaNotAllowed, buildFailed (statt „Dateien konnten nicht
  geprüft werden“ beim Erstellen), protectionTooExpensive; Hinweis zum Passwort
  genauer (IN-03, IN-05)
- Download-Adresse wird erst nach einer Sekunde widerrufen (IN-04)
- Anleitungen und Änderungsliste: Grenzen, Aufteilen mit Schlüsselschutz, Umlaut-Passwörter

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 17:10:45 +02:00
parent ff2226dfbd
commit 4b87249a4a
23 changed files with 758 additions and 113 deletions
@@ -109,7 +109,8 @@ export type IgnoredReason =
| 'suspicious'
| 'zipTooLarge'
| 'tooManyEntries'
| 'unsupportedKey';
| 'unsupportedKey'
| 'protectionTooExpensive';
export interface IgnoredEntry {
file: number;
@@ -195,20 +196,29 @@ const KNOWN_ERROR_CODES = new Set([
'formatNotPossible',
'templateNeedsKey',
'tooLarge',
'tooManyItems',
'aiaMissing',
'aiaNotAllowed',
'aiaInternal',
'aiaNotIssuer',
'aiaUnreachable',
'aiaTooLarge',
]);
/** Schluessel unter `certManager.errors.<key>`: der Fehlercode, 413 ohne Code als tooLarge, sonst generic. */
export function certErrorKey(error: unknown): string {
/**
* Schluessel unter `certManager.errors.<key>`: der Fehlercode, 413 ohne Code als tooLarge, sonst
* `fallback`. Die Pruefung der Dateien nimmt `generic` („Dateien konnten nicht geprueft werden“),
* das Erstellen einer Ausgabe `buildFailed`, damit der Text zur Aktion passt (Review IN-03).
*/
export function certErrorKey(
error: unknown,
fallback: 'generic' | 'buildFailed' = 'generic',
): string {
if (error instanceof CertManagerRequestError) {
if (error.code && KNOWN_ERROR_CODES.has(error.code)) return error.code;
if (error.status === 413) return 'tooLarge';
}
return 'generic';
return fallback;
}
async function errorFromResponse(response: Response): Promise<CertManagerRequestError> {
@@ -282,5 +292,6 @@ export function downloadBase64(filename: string, content: string, mimeType: stri
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
// Nicht sofort widerrufen: manche Browser brechen den Download sonst ab (Review IN-04).
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
@@ -0,0 +1,38 @@
'use client';
import { useTranslations } from 'next-intl';
import type { AnalysisResult } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
/**
* Die Analyse, auf der ein Ausgabe-Reiter arbeiten darf: nur die, die zu den aktuellen Eintraegen
* passt (`workspace.fresh`). Ist sie veraltet (Datei entfernt, Passwort geaendert, neue Pruefung laeuft
* oder ist fehlgeschlagen), ist das Ergebnis null und der Reiter zeigt `AnalysisPending` statt Dateien.
*/
export function freshAnalysis(workspace: CertWorkspace): AnalysisResult | null {
return workspace.fresh ? workspace.analysis : null;
}
/** Statt des Reiterinhalts, solange keine passende Analyse vorliegt: Hinweis zur laufenden Pruefung oder Fehler mit Knopf. */
export function AnalysisPending({ workspace }: { workspace: CertWorkspace }) {
const t = useTranslations('certManager');
if (workspace.status === 'error') {
return (
<div role="alert" className="space-y-3 rounded-lg border border-border bg-muted p-3 text-sm">
<p className="text-foreground">{t(`errors.${workspace.errorKey ?? 'generic'}`)}</p>
<p className="text-muted-foreground">{t('workspace.stale')}</p>
<button type="button" onClick={workspace.retry} className="btn btn-secondary">
{t('files.retry')}
</button>
</div>
);
}
if (workspace.status === 'analyzing') {
return (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
);
}
return null;
}
@@ -66,6 +66,7 @@ function workspace(
analysis,
analysisIds,
status: 'idle',
fresh: analysis !== null,
errorKey: null,
addFiles: () => [],
addText: () => null,
@@ -3,6 +3,7 @@
import { useTranslations } from 'next-intl';
import type { CertItem, CsrItem, KeyItem } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { AnalysisPending, freshAnalysis } from './AnalysisPending';
import { ChainView } from './ChainView';
import { ItemCard } from './ItemCard';
@@ -16,15 +17,10 @@ interface AnalyzeTabProps {
*/
export function AnalyzeTab({ workspace }: AnalyzeTabProps) {
const t = useTranslations('certManager');
const { analysis, entries, analysisIds, status } = workspace;
const { entries, analysisIds } = workspace;
const analysis = freshAnalysis(workspace);
if (!analysis) {
return status === 'analyzing' ? (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
) : null;
}
if (!analysis) return <AnalysisPending workspace={workspace} />;
const certs = analysis.items.filter((i): i is CertItem => i.kind === 'certificate');
const keys = analysis.items.filter((i): i is KeyItem => i.kind === 'privateKey');
@@ -91,6 +91,7 @@ function workspace(items: AnyItem[]): CertWorkspace {
analysis,
analysisIds: [],
status: 'idle',
fresh: analysis !== null,
errorKey: null,
addFiles: () => [],
addText: () => null,
@@ -12,6 +12,7 @@ import {
type KeyItem,
} from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { AnalysisPending, freshAnalysis } from './AnalysisPending';
import { PasswordInput } from './PasswordInput';
interface ConvertTabProps {
@@ -56,18 +57,13 @@ export function ConvertTab({ workspace }: ConvertTabProps) {
const [busy, setBusy] = useState(false);
const [errorKey, setErrorKey] = useState<string | null>(null);
const items = workspace.analysis?.items ?? [];
const analysis = freshAnalysis(workspace);
const items = 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 (!analysis) return <AnalysisPending workspace={workspace} />;
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('convert.nothingFound')}</p>;
}
@@ -109,7 +105,7 @@ export function ConvertTab({ workspace }: ConvertTabProps) {
const result = await buildOutput(request);
for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType);
} catch (error) {
setErrorKey(certErrorKey(error));
setErrorKey(certErrorKey(error, 'buildFailed'));
} finally {
setBusy(false);
}
@@ -142,6 +142,17 @@ interface FilesTabProps {
workspace: CertWorkspace;
}
/** Schluessel fuer die Liste abgewiesener Dateien: gleiche Namen mit gleichem Grund bekommen eine laufende Nummer. */
function withUniqueKeys(list: RejectedFile[]): { key: string; rejected: RejectedFile }[] {
const seen = new Map<string, number>();
return list.map((rejected) => {
const base = `${rejected.name}-${rejected.reason}`;
const n = (seen.get(base) ?? 0) + 1;
seen.set(base, n);
return { key: `${base}-${n}`, rejected };
});
}
/**
* Reiter „Dateien“: der einzige Ort zum Hinzufuegen (D-01). Jede Auswahl haengt an die Liste an;
* eine zweite Datei ersetzt nie die erste. Pro Eintrag steht, was Tessera darin erkannt hat.
@@ -282,8 +293,8 @@ export function FilesTab({ workspace }: FilesTabProps) {
<div role="alert" className="rounded-lg border border-border bg-muted p-3 text-sm">
<p className="font-medium text-foreground">{t('files.rejectedTitle')}</p>
<ul className="mt-1 space-y-0.5 text-muted-foreground">
{rejected.map((r) => (
<li key={`${r.name}-${r.reason}`}>
{withUniqueKeys(rejected).map(({ key, rejected: r }) => (
<li key={key}>
{t(`files.rejected.${r.reason}`, {
name: r.name,
max: MAX_ENTRIES,
@@ -75,6 +75,7 @@ function workspace(items: (CertItem | KeyItem)[], chains: ChainInfo[]): CertWork
analysis,
analysisIds: [],
status: 'idle',
fresh: analysis !== null,
errorKey: null,
addFiles: () => [],
addText: () => null,
@@ -11,6 +11,7 @@ import {
type KeyItem,
} from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { AnalysisPending, freshAnalysis } from './AnalysisPending';
import { ChainView } from './ChainView';
import { type PfxEncryption, PfxOptions, pfxPasswordReady } from './PfxOptions';
@@ -39,21 +40,12 @@ export function MergeTab({ workspace }: MergeTabProps) {
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 ?? [];
const analysis = freshAnalysis(workspace);
const certs = (analysis?.items ?? []).filter((i): i is CertItem => i.kind === 'certificate');
const keys = (analysis?.items ?? []).filter((i): i is KeyItem => i.kind === 'privateKey');
const chains = analysis?.chains ?? [];
if (workspace.status === 'analyzing' && !workspace.analysis) {
return (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
);
}
if (!analysis) return <AnalysisPending workspace={workspace} />;
if (chains.length === 0) {
return <p className="text-sm text-muted-foreground">{t('merge.noCertificates')}</p>;
}
@@ -96,7 +88,7 @@ export function MergeTab({ workspace }: MergeTabProps) {
);
for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType);
} catch (error) {
setErrorKey(certErrorKey(error));
setErrorKey(certErrorKey(error, 'buildFailed'));
} finally {
setBusy(null);
}
@@ -1,4 +1,11 @@
import { cleanup, fireEvent, render as rtlRender, screen, within } from '@testing-library/react';
import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -68,6 +75,7 @@ function workspace(items: AnyItem[] | null): CertWorkspace {
analysis,
analysisIds: [],
status: 'idle',
fresh: analysis !== null,
errorKey: null,
addFiles: () => [],
addText: () => null,
@@ -106,10 +114,10 @@ describe('SplitTab', () => {
expect(within(rows[1]).getByText('Test_Inter.crt')).toBeInTheDocument();
});
it('Herunterladen gibt das PEM des Teils als Base64 an downloadBase64, ohne Aufruf der API', () => {
it('Herunterladen gibt das PEM des Teils als Base64 an downloadBase64, ohne Aufruf der API', async () => {
render(<SplitTab workspace={workspace(items)} />);
fireEvent.click(screen.getByRole('button', { name: '„www.example.test.crt“ herunterladen' }));
expect(mockDownload).toHaveBeenCalledTimes(1);
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
const [name, content, mime] = mockDownload.mock.calls[0];
expect(name).toBe('www.example.test.crt');
expect(atob(content)).toBe(items[0].pem);
@@ -173,14 +181,16 @@ describe('SplitTab', () => {
];
render(<SplitTab workspace={workspace(dup)} />);
fireEvent.click(screen.getByRole('button', { name: 'Alle als ZIP herunterladen' }));
expect(fflate.zipSync).toHaveBeenCalledTimes(1);
await waitFor(() => expect(fflate.zipSync).toHaveBeenCalledTimes(1));
const files = (fflate.zipSync as unknown as { mock: { calls: [Record<string, Uint8Array>][] } })
.mock.calls[0][0];
expect(Object.keys(files)).toEqual(['gleich.crt', 'gleich-2.crt', 'Wurzel.crt']);
expect(new TextDecoder().decode(files['gleich-2.crt'])).toBe(dup[1].pem);
expect(click).toHaveBeenCalled();
expect((anchor as HTMLAnchorElement | null)?.download).toBe('Zertifikate.zip');
expect(revoke).toHaveBeenCalledWith('blob:x');
// nicht sofort widerrufen (Review IN-04), sondern nach einer kurzen Wartezeit
expect(revoke).not.toHaveBeenCalled();
await waitFor(() => expect(revoke).toHaveBeenCalledWith('blob:x'), { timeout: 3000 });
expect(mockFetch).not.toHaveBeenCalled();
});
@@ -207,3 +217,170 @@ describe('dedupeNames', () => {
]);
});
});
function keyItem(over: Partial<Extract<AnyItem, { kind: 'privateKey' }>> = {}): AnyItem {
return {
id: 'k-1',
kind: 'privateKey',
sources: [],
pem: '-----BEGIN PRIVATE KEY-----\nGEHEIM\n-----END PRIVATE KEY-----\n',
baseName: 'schluessel',
keyType: 'RSA',
keyBits: 2048,
curve: null,
wasEncrypted: false,
certIds: [],
...over,
};
}
/** Review WR-03: ein geschuetzter Schluessel bleibt beim Aufteilen geschuetzt. */
describe('SplitTab: Schluessel', () => {
const protectedKey = keyItem({ wasEncrypted: true, certIds: ['c-leaf'] });
function jsonResponse(content: string) {
return {
ok: true,
json: async () => ({
files: [{ filename: 'x.key', content, mimeType: 'application/x-pem-file' }],
chainComplete: true,
missingIssuerCn: null,
}),
};
}
it('war ein Schluessel geschuetzt, ist der Schutz vorgewaehlt und die Schluesselknoepfe warten auf ein Passwort', () => {
render(<SplitTab workspace={workspace([items[0], protectedKey])} />);
expect(
screen.getByRole('checkbox', { name: 'Schlüssel mit einem Passwort schützen' }),
).toBeChecked();
expect(
screen.getByText(/Bitte geben Sie ein Passwort für die Schlüssel ein/),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: '„www.example.test.key“ herunterladen' }),
).toBeDisabled();
expect(screen.getByRole('button', { name: 'Alle als ZIP herunterladen' })).toBeDisabled();
// Zertifikate bleiben frei
expect(
screen.getByRole('button', { name: '„www.example.test.crt“ herunterladen' }),
).toBeEnabled();
});
it('mit Passwort kommt die Ausgabe verschluesselt von der API, das Klartext-PEM geht nicht in den Download', async () => {
mockFetch.mockResolvedValue(jsonResponse(btoa('ENCRYPTED-BY-API')));
render(<SplitTab workspace={workspace([items[0], protectedKey])} />);
fireEvent.change(screen.getByLabelText('Passwort für die Schlüssel'), {
target: { value: 'Neu-Pass' },
});
fireEvent.click(screen.getByRole('button', { name: '„www.example.test.key“ herunterladen' }));
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
const [name, content] = mockDownload.mock.calls[0];
expect(name).toBe('www.example.test.key');
expect(atob(content)).toBe('ENCRYPTED-BY-API');
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
expect(body).toMatchObject({ content: 'key', format: 'pkcs8', password: 'Neu-Pass' });
expect(atob(content)).not.toContain('GEHEIM');
});
it('ZIP: Schluessel verschluesselt aus der API, Zertifikate unveraendert', async () => {
const fflate = await import('fflate');
vi.stubGlobal('URL', {
...URL,
createObjectURL: vi.fn(() => 'blob:z'),
revokeObjectURL: vi.fn(),
});
const realCreate = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag);
if (tag === 'a') el.click = vi.fn();
return el;
});
mockFetch.mockResolvedValue(jsonResponse(btoa('ENCRYPTED-BY-API')));
render(<SplitTab workspace={workspace([items[0], protectedKey])} />);
fireEvent.change(screen.getByLabelText('Passwort für die Schlüssel'), {
target: { value: 'Neu-Pass' },
});
fireEvent.click(screen.getByRole('button', { name: 'Alle als ZIP herunterladen' }));
await waitFor(() => expect(fflate.zipSync).toHaveBeenCalled());
const files = (
fflate.zipSync as unknown as { mock: { calls: [Record<string, Uint8Array>][] } }
).mock.calls.at(-1)?.[0] as Record<string, Uint8Array>;
expect(Object.keys(files)).toEqual(['www.example.test.crt', 'www.example.test.key']);
expect(new TextDecoder().decode(files['www.example.test.key'])).toBe('ENCRYPTED-BY-API');
});
it('Schutz abgeschaltet: sichtbarer Hinweis, Klartext-Schluessel ohne API-Aufruf', async () => {
render(<SplitTab workspace={workspace([items[0], protectedKey])} />);
fireEvent.click(
screen.getByRole('checkbox', { name: 'Schlüssel mit einem Passwort schützen' }),
);
expect(screen.getByRole('alert')).toHaveTextContent(/unverschlüsselt gespeichert/);
fireEvent.click(screen.getByRole('button', { name: '„www.example.test.key“ herunterladen' }));
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
expect(atob(mockDownload.mock.calls[0][1])).toContain('GEHEIM');
expect(mockFetch).not.toHaveBeenCalled();
});
it('ein Schluessel, der nie geschuetzt war, braucht kein Passwort und zeigt keine Warnung', async () => {
render(<SplitTab workspace={workspace([items[0], keyItem()])} />);
expect(
screen.getByRole('checkbox', { name: 'Schlüssel mit einem Passwort schützen' }),
).not.toBeChecked();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '„schluessel.key“ herunterladen' }));
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
});
it('mehrere Schluessel tragen verschiedene Dateinamen (nach ihrem Zertifikat, sonst nummeriert)', () => {
const two = [
items[0],
keyItem({ id: 'k-1', certIds: ['c-leaf'] }),
keyItem({ id: 'k-2' }),
keyItem({ id: 'k-3' }),
];
render(<SplitTab workspace={workspace(two)} />);
expect(screen.getByText('www.example.test.key')).toBeInTheDocument();
expect(screen.getByText('schluessel.key')).toBeInTheDocument();
expect(screen.getByText('schluessel-2.key')).toBeInTheDocument();
});
it('ein Fehler der API erscheint als Meldung, kein Download', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
render(<SplitTab workspace={workspace([protectedKey])} />);
fireEvent.change(screen.getByLabelText('Passwort für die Schlüssel'), {
target: { value: 'x' },
});
fireEvent.click(screen.getByRole('button', { name: '„schluessel.key“ herunterladen' }));
await waitFor(() =>
expect(screen.getByRole('alert')).toHaveTextContent(/nicht erstellt werden/),
);
expect(mockDownload).not.toHaveBeenCalled();
});
});
/** Review WR-02: ohne passende Analyse bieten die Reiter keine Dateien an. */
describe('SplitTab: veraltete Analyse', () => {
it('bietet keine Downloads an, solange die Analyse nicht zu den Dateien passt', () => {
const ws = { ...workspace(items), fresh: false, status: 'analyzing' as const };
render(<SplitTab workspace={ws} />);
expect(screen.queryByRole('button', { name: /herunterladen/ })).not.toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Dateien werden geprüft');
});
it('nach einem Fehler: Meldung und Erneut versuchen statt Dateien', () => {
const retry = vi.fn();
const ws = {
...workspace(items),
fresh: false,
status: 'error' as const,
errorKey: 'generic',
retry,
};
render(<SplitTab workspace={ws} />);
expect(screen.queryByRole('button', { name: /herunterladen/ })).not.toBeInTheDocument();
expect(screen.getByRole('alert')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Erneut versuchen' }));
expect(retry).toHaveBeenCalled();
});
});
@@ -2,10 +2,20 @@
import { zipSync } from 'fflate';
import { useTranslations } from 'next-intl';
import { type AnyItem, downloadBase64 } from '../actions';
import { useState } from 'react';
import {
type AnyItem,
buildOutput,
type CertItem,
certErrorKey,
downloadBase64,
type KeyItem,
} from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { sanitizeZipFilename } from '../zip-filename';
import { AnalysisPending, freshAnalysis } from './AnalysisPending';
import { ROLE_STYLES } from './FilesTab';
import { PasswordInput } from './PasswordInput';
interface SplitTabProps {
workspace: CertWorkspace;
@@ -13,10 +23,17 @@ interface SplitTabProps {
const MIME_PEM = 'application/x-pem-file';
/** Dateiname im natuerlichen Format des Teils; Zertifikat .crt, Schluessel .key, Anfrage .csr. */
function fileNameOf(item: AnyItem): string {
const ext = item.kind === 'certificate' ? 'crt' : item.kind === 'privateKey' ? 'key' : 'csr';
return `${item.baseName}.${ext}`;
/**
* Dateiname im natuerlichen Format des Teils; Zertifikat .crt, Schluessel .key, Anfrage .csr.
* Ein Schluessel heisst nach seinem Zertifikat, wenn es eines gibt (sonst tragen mehrere Schluessel
* denselben Namen), eine Anfrage nach ihrem Namen.
*/
function fileNameOf(item: AnyItem, certs: CertItem[]): string {
if (item.kind === 'privateKey') {
const owner = certs.find((c) => item.certIds.includes(c.id));
return `${owner?.baseName ?? item.baseName}.key`;
}
return `${item.baseName}.${item.kind === 'certificate' ? 'crt' : 'csr'}`;
}
function roleKey(item: AnyItem): string {
@@ -31,6 +48,13 @@ function toBase64(text: string): string {
return btoa(text);
}
function fromBase64(content: string): Uint8Array {
const bytes = atob(content);
const out = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) out[i] = bytes.charCodeAt(i);
return out;
}
/** Gleiche Namen bekommen -2, -3 vor der Endung (fflate ueberschriebe sie sonst stillschweigend). */
export function dedupeNames(names: string[]): string[] {
const used = new Set<string>();
@@ -46,12 +70,7 @@ export function dedupeNames(names: string[]): string[] {
});
}
function downloadZip(items: AnyItem[], zipFilename: string) {
const names = dedupeNames(items.map(fileNameOf));
const files: Record<string, Uint8Array> = {};
items.forEach((item, i) => {
files[names[i]] = new TextEncoder().encode(item.pem);
});
function downloadZipBytes(files: Record<string, Uint8Array>, zipFilename: string) {
const blob = new Blob([zipSync(files) as BlobPart], { type: 'application/zip' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
@@ -59,40 +78,138 @@ function downloadZip(items: AnyItem[], zipFilename: string) {
// Der uebersetzte Name kann Zeichen tragen, die Windows verbietet: auf das Zulaessige zurueckschneiden.
anchor.download = sanitizeZipFilename(zipFilename);
anchor.click();
URL.revokeObjectURL(url);
// Nicht sofort widerrufen: manche Browser brechen den Download sonst ab (Review IN-04).
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/**
* Reiter „Aufteilen“: jedes erkannte Teil einzeln in seinem natuerlichen Format herunterladen,
* oder alle zusammen als ZIP. Rein im Browser aus den Daten der Analyse; kein Aufruf der API.
* oder alle zusammen als ZIP. Zertifikate und Anfragen kommen direkt aus den Daten der Analyse.
*
* Private Schluessel (Review WR-03): Die Analyse liefert sie entschluesselt. War ein Schluessel in der
* Datei durch ein Passwort geschuetzt, bleibt der Schutz hier standardmaessig bestehen: die Ausgabe
* wird mit einem Passwort verschluesselt (die API baut sie, das Passwort geht nur in diese Anfrage).
* Wer ausdruecklich ohne Schutz speichern will, schaltet ihn ab; ein sichtbarer Hinweis sagt dann, dass
* die Schluesseldateien unverschluesselt sind.
*/
export function SplitTab({ workspace }: SplitTabProps) {
const t = useTranslations('certManager');
const items = workspace.analysis?.items ?? [];
const [protectChoice, setProtectChoice] = useState<boolean | null>(null);
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [errorKey, setErrorKey] = useState<string | null>(null);
if (!workspace.analysis) {
return workspace.status === 'analyzing' ? (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
) : null;
}
const analysis = freshAnalysis(workspace);
if (!analysis) return <AnalysisPending workspace={workspace} />;
const items = analysis.items;
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">{t('split.nothingFound')}</p>;
}
const certs = items.filter((i): i is CertItem => i.kind === 'certificate');
const keys = items.filter((i): i is KeyItem => i.kind === 'privateKey');
const anyWasProtected = keys.some((k) => k.wasEncrypted);
const protect = protectChoice ?? anyWasProtected;
const keysBlocked = keys.length > 0 && protect && password === '';
const names = dedupeNames(items.map((item) => fileNameOf(item, certs)));
const nameOf = (item: AnyItem) => names[items.indexOf(item)];
/** Datei eines Teils als Base64: Schluessel mit Schutz kommen verschluesselt von der API. */
const contentOf = async (item: AnyItem): Promise<string> => {
if (item.kind === 'privateKey' && protect) {
const result = await buildOutput({
content: 'key',
format: 'pkcs8',
keyPem: item.pem,
password,
});
return result.files[0].content;
}
return toBase64(item.pem);
};
const run = async (action: () => Promise<void>) => {
setBusy(true);
setErrorKey(null);
try {
await action();
} catch (error) {
setErrorKey(certErrorKey(error, 'buildFailed'));
} finally {
setBusy(false);
}
};
const downloadOne = (item: AnyItem) =>
run(async () => {
downloadBase64(nameOf(item), await contentOf(item), MIME_PEM);
});
const downloadAll = () =>
run(async () => {
const contents = await Promise.all(items.map(contentOf));
const files: Record<string, Uint8Array> = {};
items.forEach((item, i) => {
files[nameOf(item)] = fromBase64(contents[i]);
});
downloadZipBytes(files, t('split.zipFilename'));
});
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('split.intro')}</p>
{keys.length > 0 && (
<div className="space-y-3 rounded-lg border border-border p-3">
<p className="text-xs text-muted-foreground">{t('split.keyNote')}</p>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={protect}
onChange={(e) => {
setProtectChoice(e.target.checked);
if (!e.target.checked) setPassword('');
}}
/>
{t('split.protectKeys')}
</label>
{protect && (
<PasswordInput label={t('split.keyPassword')} value={password} onChange={setPassword} />
)}
{protect && password === '' && (
<p role="status" className="text-xs text-muted-foreground">
{t('split.passwordNeeded')}
</p>
)}
{!protect && anyWasProtected && (
<p
role="alert"
className="rounded border border-border bg-muted p-2 text-xs font-medium text-foreground"
>
{t('split.keysWereProtected')}
</p>
)}
</div>
)}
{items.length > 1 && (
<button
type="button"
onClick={() => downloadZip(items, t('split.zipFilename'))}
disabled={busy || keysBlocked}
onClick={downloadAll}
className="btn btn-secondary"
>
{t('split.downloadAll')}
{busy ? t('split.busy') : t('split.downloadAll')}
</button>
)}
{errorKey && (
<p
role="alert"
className="rounded-lg border border-border bg-muted p-3 text-sm text-foreground"
>
{t(`errors.${errorKey}`)}
</p>
)}
<ul className="space-y-2">
{items.map((item) => (
<li
@@ -106,13 +223,19 @@ export function SplitTab({ workspace }: SplitTabProps) {
{t(`roles.${roleKey(item)}`)}
</span>
<span className="break-all font-medium text-foreground">{itemName(item)}</span>
<span className="break-all text-xs text-muted-foreground">{fileNameOf(item)}</span>
<span className="break-all text-xs text-muted-foreground">{nameOf(item)}</span>
{item.kind === 'privateKey' && (
<span className="text-xs text-muted-foreground">
{protect ? t('split.keyProtected') : t('split.keyPlain')}
</span>
)}
</div>
<button
type="button"
onClick={() => downloadBase64(fileNameOf(item), toBase64(item.pem), MIME_PEM)}
aria-label={t('split.downloadFor', { name: fileNameOf(item) })}
className="shrink-0 rounded border border-border px-3 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
disabled={busy || (item.kind === 'privateKey' && keysBlocked)}
onClick={() => downloadOne(item)}
aria-label={t('split.downloadFor', { name: nameOf(item) })}
className="shrink-0 rounded border border-border px-3 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
{t('split.download')}
</button>
@@ -93,6 +93,7 @@ function workspace(items: (CertItem | KeyItem)[], chains: ChainInfo[]): CertWork
analysis,
analysisIds: [],
status: 'idle',
fresh: analysis !== null,
errorKey: null,
addFiles: () => [],
addText: () => null,
@@ -14,6 +14,7 @@ import {
} from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { sanitizeZipFilename } from '../zip-filename';
import { AnalysisPending, freshAnalysis } from './AnalysisPending';
import { ChainView } from './ChainView';
import { type PfxEncryption, PfxOptions, pfxPasswordReady } from './PfxOptions';
@@ -68,21 +69,12 @@ export function TemplatesTab({ workspace }: TemplatesTabProps) {
const [shown, setShown] = useState<{ id: TemplateId; snippet: string } | null>(null);
const [copied, setCopied] = useState(false);
const certs = (workspace.analysis?.items ?? []).filter(
(i): i is CertItem => i.kind === 'certificate',
);
const keys = (workspace.analysis?.items ?? []).filter(
(i): i is KeyItem => i.kind === 'privateKey',
);
const chains = workspace.analysis?.chains ?? [];
const analysis = freshAnalysis(workspace);
const certs = (analysis?.items ?? []).filter((i): i is CertItem => i.kind === 'certificate');
const keys = (analysis?.items ?? []).filter((i): i is KeyItem => i.kind === 'privateKey');
const chains = analysis?.chains ?? [];
if (workspace.status === 'analyzing' && !workspace.analysis) {
return (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
);
}
if (!analysis) return <AnalysisPending workspace={workspace} />;
if (chains.length === 0) {
return <p className="text-sm text-muted-foreground">{t('merge.noCertificates')}</p>;
}
@@ -148,7 +140,7 @@ export function TemplatesTab({ workspace }: TemplatesTabProps) {
}
if (snippet) setShown({ id: template.id, snippet });
} catch (error) {
setErrorKey(certErrorKey(error));
setErrorKey(certErrorKey(error, 'buildFailed'));
} finally {
setBusy(null);
}
@@ -0,0 +1,138 @@
import { cleanup, fireEvent, render as rtlRender, screen } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
import type { AnalysisResult, CertItem, KeyItem } from '../actions';
import type { CertWorkspace } from '../use-cert-workspace';
import { AnalyzeTab } from './AnalyzeTab';
import { ConvertTab } from './ConvertTab';
import { MergeTab } from './MergeTab';
import { SplitTab } from './SplitTab';
import { TemplatesTab } from './TemplatesTab';
/**
* Review WR-02: nach dem Entfernen einer Datei oder einer fehlgeschlagenen Pruefung darf kein
* Ausgabe-Reiter mehr Zertifikate oder Schluessel der alten Analyse zum Herunterladen anbieten.
*/
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
afterEach(cleanup);
const leaf: CertItem = {
id: 'c-leaf',
kind: 'certificate',
role: 'end-entity',
sources: [{ file: 0, path: 'a.pem' }],
pem: 'PEM',
baseName: 'www',
cn: 'www.example.test',
organization: '',
issuerCn: 'CA',
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: 'k.pem' }],
pem: 'KEYPEM',
baseName: 'schluessel',
keyType: 'RSA',
keyBits: 2048,
curve: null,
wasEncrypted: false,
certIds: ['c-leaf'],
};
const analysis: AnalysisResult = {
items: [leaf, key],
chains: [
{
headId: 'c-leaf',
path: ['c-leaf'],
rootId: null,
complete: false,
gap: null,
alternatives: 0,
},
],
locked: [],
ignored: [],
};
function stale(over: Partial<CertWorkspace>): CertWorkspace {
return {
entries: [],
analysis,
analysisIds: [],
status: 'analyzing',
fresh: false,
errorKey: null,
addFiles: () => [],
addText: () => null,
addFetched: () => 'added',
setPassword: () => {},
remove: () => {},
clear: () => {},
retry: () => {},
...over,
};
}
const TABS = [
['Analysieren', AnalyzeTab],
['Aufteilen', SplitTab],
['Zusammenführen', MergeTab],
['Konvertieren', ConvertTab],
['Vorlagen', TemplatesTab],
] as const;
describe.each(TABS)('%s mit veralteter Analyse', (_name, Tab) => {
it('laufende Pruefung: nur der Hinweis, keine Downloads, keine Zertifikate', () => {
render(<Tab workspace={stale({ status: 'analyzing' })} />);
expect(screen.getByRole('status')).toHaveTextContent('Dateien werden geprüft');
expect(
screen.queryByRole('button', { name: /herunterladen|Download/i }),
).not.toBeInTheDocument();
expect(screen.queryByText('www.example.test')).not.toBeInTheDocument();
});
it('fehlgeschlagene Pruefung: Meldung mit Erneut versuchen, keine Downloads', () => {
const retry = vi.fn();
render(<Tab workspace={stale({ status: 'error', errorKey: 'generic', retry })} />);
expect(screen.getByRole('alert')).toHaveTextContent('nicht geprüft werden');
expect(
screen.queryByRole('button', { name: /herunterladen|Download/i }),
).not.toBeInTheDocument();
expect(screen.queryByText('www.example.test')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Erneut versuchen' }));
expect(retry).toHaveBeenCalledTimes(1);
});
it('passende Analyse: der Reiter zeigt wieder seinen Inhalt', () => {
render(<Tab workspace={stale({ status: 'idle', fresh: true })} />);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,94 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CertManagerRequestError } from './actions';
import { useCertWorkspace } from './use-cert-workspace';
const mockAnalyze = vi.fn();
vi.mock('./actions', async (importOriginal) => {
const actual = await importOriginal<typeof import('./actions')>();
return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) };
});
const EMPTY = { items: [], chains: [], locked: [], ignored: [] };
const file = (name: string) => new File([name], name, { lastModified: 1 });
beforeEach(() => {
mockAnalyze.mockReset();
});
/** Review WR-02: eine Analyse zaehlt nur, solange sie zu den aktuellen Eintraegen passt. */
describe('useCertWorkspace: fresh', () => {
async function withTwoFiles() {
mockAnalyze.mockResolvedValue(EMPTY);
const hook = renderHook(() => useCertWorkspace());
act(() => {
hook.result.current.addFiles([file('a.pem'), file('b.pem')]);
});
await waitFor(() => expect(hook.result.current.fresh).toBe(true));
return hook;
}
it('ist wahr, wenn die Analyse zu den Eintraegen passt', async () => {
const { result } = await withTwoFiles();
expect(result.current.status).toBe('idle');
expect(result.current.analysis).not.toBeNull();
});
it('ist falsch ohne Eintraege', () => {
const { result } = renderHook(() => useCertWorkspace());
expect(result.current.fresh).toBe(false);
});
it('wird beim Entfernen sofort falsch, noch bevor die neue Antwort da ist', async () => {
const { result } = await withTwoFiles();
let release: (value: typeof EMPTY) => void = () => {};
mockAnalyze.mockReturnValue(new Promise((resolve) => (release = resolve)));
act(() => {
result.current.remove(result.current.entries[0].id);
});
// Die alte Analyse ist noch da, gilt aber nicht mehr
expect(result.current.analysis).not.toBeNull();
expect(result.current.fresh).toBe(false);
expect(result.current.status).toBe('analyzing');
await act(async () => {
release(EMPTY);
});
await waitFor(() => expect(result.current.fresh).toBe(true));
expect(result.current.entries).toHaveLength(1);
});
it('bleibt falsch, wenn die neue Pruefung scheitert (die alte Analyse wird nicht weiter angeboten)', async () => {
const { result } = await withTwoFiles();
mockAnalyze.mockRejectedValue(new CertManagerRequestError(500, null));
act(() => {
result.current.remove(result.current.entries[0].id);
});
await waitFor(() => expect(result.current.status).toBe('error'));
expect(result.current.analysis).not.toBeNull(); // Dateien-Reiter zeigt die letzte bekannte Analyse
expect(result.current.fresh).toBe(false);
});
it('Erneut versuchen stellt fresh nach Erfolg wieder her', async () => {
const { result } = await withTwoFiles();
mockAnalyze.mockRejectedValueOnce(new CertManagerRequestError(500, null));
act(() => {
result.current.remove(result.current.entries[0].id);
});
await waitFor(() => expect(result.current.status).toBe('error'));
mockAnalyze.mockResolvedValue(EMPTY);
act(() => {
result.current.retry();
});
await waitFor(() => expect(result.current.fresh).toBe(true));
});
it('eine Passwortaenderung macht die Analyse bis zur neuen Antwort ungueltig', async () => {
const { result } = await withTwoFiles();
mockAnalyze.mockReturnValue(new Promise(() => {}));
act(() => {
result.current.setPassword(result.current.entries[0].id, 'neu');
});
expect(result.current.fresh).toBe(false);
});
});
@@ -23,6 +23,13 @@ export interface CertWorkspace {
/** Eintrags-Kennungen zum Zeitpunkt dieser Analyse: Quelle `file` zeigt auf diesen Index */
analysisIds: string[];
status: AnalysisStatus;
/**
* Wahr, nur wenn `analysis` genau zu den aktuellen Eintraegen gehoert: keine Analyse laeuft, die
* letzte war erfolgreich und kennt dieselben Eintraege in derselben Reihenfolge. Die Ausgabe-
* Reiter bieten nur dann Dateien an (sonst blieben Zertifikate oder Schluessel einer gerade
* entfernten Datei herunterladbar, Review WR-02).
*/
fresh: boolean;
/** Schluessel unter certManager.errors.* */
errorKey: string | null;
addFiles: (files: File[]) => RejectedFile[];
@@ -147,11 +154,18 @@ export function useCertWorkspace(): CertWorkspace {
runAnalysis(entriesRef.current);
}, [runAnalysis]);
const fresh =
analysis !== null &&
status === 'idle' &&
analysisIds.length === entries.length &&
analysisIds.every((id, i) => id === entries[i].id);
return {
entries,
analysis,
analysisIds,
status,
fresh,
errorKey,
addFiles,
addText,
+19 -6
View File
@@ -1298,7 +1298,8 @@
"suspicious": "Die Datei ist ungewöhnlich stark gepackt und wird übersprungen.",
"zipTooLarge": "Das ZIP enthält zu viele Daten und wird nicht geöffnet.",
"tooManyEntries": "Das ZIP enthält zu viele Dateien und wird nicht geöffnet.",
"unsupportedKey": "Dieser Schlüsseltyp wird nicht unterstützt."
"unsupportedKey": "Dieser Schlüsseltyp wird nicht unterstützt.",
"protectionTooExpensive": "Der Passwortschutz dieser Datei wäre zu aufwendig zu prüfen. Sie wird übersprungen."
},
"pasteToggle": "PEM-Text einfügen",
"pasteHint": "Fügen Sie Text ein, der mit „-----BEGIN“ beginnt. Mehrere Blöcke hintereinander sind möglich.",
@@ -1313,7 +1314,7 @@
"unlock": "Entsperren",
"unlockAnyway": "Trotzdem entsperren",
"wrong": "Das Passwort passt nicht.",
"note": "Das Passwort bleibt in diesem Browserfenster und wird nur zum Öffnen der Datei übertragen."
"note": "Das Passwort bleibt in diesem Browserfenster. Es wird bei jeder Prüfung der Dateien mitgesendet, aber nie gespeichert."
},
"keyWasEncrypted": "war verschlüsselt",
"fetchedFrom": "nachgeladen von {host}"
@@ -1333,12 +1334,16 @@
"aiaInternal": "Die Adresse im Zertifikat verweist nicht auf einen öffentlichen Server und wird nicht abgerufen.",
"aiaNotIssuer": "Die Adresse lieferte kein passendes Zertifikat des Ausstellers.",
"aiaUnreachable": "Der Server des Ausstellers ist nicht erreichbar. Laden Sie das Zertifikat selbst herunter und fügen Sie es hinzu.",
"aiaTooLarge": "Die Antwort des Ausstellers ist zu groß und wurde verworfen."
"aiaTooLarge": "Die Antwort des Ausstellers ist zu groß und wurde verworfen.",
"buildFailed": "Die Datei konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"tooManyItems": "In den Dateien stecken zu viele Zertifikate, Schlüssel oder Anfragen (höchstens 200 Zertifikate, 50 Schlüssel und 50 Anfragen auf einmal). Entfernen Sie einzelne Dateien und versuchen Sie es erneut.",
"aiaNotAllowed": "Die Adresse im Zertifikat darf nicht abgerufen werden. Erlaubt sind nur gewöhnliche http- oder https-Adressen ohne Zugangsdaten und ohne besonderen Anschluss. Laden Sie das Zertifikat selbst herunter und fügen Sie es hinzu."
},
"workspace": {
"empty": "Noch keine Dateien. Laden Sie Ihre Zertifikate im Reiter „Dateien“ hoch.",
"goToFiles": "Zum Reiter „Dateien“",
"basis": "{count, plural, one {Grundlage: 1 Datei aus dem Reiter „Dateien“.} other {Grundlage: # Dateien aus dem Reiter „Dateien“.}}"
"basis": "{count, plural, one {Grundlage: 1 Datei aus dem Reiter „Dateien“.} other {Grundlage: # Dateien aus dem Reiter „Dateien“.}}",
"stale": "Die Ergebnisse passen gerade nicht zu Ihren Dateien. Bitte prüfen Sie die Dateien erneut."
},
"analyze": {
"intro": "Alles, was in Ihren Dateien erkannt wurde: zuerst die Ketten, dann jedes Zertifikat im Einzelnen. Alle Zeiten stehen in UTC, wie im Zertifikat.",
@@ -1385,12 +1390,20 @@
"explainCsr": "Die Anfrage, mit der ein Zertifikat bei der Zertifizierungsstelle bestellt wird."
},
"split": {
"intro": "Jedes erkannte Teil können Sie einzeln in seinem natürlichen Format herunterladen, oder alle zusammen als ZIP.",
"intro": "Jedes erkannte Teil können Sie einzeln in seinem natürlichen Format herunterladen, oder alle zusammen als ZIP. Private Schlüssel bleiben dabei durch ein Passwort geschützt, wenn sie es in Ihrer Datei schon waren.",
"nothingFound": "Es wurde noch nichts erkannt, das sich herunterladen ließe.",
"download": "Herunterladen",
"downloadFor": "„{name}“ herunterladen",
"downloadAll": "Alle als ZIP herunterladen",
"zipFilename": "Zertifikate.zip"
"zipFilename": "Zertifikate.zip",
"keyNote": "Private Schlüssel sind geheim. Schützen Sie die Dateien mit einem Passwort, wenn Sie sie ablegen oder weitergeben.",
"protectKeys": "Schlüssel mit einem Passwort schützen",
"keyPassword": "Passwort für die Schlüssel",
"keysWereProtected": "Mindestens ein Schlüssel war in Ihrer Datei durch ein Passwort geschützt. Ohne den Schutz werden die Schlüsseldateien unverschlüsselt gespeichert. Jeder, der sie bekommt, kann sie benutzen.",
"passwordNeeded": "Bitte geben Sie ein Passwort für die Schlüssel ein, oder schalten Sie den Schutz aus.",
"busy": "Wird vorbereitet …",
"keyProtected": "mit Passwort geschützt",
"keyPlain": "ohne Passwortschutz"
},
"merge": {
"intro": "Tessera ordnet Serverzertifikat, Zwischenzertifikate und Stammzertifikat selbst und prüft dabei, dass jedes Zertifikat wirklich vom nächsten ausgestellt wurde. Die Reihenfolge Ihrer Dateien spielt keine Rolle.",
+19 -6
View File
@@ -1298,7 +1298,8 @@
"suspicious": "The file is packed unusually tightly and is skipped.",
"zipTooLarge": "The ZIP contains too much data and is not opened.",
"tooManyEntries": "The ZIP contains too many files and is not opened.",
"unsupportedKey": "This key type is not supported."
"unsupportedKey": "This key type is not supported.",
"protectionTooExpensive": "The password protection of this file would take too much work to check. It is skipped."
},
"pasteToggle": "Paste PEM text",
"pasteHint": "Paste text that starts with \"-----BEGIN\". Several blocks in a row are fine.",
@@ -1313,7 +1314,7 @@
"unlock": "Unlock",
"unlockAnyway": "Unlock anyway",
"wrong": "The password does not match.",
"note": "The password stays in this browser window and is only sent to open the file."
"note": "The password stays in this browser window. It is sent along with every check of the files, but never stored."
},
"keyWasEncrypted": "was encrypted",
"fetchedFrom": "fetched from {host}"
@@ -1333,12 +1334,16 @@
"aiaInternal": "The address in the certificate does not point to a public server and is not contacted.",
"aiaNotIssuer": "The address did not deliver a matching certificate of the issuer.",
"aiaUnreachable": "The issuer's server cannot be reached. Download the certificate yourself and add it.",
"aiaTooLarge": "The issuer's answer is too large and was discarded."
"aiaTooLarge": "The issuer's answer is too large and was discarded.",
"buildFailed": "The file could not be created. Please try again.",
"tooManyItems": "The files contain too many certificates, keys or requests (at most 200 certificates, 50 keys and 50 requests at once). Remove some files and try again.",
"aiaNotAllowed": "The address in the certificate may not be fetched. Only ordinary http or https addresses without credentials and without a special port are allowed. Download the certificate yourself and add it."
},
"workspace": {
"empty": "No files yet. Upload your certificates in the “Files” tab.",
"goToFiles": "Go to the “Files” tab",
"basis": "{count, plural, one {Based on 1 file from the “Files” tab.} other {Based on # files from the “Files” tab.}}"
"basis": "{count, plural, one {Based on 1 file from the “Files” tab.} other {Based on # files from the “Files” tab.}}",
"stale": "The results do not match your files right now. Please check the files again."
},
"analyze": {
"intro": "Everything recognised in your files: the chains first, then every certificate in detail. All times are shown in UTC, as in the certificate.",
@@ -1385,12 +1390,20 @@
"explainCsr": "The request used to order a certificate from the certification authority."
},
"split": {
"intro": "You can download every recognised part on its own in its natural format, or all of them together as a ZIP.",
"intro": "You can download every detected part on its own in its natural format, or all together as a ZIP. Private keys stay protected by a password if they already were in your file.",
"nothingFound": "Nothing has been recognised yet that could be downloaded.",
"download": "Download",
"downloadFor": "Download \"{name}\"",
"downloadAll": "Download all as ZIP",
"zipFilename": "Certificates.zip"
"zipFilename": "Certificates.zip",
"keyNote": "Private keys are secret. Protect the files with a password if you store or pass them on.",
"protectKeys": "Protect keys with a password",
"keyPassword": "Password for the keys",
"keysWereProtected": "At least one key was protected by a password in your file. Without protection the key files are saved unencrypted. Anyone who gets them can use them.",
"passwordNeeded": "Please enter a password for the keys, or switch the protection off.",
"busy": "Preparing …",
"keyProtected": "protected by a password",
"keyPlain": "without password protection"
},
"merge": {
"intro": "Tessera orders the server certificate, intermediate certificates and root certificate itself and checks that each certificate was really issued by the next one. The order of your files does not matter.",
@@ -263,4 +263,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'klassischen',
'SSLHostConfig',
'PASSWORT',
// quick-261009-ikt Review-Korrekturen: Aufteilen mit Schlüsselschutz, Grenzen
'Passwortschutz',
'Anschluss',
'passen',
'Schlüsseldateien',
'unverschlüsselt',
];