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:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user