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
+3 -3
View File
@@ -19,8 +19,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Dateien, Hochladen und Herunterladen: Dateien und ganze Ordner per Ziehen und Ablegen (auch direkt auf einen Ordner) oder über „Hochladen“ hochladen; große Dateien gehen in Stücken hoch. Die Übertragungsleiste am unteren Rand zeigt den Fortschritt jeder Datei, lässt Abbrechen und Wiederholen zu und nennt Fehler in einfachen Worten. Tessera überschreibt nie stillschweigend: Gibt es den Namen schon, wählen Sie „Ersetzen“, „Beide behalten“ oder „Überspringen“. Dateien werden direkt heruntergeladen, Ordner und Mehrfachauswahl als ZIP-Datei.
- Dateien, Teilen: Im Zeilenmenü einer Datei oder eines Ordners finden Sie jetzt „Teilen“. Dort suchen Sie Kolleginnen, Kollegen und Gruppen aus Ihrer Nextcloud und geben die Datei zum „Ansehen“ oder „Bearbeiten“ frei; die Berechtigung ändern Sie später, und eine Freigabe nehmen Sie mit „Entfernen“ wieder zurück. Außerdem erstellen Sie öffentliche Links mit „Ansehen“ oder „Bearbeiten“, bei Ordnern auch mit „Nur hochladen“, und kopieren sie mit „Link kopieren“. Passwort und Ablaufdatum richten sich nach den Regeln der Firmen-Nextcloud: Verlangt sie ein Passwort, fragt Tessera danach und kann auf Wunsch eines erzeugen. Tessera speichert das Passwort nie und zeigt es nur, solange der Dialog offen ist. Zum Schutz der Nextcloud legt Tessera höchstens 10 neue Freigaben innerhalb von 10 Minuten an.
- Dateien, Übersicht der Freigaben: Die Reiter „Von mir geteilt“ und „Mit mir geteilt“ zeigen alle Freigaben auf einen Blick. Sie öffnen Einträge direkt im zugehörigen Ordner, ändern oder entfernen Ihre eigenen Freigaben, verlassen Freigaben anderer und nehmen offene Freigaben an oder lehnen sie ab. Geteilte Einträge sind in der Dateiliste markiert. Freigaben per E-Mail und an andere Server zeigt Tessera nicht an; sie bleiben der Nextcloud vorbehalten.
- Zertifikat-Manager, ein gemeinsamer Arbeitsbereich: Im ersten Reiter „Dateien“ laden Sie mehrere Dateien nacheinander oder auf einmal hoch, auch ZIP-Dateien vom Zertifikatsaussteller, und können PEM-Text einfügen. Jede Datei bleibt in der Liste, zu jeder steht, was Tessera darin erkannt hat; einzelne Dateien nehmen Sie mit ihrem eigenen Knopf wieder heraus. Passwörter geben Sie je Datei ein. Die Liste, Schlüssel und Passwörter bleiben nur in diesem Browserfenster und sind nach dem Neuladen weg; Tessera speichert sie nirgends. Analysieren, Aufteilen, Zusammenführen, Konvertieren und Vorlagen arbeiten alle mit dieser Liste. Modulversion 1.2.0.
- Zertifikat-Manager, Zusammenführen und Formate: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst und prüft dabei die echte Unterschrift jedes Zertifikats. „Fullchain“ und „Nur Kette“ gibt es als PEM, .p7b und .p7c, dazu Zertifikat und Schlüssel in einer PEM-Datei und PFX-Dateien mit eigenem Passwort, wahlweise „Kompatibel“ (auch für ältere Windows-Server, Vorgabe) oder „Modern“ (AES-256). Das Wurzelzertifikat kommt nur mit, wenn Sie „Root-Zertifikat mitnehmen“ ankreuzen. Fehlt ein Zwischenzertifikat, meldet Tessera es mit Namen und bietet „Fehlendes Zertifikat holen“ an: Nur wenn Sie darauf klicken, holt Tessera das Zertifikat von der Adresse des Ausstellers, die im Zertifikat selbst steht, prüft, dass es das Zertifikat wirklich ausgestellt hat, und kennzeichnet es als „nachgeladen“. Neu im Reiter „Konvertieren“: jedes erkannte Zertifikat, jeder Schlüssel und jede Anfrage in jedes passende Format, auch Schlüssel mit Passwort.
- Zertifikat-Manager, ein gemeinsamer Arbeitsbereich: Im ersten Reiter „Dateien“ laden Sie mehrere Dateien nacheinander oder auf einmal hoch, auch ZIP-Dateien vom Zertifikatsaussteller, und können PEM-Text einfügen. Jede Datei bleibt in der Liste, zu jeder steht, was Tessera darin erkannt hat; einzelne Dateien nehmen Sie mit ihrem eigenen Knopf wieder heraus. Passwörter geben Sie je Datei ein. Die Liste, Schlüssel und Passwörter bleiben nur in diesem Browserfenster und sind nach dem Neuladen weg; Tessera speichert sie nirgends. Analysieren, Aufteilen, Zusammenführen, Konvertieren und Vorlagen arbeiten alle mit dieser Liste und zeigen nach dem Entfernen einer Datei nichts mehr aus der alten Auswertung an. Beim Aufteilen bleibt ein Schlüssel, der in Ihrer Datei durch ein Passwort geschützt war, geschützt (Sie geben ein Passwort ein; ohne Schutz speichern Sie nur ausdrücklich, mit Hinweis). Auf einmal prüft Tessera höchstens 200 Zertifikate, 50 Schlüssel und 50 Zertifikatsanfragen. Modulversion 1.2.0.
- Zertifikat-Manager, Zusammenführen und Formate: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst und prüft dabei die echte Unterschrift jedes Zertifikats. „Fullchain“ und „Nur Kette“ gibt es als PEM, .p7b und .p7c, dazu Zertifikat und Schlüssel in einer PEM-Datei und PFX-Dateien mit eigenem Passwort, wahlweise „Kompatibel“ (auch für ältere Windows-Server, Vorgabe) oder „Modern“ (AES-256). Das Wurzelzertifikat kommt nur mit, wenn Sie „Root-Zertifikat mitnehmen“ ankreuzen. Fehlt ein Zwischenzertifikat, meldet Tessera es mit Namen und bietet „Fehlendes Zertifikat holen“ an: Nur wenn Sie darauf klicken, holt Tessera das Zertifikat von der Adresse des Ausstellers, die im Zertifikat selbst steht, prüft, dass es das Zertifikat wirklich ausgestellt hat, und kennzeichnet es als „nachgeladen“. Neu im Reiter „Konvertieren“: jedes erkannte Zertifikat, jeder Schlüssel und jede Anfrage in jedes passende Format, auch Schlüssel mit Passwort. PFX-Dateien mit Passwörtern, die Umlaute oder das Eurozeichen enthalten, lassen sich jetzt in beide Richtungen mit Windows, OpenSSL und Java austauschen.
- Zertifikat-Manager, Vorlagen: Für Nginx, Apache (ab und vor 2.4.8), Windows/IIS, Nginx Proxy Manager, HAProxy und Tomcat erzeugt Tessera mit einem Klick die passenden Dateien samt den Zeilen für die Einrichtung und einer kurzen Anleitung. Dafür braucht es das Zertifikat und den passenden privaten Schlüssel.
### Geändert
@@ -32,7 +32,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Behoben
- Sicherheit: Der Schutz davor, dass Tessera interne Adressen abruft (Favoriten-Symbole, Logos in Nextcloud-Status und das neue „Fehlendes Zertifikat holen“), erkennt jetzt auch versteckte Schreibweisen interner IPv6-Adressen, zum Beispiel die gemappte Form ::ffff:7f00:1, NAT64 und 6to4. Beim neuen Abruf prüft Tessera die Adresse außerdem noch einmal im Moment des Verbindens.
- Sicherheit: Der Schutz davor, dass Tessera interne Adressen abruft (Favoriten-Symbole, Logos in Nextcloud-Status und das neue „Fehlendes Zertifikat holen“), erkennt jetzt auch versteckte Schreibweisen interner IPv6-Adressen, zum Beispiel die gemappte Form ::ffff:7f00:1, NAT64 und 6to4. Beim neuen Abruf prüft Tessera die Adresse außerdem noch einmal im Moment des Verbindens. Der Zertifikat-Manager ist zudem gegen präparierte Dateien abgesichert, die ihn verlangsamen oder zum Absturz bringen könnten: ZIP-Dateien, die sich zu riesigen Datenmengen entpacken (sogenannte ZIP-Bomben), Dateien aus tausenden unvollständigen Zertifikatsblöcken und Passwortschutz mit übermäßig vielen Rechenschritten werden abgewiesen oder übersprungen, und Uploads über der Gesamtgrenze bricht Tessera schon beim Empfang ab. Auch IPv6-Adressen in eckigen Klammern (zum Beispiel für öffentliche Server) werden dabei jetzt richtig beurteilt.
- Zertifikat-Manager: Beim Zusammenführen ersetzte eine zweite Datei die erste; jetzt bleiben alle Dateien in der Liste. Zertifikate und Schlüssel mit elliptischen Kurven (EC) wurden bisher nicht erkannt; jetzt funktionieren sie in allen Reitern.
- Desktop-App: Dateien lassen sich jetzt auch in der Desktop-App per Ziehen und Ablegen hochladen, zum Beispiel im Modul Dateien oder im Zertifikat-Manager. Bisher übernahm die App das Ablegen selbst, und auf der Seite kam nichts an (Linux und Windows). Dafür ist die neue Version der Desktop-App nötig.
- Nextcloud-Status: Das Formular zum Hinzufügen und Bearbeiten einer Cloud zeigte bei manchen Eingaben englische Meldungen (zum Beispiel bei einer Bildadresse mit http://). Alle Hinweise in diesem Formular erscheinen jetzt auf Deutsch (bei englischer Spracheinstellung auf Englisch).
@@ -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',
];
+4 -4
View File
@@ -153,10 +153,10 @@ Der Zertifikat-Manager hilft Ihnen, Zertifikate, Schlüssel und Zertifikatsanfra
**Wichtig zu Schlüsseln und Passwörtern:** Die Liste lebt nur im Arbeitsspeicher Ihres Browserfensters. Private Schlüssel und Passwörter werden von Tessera nirgends gespeichert und in keinem Protokoll festgehalten. Laden Sie die Seite neu oder schließen Sie das Fenster, ist die Liste weg, und Sie laden Ihre Dateien bei Bedarf noch einmal hoch.
- **Dateien** — Hier sammeln Sie alles. Ziehen Sie Dateien in das Feld oder klicken Sie hinein und wählen Sie Dateien aus, so oft und in beliebiger Menge nacheinander; **jede neue Datei kommt zur Liste hinzu und ersetzt nie eine frühere**. Sie können auch die ZIP-Datei Ihres Zertifikatsausstellers hochladen: Tessera schaut hinein und nennt zu jedem enthaltenen Eintrag, was es darin gefunden hat. Überflüssiges wie Ordner mit dem Namen `__MACOSX` überspringt Tessera still; verschachtelte oder passwortgeschützte ZIP-Dateien nennt es mit dem Grund, warum es sie nicht öffnet. Zusätzlich können Sie PEM-Text einfügen („PEM-Text einfügen“, Text beginnt mit `-----BEGIN`); er erscheint als eigener Eintrag. Die Liste fasst bis zu 30 Einträge, höchstens 5 MB je Datei und zusammen höchstens 10 MB; dieselbe Datei kann nicht zweimal hinzugefügt werden. Zu jedem Eintrag zeigt die Liste, was erkannt wurde: Serverzertifikat, Zwischenzertifikat, Stammzertifikat, privater Schlüssel oder Zertifikatsanfrage. Mit dem Knopf am Eintrag nehmen Sie eine einzelne Datei wieder aus der Liste. Ist eine Datei durch ein Passwort geschützt (PFX-/P12-Datei oder verschlüsselter Schlüssel), erscheint beim Eintrag ein Feld für das Passwort; es gilt nur für diese eine Datei. Hat Tessera zum Serverzertifikat schon einen Schlüssel, erscheint eine passwortgeschützte PFX-Datei nur als ruhiger Hinweis, den Sie nicht beachten müssen.
- **Dateien** — Hier sammeln Sie alles. Ziehen Sie Dateien in das Feld oder klicken Sie hinein und wählen Sie Dateien aus, so oft und in beliebiger Menge nacheinander; **jede neue Datei kommt zur Liste hinzu und ersetzt nie eine frühere**. Sie können auch die ZIP-Datei Ihres Zertifikatsausstellers hochladen: Tessera schaut hinein und nennt zu jedem enthaltenen Eintrag, was es darin gefunden hat. Überflüssiges wie Ordner mit dem Namen `__MACOSX` überspringt Tessera still; verschachtelte oder passwortgeschützte ZIP-Dateien nennt es mit dem Grund, warum es sie nicht öffnet. Zusätzlich können Sie PEM-Text einfügen („PEM-Text einfügen“, Text beginnt mit `-----BEGIN`); er erscheint als eigener Eintrag. Die Liste fasst bis zu 30 Einträge, höchstens 5 MB je Datei und zusammen höchstens 10 MB; dieselbe Datei kann nicht zweimal hinzugefügt werden. Auf einmal prüft Tessera höchstens 200 Zertifikate, 50 Schlüssel und 50 Zertifikatsanfragen; steckt mehr in Ihren Dateien, erscheint ein Hinweis, und Sie nehmen einzelne Dateien aus der Liste. Zu jedem Eintrag zeigt die Liste, was erkannt wurde: Serverzertifikat, Zwischenzertifikat, Stammzertifikat, privater Schlüssel oder Zertifikatsanfrage. Mit dem Knopf am Eintrag nehmen Sie eine einzelne Datei wieder aus der Liste. Ist eine Datei durch ein Passwort geschützt (PFX-/P12-Datei oder verschlüsselter Schlüssel), erscheint beim Eintrag ein Feld für das Passwort; es gilt nur für diese eine Datei und darf Umlaute und Sonderzeichen enthalten. Ein Passwortschutz, den zu prüfen übermäßig aufwendig wäre (mehr als eine Million Rechenschritte, wie sie nur eine absichtlich präparierte Datei verlangt), wird mit einem Hinweis beim Eintrag übersprungen. Hat Tessera zum Serverzertifikat schon einen Schlüssel, erscheint eine passwortgeschützte PFX-Datei nur als ruhiger Hinweis, den Sie nicht beachten müssen.
- **Analysieren** — Zeigt zu jedem erkannten Teil die Einzelheiten: Name, Aussteller, Gültigkeit (mit den verbleibenden Tagen), Schlüsselart (RSA oder EC mit Kurve), die Namen, für die das Zertifikat gilt, sowie Seriennummer und Fingerabdrücke. Außerdem sehen Sie, was zusammengehört: welcher Schlüssel zu welchem Zertifikat passt und welche Zertifikatsanfrage zu welchem Zertifikat gehört. Tessera ordnet nie nach dem Namen zu, sondern prüft den Schlüssel selbst.
- **Aufteilen** — Zerlegt Ihre Dateien in die einzelnen Teile. Jedes Teil laden Sie in seinem natürlichen Format herunter (Zertifikat als `.crt`, Schlüssel als `.key`, Anfrage als `.csr`) oder alle zusammen als ZIP-Datei.
- **Zusammenführen** — Tessera ordnet Serverzertifikat, Zwischenzertifikate und Stammzertifikat **selbst**; die Reihenfolge Ihrer Dateien spielt keine Rolle. Dabei prüft es nicht nur Namen, sondern die echte Unterschrift jedes Zertifikats, sodass ein gleichnamiges, aber falsches Zwischenzertifikat nie verwendet wird. Gibt es mehrere Möglichkeiten, wählt Tessera nachvollziehbar die beste (zum Beispiel nicht abgelaufen). Sie können herunterladen: **Fullchain** (Serverzertifikat plus Zwischenzertifikate), **Nur Kette** (nur die Zwischenzertifikate, zum Beispiel für Systeme, die das Serverzertifikat getrennt wollen), **Zertifikat und Schlüssel** in einer PEM-Datei sowie eine **PFX-Datei**. Fullchain und Nur Kette gibt es als PEM, als `.p7b` oder als binäre `.p7c`. Für die PFX-Datei vergeben Sie ein Passwort (zweimal eingeben) und wählen die Verschlüsselung: **„Kompatibel (auch ältere Windows-Server)“** ist vorgewählt und die sichere Wahl, wenn Sie nicht wissen, wo die Datei eingespielt wird; **„Modern (AES-256)“** ist stärker, wird aber von älteren Systemen wie Windows Server 2016 oft nicht gelesen. Das Häkchen **„Root-Zertifikat mitnehmen“** ist standardmäßig aus, denn die meisten Server und Browser kennen die Wurzel schon und brauchen sie nicht; setzen Sie es nur, wenn ein Gerät es ausdrücklich verlangt (manche Geräte, Java-Anwendungen oder eigene Firmenwurzeln). Fehlt ein Aussteller in Ihrer Liste, sagt Tessera das ausdrücklich: **„Zwischenzertifikat fehlt“** heißt, dass direkt über dem Serverzertifikat das Zwischenzertifikat nicht in der Liste ist; fehlt dagegen nur das Zertifikat ganz oben (meist die Wurzel), erscheint ein ruhiger Hinweis, denn das ist für die meisten Server in Ordnung. Das fehlende Zertifikat können Sie selbst im Reiter „Dateien“ hinzufügen oder, wie unten beschrieben, von Tessera holen lassen.
- **Aufteilen** — Zerlegt Ihre Dateien in die einzelnen Teile. Jedes Teil laden Sie in seinem natürlichen Format herunter (Zertifikat als `.crt`, Schlüssel als `.key`, Anfrage als `.csr`) oder alle zusammen als ZIP-Datei. **Private Schlüssel bleiben geschützt:** War ein Schlüssel in Ihrer Datei durch ein Passwort geschützt, ist oben im Reiter „Schlüssel mit einem Passwort schützen“ schon angekreuzt; Sie geben ein Passwort ein (es darf ein anderes als das alte sein), und die heruntergeladenen Schlüsseldateien sind damit verschlüsselt, auch in der ZIP-Datei. Solange das Passwort fehlt, sind die Knöpfe für die Schlüssel und für die ZIP-Datei gesperrt; die Zertifikate laden Sie trotzdem herunter. Möchten Sie die Schlüssel bewusst ohne Schutz speichern, nehmen Sie das Häkchen heraus; Tessera weist dann ausdrücklich darauf hin, dass die Schlüsseldateien unverschlüsselt sind. Jeder Schlüssel trägt den Namen seines Zertifikats (`name.key`), gibt es keines, wird nummeriert.
- **Zusammenführen** — Tessera ordnet Serverzertifikat, Zwischenzertifikate und Stammzertifikat **selbst**; die Reihenfolge Ihrer Dateien spielt keine Rolle. Dabei prüft es nicht nur Namen, sondern die echte Unterschrift jedes Zertifikats, sodass ein gleichnamiges, aber falsches Zwischenzertifikat nie verwendet wird. Gibt es mehrere Möglichkeiten, wählt Tessera nachvollziehbar die beste (zum Beispiel nicht abgelaufen). Sie können herunterladen: **Fullchain** (Serverzertifikat plus Zwischenzertifikate), **Nur Kette** (nur die Zwischenzertifikate, zum Beispiel für Systeme, die das Serverzertifikat getrennt wollen), **Zertifikat und Schlüssel** in einer PEM-Datei sowie eine **PFX-Datei**. Fullchain und Nur Kette gibt es als PEM, als `.p7b` oder als binäre `.p7c`. Für die PFX-Datei vergeben Sie ein Passwort (zweimal eingeben; Umlaute und Sonderzeichen sind erlaubt, die Datei lässt sich damit auch mit Windows, OpenSSL und Java öffnen) und wählen die Verschlüsselung: **„Kompatibel (auch ältere Windows-Server)“** ist vorgewählt und die sichere Wahl, wenn Sie nicht wissen, wo die Datei eingespielt wird; **„Modern (AES-256)“** ist stärker, wird aber von älteren Systemen wie Windows Server 2016 oft nicht gelesen. Das Häkchen **„Root-Zertifikat mitnehmen“** ist standardmäßig aus, denn die meisten Server und Browser kennen die Wurzel schon und brauchen sie nicht; setzen Sie es nur, wenn ein Gerät es ausdrücklich verlangt (manche Geräte, Java-Anwendungen oder eigene Firmenwurzeln). Fehlt ein Aussteller in Ihrer Liste, sagt Tessera das ausdrücklich: **„Zwischenzertifikat fehlt“** heißt, dass direkt über dem Serverzertifikat das Zwischenzertifikat nicht in der Liste ist; fehlt dagegen nur das Zertifikat ganz oben (meist die Wurzel), erscheint ein ruhiger Hinweis, denn das ist für die meisten Server in Ordnung. Das fehlende Zertifikat können Sie selbst im Reiter „Dateien“ hinzufügen oder, wie unten beschrieben, von Tessera holen lassen.
- **Konvertieren** — Wandelt ein einzelnes Teil in ein anderes Format um: Zertifikate als PEM, DER, PKCS#7 (`.p7b`, `.p7c`); Schlüssel als PKCS#8, klassisch (PKCS#1 bei RSA, SEC1 bei EC) oder DER, auf Wunsch mit eigenem Passwort verschlüsselt; Zertifikatsanfragen als PEM oder DER.
- **Vorlagen** — Wählen Sie das System, auf dem Ihr Zertifikat laufen soll, und Tessera erzeugt mit einem Klick die passenden Dateien und zeigt die Zeilen für die Einrichtung (mit „Kopieren“). Mehrere Dateien kommen in einer ZIP-Datei, die zusätzlich eine kurze Anleitung enthält. Jede Vorlage braucht das Serverzertifikat **und** den dazu passenden privaten Schlüssel; fehlt der Schlüssel, erklärt Tessera das. Es gibt sieben Vorlagen: **Nginx** (`fullchain.pem` und `privkey.pem`); **Apache 2.4.8 und neuer** (ebenfalls `fullchain.pem` und `privkey.pem`); **Apache älter als 2.4.8** (getrennt `cert.pem`, `chain.pem` und `privkey.pem`); **Windows / IIS** (eine PFX-Datei mit dem Passwort, das Sie vergeben, vorgewählt ist „Kompatibel“); **Nginx Proxy Manager** (`certificate.pem`, `intermediate.pem` und `privkey.pem`; die Anleitung nennt, welche Datei in welches Feld unter „SSL Certificates“, „Add SSL Certificate“, „Custom“ gehört); **HAProxy** (eine einzige PEM-Datei mit Zertifikat, Zwischenzertifikaten und Schlüssel) und **Tomcat / Java** (eine `.p12`-Datei mit dem Passwort, das Sie vergeben, ebenfalls mit vorgewähltem „Kompatibel“). Das Passwort steht nie in den angezeigten Zeilen; dort steht stattdessen `IHR-PASSWORT`.
@@ -164,7 +164,7 @@ Der Zertifikat-Manager hilft Ihnen, Zertifikate, Schlüssel und Zertifikatsanfra
- **Nur auf Ihren Klick.** Tessera holt nie von sich aus etwas aus dem Internet, auch nicht beim Öffnen des Reiters oder wenn Sie Dateien hinzufügen oder entfernen. Der Hinweis über dem Knopf nennt den Server, bei dem Tessera nachfragt.
- **Nur eine geprüfte Antwort wird übernommen.** Tessera nimmt das heruntergeladene Zertifikat nur an, wenn es das betroffene Zertifikat wirklich ausgestellt hat (die Unterschrift wird geprüft). Alles andere verwirft Tessera; Sie sehen dann einen Hinweis, dass die Adresse kein passendes Zertifikat geliefert hat.
- **Nur öffentliche Adressen.** Adressen im eigenen Firmennetz oder auf dem Tessera-Server selbst ruft Tessera nie ab, ebenso keine Adressen mit besonderen Anschlussnummern oder Zugangsdaten. Dafür gibt es dann den Hinweis, dass die Adresse nicht abgerufen wird.
- **Nur öffentliche Adressen.** Adressen im eigenen Firmennetz oder auf dem Tessera-Server selbst ruft Tessera nie ab, ebenso keine Adressen mit besonderen Anschlussnummern, mit Zugangsdaten oder ohne http beziehungsweise https. Bei einer internen Adresse lautet der Hinweis, dass sie nicht auf einen öffentlichen Server verweist, bei den anderen, dass die Adresse nicht abgerufen werden darf. Den Knopf zeigt Tessera nur, wenn überhaupt eine abrufbare Adresse im Zertifikat steht.
- **Gekennzeichnet als „nachgeladen“.** Das geholte Zertifikat erscheint in der Liste im Reiter „Dateien“ als eigener Eintrag mit dem Vermerk „nachgeladen von“ und dem Namen des Servers, ebenso auf seiner Karte im Reiter „Analysieren“. Wie jeden anderen Eintrag können Sie ihn mit dem Knopf am Eintrag wieder entfernen.
- **Unter Umständen ein zweiter Klick.** Ein Klick holt genau eine Stufe. Fehlt über dem geholten Zwischenzertifikat noch ein weiteres (zum Beispiel das Stammzertifikat), erscheint an dieser Stelle der Knopf erneut, und Sie können noch einmal klicken. Das Stammzertifikat brauchen Sie für eine Fullchain meist nicht.
- **Wenn es nicht klappt.** Ist der Server des Ausstellers nicht erreichbar (zum Beispiel, weil der Tessera-Server selbst keinen Zugang zum Internet hat), meldet Tessera das. Laden Sie das Zertifikat dann beim Aussteller herunter und fügen Sie es im Reiter „Dateien“ hinzu. Steht im Zertifikat gar keine Adresse, gibt es keinen Knopf, sondern nur diesen Hinweis.
+1 -1
View File
@@ -202,7 +202,7 @@ Das Modul „Zertifikat-Manager“ braucht keine Einstellungen und keine eigene
- **Größe der Anfragen:** Die Dateien gehen über `/api-proxy` an die API. Eine einzelne Analyse schickt höchstens 10 MB (höchstens 30 Dateien, je Datei bis 5 MB). Die Voraussetzung am Proxy ist dieselbe wie im Abschnitt „Dateien (Nextcloud)“: `client_max_body_size` von mindestens `10m`. Beim Herunterladen eines Ergebnisses schickt der Browser nur Zertifikate und höchstens einen Schlüssel als JSON, höchstens 512 KiB je Anfrage (Tessera legt für genau diese Anfrage eine eigene Grenze fest, alle anderen JSON-Anfragen bleiben bei 100 kB).
- **Ausgehender Zugriff:** Der Zertifikat-Manager ruft von sich aus nichts im Internet ab. Eine Ausnahme gibt es: Klickt ein Benutzer auf „Fehlendes Zertifikat holen“, schickt der `api`-Container eine einzelne Anfrage an die Adresse, die im Zertifikat als Aussteller-Adresse steht (meist `http://`, selten `https://`). Dafür muss der `api`-Container ausgehend per HTTP und HTTPS (Ports 80 und 443) ins Internet kommen; andere Ports ruft Tessera nie ab. Ist das ausgehend gesperrt (Firewall, Proxy-Pflicht), meldet der Knopf „nicht erreichbar“, alles andere im Modul funktioniert weiter, und die Benutzer laden das Zertifikat selbst herunter. Tessera ruft dabei nur öffentliche Adressen ab (keine internen Rechner, keine Adressen des eigenen Netzes), prüft die Adresse im Moment des Verbindens noch einmal, folgt höchstens drei Weiterleitungen, begrenzt Wartezeit (8 Sekunden) und Antwortgröße (256 KiB) und übernimmt nur ein Zertifikat, das das betroffene Zertifikat wirklich ausgestellt hat. Im Protokoll steht bei einem Fehler genau eine Zeile mit dem Servernamen und einem Fehlercode, nie ein Zertifikat.
- **Rechenaufwand:** Passwortgeschützte PFX-Dateien und verschlüsselte Schlüssel öffnet Tessera mit den eingegebenen Passwörtern (je Datei höchstens zehn verschiedene Versuche); das kostet kurz Rechenzeit, belastet den Server aber nicht dauerhaft.
- **Rechenaufwand:** Passwortgeschützte PFX-Dateien und verschlüsselte Schlüssel öffnet Tessera mit den eingegebenen Passwörtern (je Datei höchstens zehn verschiedene Versuche); das kostet kurz Rechenzeit, belastet den Server aber nicht dauerhaft. Damit eine präparierte Datei die API nicht ausbremsen kann, gelten Grenzen je Anfrage: eine Passwortableitung höchstens eine Million Runden, alle zusammen höchstens sechs Millionen (mehr überspringt Tessera mit Hinweis), höchstens 200 Zertifikate, 50 Schlüssel und 50 Zertifikatsanfragen sowie 20 MiB für alle Dateien zusammen (Tessera bricht schon beim Empfang ab, mit der Meldung zu große Dateien, HTTP 413). ZIP-Dateien werden mit hartem Deckel entpackt (1 MiB je Eintrag, 20 MiB zusammen).
## 4. Neue Fassung einspielen
+34 -7
View File
@@ -909,22 +909,49 @@ deshalb für die Dauer **eines** synchronen Aufrufs drei Funktionen von `forge.p
(`privateKeyToAsn1`, `wrapRsaPrivateKey`, `certificateToAsn1`) gegen Durchreicher aus und stellt
sie im `finally` wieder her; ASN.1 für Zertifikate und Schlüssel kommt aus `node:crypto`, die
Verschlüsselung und den MAC macht weiterhin forge. Das ist sicher, weil Node einfädig ist und der
Aufruf nicht abgibt; das Spec prüft die Wiederherstellung auch nach einem Fehler. Profile:
Aufruf nicht abgibt; das Spec prüft die Wiederherstellung auch nach einem Fehler. **Passwörter:**
forge leitet den AES-Schlüssel (PBES2/PBKDF2) aus einem Byte je UTF-16-Einheit ab, OpenSSL 3, Windows
und Java nehmen die UTF-8-Bytes; mit Umlaut oder `€` wäre ein „modernes“ PFX sonst für jedes andere
Programm unlesbar (und umgekehrt, Review CR-03). Darum ersetzt `withForgeKdf` (Lesen **und**
Schreiben) zusätzlich `forge.pkcs5.pbkdf2` durch eine Ableitung mit `node:crypto` aus den UTF-8-Bytes
(auch SHA-256/512 nativ statt in reinem JavaScript). Die PKCS#12-eigene Ableitung (3DES, RC2, MAC)
bleibt, wie sie ist: sie nimmt BMPString (UTF-16) aus den Zeichen, genau wie OpenSSL. Dieselbe
Stelle zählt die Ableitungsrunden (siehe „Grenzen je Anfrage“). Profile:
`compat` (3DES, SHA-1, Vorgabe, lesbar bis Windows Server 2016) und `modern` (AES-256, der MAC bleibt
bei forge SHA-1). Die Vorlagen für IIS und Tomcat nutzen dieselbe Funktion (`cert-templates.ts`);
die Vorlagen für Dateien bauen aus denselben Bausteinen wie `buildOutput` und kennen `cert-output.ts`
bewusst nicht (sonst entstünde eine Importschleife).
*ZIP-Grenzen.* `zip-expand.ts` prüft **alles vor dem ersten Entpacken** an den Kopfdaten: höchstens
100 Einträge, 1 MiB je Eintrag, Verhältnis entpackt zu gepackt höchstens 100, Summe 20 MiB, nur eine
Ebene (ein ZIP im ZIP wird gemeldet, nicht geöffnet), verschlüsselte Einträge werden gemeldet.
Eintragsnamen dienen nur der Anzeige und werden nie als Dateipfad benutzt.
*ZIP-Grenzen.* `zip-expand.ts`: höchstens 100 Einträge, 1 MiB je Eintrag, Verhältnis entpackt zu
gepackt höchstens 100, Summe 20 MiB, nur eine Ebene (ein ZIP im ZIP wird gemeldet, nicht geöffnet),
verschlüsselte Einträge werden gemeldet. Die Kopfdaten (deklarierte Größe im Zentralverzeichnis)
sind nur Angreiferwunsch: adm-zip begrenzt die Ausgabe bei deklarierter Größe 0 gar nicht, ein
300-kB-ZIP entpackte dort zu 300 MiB (Review CR-01). Darum entpackt das Modul selbst
(`inflateRawSync` mit `maxOutputLength` = Einzelgrenze, höchstens die Restgrenze der Summe) und prüft
Größe, Verhältnis und CRC am echten Ergebnis; weicht die echte Größe von der deklarierten ab, gilt der
Eintrag als `suspicious`. Eintragsnamen dienen nur der Anzeige und werden nie als Dateipfad benutzt.
*Grenzen je Anfrage.* `cert-budget.ts` (`RequestBudget`, in `analyzeWorkingSet` einmal je Anfrage
angelegt und über `DetectContext.budget` durch alle Stufen gereicht): höchstens 200 Zertifikate, 50
Schlüssel, 50 Zertifikatsanfragen (413 `tooManyItems`; der Kettenbau ist quadratisch). Der Aufwand des
Passwortschutzes ist begrenzt (Review WR-04): eine Ableitung höchstens 1 000 000 Runden, alle zusammen
höchstens 6 000 000 je Anfrage; Runden werden vor dem Rechnen gemeldet (PKCS#12 über die drei
Ableitungsfunktionen von forge, verschlüsseltes PKCS#8 über `pkcs8Iterations` aus den Parametern),
zu teuer ergibt `protectionTooExpensive` für diese Datei. Der PEM-Scanner `pem-scan.ts` ist linear
(Review CR-02): die alte Regex `BEGIN … END` war bei vielen BEGIN-Zeilen ohne END quadratisch und
blockierte die API (5 MiB: Minuten); der Scanner sammelt die END-Stellen je Etikett in einem
Durchlauf und begrenzt die Blöcke auf 1000 je Text. Stellen, die Lesefehler bewusst verschlucken,
reichen die Anfragegrenzen mit `rethrowRequestError` weiter. Beim Empfang zählt
`cert-upload.ts` (eigener multer-Speicher) die Summe mit und bricht bei 20 MiB mit 413 `tooLarge` ab,
statt erst nach 30 vollständig gepufferten Dateien (Review WR-06). Die HTTP-Einrichtung
(`http-setup.ts`, aus `main.ts`) hängt den `build`-Leser **hinter** `enableCors` ein, damit auch
seine 413/400-Antworten CORS-Kopfzeilen tragen (Review WR-01; das Spec prüft die Reihenfolge).
*Grenze des Anfragekörpers von `build` (D-26).* Die Express-Voreinstellung von 100 kB reicht für die
größte erlaubte `build`-Anfrage nicht (Zertifikat, 20 Pool-Zertifikate, Schlüssel und Anfrage zu je
16 384 Zeichen ergeben rund 384 kB). Deshalb hat nur diese Route einen eigenen JSON-Leser mit
512 KiB (`cert-json-body.ts`), den `main.ts` per `app.use(CERT_BUILD_ROUTE, certBuildJsonBody,
certBuildBodyErrors)` **vor** `app.listen` einhängt (Nest registriert seine eigenen Leser erst in
512 KiB (`cert-json-body.ts`), den `configureHttp` in `http-setup.ts` per `app.use(CERT_BUILD_ROUTE,
certBuildJsonBody, certBuildBodyErrors)` nach `enableCors` und **vor** `app.listen` einhängt (Nest registriert seine eigenen Leser erst in
`init()`). Zwei Fallen: Erstens liegt `express` nicht in `apps/api/node_modules`, der Leser kommt
deshalb über `createRequire(...)` aus der Kopie, die auch `@nestjs/platform-express` lädt. Zweitens
**darf die Funktion nicht `jsonParser` heißen**: Nests `ExpressAdapter` überspringt seinen eigenen