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:
@@ -0,0 +1,361 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { fetch as undiciFetch } from 'undici';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createGuardedLookup, fetchIssuer } from './cert-aia';
|
||||
|
||||
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
|
||||
const text = (name: string) => fx(name).toString('utf8');
|
||||
const cert = (name: string) => new X509Certificate(fx(name));
|
||||
|
||||
type FetchImpl = typeof undiciFetch;
|
||||
type Handler = (url: string, init: Record<string, unknown>) => Response | Promise<Response>;
|
||||
|
||||
function fakeFetch(handler: Handler) {
|
||||
const calls: { url: string; init: Record<string, unknown> }[] = [];
|
||||
const impl = (async (url: string, init: Record<string, unknown>) => {
|
||||
calls.push({ url, init });
|
||||
return handler(url, init);
|
||||
}) as unknown as FetchImpl;
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
const answer = (body: ConstructorParameters<typeof Response>[0], init: ResponseInit = {}) =>
|
||||
new Response(body, init);
|
||||
const publicAlways = async () => true;
|
||||
|
||||
async function failure(promise: Promise<unknown>): Promise<{ status: number; code: string }> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
const e = error as { getStatus(): number; getResponse(): { code: string } };
|
||||
return { status: e.getStatus(), code: e.getResponse().code };
|
||||
}
|
||||
throw new Error('expected a throw');
|
||||
}
|
||||
|
||||
let warn: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
describe('fetchIssuer: Antwortformen', () => {
|
||||
it('nimmt das DER-Zertifikat des Ausstellers an und meldet Server, Name und Dateiname', async () => {
|
||||
const { impl, calls } = fakeFetch(() => answer(cert('rsa-inter.pem').raw));
|
||||
const result = await fetchIssuer(text('rsa-leaf.pem'), {
|
||||
fetchImpl: impl,
|
||||
isPublic: publicAlways,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
host: 'pki.example.test',
|
||||
cn: 'Tessera Test Inter RSA',
|
||||
filename: 'Tessera_Test_Inter_RSA.crt',
|
||||
});
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe('http://pki.example.test/rsa-inter.cer');
|
||||
const accepted = new X509Certificate(result.pem);
|
||||
expect(cert('rsa-leaf.pem').checkIssued(accepted)).toBe(true);
|
||||
expect(cert('rsa-leaf.pem').verify(accepted.publicKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('aus einer PKCS#7-Antwort (p7c) kommt nur der echte Aussteller zurueck', async () => {
|
||||
const { impl } = fakeFetch(() => answer(fx('rsa-chain.p7c')));
|
||||
const result = await fetchIssuer(text('rsa-leaf.pem'), {
|
||||
fetchImpl: impl,
|
||||
isPublic: publicAlways,
|
||||
});
|
||||
expect(result.cn).toBe('Tessera Test Inter RSA');
|
||||
expect(result.pem.match(/BEGIN CERTIFICATE/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('versteht auch PEM-Text und PKCS#7 als PEM', async () => {
|
||||
const pemAnswer = fakeFetch(() => answer(text('rsa-inter.pem')));
|
||||
const fromPem = await fetchIssuer(text('rsa-leaf.pem'), {
|
||||
fetchImpl: pemAnswer.impl,
|
||||
isPublic: publicAlways,
|
||||
});
|
||||
expect(fromPem.cn).toBe('Tessera Test Inter RSA');
|
||||
|
||||
const p7bAnswer = fakeFetch(() => answer(text('rsa-chain.p7b')));
|
||||
const fromP7b = await fetchIssuer(text('rsa-leaf.pem'), {
|
||||
fetchImpl: p7bAnswer.impl,
|
||||
isPublic: publicAlways,
|
||||
});
|
||||
expect(fromP7b.cn).toBe('Tessera Test Inter RSA');
|
||||
});
|
||||
|
||||
it('lehnt ein Zertifikat ab, das den Zielschluessel nicht ausgestellt hat (aiaNotIssuer)', async () => {
|
||||
const { impl } = fakeFetch(() => answer(cert('ec-inter.pem').raw));
|
||||
const result = await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
|
||||
);
|
||||
expect(result).toEqual({ status: 422, code: 'aiaNotIssuer' });
|
||||
});
|
||||
|
||||
it('lehnt ein gleichnamiges Zertifikat mit anderem Schluessel ab (Name allein genuegt nicht)', async () => {
|
||||
const { impl } = fakeFetch(() => answer(cert('rsa-inter-decoy.pem').raw));
|
||||
const result = await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
|
||||
);
|
||||
expect(result).toEqual({ status: 422, code: 'aiaNotIssuer' });
|
||||
});
|
||||
|
||||
it('lehnt Antworttext ab, der kein Zertifikat ist', async () => {
|
||||
const { impl } = fakeFetch(() => answer('<html>Not found</html>'));
|
||||
const result = await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
|
||||
);
|
||||
expect(result).toEqual({ status: 422, code: 'aiaNotIssuer' });
|
||||
});
|
||||
|
||||
it('ein selbstsigniertes Zertifikat ohne AIA-Adresse ergibt aiaMissing', async () => {
|
||||
const { impl } = fakeFetch(() => answer(cert('selfsigned-leaf.pem').raw));
|
||||
const result = await failure(
|
||||
fetchIssuer(text('selfsigned-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
|
||||
);
|
||||
expect(result.code).toBe('aiaMissing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchIssuer: Eingabe', () => {
|
||||
it('weist Text ab, der kein Zertifikat ist (notACertificate)', async () => {
|
||||
const { impl, calls } = fakeFetch(() => answer(''));
|
||||
const result = await failure(fetchIssuer('kein Zertifikat', { fetchImpl: impl }));
|
||||
expect(result).toEqual({ status: 400, code: 'notACertificate' });
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ohne AIA-Adresse: aiaMissing, keine Anfrage', async () => {
|
||||
const { impl, calls } = fakeFetch(() => answer(''));
|
||||
const result = await failure(
|
||||
fetchIssuer(text('rsa-leaf-noaki.pem'), { fetchImpl: impl, isPublic: publicAlways }),
|
||||
);
|
||||
expect(result).toEqual({ status: 422, code: 'aiaMissing' });
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('interne Adressen im Zertifikat: aiaInternal, es wird nie angefragt (echter Adressschutz)', async () => {
|
||||
const { impl, calls } = fakeFetch(() => answer(cert('rsa-inter.pem').raw));
|
||||
const result = await failure(fetchIssuer(text('aia-private-leaf.pem'), { fetchImpl: impl }));
|
||||
expect(result).toEqual({ status: 422, code: 'aiaInternal' });
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchIssuer: Weiterleitungen, Grenzen, Zeit', () => {
|
||||
const redirect = (location: string) => answer('', { status: 302, headers: { location } });
|
||||
|
||||
it('verweigert eine Weiterleitung auf eine interne Adresse vor der zweiten Anfrage', async () => {
|
||||
const { impl, calls } = fakeFetch(() => redirect('http://10.0.0.5/x'));
|
||||
const result = await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), {
|
||||
fetchImpl: impl,
|
||||
isPublic: async (url) => !url.hostname.startsWith('10.'),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ status: 422, code: 'aiaInternal' });
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('folgt einer Weiterleitung auf eine oeffentliche Adresse und meldet deren Server', async () => {
|
||||
const { impl, calls } = fakeFetch((url) =>
|
||||
url.startsWith('http://pki.example.test/')
|
||||
? redirect('https://cdn.example.test/rsa-inter.cer')
|
||||
: answer(cert('rsa-inter.pem').raw),
|
||||
);
|
||||
const result = await fetchIssuer(text('rsa-leaf.pem'), {
|
||||
fetchImpl: impl,
|
||||
isPublic: publicAlways,
|
||||
});
|
||||
expect(calls.map((c) => c.url)).toEqual([
|
||||
'http://pki.example.test/rsa-inter.cer',
|
||||
'https://cdn.example.test/rsa-inter.cer',
|
||||
]);
|
||||
expect(result.host).toBe('cdn.example.test');
|
||||
});
|
||||
|
||||
it('verweigert eine Weiterleitung auf einen anderen Port als 80 oder 443', async () => {
|
||||
const { impl, calls } = fakeFetch(() => redirect('http://pki.example.test:8080/x'));
|
||||
const result = await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
|
||||
);
|
||||
expect(result).toEqual({ status: 422, code: 'aiaInternal' });
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('verweigert eine Weiterleitung mit Zugangsdaten und eine auf ein anderes Protokoll', async () => {
|
||||
const withLogin = fakeFetch(() => redirect('http://user:pw@pki.example.test/x'));
|
||||
expect(
|
||||
await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: withLogin.impl, isPublic: publicAlways }),
|
||||
),
|
||||
).toEqual({ status: 422, code: 'aiaInternal' });
|
||||
const ftp = fakeFetch(() => redirect('ftp://pki.example.test/x'));
|
||||
expect(
|
||||
await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: ftp.impl, isPublic: publicAlways }),
|
||||
),
|
||||
).toEqual({ status: 502, code: 'aiaUnreachable' });
|
||||
});
|
||||
|
||||
it('bricht nach drei Weiterleitungen ab (vier sind zu viel)', async () => {
|
||||
const { impl, calls } = fakeFetch(() => redirect('http://pki.example.test/weiter'));
|
||||
const result = await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
|
||||
);
|
||||
expect(result).toEqual({ status: 502, code: 'aiaUnreachable' });
|
||||
expect(calls).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('weist eine angekuendigte Groesse ueber 256 KiB ab, ohne zu lesen', async () => {
|
||||
let pulled = 0;
|
||||
const body = new ReadableStream<Uint8Array>(
|
||||
{
|
||||
pull(controller) {
|
||||
pulled++;
|
||||
controller.enqueue(new Uint8Array(10));
|
||||
controller.close();
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
);
|
||||
const { impl } = fakeFetch(() => answer(body, { headers: { 'content-length': '300000' } }));
|
||||
const result = await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
|
||||
);
|
||||
expect(result).toEqual({ status: 502, code: 'aiaTooLarge' });
|
||||
expect(pulled).toBe(0);
|
||||
});
|
||||
|
||||
it('bricht beim Lesen ab, sobald mehr als 256 KiB eintreffen', async () => {
|
||||
let chunks = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
chunks++;
|
||||
controller.enqueue(new Uint8Array(100 * 1024));
|
||||
},
|
||||
});
|
||||
const { impl } = fakeFetch(() => answer(body));
|
||||
const result = await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
|
||||
);
|
||||
expect(result).toEqual({ status: 502, code: 'aiaTooLarge' });
|
||||
expect(chunks).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it('meldet einen haengenden Server nach dem Zeitlimit als aiaUnreachable', async () => {
|
||||
const { impl } = fakeFetch(() => new Promise<Response>(() => {}));
|
||||
const started = Date.now();
|
||||
const result = await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways, timeoutMs: 40 }),
|
||||
);
|
||||
expect(result).toEqual({ status: 502, code: 'aiaUnreachable' });
|
||||
expect(Date.now() - started).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it('meldet HTTP-Fehler und Verbindungsfehler als aiaUnreachable', async () => {
|
||||
const notFound = fakeFetch(() => answer('', { status: 404 }));
|
||||
expect(
|
||||
await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: notFound.impl, isPublic: publicAlways }),
|
||||
),
|
||||
).toEqual({ status: 502, code: 'aiaUnreachable' });
|
||||
const broken = fakeFetch(() => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
expect(
|
||||
await failure(
|
||||
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: broken.impl, isPublic: publicAlways }),
|
||||
),
|
||||
).toEqual({ status: 502, code: 'aiaUnreachable' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchIssuer: Anfrage und Log', () => {
|
||||
it('sendet keine Cookies, keine Zugangsdaten und keinen eigenen User-Agent', async () => {
|
||||
const { impl, calls } = fakeFetch(() => answer(cert('rsa-inter.pem').raw));
|
||||
await fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways });
|
||||
const init = calls[0].init;
|
||||
const headers = Object.keys(init.headers as Record<string, string>).map((h) => h.toLowerCase());
|
||||
expect(headers).not.toContain('cookie');
|
||||
expect(headers).not.toContain('authorization');
|
||||
expect(headers).not.toContain('user-agent');
|
||||
expect(init.method).toBe('GET');
|
||||
expect(init.redirect).toBe('manual');
|
||||
expect(init.credentials).toBe('omit');
|
||||
});
|
||||
|
||||
it('schreibt bei einem Fehler genau eine Warnzeile mit Server und Code, ohne Zertifikatstext', async () => {
|
||||
const { impl } = fakeFetch(() => answer('', { status: 500 }));
|
||||
await failure(fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }));
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
const line = String(warn.mock.calls[0][0]);
|
||||
expect(line).toContain('pki.example.test');
|
||||
expect(line).toContain('aiaUnreachable');
|
||||
expect(line).not.toContain('BEGIN');
|
||||
expect(line).not.toContain('rsa-inter.cer');
|
||||
});
|
||||
|
||||
it('schreibt bei Erfolg keine Warnzeile', async () => {
|
||||
const { impl } = fakeFetch(() => answer(cert('rsa-inter.pem').raw));
|
||||
await fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways });
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGuardedLookup', () => {
|
||||
type Addr = { address: string; family: number };
|
||||
const resolver = (addresses: Addr[] | Error) =>
|
||||
((_host: string, _options: unknown, cb: (e: Error | null, a: Addr[]) => void) => {
|
||||
if (addresses instanceof Error) cb(addresses, []);
|
||||
else cb(null, addresses);
|
||||
}) as Parameters<typeof createGuardedLookup>[0];
|
||||
|
||||
const run = (
|
||||
lookup: ReturnType<typeof createGuardedLookup>,
|
||||
options: { all?: boolean } = {},
|
||||
): Promise<{ error: Error | null; address: unknown; family?: number }> =>
|
||||
new Promise((resolve) => {
|
||||
lookup('pki.example.test', options, (error, address, family) =>
|
||||
resolve({ error, address, family }),
|
||||
);
|
||||
});
|
||||
|
||||
it('bricht ab, wenn der Name auf eine interne Adresse zeigt, ohne die Adresse zu nennen', async () => {
|
||||
const result = await run(createGuardedLookup(resolver([{ address: '10.0.0.1', family: 4 }])));
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
expect(result.error?.message).not.toContain('10.0.0.1');
|
||||
});
|
||||
|
||||
it('bricht ab, wenn nur eine von mehreren Adressen intern ist', async () => {
|
||||
const lookup = createGuardedLookup(
|
||||
resolver([
|
||||
{ address: '93.184.215.14', family: 4 },
|
||||
{ address: '::ffff:7f00:1', family: 6 },
|
||||
]),
|
||||
);
|
||||
expect((await run(lookup, { all: true })).error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('gibt eine oeffentliche Adresse weiter (einzeln und als Liste)', async () => {
|
||||
const lookup = createGuardedLookup(resolver([{ address: '93.184.215.14', family: 4 }]));
|
||||
const single = await run(lookup);
|
||||
expect(single).toMatchObject({ error: null, address: '93.184.215.14', family: 4 });
|
||||
const all = await run(lookup, { all: true });
|
||||
expect(all.error).toBeNull();
|
||||
expect(all.address).toEqual([{ address: '93.184.215.14', family: 4 }]);
|
||||
});
|
||||
|
||||
it('reicht Aufloesungsfehler und leere Antworten als Fehler weiter', async () => {
|
||||
const failed = await run(createGuardedLookup(resolver(new Error('ENOTFOUND'))));
|
||||
expect(failed.error?.message).toBe('ENOTFOUND');
|
||||
const empty = await run(createGuardedLookup(resolver([])));
|
||||
expect(empty.error).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import * as dns from 'node:dns';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Agent, type Dispatcher, fetch as undiciFetch } from 'undici';
|
||||
import { isPrivateIpAddress, isPublicHttpUrl } from '../common/public-url-guard';
|
||||
import { leadingDerSequence, pkcs7Certificates } from './cert-model';
|
||||
import { safeBaseName } from './cert-names';
|
||||
import { type CertErrorCode, certError } from './cert-types';
|
||||
|
||||
/**
|
||||
* „Fehlendes Zertifikat holen“ (quick-261009-ikt, D-03, D-22).
|
||||
*
|
||||
* Der Server holt das Zwischenzertifikat von der Adresse, die im Zertifikat selbst steht
|
||||
* (Eintrag „CA Issuers“ der Zugriffsinformationen, AIA). Das geschieht nur auf Knopfdruck, ein
|
||||
* Sprung je Klick. Die Adresse kommt nie vom Browser, sondern wird hier aus dem Zertifikat gelesen.
|
||||
*
|
||||
* Schutz (der Server ruft eine Adresse auf, die in einer hochgeladenen Datei steht):
|
||||
* - Nur http/https, ohne Benutzername und Kennwort, hoechstens 2048 Zeichen, nur der
|
||||
* Standardport (80/443); hoechstens drei Adressen werden der Reihe nach versucht.
|
||||
* - Gemeinsamer Adressschutz (`isPublicHttpUrl`) vor der ersten Anfrage UND vor jeder
|
||||
* Weiterleitung; eine abgelehnte Adresse bekommt gar keine Anfrage.
|
||||
* - redirect 'manual', hoechstens 3 Weiterleitungen, jede mit denselben Pruefungen.
|
||||
* - Aufloesung beim Verbinden: der echte Verbindungsaufbau laeuft ueber einen eigenen undici-Agent,
|
||||
* dessen `lookup` (`createGuardedLookup`) jede aufgeloeste Adresse prueft und bei einer nicht
|
||||
* oeffentlichen abbricht. Das schliesst das Fenster fuer DNS-Rebinding fuer diese Funktion.
|
||||
* - Ein Zeitlimit (8 s) je Adresse fuer alle Spruenge und das Lesen; gegen haengende Server wird
|
||||
* zusaetzlich gegen den Abbruch gewettet.
|
||||
* - Groessendeckel 256 KiB: content-length vorab, danach beim Lesen.
|
||||
* - Keine Cookies, keine Zugangsdaten, kein eigener User-Agent.
|
||||
* - Angenommen wird nur ein Zertifikat, das das Zielzertifikat wirklich ausgestellt hat
|
||||
* (`checkIssued` und Signaturpruefung); alles andere ergibt aiaNotIssuer.
|
||||
* - Im Log steht bei einem Fehler genau eine Zeile mit Server und Fehlercode, nie ein
|
||||
* Zertifikat, nie der Pfad der Adresse.
|
||||
*
|
||||
* Bewusst akzeptierter Rest: jeder angemeldete Benutzer des Moduls kann den API-Server dazu
|
||||
* bringen, einen einzigen GET an eine oeffentliche Adresse zu senden, die in einem von ihm
|
||||
* hochgeladenen Zertifikat steht. Zurueck kommt nur ein geprueftes Ausstellerzertifikat, nie
|
||||
* der Antworttext.
|
||||
*/
|
||||
|
||||
export const AIA_TIMEOUT_MS = 8000;
|
||||
export const AIA_MAX_REDIRECTS = 3;
|
||||
export const AIA_MAX_BYTES = 256 * 1024;
|
||||
export const AIA_MAX_URLS = 3;
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
const MAX_ANSWER_CERTIFICATES = 20;
|
||||
|
||||
export interface FetchIssuerResult {
|
||||
filename: string;
|
||||
/** die angenommenen Ausstellerzertifikate als PEM */
|
||||
pem: string;
|
||||
/** der Server, von dem die Antwort kam */
|
||||
host: string;
|
||||
cn: string;
|
||||
}
|
||||
|
||||
export interface FetchIssuerOptions {
|
||||
fetchImpl?: typeof undiciFetch;
|
||||
isPublic?: (url: URL) => Promise<boolean>;
|
||||
timeoutMs?: number;
|
||||
dispatcher?: Dispatcher;
|
||||
}
|
||||
|
||||
type LookupResolver = (
|
||||
hostname: string,
|
||||
options: dns.LookupAllOptions,
|
||||
callback: (error: NodeJS.ErrnoException | null, addresses: dns.LookupAddress[]) => void,
|
||||
) => void;
|
||||
|
||||
type GuardedLookup = (
|
||||
hostname: string,
|
||||
options: dns.LookupOptions,
|
||||
callback: (
|
||||
error: NodeJS.ErrnoException | null,
|
||||
address: string | dns.LookupAddress[],
|
||||
family?: number,
|
||||
) => void,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* `lookup` fuer `net.connect`: loest den Namen auf und bricht ab, sobald EINE der Adressen nicht
|
||||
* oeffentlich ist. Der Verbindungsaufbau benutzt danach genau die geprueften Adressen, so kann
|
||||
* der Name zwischen Pruefung und Verbindung nicht auf intern wechseln. Die Fehlermeldung nennt
|
||||
* keine Adresse.
|
||||
*/
|
||||
export function createGuardedLookup(
|
||||
resolve: LookupResolver = dns.lookup as unknown as LookupResolver,
|
||||
): GuardedLookup {
|
||||
return (hostname, options, callback) => {
|
||||
resolve(hostname, { ...options, all: true }, (error, addresses) => {
|
||||
if (error) {
|
||||
callback(error, '');
|
||||
return;
|
||||
}
|
||||
if (!addresses || addresses.length === 0) {
|
||||
callback(Object.assign(new Error('No address found'), { code: 'ENOTFOUND' }), '');
|
||||
return;
|
||||
}
|
||||
if (addresses.some((entry) => isPrivateIpAddress(entry.address))) {
|
||||
callback(
|
||||
Object.assign(new Error('Refusing to connect to a non-public address'), {
|
||||
code: 'EAIBLOCKED',
|
||||
}),
|
||||
'',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (options.all) {
|
||||
callback(null, addresses);
|
||||
} else {
|
||||
callback(null, addresses[0].address, addresses[0].family);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
let sharedAgent: Agent | undefined;
|
||||
|
||||
function guardedAgent(): Agent {
|
||||
sharedAgent ??= new Agent({
|
||||
connect: { lookup: createGuardedLookup() as never },
|
||||
});
|
||||
return sharedAgent;
|
||||
}
|
||||
|
||||
const logger = new Logger('CertAia');
|
||||
|
||||
function discard(response: { body?: { cancel(): Promise<void> } | null }): void {
|
||||
try {
|
||||
response.body?.cancel().catch(() => {});
|
||||
} catch {
|
||||
// schon verbraucht — nichts zu tun
|
||||
}
|
||||
}
|
||||
|
||||
/** Die „CA Issuers“-Adressen eines Zertifikats: nur http/https, ohne Zugangsdaten, hoechstens drei. */
|
||||
function issuerUrls(target: X509Certificate): URL[] {
|
||||
const info = (target.toLegacyObject() as { infoAccess?: Record<string, unknown> }).infoAccess;
|
||||
const raw = info?.['CA Issuers - URI'];
|
||||
const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
|
||||
const urls: URL[] = [];
|
||||
for (const entry of list) {
|
||||
if (typeof entry !== 'string' || entry.length > MAX_URL_LENGTH) continue;
|
||||
try {
|
||||
const url = new URL(entry);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') continue;
|
||||
if (url.username || url.password) continue;
|
||||
urls.push(url);
|
||||
} catch {
|
||||
// keine gueltige Adresse: ueberspringen
|
||||
}
|
||||
if (urls.length >= AIA_MAX_URLS) break;
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/** Nur der Standardport (leer = 80/443), sonst waere der Abruf ein Portscanner. */
|
||||
function hasDefaultPort(url: URL): boolean {
|
||||
return url.port === '';
|
||||
}
|
||||
|
||||
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g;
|
||||
|
||||
/** Zertifikate aus einer Antwort: DER-Zertifikat, PKCS#7 (DER oder PEM) oder PEM-Text. */
|
||||
function certificatesFromAnswer(data: Buffer): X509Certificate[] {
|
||||
const found: X509Certificate[] = [];
|
||||
const add = (input: Buffer | string): void => {
|
||||
if (found.length >= MAX_ANSWER_CERTIFICATES) return;
|
||||
try {
|
||||
found.push(new X509Certificate(input));
|
||||
} catch {
|
||||
// kein lesbares Zertifikat: ueberspringen
|
||||
}
|
||||
};
|
||||
const addPkcs7 = (der: Buffer): void => {
|
||||
try {
|
||||
for (const certDer of pkcs7Certificates(der)) add(certDer);
|
||||
} catch {
|
||||
// kein lesbares PKCS#7: ueberspringen
|
||||
}
|
||||
};
|
||||
|
||||
if (data.includes('-----BEGIN ')) {
|
||||
const text = data.toString('latin1');
|
||||
for (const match of text.matchAll(PEM_BLOCK)) {
|
||||
const label = match[1];
|
||||
if (label === 'CERTIFICATE' || label === 'X509 CERTIFICATE') {
|
||||
add(match[0]);
|
||||
} else if (label === 'PKCS7' || label === 'CMS') {
|
||||
addPkcs7(Buffer.from(match[2].replace(/\s+/g, ''), 'base64'));
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const sequence = leadingDerSequence(data);
|
||||
if (sequence) {
|
||||
add(sequence);
|
||||
if (found.length === 0) addPkcs7(data);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function issuedBy(target: X509Certificate, candidate: X509Certificate): boolean {
|
||||
try {
|
||||
return (
|
||||
candidate.fingerprint256 !== target.fingerprint256 &&
|
||||
target.checkIssued(candidate) &&
|
||||
target.verify(candidate.publicKey)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type AttemptResult =
|
||||
| { ok: true; issuers: X509Certificate[]; host: string }
|
||||
| { ok: false; code: Extract<CertErrorCode, `aia${string}`> };
|
||||
|
||||
/** Die Rangfolge, wenn alle Adressen scheitern: die Meldung mit dem meisten Fortschritt gewinnt. */
|
||||
const FAILURE_RANK: Record<string, number> = {
|
||||
aiaNotIssuer: 4,
|
||||
aiaTooLarge: 3,
|
||||
aiaUnreachable: 2,
|
||||
aiaInternal: 1,
|
||||
};
|
||||
|
||||
const FAILURE_STATUS: Record<string, number> = {
|
||||
aiaNotIssuer: 422,
|
||||
aiaInternal: 422,
|
||||
aiaTooLarge: 502,
|
||||
aiaUnreachable: 502,
|
||||
};
|
||||
|
||||
const FAILURE_TEXT: Record<string, string> = {
|
||||
aiaNotIssuer: 'The downloaded certificate did not issue this certificate',
|
||||
aiaInternal: 'The issuer address is not a public address',
|
||||
aiaTooLarge: 'The issuer answer is too large',
|
||||
aiaUnreachable: 'The issuer address could not be reached',
|
||||
};
|
||||
|
||||
/**
|
||||
* Holt das Ausstellerzertifikat zum uebergebenen Zertifikat (PEM). Fehler: notACertificate 400,
|
||||
* aiaMissing 422, aiaInternal 422, aiaNotIssuer 422, aiaUnreachable 502, aiaTooLarge 502.
|
||||
*/
|
||||
export async function fetchIssuer(
|
||||
pem: string,
|
||||
opts: FetchIssuerOptions = {},
|
||||
): Promise<FetchIssuerResult> {
|
||||
let target: X509Certificate;
|
||||
try {
|
||||
target = new X509Certificate(pem);
|
||||
} catch {
|
||||
return certError('notACertificate', 400, 'The provided text is not a certificate');
|
||||
}
|
||||
|
||||
const urls = issuerUrls(target);
|
||||
if (urls.length === 0) {
|
||||
return certError('aiaMissing', 422, 'The certificate names no issuer address');
|
||||
}
|
||||
|
||||
const fetchImpl = opts.fetchImpl ?? undiciFetch;
|
||||
const isPublic = opts.isPublic ?? isPublicHttpUrl;
|
||||
const timeoutMs = opts.timeoutMs ?? AIA_TIMEOUT_MS;
|
||||
const dispatcher = opts.dispatcher ?? (opts.fetchImpl ? undefined : guardedAgent());
|
||||
|
||||
const attempt = async (first: URL): Promise<AttemptResult> => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
// Fuer haengende Server, die ein abgebrochenes fetch/read nicht beenden.
|
||||
const aborted = new Promise<'timeout'>((resolve) => {
|
||||
controller.signal.addEventListener('abort', () => resolve('timeout'));
|
||||
});
|
||||
|
||||
const run = async (): Promise<AttemptResult> => {
|
||||
let current = first;
|
||||
for (let hop = 0; ; hop++) {
|
||||
if (!hasDefaultPort(current) || !(await isPublic(current))) {
|
||||
return { ok: false, code: 'aiaInternal' };
|
||||
}
|
||||
|
||||
let response: Awaited<ReturnType<typeof undiciFetch>>;
|
||||
try {
|
||||
response = await fetchImpl(current.toString(), {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
credentials: 'omit',
|
||||
headers: {
|
||||
Accept: 'application/pkix-cert, application/x-pkcs7-certificates, */*;q=0.1',
|
||||
},
|
||||
...(dispatcher ? { dispatcher } : {}),
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, code: 'aiaUnreachable' };
|
||||
}
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
discard(response);
|
||||
if (!location || hop >= AIA_MAX_REDIRECTS) return { ok: false, code: 'aiaUnreachable' };
|
||||
let next: URL;
|
||||
try {
|
||||
next = new URL(location, current);
|
||||
} catch {
|
||||
return { ok: false, code: 'aiaUnreachable' };
|
||||
}
|
||||
if (next.protocol !== 'http:' && next.protocol !== 'https:') {
|
||||
return { ok: false, code: 'aiaUnreachable' };
|
||||
}
|
||||
if (next.username || next.password || next.href.length > MAX_URL_LENGTH) {
|
||||
return { ok: false, code: 'aiaInternal' };
|
||||
}
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
discard(response);
|
||||
return { ok: false, code: 'aiaUnreachable' };
|
||||
}
|
||||
|
||||
const declared = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declared) && declared > AIA_MAX_BYTES) {
|
||||
discard(response);
|
||||
return { ok: false, code: 'aiaTooLarge' };
|
||||
}
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.length;
|
||||
if (total > AIA_MAX_BYTES) {
|
||||
reader.cancel().catch(() => {});
|
||||
return { ok: false, code: 'aiaTooLarge' };
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} catch {
|
||||
reader.cancel().catch(() => {});
|
||||
return { ok: false, code: 'aiaUnreachable' };
|
||||
}
|
||||
}
|
||||
|
||||
const issuers = certificatesFromAnswer(Buffer.concat(chunks)).filter((candidate) =>
|
||||
issuedBy(target, candidate),
|
||||
);
|
||||
if (issuers.length === 0) return { ok: false, code: 'aiaNotIssuer' };
|
||||
return { ok: true, issuers, host: current.hostname };
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const outcome = await Promise.race([run(), aborted]);
|
||||
return outcome === 'timeout' ? { ok: false, code: 'aiaUnreachable' } : outcome;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
let worst: AttemptResult & { ok: false } = { ok: false, code: 'aiaInternal' };
|
||||
let rank = 0;
|
||||
for (const url of urls) {
|
||||
const result = await attempt(url);
|
||||
if (result.ok) {
|
||||
const first = result.issuers[0];
|
||||
const subject = (first.toLegacyObject() as { subject?: Record<string, unknown> }).subject;
|
||||
const rawCn = subject?.CN;
|
||||
const cn = (Array.isArray(rawCn) ? rawCn[0] : rawCn) as unknown;
|
||||
const name = typeof cn === 'string' ? cn : '';
|
||||
return {
|
||||
filename: `${safeBaseName(name, 'zertifikat')}.crt`,
|
||||
pem: result.issuers.map((c) => `${c.toString().trim()}\n`).join(''),
|
||||
host: result.host,
|
||||
cn: name,
|
||||
};
|
||||
}
|
||||
if ((FAILURE_RANK[result.code] ?? 0) > rank) {
|
||||
rank = FAILURE_RANK[result.code] ?? 0;
|
||||
worst = result;
|
||||
}
|
||||
}
|
||||
|
||||
const hosts = [...new Set(urls.map((u) => u.hostname))].join(', ');
|
||||
logger.warn(`Abruf des Ausstellerzertifikats von ${hosts} fehlgeschlagen: ${worst.code}`);
|
||||
return certError(worst.code, FAILURE_STATUS[worst.code], FAILURE_TEXT[worst.code]);
|
||||
}
|
||||
@@ -30,6 +30,11 @@ export const CERT_MANAGER_CHANGELOG: ModuleChangelog = [
|
||||
de: 'Vorlagen für Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy und Tomcat liefern die passenden Dateien mit einem Klick.',
|
||||
en: 'Templates for Nginx, Apache, Windows/IIS, Nginx Proxy Manager, HAProxy and Tomcat deliver the right files with one click.',
|
||||
},
|
||||
{
|
||||
kind: 'new',
|
||||
de: 'Fehlt ein Zwischenzertifikat, holt „Fehlendes Zertifikat holen“ es auf Knopfdruck beim Aussteller.',
|
||||
en: 'If an intermediate certificate is missing, “Fetch missing certificate” gets it from the issuer at the click of a button.',
|
||||
},
|
||||
{
|
||||
kind: 'fixed',
|
||||
de: 'Beim Zusammenführen ersetzt eine zweite Datei nicht mehr die erste.',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { MODULE_SLUG_KEY } from '../module-registry/module.guard';
|
||||
import { CertManagerController, parsePasswords, repairFileName } from './cert-manager.controller';
|
||||
import { BuildOutputDto, CERT_PEM_MAX } from './dto/cert-build.dto';
|
||||
import { FetchIssuerDto } from './dto/cert-fetch-issuer.dto';
|
||||
|
||||
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
|
||||
const upload = (name: string, buffer: Buffer = fx(name)) => ({
|
||||
@@ -33,14 +34,15 @@ describe('CertManagerController', () => {
|
||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, CertManagerController)).toBe('cert-manager');
|
||||
});
|
||||
|
||||
it('bietet in diesem Stand genau die Handler analyze und build (POST, Code 200)', () => {
|
||||
it('bietet genau die Handler analyze, build und fetchIssuer (POST, Code 200)', () => {
|
||||
const handlers = Object.getOwnPropertyNames(CertManagerController.prototype).filter(
|
||||
(n) => n !== 'constructor',
|
||||
);
|
||||
expect(handlers).toEqual(['analyze', 'build']);
|
||||
expect(handlers).toEqual(['analyze', 'build', 'fetchIssuer']);
|
||||
for (const [name, path] of [
|
||||
['analyze', 'analyze'],
|
||||
['build', 'build'],
|
||||
['fetchIssuer', 'fetch-issuer'],
|
||||
] as const) {
|
||||
const handler = CertManagerController.prototype[name];
|
||||
expect(Reflect.getMetadata('path', handler)).toBe(path);
|
||||
@@ -193,3 +195,30 @@ describe('build: Anfrage und Pruefung', () => {
|
||||
expect((dto as unknown as Record<string, unknown>).url).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch-issuer: Anfrage', () => {
|
||||
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||
const validate = (body: object) =>
|
||||
pipe.transform(body, { type: 'body', metatype: FetchIssuerDto }) as Promise<FetchIssuerDto>;
|
||||
const pem = fx('rsa-leaf.pem').toString('utf8');
|
||||
|
||||
it('nimmt ein Zertifikat an und entfernt jedes weitere Feld, auch eine Adresse', async () => {
|
||||
const dto = await validate({ pem, url: 'http://127.0.0.1/', host: 'x' });
|
||||
expect(dto.pem).toBe(pem);
|
||||
expect((dto as unknown as Record<string, unknown>).url).toBeUndefined();
|
||||
expect((dto as unknown as Record<string, unknown>).host).toBeUndefined();
|
||||
});
|
||||
|
||||
it('weist ein zu langes, ein leeres und ein fehlendes Zertifikat ab', async () => {
|
||||
await expect(validate({ pem: 'p'.repeat(CERT_PEM_MAX + 1) })).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
await expect(validate({ pem: '' })).rejects.toMatchObject({ status: 400 });
|
||||
await expect(validate({})).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('nimmt genau die Obergrenze noch an', async () => {
|
||||
const dto = await validate({ pem: 'p'.repeat(CERT_PEM_MAX) });
|
||||
expect(dto.pem.length).toBe(CERT_PEM_MAX);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,12 @@ import { Body, Controller, HttpCode, Post, UploadedFiles, UseInterceptors } from
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import { type FetchIssuerResult, fetchIssuer } from './cert-aia';
|
||||
import { analyzeWorkingSet } from './cert-analyze';
|
||||
import { buildOutput } from './cert-output';
|
||||
import { type AnalysisResult, type BuildResult, certError } from './cert-types';
|
||||
import { BuildOutputDto } from './dto/cert-build.dto';
|
||||
import { FetchIssuerDto } from './dto/cert-fetch-issuer.dto';
|
||||
|
||||
/** Obergrenzen (D-17): je Datei 5 MiB, alle Dateien zusammen 20 MiB, hoechstens 30 Dateien. */
|
||||
export const CERT_MAX_FILES = 30;
|
||||
@@ -60,7 +62,8 @@ export function repairFileName(name: string): string {
|
||||
* Routen (alle POST, 200):
|
||||
* - analyze Task 1 mehrere Dateien (multipart) erkennen und zusammenfassen; Task 4: optionales Feld `passwords`
|
||||
* - build Task 2 Ausgabe bauen (JSON, eigene Grenze 512 KiB, siehe cert-json-body.ts), ab Task 5/6 erweitert
|
||||
* - fetch-issuer Task 7 fehlendes Zwischenzertifikat nur auf Knopfdruck holen
|
||||
* - fetch-issuer Task 7 fehlendes Zwischenzertifikat nur auf Knopfdruck holen (JSON, nur `pem`;
|
||||
* die Adresse liest der Server aus dem Zertifikat, siehe cert-aia.ts)
|
||||
*/
|
||||
@Controller('modules/cert-manager')
|
||||
@UseModule('cert-manager')
|
||||
@@ -93,4 +96,10 @@ export class CertManagerController {
|
||||
build(@Body() dto: BuildOutputDto): BuildResult {
|
||||
return buildOutput(dto);
|
||||
}
|
||||
|
||||
@Post('fetch-issuer')
|
||||
@HttpCode(200)
|
||||
fetchIssuer(@Body() dto: FetchIssuerDto): Promise<FetchIssuerResult> {
|
||||
return fetchIssuer(dto.pem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ const OID_SIGNED_DATA = '1.2.840.113549.1.7.2';
|
||||
* Reiner ASN.1-Lauf: ContentInfo -> [0] SignedData -> [0] certificates. Jedes Zertifikat wird
|
||||
* als DER an node:crypto gegeben; die RSA-only-Zertifikatsleser von forge kommen nie vor.
|
||||
*/
|
||||
function pkcs7Certificates(der: Buffer): Buffer[] {
|
||||
export function pkcs7Certificates(der: Buffer): Buffer[] {
|
||||
// Die Typdefinition kennt nur `strict: boolean`; forge nimmt zur Laufzeit ein Optionsobjekt.
|
||||
// decodeBitStrings aus: Bitfolgen bleiben unveraendert, damit die Zertifikats-Bytes beim
|
||||
// erneuten Schreiben mit den Originalen uebereinstimmen (gleicher Fingerabdruck).
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
import { CERT_PEM_MAX } from './cert-build.dto';
|
||||
|
||||
/**
|
||||
* Anfrage fuer POST fetch-issuer (quick-261009-ikt, D-22): nur das Zertifikat, dessen Aussteller
|
||||
* fehlt. Eine Adresse wird nie angenommen: die globale ValidationPipe (whitelist) entfernt jedes
|
||||
* weitere Feld, die Adresse liest der Server selbst aus dem Zertifikat.
|
||||
*/
|
||||
export class FetchIssuerDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CERT_PEM_MAX)
|
||||
pem!: string;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const lookupMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('node:dns/promises', () => ({ lookup: lookupMock }));
|
||||
|
||||
import { isPrivateIpAddress, isPublicHttpUrl } from './public-url-guard';
|
||||
|
||||
describe('isPrivateIpAddress', () => {
|
||||
const blocked = [
|
||||
// IPv4
|
||||
'127.0.0.1',
|
||||
'10.1.2.3',
|
||||
'100.64.0.1',
|
||||
'169.254.169.254',
|
||||
'172.16.0.1',
|
||||
'192.168.1.1',
|
||||
'0.0.0.0',
|
||||
// IPv6: unspezifiziert, Loopback
|
||||
'::',
|
||||
'::1',
|
||||
// IPv4-gemappt in allen Schreibweisen
|
||||
'::ffff:127.0.0.1',
|
||||
'::ffff:7f00:1',
|
||||
'0:0:0:0:0:ffff:7f00:1',
|
||||
'::ffff:a9fe:a9fe',
|
||||
'::FFFF:7F00:1',
|
||||
// IPv4-kompatibel
|
||||
'::7f00:1',
|
||||
// NAT64
|
||||
'64:ff9b::7f00:1',
|
||||
'64:ff9b::10.0.0.1',
|
||||
'64:ff9b:1::1',
|
||||
// 6to4
|
||||
'2002:7f00:1::1',
|
||||
'2002:c0a8:101::1',
|
||||
// Unique-Local, Link-Local, Site-Local, Multicast
|
||||
'fc00::1',
|
||||
'fd12::1',
|
||||
'fe80::1',
|
||||
'fe80::1%eth0',
|
||||
'febf::1',
|
||||
'fec0::1',
|
||||
'ff02::1',
|
||||
// Dokumentation, Verwerfen, Teredo
|
||||
'2001:db8::1',
|
||||
'3fff::1',
|
||||
'100::1',
|
||||
'2001::1',
|
||||
// nicht lesbar
|
||||
'not-an-address',
|
||||
'',
|
||||
'1:2:3:4:5:6:7:8:9',
|
||||
':::',
|
||||
'12345::1',
|
||||
'::1::2',
|
||||
'::ffff:300.1.1.1',
|
||||
];
|
||||
const allowed = [
|
||||
'8.8.8.8',
|
||||
'93.184.215.14',
|
||||
'2606:4700:4700::1111',
|
||||
'64:ff9b::808:808',
|
||||
'2002:808:808::1',
|
||||
'2a00:1450:4001:82a::200e',
|
||||
];
|
||||
|
||||
it.each(blocked)('sperrt %j', (address) => {
|
||||
expect(isPrivateIpAddress(address)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(allowed)('erlaubt %s', (address) => {
|
||||
expect(isPrivateIpAddress(address)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPublicHttpUrl', () => {
|
||||
beforeEach(() => {
|
||||
lookupMock.mockReset();
|
||||
});
|
||||
|
||||
it('lehnt eine Adresse ab, deren AAAA-Eintrag versteckt auf 127.0.0.1 zeigt', async () => {
|
||||
lookupMock.mockResolvedValue([{ address: '::ffff:7f00:1', family: 6 }]);
|
||||
expect(await isPublicHttpUrl(new URL('http://versteckt.example.test/'))).toBe(false);
|
||||
});
|
||||
|
||||
it('lehnt ab, sobald eine von mehreren Adressen intern ist', async () => {
|
||||
lookupMock.mockResolvedValue([
|
||||
{ address: '8.8.8.8', family: 4 },
|
||||
{ address: '64:ff9b::7f00:1', family: 6 },
|
||||
]);
|
||||
expect(await isPublicHttpUrl(new URL('http://gemischt.example.test/'))).toBe(false);
|
||||
});
|
||||
|
||||
it('erlaubt eine Adresse, die nur oeffentlich aufloest', async () => {
|
||||
lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]);
|
||||
expect(await isPublicHttpUrl(new URL('https://oeffentlich.example.test/'))).toBe(true);
|
||||
});
|
||||
|
||||
it('lehnt andere Protokolle ab, ohne aufzuloesen', async () => {
|
||||
expect(await isPublicHttpUrl(new URL('ftp://example.test/'))).toBe(false);
|
||||
expect(await isPublicHttpUrl(new URL('ldap://example.test/'))).toBe(false);
|
||||
expect(lookupMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lehnt localhost und .local ab, ohne aufzuloesen', async () => {
|
||||
expect(await isPublicHttpUrl(new URL('http://localhost/'))).toBe(false);
|
||||
expect(await isPublicHttpUrl(new URL('http://drucker.local/'))).toBe(false);
|
||||
expect(lookupMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prueft eine Adresse als Zahl direkt', async () => {
|
||||
expect(await isPublicHttpUrl(new URL('http://127.0.0.1/'))).toBe(false);
|
||||
expect(await isPublicHttpUrl(new URL('http://169.254.169.254/latest'))).toBe(false);
|
||||
expect(await isPublicHttpUrl(new URL('http://8.8.8.8/'))).toBe(true);
|
||||
expect(lookupMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lehnt ab, wenn die Aufloesung scheitert oder nichts liefert', async () => {
|
||||
lookupMock.mockRejectedValueOnce(new Error('ENOTFOUND'));
|
||||
expect(await isPublicHttpUrl(new URL('http://gibt-es-nicht.example.test/'))).toBe(false);
|
||||
lookupMock.mockResolvedValueOnce([]);
|
||||
expect(await isPublicHttpUrl(new URL('http://leer.example.test/'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,15 @@ import { isIP } from 'node:net';
|
||||
* http/https ist, der Name nicht localhost/.local/0.0.0.0 ist und jede
|
||||
* aufgeloeste Adresse ausserhalb privater, Loopback-, Link-Local-, CGNAT- und
|
||||
* Multicast-Bereiche liegt.
|
||||
*
|
||||
* Gehaertet in quick-261009-ikt (Abruf fehlender Zertifikate): IPv6-Adressen werden
|
||||
* vollstaendig in acht Gruppen zerlegt, damit versteckte Schreibweisen interner
|
||||
* Adressen erkannt werden: IPv4-gemappt in Hex-Form (::ffff:7f00:1), IPv4-kompatibel
|
||||
* (::7f00:1), NAT64 (64:ff9b::/96 und 64:ff9b:1::/48), 6to4 (2002::/16), Teredo
|
||||
* (2001::/32), Dokumentation, Verwerfen (100::/64), Unique-Local, Link-Local,
|
||||
* Site-Local, Multicast und Adressen mit Zonen-Kennung. Nicht lesbare Adressen
|
||||
* bleiben gesperrt. Die Pruefung der aufgeloesten Adressen beim Verbindungsaufbau
|
||||
* (gegen DNS-Rebinding) liegt in cert-manager/cert-aia.ts (`createGuardedLookup`).
|
||||
*/
|
||||
|
||||
function isPrivateIpv4(address: string): boolean {
|
||||
@@ -38,31 +47,95 @@ function isPrivateIpv4(address: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIpv6(address: string): boolean {
|
||||
const lower = address.toLowerCase();
|
||||
/**
|
||||
* Zerlegt eine IPv6-Adresse in acht 16-Bit-Gruppen (Zonen-Kennung wie `%eth0` entfernt, `::`
|
||||
* aufgefuellt, eingebettete Punkt-Schreibweise des Schlusses in zwei Gruppen umgerechnet).
|
||||
* Nicht lesbare Adressen ergeben null.
|
||||
*/
|
||||
function expandIpv6(address: string): number[] | null {
|
||||
let text = address.trim().toLowerCase();
|
||||
const zone = text.indexOf('%');
|
||||
if (zone !== -1) text = text.slice(0, zone);
|
||||
if (text === '' || /[^0-9a-f:.]/.test(text)) return null;
|
||||
|
||||
if (
|
||||
lower === '::' ||
|
||||
lower === '::1' ||
|
||||
lower.startsWith('fc') ||
|
||||
lower.startsWith('fd') ||
|
||||
lower.startsWith('fe80:') ||
|
||||
lower.startsWith('ff')
|
||||
) {
|
||||
return true;
|
||||
const lastColon = text.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
const tail = text.slice(lastColon + 1);
|
||||
if (tail.includes('.')) {
|
||||
const octets = tail.split('.').map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : -1));
|
||||
if (octets.length !== 4 || octets.some((o) => o < 0 || o > 255)) return null;
|
||||
const high = ((octets[0] << 8) | octets[1]).toString(16);
|
||||
const low = ((octets[2] << 8) | octets[3]).toString(16);
|
||||
text = `${text.slice(0, lastColon + 1)}${high}:${low}`;
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6 (::ffff:<ipv4>) — delegate to isPrivateIpv4 to cover all
|
||||
// RFC 1918 ranges (10.x, 172.16-31.x, 192.168.x) and 169.254.x link-local
|
||||
const v4MappedMatch = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (v4MappedMatch) {
|
||||
return isPrivateIpv4(v4MappedMatch[1]);
|
||||
const halves = text.split('::');
|
||||
if (halves.length > 2) return null;
|
||||
const parse = (part: string): number[] | null => {
|
||||
if (part === '') return [];
|
||||
const groups: number[] = [];
|
||||
for (const group of part.split(':')) {
|
||||
if (!/^[0-9a-f]{1,4}$/.test(group)) return null;
|
||||
groups.push(Number.parseInt(group, 16));
|
||||
}
|
||||
return groups;
|
||||
};
|
||||
const head = parse(halves[0]);
|
||||
if (head === null) return null;
|
||||
if (halves.length === 1) return head.length === 8 ? head : null;
|
||||
const rest = parse(halves[1]);
|
||||
if (rest === null || head.length + rest.length > 7) return null;
|
||||
return [...head, ...new Array<number>(8 - head.length - rest.length).fill(0), ...rest];
|
||||
}
|
||||
|
||||
function embeddedIpv4(high: number, low: number): string {
|
||||
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* IPv6-Adressen, die auf intern zeigen koennen. Gesperrt sind: unspezifiziert und Loopback,
|
||||
* der ganze Bereich ::/96 (IPv4-kompatibel, veraltet), ::ffff:0:0/96 (IPv4-gemappt) und
|
||||
* ::ffff:0:0:0/96 (IPv4-uebersetzt), NAT64 64:ff9b::/96 und 6to4 2002::/16 je nach
|
||||
* eingebetteter IPv4-Adresse, das lokale NAT64-Praefix 64:ff9b:1::/48, Teredo 2001::/32,
|
||||
* Dokumentation 2001:db8::/32 und 3fff::/20, Verwerfen 100::/64, Unique-Local fc00::/7,
|
||||
* Link-Local fe80::/10, das veraltete Site-Local fec0::/10 und Multicast ff00::/8.
|
||||
* Alles, was nicht lesbar ist, bleibt gesperrt.
|
||||
*/
|
||||
export function isPrivateIpv6(address: string): boolean {
|
||||
const g = expandIpv6(address);
|
||||
if (g === null) return true;
|
||||
|
||||
const zeros = (from: number, to: number): boolean => g.slice(from, to).every((x) => x === 0);
|
||||
|
||||
// ::/96 komplett (unspezifiziert, Loopback, IPv4-kompatibel)
|
||||
if (zeros(0, 6)) return true;
|
||||
// ::ffff:a.b.c.d (IPv4-gemappt, auch in der Hex-Schreibweise ::ffff:7f00:1)
|
||||
if (zeros(0, 5) && g[5] === 0xffff) return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
|
||||
// ::ffff:0:a.b.c.d (IPv4-uebersetzt)
|
||||
if (zeros(0, 4) && g[4] === 0xffff && g[5] === 0) return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
|
||||
// 64:ff9b::/96 (NAT64) nach eingebetteter IPv4; 64:ff9b:1::/48 (lokales NAT64) immer
|
||||
if (g[0] === 0x64 && g[1] === 0xff9b) {
|
||||
if (g[2] === 1) return true;
|
||||
if (zeros(2, 6)) return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
|
||||
}
|
||||
// 2002::/16 (6to4): die IPv4-Adresse steht in Gruppe 2 und 3
|
||||
if (g[0] === 0x2002) return isPrivateIpv4(embeddedIpv4(g[1], g[2]));
|
||||
// 2001::/32 (Teredo) und 2001:db8::/32 (Dokumentation)
|
||||
if (g[0] === 0x2001 && (g[1] === 0 || g[1] === 0x0db8)) return true;
|
||||
// 3fff::/20 (Dokumentation)
|
||||
if (g[0] === 0x3fff && g[1] < 0x1000) return true;
|
||||
// 100::/64 (Verwerfen)
|
||||
if (g[0] === 0x100 && zeros(1, 4)) return true;
|
||||
// fc00::/7, fe80::/10, fec0::/10, ff00::/8
|
||||
if ((g[0] & 0xfe00) === 0xfc00) return true;
|
||||
if ((g[0] & 0xffc0) === 0xfe80) return true;
|
||||
if ((g[0] & 0xffc0) === 0xfec0) return true;
|
||||
if ((g[0] & 0xff00) === 0xff00) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateIpAddress(address: string): boolean {
|
||||
export function isPrivateIpAddress(address: string): boolean {
|
||||
const version = isIP(address);
|
||||
|
||||
if (version === 4) return isPrivateIpv4(address);
|
||||
|
||||
@@ -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