feat(cert-manager): Fehlendes Zertifikat holen, gehärteter Adressschutz

- Neuer Knopf „Fehlendes Zertifikat holen“ nur auf Klick: POST fetch-issuer liest die
  Aussteller-Adresse (AIA) serverseitig aus dem Zertifikat, nie vom Browser; nur Standardport,
  Adressschutz vor jedem Sprung, Aufloesung beim Verbinden geprueft, 8 s und 256 KiB, hoechstens
  3 Weiterleitungen; angenommen wird nur ein Zertifikat, das wirklich ausgestellt hat
- Geholte Zertifikate erscheinen als „nachgeladen von <Server>“ in der Liste und auf der Karte
- Gemeinsamer Adressschutz gehaertet: versteckte IPv6-Schreibweisen interner Adressen
  (IPv4-gemappt in Hex, NAT64, 6to4, Teredo, Zonenkennung u. a.), neues Spec
- Modul-Changelog 1.2.0, CHANGELOG (Sicherheit), drei Anleitungen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:10:39 +02:00
parent 47b26219b2
commit a2fc2cb300
32 changed files with 1630 additions and 36 deletions
@@ -245,6 +245,30 @@ export async function buildOutput(input: BuildInput): Promise<BuildResult> {
return (await response.json()) as BuildResult;
}
/** Antwort von POST fetch-issuer: das geholte Ausstellerzertifikat (PEM), der Server und sein Name. */
export interface FetchIssuerResult {
filename: string;
pem: string;
host: string;
cn: string;
}
/**
* POST /modules/cert-manager/fetch-issuer: holt das Ausstellerzertifikat zu einem Zertifikat.
* Nur auf Knopfdruck. Gesendet wird allein das Zertifikat; die Adresse liest der Server selbst
* aus dem Zertifikat, der Browser nennt nie eine Adresse.
*/
export async function fetchIssuer(pem: string): Promise<FetchIssuerResult> {
const response = await fetch(`${API_URL}/modules/cert-manager/fetch-issuer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pem }),
credentials: 'include',
});
if (!response.ok) throw await errorFromResponse(response);
return (await response.json()) as FetchIssuerResult;
}
/** Laedt eine Base64-Datei als Browser-Download herunter. Der Dateiname enthaelt nie ein Passwort. */
export function downloadBase64(filename: string, content: string, mimeType: string): void {
const bytes = atob(content);
@@ -69,6 +69,7 @@ function workspace(
errorKey: null,
addFiles: () => [],
addText: () => null,
addFetched: () => 'added',
setPassword: () => {},
remove: () => {},
clear: () => {},
@@ -146,6 +147,25 @@ describe('AnalyzeTab', () => {
expect(within(chainsRegion).getByText(/Zwischenzertifikat fehlt/)).toBeInTheDocument();
});
it('markiert ein nachgeladenes Zertifikat mit dem Server, von dem es kam', () => {
const fetchedEntry: WorkingEntry = {
...entry('e2', 'Test_Inter.crt'),
origin: 'fetched',
host: 'pki.example.test',
};
render(
<AnalyzeTab
workspace={workspace(
{ items: [inter], chains: [], locked: [], ignored: [] },
[entry('e1', 'a.pem'), fetchedEntry],
['e1', 'e2'],
)}
/>,
);
// inter hat die Quelle `file: 1`, also den nachgeladenen Eintrag
expect(screen.getByText('nachgeladen von pki.example.test')).toBeInTheDocument();
});
it('eine Karte je Zertifikat mit Rolle, Name, Aussteller, Gueltigkeit in UTC, Namen, Schluessel', () => {
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
const cards = screen.getAllByTestId('cert-card');
@@ -46,7 +46,12 @@ export function AnalyzeTab({ workspace }: AnalyzeTabProps) {
<section className="space-y-3" aria-label={t('analyze.chainsTitle')}>
<h2 className="text-sm font-semibold text-foreground">{t('analyze.chainsTitle')}</h2>
{analysis.chains.map((chain) => (
<ChainView key={chain.headId} chain={chain} certs={certs} />
<ChainView
key={chain.headId}
chain={chain}
certs={certs}
onFetched={workspace.addFetched}
/>
))}
</section>
)}
@@ -0,0 +1,188 @@
import {
act,
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
import type { CertItem, ChainInfo, FetchIssuerResult } from '../actions';
import { CertManagerRequestError } from '../actions';
import { ChainView } from './ChainView';
const mockFetchIssuer = vi.fn();
vi.mock('../actions', async (importOriginal) => {
const actual = await importOriginal<typeof import('../actions')>();
return { ...actual, fetchIssuer: (...args: unknown[]) => mockFetchIssuer(...args) };
});
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
function cert(id: string, cn: string, role: CertItem['role'], over: Partial<CertItem> = {}) {
return {
id,
kind: 'certificate',
role,
sources: [{ file: 0, path: 'a.pem' }],
pem: `PEM-${id}`,
baseName: cn,
cn,
organization: '',
issuerCn: 'Test Inter',
issuerOrganization: '',
notBefore: '2026-01-01T00:00:00.000Z',
notAfter: '2126-01-01T00:00:00.000Z',
isExpired: false,
daysLeft: 36000,
san: [],
keyType: 'RSA',
keyBits: 2048,
curve: null,
serialNumber: '01',
sha256: '',
sha1: '',
isCa: role !== 'end-entity',
selfSigned: false,
aiaIssuerUrls: [],
keyId: null,
csrIds: [],
...over,
} satisfies CertItem;
}
const leaf = cert('c-leaf', 'www.example.test', 'end-entity');
const inter = cert('c-inter', 'Test Inter', 'intermediate');
function chain(kind: 'afterLeaf' | 'afterCa', aiaUrls: string[]): ChainInfo {
return {
headId: 'c-leaf',
path: kind === 'afterLeaf' ? ['c-leaf'] : ['c-leaf', 'c-inter'],
rootId: null,
complete: false,
alternatives: 0,
gap: {
certId: kind === 'afterLeaf' ? 'c-leaf' : 'c-inter',
kind,
missingIssuerCn: kind === 'afterLeaf' ? 'Test Inter' : 'Test Root',
aiaUrls,
},
};
}
const fetched: FetchIssuerResult = {
filename: 'Test_Inter.crt',
pem: '-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n',
host: 'pki.example.test',
cn: 'Test Inter',
};
beforeEach(() => {
mockFetchIssuer.mockReset();
});
afterEach(cleanup);
describe('ChainView, fehlendes Zertifikat holen', () => {
const aia = ['http://pki.example.test/inter.cer'];
it('zeigt bei einer Luecke mit Adresse den Knopf und den Hinweis mit dem Server', () => {
render(<ChainView chain={chain('afterLeaf', aia)} certs={[leaf]} onFetched={() => 'added'} />);
expect(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' })).toBeEnabled();
expect(screen.getByText(/pki\.example\.test/)).toBeInTheDocument();
});
it('zeigt den Knopf auch bei einer Luecke hinter einem Zwischenzertifikat', () => {
render(
<ChainView chain={chain('afterCa', aia)} certs={[leaf, inter]} onFetched={() => 'added'} />,
);
expect(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' })).toBeInTheDocument();
});
it('ohne Adresse im Zertifikat gibt es keinen Knopf, nur den Hinweis zum Herunterladen', () => {
render(<ChainView chain={chain('afterLeaf', [])} certs={[leaf]} onFetched={() => 'added'} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByText(/keine Adresse zum Nachladen/)).toBeInTheDocument();
cleanup();
render(
<ChainView chain={chain('afterCa', [])} certs={[leaf, inter]} onFetched={() => 'added'} />,
);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('ohne onFetched zeigt die Kette keinen Knopf', () => {
render(<ChainView chain={chain('afterLeaf', aia)} certs={[leaf]} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('holt nichts beim Anzeigen und nichts beim erneuten Anzeigen', () => {
const view = render(
<ChainView chain={chain('afterLeaf', aia)} certs={[leaf]} onFetched={() => 'added'} />,
);
view.rerender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
<ChainView chain={chain('afterLeaf', aia)} certs={[leaf]} onFetched={() => 'added'} />
</NextIntlClientProvider>,
);
expect(mockFetchIssuer).not.toHaveBeenCalled();
});
it('ein Klick schickt genau das Zertifikat mit der Luecke, der Knopf ist waehrenddessen gesperrt', async () => {
let resolve: (value: FetchIssuerResult) => void = () => {};
mockFetchIssuer.mockReturnValue(new Promise<FetchIssuerResult>((r) => (resolve = r)));
const onFetched = vi.fn(() => 'added' as const);
render(<ChainView chain={chain('afterCa', aia)} certs={[leaf, inter]} onFetched={onFetched} />);
fireEvent.click(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' }));
const busy = await screen.findByRole('button', { name: 'Wird geholt …' });
expect(busy).toBeDisabled();
fireEvent.click(busy);
expect(mockFetchIssuer).toHaveBeenCalledTimes(1);
expect(mockFetchIssuer).toHaveBeenCalledWith('PEM-c-inter');
await act(async () => resolve(fetched));
expect(onFetched).toHaveBeenCalledWith(fetched);
expect(onFetched).toHaveBeenCalledTimes(1);
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' })).toBeEnabled(),
);
});
it('Fehlercodes der API zeigen ihren Text', async () => {
mockFetchIssuer.mockRejectedValue(new CertManagerRequestError(502, 'aiaUnreachable'));
render(<ChainView chain={chain('afterLeaf', aia)} certs={[leaf]} onFetched={() => 'added'} />);
fireEvent.click(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' }));
expect(
await screen.findByText(/Server des Ausstellers ist nicht erreichbar/),
).toBeInTheDocument();
});
it('ein unbekannter Fehler zeigt den allgemeinen Hinweis zum Nachladen', async () => {
mockFetchIssuer.mockRejectedValue(new Error('boom'));
render(<ChainView chain={chain('afterLeaf', aia)} certs={[leaf]} onFetched={() => 'added'} />);
fireEvent.click(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' }));
expect(
await screen.findByText(/Das Zertifikat konnte nicht geholt werden/),
).toBeInTheDocument();
});
it('dasselbe Zertifikat noch einmal: ruhiger Hinweis statt Doppelung', async () => {
mockFetchIssuer.mockResolvedValue(fetched);
render(
<ChainView chain={chain('afterLeaf', aia)} certs={[leaf]} onFetched={() => 'duplicate'} />,
);
fireEvent.click(screen.getByRole('button', { name: 'Fehlendes Zertifikat holen' }));
expect(
await screen.findByText('Dieses Zertifikat ist schon in der Liste.'),
).toBeInTheDocument();
});
});
@@ -1,19 +1,104 @@
'use client';
import { useFormatter, useTranslations } from 'next-intl';
import type { CertItem, ChainInfo } from '../actions';
import { useState } from 'react';
import {
type CertItem,
type ChainGap,
type ChainInfo,
certErrorKey,
type FetchIssuerResult,
fetchIssuer,
} from '../actions';
import type { AddFetchedOutcome } from '../working-set';
import { ROLE_STYLES } from './FilesTab';
interface ChainViewProps {
chain: ChainInfo;
certs: CertItem[];
/**
* Haengt ein geholtes Zertifikat an den Arbeitsbereich an. Ohne diese Funktion zeigt die Kette
* den Knopf „Fehlendes Zertifikat holen“ nicht.
*/
onFetched?: (fetched: FetchIssuerResult) => AddFetchedOutcome;
}
function hostOf(url: string | undefined): string | null {
if (!url) return null;
try {
return new URL(url).hostname;
} catch {
return null;
}
}
interface FetchMissingProps {
gap: ChainGap;
certs: CertItem[];
onFetched: (fetched: FetchIssuerResult) => AddFetchedOutcome;
}
/**
* „Fehlendes Zertifikat holen“ (D-03): geschieht nur auf Klick, nie beim Anzeigen und nie nach
* einer neuen Pruefung. Gesendet wird das Zertifikat, dem der Aussteller fehlt; die Adresse liest
* der Server selbst aus dem Zertifikat. Ein Klick holt eine Stufe; fehlt danach noch eine,
* erscheint der Knopf an der neuen Luecke wieder.
*/
function FetchMissing({ gap, certs, onFetched }: FetchMissingProps) {
const t = useTranslations('certManager');
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
const host = hostOf(gap.aiaUrls[0]);
if (gap.aiaUrls.length === 0) {
return <p className="mt-2 text-xs">{t('chain.fetchNoAddress')}</p>;
}
const click = async () => {
const cert = certs.find((c) => c.id === gap.certId);
if (!cert || busy) return;
setBusy(true);
setProblem(null);
try {
const fetched = await fetchIssuer(cert.pem);
const outcome = onFetched(fetched);
if (outcome === 'duplicate') setProblem(t('chain.fetchAlready'));
else if (outcome === 'tooMany') setProblem(t('chain.fetchListFull'));
else if (outcome === 'totalTooLarge') setProblem(t('chain.fetchListTooLarge'));
} catch (error) {
const key = certErrorKey(error);
setProblem(key === 'generic' ? t('chain.fetchFailed') : t(`errors.${key}`));
} finally {
setBusy(false);
}
};
return (
<div className="mt-2 space-y-2">
<p className="text-xs">{t('chain.fetchHint', { host: host ?? '' })}</p>
<button
type="button"
onClick={click}
disabled={busy}
aria-busy={busy}
className="btn btn-secondary"
>
{busy ? t('chain.fetching') : t('chain.fetchButton')}
</button>
{problem && (
<p role="alert" className="text-xs font-medium">
{problem}
</p>
)}
</div>
);
}
/**
* Eine Kette in Reihenfolge: Serverzertifikat zuerst, dann jeder Aussteller. Eine duenne Linie
* verbindet die Schritte. Fehlt ein Aussteller, steht darunter, was fehlt (D-18).
*/
export function ChainView({ chain, certs }: ChainViewProps) {
export function ChainView({ chain, certs, onFetched }: ChainViewProps) {
const t = useTranslations('certManager');
const format = useFormatter();
const steps = chain.path
@@ -69,12 +154,18 @@ export function ChainView({ chain, certs }: ChainViewProps) {
})}
</p>
<p className="mt-1 text-xs">{t('chain.gapAfterLeafHint')}</p>
{onFetched && <FetchMissing gap={chain.gap} certs={certs} onFetched={onFetched} />}
</div>
)}
{chain.gap?.kind === 'afterCa' && (
<p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
{t('chain.gapAfterCa', { name: chain.gap.missingIssuerCn || t('chain.unknownIssuer') })}
</p>
<div className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<p>
{t('chain.gapAfterCa', { name: chain.gap.missingIssuerCn || t('chain.unknownIssuer') })}
</p>
{onFetched && chain.gap.aiaUrls.length > 0 && (
<FetchMissing gap={chain.gap} certs={certs} onFetched={onFetched} />
)}
</div>
)}
</div>
);
@@ -94,6 +94,7 @@ function workspace(items: AnyItem[]): CertWorkspace {
errorKey: null,
addFiles: () => [],
addText: () => null,
addFetched: () => 'added',
setPassword: () => {},
remove: () => {},
clear: () => {},
@@ -466,4 +466,48 @@ describe('FilesTab', () => {
expect(container.textContent).not.toContain('geheim-xyz');
});
});
describe('nachgeladene Zertifikate', () => {
const fetchedCert = {
filename: 'inter-nachgeladen.crt',
pem: '-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n',
host: 'pki.example.test',
};
function FetchHarness() {
const workspace = useCertWorkspace();
return (
<>
<button type="button" onClick={() => workspace.addFetched(fetchedCert)}>
nachladen
</button>
<FilesTab workspace={workspace} />
</>
);
}
it('zeigt den Eintrag mit „nachgeladen von“ und sendet ihn mit; ein zweites Mal ergibt keinen zweiten Eintrag', async () => {
render(<FetchHarness />);
selectFiles([makeFile('leaf.pem')]);
await screen.findByText('www.example.test');
fireEvent.click(screen.getByRole('button', { name: 'nachladen' }));
expect(await screen.findByText('inter-nachgeladen.crt')).toBeInTheDocument();
expect(screen.getByText('nachgeladen von pki.example.test')).toBeInTheDocument();
await waitFor(() => expect(mockAnalyze).toHaveBeenCalledTimes(2));
const lastCall = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[];
expect(lastCall.map((e) => e.file.name)).toEqual(['leaf.pem', 'inter-nachgeladen.crt']);
fireEvent.click(screen.getByRole('button', { name: 'nachladen' }));
expect(screen.getAllByText('inter-nachgeladen.crt')).toHaveLength(1);
expect(mockAnalyze).toHaveBeenCalledTimes(2);
});
it('ein hochgeladener Eintrag traegt die Marke nicht', async () => {
render(<Harness />);
selectFiles([makeFile('leaf.pem')]);
await screen.findByText('www.example.test');
expect(screen.queryByText(/nachgeladen von/)).not.toBeInTheDocument();
});
});
});
@@ -351,6 +351,9 @@ export function FilesTab({ workspace }: FilesTabProps) {
<p className="flex flex-wrap gap-x-3 text-xs text-muted-foreground">
<span>{formatBytes(entry.file.size)}</span>
{entry.origin === 'paste' && <span>{t('files.originPaste')}</span>}
{entry.origin === 'fetched' && (
<span>{t('files.fetchedFrom', { host: entry.host ?? '' })}</span>
)}
</p>
</div>
<button
@@ -21,6 +21,20 @@ function sourceLabel(source: ItemSource, entries: WorkingEntry[], analysisIds: s
return source.path;
}
/** Die Server, von denen nachgeladene Quellen dieses Teils kamen (leer, wenn nichts nachgeladen wurde). */
function fetchedHosts(
sources: ItemSource[],
entries: WorkingEntry[],
analysisIds: string[],
): string[] {
const hosts = new Set<string>();
for (const source of sources) {
const entry = entries.find((e) => e.id === analysisIds[source.file]);
if (entry?.origin === 'fetched' && entry.host) hosts.add(entry.host);
}
return [...hosts];
}
type Translate = ReturnType<typeof useTranslations>;
function keyDescription(
@@ -161,6 +175,7 @@ function CertCard({
const keyText = keyDescription(cert, t);
const sources = cert.sources.map((s) => sourceLabel(s, entries, analysisIds));
const fetched = fetchedHosts(cert.sources, entries, analysisIds);
return (
<li className="rounded-lg border border-border p-4" data-testid="cert-card">
@@ -172,6 +187,14 @@ function CertCard({
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
{status.text}
</span>
{fetched.map((host) => (
<span
key={host}
className="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"
>
{t('files.fetchedFrom', { host })}
</span>
))}
{cert.keyId && (
<span className="rounded bg-status-ok/15 px-2 py-0.5 text-xs font-medium text-status-ok-fg">
{t('analyze.matchingKey')}
@@ -78,6 +78,7 @@ function workspace(items: (CertItem | KeyItem)[], chains: ChainInfo[]): CertWork
errorKey: null,
addFiles: () => [],
addText: () => null,
addFetched: () => 'added',
setPassword: () => {},
remove: () => {},
clear: () => {},
@@ -126,7 +126,7 @@ export function MergeTab({ workspace }: MergeTabProps) {
</fieldset>
)}
<ChainView chain={chain} certs={certs} />
<ChainView chain={chain} certs={certs} onFetched={workspace.addFetched} />
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm text-foreground">
@@ -71,6 +71,7 @@ function workspace(items: AnyItem[] | null): CertWorkspace {
errorKey: null,
addFiles: () => [],
addText: () => null,
addFetched: () => 'added',
setPassword: () => {},
remove: () => {},
clear: () => {},
@@ -96,6 +96,7 @@ function workspace(items: (CertItem | KeyItem)[], chains: ChainInfo[]): CertWork
errorKey: null,
addFiles: () => [],
addText: () => null,
addFetched: () => 'added',
setPassword: () => {},
remove: () => {},
clear: () => {},
@@ -189,7 +189,7 @@ export function TemplatesTab({ workspace }: TemplatesTabProps) {
</fieldset>
)}
<ChainView chain={chain} certs={certs} />
<ChainView chain={chain} certs={certs} onFetched={workspace.addFetched} />
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm text-foreground">
@@ -3,8 +3,11 @@
import { useCallback, useRef, useState } from 'react';
import { type AnalysisResult, analyzeWorkingSet, certErrorKey } from './actions';
import {
type AddFetchedOutcome,
addFetched as addFetchedToSet,
addText as addTextToSet,
addFiles as addToSet,
type FetchedCertificate,
type RejectedFile,
removeEntry,
setEntryPassword,
@@ -25,6 +28,8 @@ export interface CertWorkspace {
addFiles: (files: File[]) => RejectedFile[];
/** Eingefuegten PEM-Text als Eintrag anhaengen; `label` liefert die Beschriftung in der Sprache der Oberflaeche */
addText: (text: string, label?: (n: number) => string) => RejectedFile | null;
/** Haengt ein auf Knopfdruck nachgeladenes Zertifikat an (Herkunft „nachgeladen“); doppelt wird es nicht angehaengt */
addFetched: (fetched: FetchedCertificate) => AddFetchedOutcome;
remove: (id: string) => void;
/** Passwort fuer eine Datei setzen und alles neu pruefen; das Passwort lebt nur in diesem Zustand */
setPassword: (id: string, password: string) => void;
@@ -106,6 +111,19 @@ export function useCertWorkspace(): CertWorkspace {
[commit],
);
const addFetched = useCallback(
(fetched: FetchedCertificate): AddFetchedOutcome => {
const result = addFetchedToSet(
entriesRef.current,
fetched,
() => `entry-${++idCounter.current}`,
);
if (result.outcome === 'added') commit(result.entries);
return result.outcome;
},
[commit],
);
const remove = useCallback(
(id: string) => {
commit(removeEntry(entriesRef.current, id));
@@ -137,6 +155,7 @@ export function useCertWorkspace(): CertWorkspace {
errorKey,
addFiles,
addText,
addFetched,
remove,
setPassword,
clear,
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
addFetched,
addFiles,
addText,
MAX_ENTRIES,
@@ -171,3 +172,49 @@ describe('Passwoerter', () => {
expect(entry.file.name).not.toContain('geheim');
});
});
describe('addFetched', () => {
const fetched = {
filename: 'Test_Inter.crt',
pem: '-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n',
host: 'pki.example.test',
};
it('haengt das nachgeladene Zertifikat mit Herkunft und Server an', () => {
const first = addFiles(NONE, [makeFile('a.pem')], nextId);
const result = addFetched(first.entries, fetched, nextId);
expect(result.outcome).toBe('added');
expect(result.entries.map((e) => e.label)).toEqual(['a.pem', 'Test_Inter.crt']);
const added = result.entries[1];
expect(added.origin).toBe('fetched');
expect(added.host).toBe('pki.example.test');
expect(added.file.name).toBe('Test_Inter.crt');
expect(added.password).toBe('');
});
it('dasselbe Zertifikat zweimal wird nicht noch einmal angehaengt', () => {
const once = addFetched(NONE, fetched, nextId);
const twice = addFetched(once.entries, { ...fetched, pem: `${fetched.pem}\n` }, nextId);
expect(twice.outcome).toBe('duplicate');
expect(twice.entries).toBe(once.entries);
});
it('nach dem Entfernen darf es wieder geholt werden', () => {
const once = addFetched(NONE, fetched, nextId);
const removed = removeEntry(once.entries, once.entries[0].id);
expect(addFetched(removed, fetched, nextId).outcome).toBe('added');
});
it('eine volle Liste nimmt nichts mehr an', () => {
const files = Array.from({ length: MAX_ENTRIES }, (_, i) => makeFile(`f${i}.pem`, 10, i));
const full = addFiles(NONE, files, nextId);
const result = addFetched(full.entries, fetched, nextId);
expect(result.outcome).toBe('tooMany');
expect(result.entries).toBe(full.entries);
});
it('wird nach dem Anhaengen mit der Datei in der Analyse gesendet', () => {
const result = addFetched(NONE, fetched, nextId);
expect(toFormData(result.entries).getAll('files')).toHaveLength(1);
});
});
@@ -23,6 +23,8 @@ export interface WorkingEntry {
host: string | null;
/** Passwort fuer diese Datei; bleibt im Arbeitsspeicher, wird nie gespeichert */
password: string;
/** Nur fuer nachgeladene Zertifikate: der PEM-Text, zum Erkennen eines doppelten Nachladens */
pem?: string;
}
export type RejectReason = 'duplicate' | 'tooMany' | 'tooLarge' | 'totalTooLarge' | 'pasteTooLarge';
@@ -122,6 +124,61 @@ export function addText(
};
}
export interface FetchedCertificate {
filename: string;
pem: string;
host: string;
}
export type AddFetchedOutcome = 'added' | 'duplicate' | 'tooMany' | 'totalTooLarge';
export interface AddFetchedResult {
entries: WorkingEntry[];
outcome: AddFetchedOutcome;
}
function normalizedPem(pem: string): string {
return pem.replace(/\s+/g, '');
}
/**
* Haengt ein nachgeladenes Zertifikat an (D-03): Eintrag mit Herkunft „fetched“ und dem Server,
* von dem es kam. Dasselbe Zertifikat wird nicht noch einmal angehaengt (Vergleich des PEM-Texts
* mit den schon nachgeladenen Eintraegen). Wurde der Eintrag entfernt, darf es wieder geholt werden.
*/
export function addFetched(
entries: WorkingEntry[],
fetched: FetchedCertificate,
nextId: () => string,
): AddFetchedResult {
const wanted = normalizedPem(fetched.pem);
if (entries.some((e) => e.pem !== undefined && normalizedPem(e.pem) === wanted)) {
return { entries, outcome: 'duplicate' };
}
const file = new File([fetched.pem], fetched.filename, {
type: 'application/x-pem-file',
lastModified: Date.now(),
});
if (entries.length >= MAX_ENTRIES) return { entries, outcome: 'tooMany' };
const total = entries.reduce((sum, e) => sum + e.file.size, 0);
if (total + file.size > MAX_TOTAL_BYTES) return { entries, outcome: 'totalTooLarge' };
return {
entries: [
...entries,
{
id: nextId(),
file,
label: fetched.filename,
origin: 'fetched',
host: fetched.host,
password: '',
pem: fetched.pem,
},
],
outcome: 'added',
};
}
/** Entfernt einen Eintrag; die Reihenfolge der uebrigen bleibt. */
export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[] {
return entries.filter((e) => e.id !== id);
+11 -2
View File
@@ -1315,7 +1315,8 @@
"wrong": "Das Passwort passt nicht.",
"note": "Das Passwort bleibt in diesem Browserfenster und wird nur zum Öffnen der Datei übertragen."
},
"keyWasEncrypted": "war verschlüsselt"
"keyWasEncrypted": "war verschlüsselt",
"fetchedFrom": "nachgeladen von {host}"
},
"errors": {
"generic": "Die Dateien konnten nicht geprüft werden. Bitte versuchen Sie es erneut.",
@@ -1422,7 +1423,15 @@
"unknownIssuer": "unbekannt",
"gapAfterLeaf": "Zwischenzertifikat fehlt: „{name}“",
"gapAfterLeafHint": "Ohne dieses Zertifikat vertrauen manche Geräte dem Server nicht. Fügen Sie es im Reiter „Dateien“ hinzu.",
"gapAfterCa": "Darüber fehlt noch ein Zertifikat, meist das Stammzertifikat „{name}“. Für eine Fullchain ohne Stammzertifikat wird es nicht gebraucht."
"gapAfterCa": "Darüber fehlt noch ein Zertifikat, meist das Stammzertifikat „{name}“. Für eine Fullchain ohne Stammzertifikat wird es nicht gebraucht.",
"fetchButton": "Fehlendes Zertifikat holen",
"fetching": "Wird geholt …",
"fetchHint": "Tessera fragt dafür nur auf Ihren Klick bei {host} nach, der Adresse des Ausstellers, die im Zertifikat steht. Es wird nichts automatisch abgerufen.",
"fetchNoAddress": "Im Zertifikat steht keine Adresse zum Nachladen. Laden Sie das fehlende Zertifikat beim Aussteller herunter und fügen Sie es im Reiter „Dateien“ hinzu.",
"fetchAlready": "Dieses Zertifikat ist schon in der Liste.",
"fetchListFull": "Die Liste ist voll. Entfernen Sie eine Datei und versuchen Sie es erneut.",
"fetchListTooLarge": "Die Dateien wären zusammen zu groß. Entfernen Sie eine Datei und versuchen Sie es erneut.",
"fetchFailed": "Das Zertifikat konnte nicht geholt werden. Bitte versuchen Sie es später erneut oder laden Sie es beim Aussteller herunter."
},
"passwordInput": {
"show": "Passwort anzeigen",
+11 -2
View File
@@ -1315,7 +1315,8 @@
"wrong": "The password does not match.",
"note": "The password stays in this browser window and is only sent to open the file."
},
"keyWasEncrypted": "was encrypted"
"keyWasEncrypted": "was encrypted",
"fetchedFrom": "fetched from {host}"
},
"errors": {
"generic": "The files could not be checked. Please try again.",
@@ -1422,7 +1423,15 @@
"unknownIssuer": "unknown",
"gapAfterLeaf": "Intermediate certificate missing: “{name}”",
"gapAfterLeafHint": "Without this certificate some devices will not trust the server. Add it in the “Files” tab.",
"gapAfterCa": "A certificate above this one is missing, usually the root certificate “{name}”. A fullchain without the root does not need it."
"gapAfterCa": "A certificate above this one is missing, usually the root certificate “{name}”. A fullchain without the root does not need it.",
"fetchButton": "Fetch missing certificate",
"fetching": "Fetching …",
"fetchHint": "Only when you click, Tessera asks {host}, the issuer address named in the certificate. Nothing is fetched automatically.",
"fetchNoAddress": "The certificate names no address to fetch from. Download the missing certificate from the issuer and add it in the “Files” tab.",
"fetchAlready": "This certificate is already in the list.",
"fetchListFull": "The list is full. Remove a file and try again.",
"fetchListTooLarge": "The files would be too large together. Remove a file and try again.",
"fetchFailed": "The certificate could not be fetched. Please try again later or download it from the issuer."
},
"passwordInput": {
"show": "Show password",