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:
+2
-1
@@ -20,7 +20,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
- Dateien, Teilen: Im Zeilenmenü einer Datei oder eines Ordners finden Sie jetzt „Teilen“. Dort suchen Sie Kolleginnen, Kollegen und Gruppen aus Ihrer Nextcloud und geben die Datei zum „Ansehen“ oder „Bearbeiten“ frei; die Berechtigung ändern Sie später, und eine Freigabe nehmen Sie mit „Entfernen“ wieder zurück. Außerdem erstellen Sie öffentliche Links mit „Ansehen“ oder „Bearbeiten“, bei Ordnern auch mit „Nur hochladen“, und kopieren sie mit „Link kopieren“. Passwort und Ablaufdatum richten sich nach den Regeln der Firmen-Nextcloud: Verlangt sie ein Passwort, fragt Tessera danach und kann auf Wunsch eines erzeugen. Tessera speichert das Passwort nie und zeigt es nur, solange der Dialog offen ist. Zum Schutz der Nextcloud legt Tessera höchstens 10 neue Freigaben innerhalb von 10 Minuten an.
|
||||
- Dateien, Übersicht der Freigaben: Die Reiter „Von mir geteilt“ und „Mit mir geteilt“ zeigen alle Freigaben auf einen Blick. Sie öffnen Einträge direkt im zugehörigen Ordner, ändern oder entfernen Ihre eigenen Freigaben, verlassen Freigaben anderer und nehmen offene Freigaben an oder lehnen sie ab. Geteilte Einträge sind in der Dateiliste markiert. Freigaben per E-Mail und an andere Server zeigt Tessera nicht an; sie bleiben der Nextcloud vorbehalten.
|
||||
- Zertifikat-Manager, ein gemeinsamer Arbeitsbereich: Im ersten Reiter „Dateien“ laden Sie mehrere Dateien nacheinander oder auf einmal hoch, auch ZIP-Dateien vom Zertifikatsaussteller, und können PEM-Text einfügen. Jede Datei bleibt in der Liste, zu jeder steht, was Tessera darin erkannt hat; einzelne Dateien nehmen Sie mit ihrem eigenen Knopf wieder heraus. Passwörter geben Sie je Datei ein. Die Liste, Schlüssel und Passwörter bleiben nur in diesem Browserfenster und sind nach dem Neuladen weg; Tessera speichert sie nirgends. Analysieren, Aufteilen, Zusammenführen, Konvertieren und Vorlagen arbeiten alle mit dieser Liste. Modulversion 1.2.0.
|
||||
- Zertifikat-Manager, Zusammenführen und Formate: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst und prüft dabei die echte Unterschrift jedes Zertifikats. „Fullchain“ und „Nur Kette“ gibt es als PEM, .p7b und .p7c, dazu Zertifikat und Schlüssel in einer PEM-Datei und PFX-Dateien mit eigenem Passwort, wahlweise „Kompatibel“ (auch für ältere Windows-Server, Vorgabe) oder „Modern“ (AES-256). Das Wurzelzertifikat kommt nur mit, wenn Sie „Root-Zertifikat mitnehmen“ ankreuzen. Fehlt ein Zwischenzertifikat, meldet Tessera es mit Namen. Neu im Reiter „Konvertieren“: jedes erkannte Zertifikat, jeder Schlüssel und jede Anfrage in jedes passende Format, auch Schlüssel mit Passwort.
|
||||
- Zertifikat-Manager, Zusammenführen und Formate: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst und prüft dabei die echte Unterschrift jedes Zertifikats. „Fullchain“ und „Nur Kette“ gibt es als PEM, .p7b und .p7c, dazu Zertifikat und Schlüssel in einer PEM-Datei und PFX-Dateien mit eigenem Passwort, wahlweise „Kompatibel“ (auch für ältere Windows-Server, Vorgabe) oder „Modern“ (AES-256). Das Wurzelzertifikat kommt nur mit, wenn Sie „Root-Zertifikat mitnehmen“ ankreuzen. Fehlt ein Zwischenzertifikat, meldet Tessera es mit Namen und bietet „Fehlendes Zertifikat holen“ an: Nur wenn Sie darauf klicken, holt Tessera das Zertifikat von der Adresse des Ausstellers, die im Zertifikat selbst steht, prüft, dass es das Zertifikat wirklich ausgestellt hat, und kennzeichnet es als „nachgeladen“. Neu im Reiter „Konvertieren“: jedes erkannte Zertifikat, jeder Schlüssel und jede Anfrage in jedes passende Format, auch Schlüssel mit Passwort.
|
||||
- Zertifikat-Manager, Vorlagen: Für Nginx, Apache (ab und vor 2.4.8), Windows/IIS, Nginx Proxy Manager, HAProxy und Tomcat erzeugt Tessera mit einem Klick die passenden Dateien samt den Zeilen für die Einrichtung und einer kurzen Anleitung. Dafür braucht es das Zertifikat und den passenden privaten Schlüssel.
|
||||
|
||||
### Geändert
|
||||
@@ -32,6 +32,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
### Behoben
|
||||
|
||||
- Sicherheit: Der Schutz davor, dass Tessera interne Adressen abruft (Favoriten-Symbole, Logos in Nextcloud-Status und das neue „Fehlendes Zertifikat holen“), erkennt jetzt auch versteckte Schreibweisen interner IPv6-Adressen, zum Beispiel die gemappte Form ::ffff:7f00:1, NAT64 und 6to4. Beim neuen Abruf prüft Tessera die Adresse außerdem noch einmal im Moment des Verbindens.
|
||||
- Zertifikat-Manager: Beim Zusammenführen ersetzte eine zweite Datei die erste; jetzt bleiben alle Dateien in der Liste. Zertifikate und Schlüssel mit elliptischen Kurven (EC) wurden bisher nicht erkannt; jetzt funktionieren sie in allen Reitern.
|
||||
- Desktop-App: Dateien lassen sich jetzt auch in der Desktop-App per Ziehen und Ablegen hochladen, zum Beispiel im Modul Dateien oder im Zertifikat-Manager. Bisher übernahm die App das Ablegen selbst, und auf der Seite kam nichts an (Linux und Windows). Dafür ist die neue Version der Desktop-App nötig.
|
||||
- Nextcloud-Status: Das Formular zum Hinzufügen und Bearbeiten einer Cloud zeigte bei manchen Eingaben englische Meldungen (zum Beispiel bei einer Bildadresse mit http://). Alle Hinweise in diesem Formular erscheinen jetzt auf Deutsch (bei englischer Spracheinstellung auf Englisch).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -156,10 +156,19 @@ Der Zertifikat-Manager hilft Ihnen, Zertifikate, Schlüssel und Zertifikatsanfra
|
||||
- **Dateien** — Hier sammeln Sie alles. Ziehen Sie Dateien in das Feld oder klicken Sie hinein und wählen Sie Dateien aus, so oft und in beliebiger Menge nacheinander; **jede neue Datei kommt zur Liste hinzu und ersetzt nie eine frühere**. Sie können auch die ZIP-Datei Ihres Zertifikatsausstellers hochladen: Tessera schaut hinein und nennt zu jedem enthaltenen Eintrag, was es darin gefunden hat. Überflüssiges wie Ordner mit dem Namen `__MACOSX` überspringt Tessera still; verschachtelte oder passwortgeschützte ZIP-Dateien nennt es mit dem Grund, warum es sie nicht öffnet. Zusätzlich können Sie PEM-Text einfügen („PEM-Text einfügen“, Text beginnt mit `-----BEGIN`); er erscheint als eigener Eintrag. Die Liste fasst bis zu 30 Einträge, höchstens 5 MB je Datei und zusammen höchstens 10 MB; dieselbe Datei kann nicht zweimal hinzugefügt werden. Zu jedem Eintrag zeigt die Liste, was erkannt wurde: Serverzertifikat, Zwischenzertifikat, Stammzertifikat, privater Schlüssel oder Zertifikatsanfrage. Mit dem Knopf am Eintrag nehmen Sie eine einzelne Datei wieder aus der Liste. Ist eine Datei durch ein Passwort geschützt (PFX-/P12-Datei oder verschlüsselter Schlüssel), erscheint beim Eintrag ein Feld für das Passwort; es gilt nur für diese eine Datei. Hat Tessera zum Serverzertifikat schon einen Schlüssel, erscheint eine passwortgeschützte PFX-Datei nur als ruhiger Hinweis, den Sie nicht beachten müssen.
|
||||
- **Analysieren** — Zeigt zu jedem erkannten Teil die Einzelheiten: Name, Aussteller, Gültigkeit (mit den verbleibenden Tagen), Schlüsselart (RSA oder EC mit Kurve), die Namen, für die das Zertifikat gilt, sowie Seriennummer und Fingerabdrücke. Außerdem sehen Sie, was zusammengehört: welcher Schlüssel zu welchem Zertifikat passt und welche Zertifikatsanfrage zu welchem Zertifikat gehört. Tessera ordnet nie nach dem Namen zu, sondern prüft den Schlüssel selbst.
|
||||
- **Aufteilen** — Zerlegt Ihre Dateien in die einzelnen Teile. Jedes Teil laden Sie in seinem natürlichen Format herunter (Zertifikat als `.crt`, Schlüssel als `.key`, Anfrage als `.csr`) oder alle zusammen als ZIP-Datei.
|
||||
- **Zusammenführen** — Tessera ordnet Serverzertifikat, Zwischenzertifikate und Stammzertifikat **selbst**; die Reihenfolge Ihrer Dateien spielt keine Rolle. Dabei prüft es nicht nur Namen, sondern die echte Unterschrift jedes Zertifikats, sodass ein gleichnamiges, aber falsches Zwischenzertifikat nie verwendet wird. Gibt es mehrere Möglichkeiten, wählt Tessera nachvollziehbar die beste (zum Beispiel nicht abgelaufen). Sie können herunterladen: **Fullchain** (Serverzertifikat plus Zwischenzertifikate), **Nur Kette** (nur die Zwischenzertifikate, zum Beispiel für Systeme, die das Serverzertifikat getrennt wollen), **Zertifikat und Schlüssel** in einer PEM-Datei sowie eine **PFX-Datei**. Fullchain und Nur Kette gibt es als PEM, als `.p7b` oder als binäre `.p7c`. Für die PFX-Datei vergeben Sie ein Passwort (zweimal eingeben) und wählen die Verschlüsselung: **„Kompatibel (auch ältere Windows-Server)“** ist vorgewählt und die sichere Wahl, wenn Sie nicht wissen, wo die Datei eingespielt wird; **„Modern (AES-256)“** ist stärker, wird aber von älteren Systemen wie Windows Server 2016 oft nicht gelesen. Das Häkchen **„Root-Zertifikat mitnehmen“** ist standardmäßig aus, denn die meisten Server und Browser kennen die Wurzel schon und brauchen sie nicht; setzen Sie es nur, wenn ein Gerät es ausdrücklich verlangt (manche Geräte, Java-Anwendungen oder eigene Firmenwurzeln). Fehlt ein Aussteller in Ihrer Liste, sagt Tessera das ausdrücklich: **„Zwischenzertifikat fehlt“** heißt, dass direkt über dem Serverzertifikat das Zwischenzertifikat nicht in der Liste ist; fehlt dagegen nur das Zertifikat ganz oben (meist die Wurzel), erscheint ein ruhiger Hinweis, denn das ist für die meisten Server in Ordnung. Fügen Sie das fehlende Zertifikat im Reiter „Dateien“ hinzu.
|
||||
- **Zusammenführen** — Tessera ordnet Serverzertifikat, Zwischenzertifikate und Stammzertifikat **selbst**; die Reihenfolge Ihrer Dateien spielt keine Rolle. Dabei prüft es nicht nur Namen, sondern die echte Unterschrift jedes Zertifikats, sodass ein gleichnamiges, aber falsches Zwischenzertifikat nie verwendet wird. Gibt es mehrere Möglichkeiten, wählt Tessera nachvollziehbar die beste (zum Beispiel nicht abgelaufen). Sie können herunterladen: **Fullchain** (Serverzertifikat plus Zwischenzertifikate), **Nur Kette** (nur die Zwischenzertifikate, zum Beispiel für Systeme, die das Serverzertifikat getrennt wollen), **Zertifikat und Schlüssel** in einer PEM-Datei sowie eine **PFX-Datei**. Fullchain und Nur Kette gibt es als PEM, als `.p7b` oder als binäre `.p7c`. Für die PFX-Datei vergeben Sie ein Passwort (zweimal eingeben) und wählen die Verschlüsselung: **„Kompatibel (auch ältere Windows-Server)“** ist vorgewählt und die sichere Wahl, wenn Sie nicht wissen, wo die Datei eingespielt wird; **„Modern (AES-256)“** ist stärker, wird aber von älteren Systemen wie Windows Server 2016 oft nicht gelesen. Das Häkchen **„Root-Zertifikat mitnehmen“** ist standardmäßig aus, denn die meisten Server und Browser kennen die Wurzel schon und brauchen sie nicht; setzen Sie es nur, wenn ein Gerät es ausdrücklich verlangt (manche Geräte, Java-Anwendungen oder eigene Firmenwurzeln). Fehlt ein Aussteller in Ihrer Liste, sagt Tessera das ausdrücklich: **„Zwischenzertifikat fehlt“** heißt, dass direkt über dem Serverzertifikat das Zwischenzertifikat nicht in der Liste ist; fehlt dagegen nur das Zertifikat ganz oben (meist die Wurzel), erscheint ein ruhiger Hinweis, denn das ist für die meisten Server in Ordnung. Das fehlende Zertifikat können Sie selbst im Reiter „Dateien“ hinzufügen oder, wie unten beschrieben, von Tessera holen lassen.
|
||||
- **Konvertieren** — Wandelt ein einzelnes Teil in ein anderes Format um: Zertifikate als PEM, DER, PKCS#7 (`.p7b`, `.p7c`); Schlüssel als PKCS#8, klassisch (PKCS#1 bei RSA, SEC1 bei EC) oder DER, auf Wunsch mit eigenem Passwort verschlüsselt; Zertifikatsanfragen als PEM oder DER.
|
||||
- **Vorlagen** — Wählen Sie das System, auf dem Ihr Zertifikat laufen soll, und Tessera erzeugt mit einem Klick die passenden Dateien und zeigt die Zeilen für die Einrichtung (mit „Kopieren“). Mehrere Dateien kommen in einer ZIP-Datei, die zusätzlich eine kurze Anleitung enthält. Jede Vorlage braucht das Serverzertifikat **und** den dazu passenden privaten Schlüssel; fehlt der Schlüssel, erklärt Tessera das. Es gibt sieben Vorlagen: **Nginx** (`fullchain.pem` und `privkey.pem`); **Apache 2.4.8 und neuer** (ebenfalls `fullchain.pem` und `privkey.pem`); **Apache älter als 2.4.8** (getrennt `cert.pem`, `chain.pem` und `privkey.pem`); **Windows / IIS** (eine PFX-Datei mit dem Passwort, das Sie vergeben, vorgewählt ist „Kompatibel“); **Nginx Proxy Manager** (`certificate.pem`, `intermediate.pem` und `privkey.pem`; die Anleitung nennt, welche Datei in welches Feld unter „SSL Certificates“, „Add SSL Certificate“, „Custom“ gehört); **HAProxy** (eine einzige PEM-Datei mit Zertifikat, Zwischenzertifikaten und Schlüssel) und **Tomcat / Java** (eine `.p12`-Datei mit dem Passwort, das Sie vergeben, ebenfalls mit vorgewähltem „Kompatibel“). Das Passwort steht nie in den angezeigten Zeilen; dort steht stattdessen `IHR-PASSWORT`.
|
||||
|
||||
**Fehlendes Zertifikat holen:** Fehlt ein Zwischenzertifikat, erscheint unter der Meldung „Zwischenzertifikat fehlt“ der Knopf **„Fehlendes Zertifikat holen“**. Er steht in den Reitern „Analysieren“, „Zusammenführen“ und „Vorlagen“ an der Stelle, an der die Kette unterbrochen ist. Die Sache funktioniert so: Fast jedes Serverzertifikat trägt eine Adresse in sich, unter der sein Aussteller das Zwischenzertifikat bereithält. Wenn Sie auf den Knopf klicken, fragt Tessera genau diese Adresse einmal ab und fügt das Ergebnis Ihrer Liste hinzu. Dabei gilt:
|
||||
|
||||
- **Nur auf Ihren Klick.** Tessera holt nie von sich aus etwas aus dem Internet, auch nicht beim Öffnen des Reiters oder wenn Sie Dateien hinzufügen oder entfernen. Der Hinweis über dem Knopf nennt den Server, bei dem Tessera nachfragt.
|
||||
- **Nur eine geprüfte Antwort wird übernommen.** Tessera nimmt das heruntergeladene Zertifikat nur an, wenn es das betroffene Zertifikat wirklich ausgestellt hat (die Unterschrift wird geprüft). Alles andere verwirft Tessera; Sie sehen dann einen Hinweis, dass die Adresse kein passendes Zertifikat geliefert hat.
|
||||
- **Nur öffentliche Adressen.** Adressen im eigenen Firmennetz oder auf dem Tessera-Server selbst ruft Tessera nie ab, ebenso keine Adressen mit besonderen Anschlussnummern oder Zugangsdaten. Dafür gibt es dann den Hinweis, dass die Adresse nicht abgerufen wird.
|
||||
- **Gekennzeichnet als „nachgeladen“.** Das geholte Zertifikat erscheint in der Liste im Reiter „Dateien“ als eigener Eintrag mit dem Vermerk „nachgeladen von“ und dem Namen des Servers, ebenso auf seiner Karte im Reiter „Analysieren“. Wie jeden anderen Eintrag können Sie ihn mit dem Knopf am Eintrag wieder entfernen.
|
||||
- **Unter Umständen ein zweiter Klick.** Ein Klick holt genau eine Stufe. Fehlt über dem geholten Zwischenzertifikat noch ein weiteres (zum Beispiel das Stammzertifikat), erscheint an dieser Stelle der Knopf erneut, und Sie können noch einmal klicken. Das Stammzertifikat brauchen Sie für eine Fullchain meist nicht.
|
||||
- **Wenn es nicht klappt.** Ist der Server des Ausstellers nicht erreichbar (zum Beispiel, weil der Tessera-Server selbst keinen Zugang zum Internet hat), meldet Tessera das. Laden Sie das Zertifikat dann beim Aussteller herunter und fügen Sie es im Reiter „Dateien“ hinzu. Steht im Zertifikat gar keine Adresse, gibt es keinen Knopf, sondern nur diesen Hinweis.
|
||||
|
||||
Unterstützte Eingaben sind unter anderem `.pem`, `.crt`, `.cer`, `.der`, `.pfx`, `.p12`, `.p7b`, `.p7c`, Schlüssel (`.key`, PKCS#1, PKCS#8 und SEC1, im Text oder binär, mit und ohne Passwort), Zertifikatsanfragen (`.csr`) und ZIP-Dateien. Tessera erkennt Dateien an ihrem Inhalt, nicht an der Endung, sodass auch eine Datei ohne passende Endung geöffnet wird. Zertifikate und Schlüssel mit elliptischen Kurven (EC) werden ebenso verarbeitet wie RSA.
|
||||
|
||||
### Domaincheck
|
||||
|
||||
@@ -201,7 +201,7 @@ Das Modul „Dateien“ spricht aus dem `api`-Container mit der Nextcloud der In
|
||||
Das Modul „Zertifikat-Manager“ braucht keine Einstellungen und keine eigene Konfiguration. Es speichert nichts: Zertifikate, Schlüssel und Passwörter kommen mit jeder Anfrage aus dem Browser, werden im Arbeitsspeicher des `api`-Containers verarbeitet und nicht in der Datenbank oder in Protokollen abgelegt. Für den Betrieb gilt:
|
||||
|
||||
- **Größe der Anfragen:** Die Dateien gehen über `/api-proxy` an die API. Eine einzelne Analyse schickt höchstens 10 MB (höchstens 30 Dateien, je Datei bis 5 MB). Die Voraussetzung am Proxy ist dieselbe wie im Abschnitt „Dateien (Nextcloud)“: `client_max_body_size` von mindestens `10m`. Beim Herunterladen eines Ergebnisses schickt der Browser nur Zertifikate und höchstens einen Schlüssel als JSON, höchstens 512 KiB je Anfrage (Tessera legt für genau diese Anfrage eine eigene Grenze fest, alle anderen JSON-Anfragen bleiben bei 100 kB).
|
||||
- **Ausgehender Zugriff:** Der Zertifikat-Manager ruft von sich aus nichts im Internet ab.
|
||||
- **Ausgehender Zugriff:** Der Zertifikat-Manager ruft von sich aus nichts im Internet ab. Eine Ausnahme gibt es: Klickt ein Benutzer auf „Fehlendes Zertifikat holen“, schickt der `api`-Container eine einzelne Anfrage an die Adresse, die im Zertifikat als Aussteller-Adresse steht (meist `http://`, selten `https://`). Dafür muss der `api`-Container ausgehend per HTTP und HTTPS (Ports 80 und 443) ins Internet kommen; andere Ports ruft Tessera nie ab. Ist das ausgehend gesperrt (Firewall, Proxy-Pflicht), meldet der Knopf „nicht erreichbar“, alles andere im Modul funktioniert weiter, und die Benutzer laden das Zertifikat selbst herunter. Tessera ruft dabei nur öffentliche Adressen ab (keine internen Rechner, keine Adressen des eigenen Netzes), prüft die Adresse im Moment des Verbindens noch einmal, folgt höchstens drei Weiterleitungen, begrenzt Wartezeit (8 Sekunden) und Antwortgröße (256 KiB) und übernimmt nur ein Zertifikat, das das betroffene Zertifikat wirklich ausgestellt hat. Im Protokoll steht bei einem Fehler genau eine Zeile mit dem Servernamen und einem Fehlercode, nie ein Zertifikat.
|
||||
- **Rechenaufwand:** Passwortgeschützte PFX-Dateien und verschlüsselte Schlüssel öffnet Tessera mit den eingegebenen Passwörtern (je Datei höchstens zehn verschiedene Versuche); das kostet kurz Rechenzeit, belastet den Server aber nicht dauerhaft.
|
||||
|
||||
## 4. Neue Fassung einspielen
|
||||
@@ -774,5 +774,6 @@ Für die Desktop-Auslieferung ist keine neue Pflichtvariable nötig.
|
||||
| Eine Fehlermeldung aus der Desktop-App nennt als Herkunft „Desktop-App (unbekannt)“ ohne Version, Betreff-Kürzel `[Desktop]` | Der Client ist älter als diese Fassung: er meldet dem Server beim Start nur `desktop=1`, nicht Version, Stand und Betriebssystem (Parameter `dv`, `dc`, `dos`, aus denen `web` das Cookie `tessera_desktop_client` bildet) | Kein Fehler, die Meldung ist trotzdem als Desktop-App erkennbar. Client über „Auf Version … aktualisieren“ im Infobereich oder den Browser-Installer aktualisieren; danach stehen Betriebssystem, Version und Stand in der Meldung. |
|
||||
| Beim Hochladen in „Dateien“ bricht jede größere Datei beim ersten 8-MB-Stück ab (Fehler „Die Verbindung wurde unterbrochen“, im Proxy-Protokoll `413`) | Der Proxy vor Tessera (Nginx Proxy Manager) lässt keine Anfragen über seiner Größengrenze durch (`client_max_body_size`), oder sein Zeitlimit ist zu kurz | Für die Tessera-Adresse `client_max_body_size` auf mindestens `10m` und die Lese-/Sendezeitlimits auf mindestens 120 Sekunden stellen (Kapitel 3, Abschnitt „Dateien (Nextcloud)“). |
|
||||
| Im Zertifikat-Manager bricht das Hochladen mehrerer Dateien ab (Fehlertext „Die Dateien sind zusammen zu groß“ oder `413` im Proxy-Protokoll) | Der Proxy vor Tessera (Nginx Proxy Manager) lässt Anfragen über seiner Größengrenze nicht durch (`client_max_body_size`); die Analyse schickt bis zu 10 MB | Für die Tessera-Adresse `client_max_body_size` auf mindestens `10m` stellen (Kapitel 3, Abschnitt „Dateien (Nextcloud)“); Tessera selbst erlaubt höchstens 20 MB je Analyse. |
|
||||
| Im Zertifikat-Manager meldet „Fehlendes Zertifikat holen“, der Server des Ausstellers sei nicht erreichbar | Der `api`-Container kommt ausgehend nicht per HTTP/HTTPS (Ports 80 und 443) ins Internet, oder der Server des Ausstellers antwortet nicht; steht die Meldung, die Adresse werde nicht abgerufen, zeigt die Adresse im Zertifikat auf einen internen Rechner oder einen besonderen Anschluss, was Tessera absichtlich nie abruft | Ausgehenden Zugriff für den `api`-Container freigeben (`docker compose exec api node -e "fetch('http://ye2.i.lencr.org/').then(r=>console.log(r.status))"` muss `200` ausgeben); sonst das Zertifikat beim Aussteller herunterladen und im Reiter „Dateien“ hinzufügen. |
|
||||
| In „Dateien“ tragen öffentliche Links eine interne Adresse (zum Beispiel ein interner Rechnername oder eine IP-Adresse) und lassen sich von außen nicht öffnen | Die Nextcloud baut die Adresse eines Links aus dem Namen, unter dem Tessera sie aufruft; in Tessera ist die interne Adresse der Nextcloud eingetragen | In den Einstellungen des Moduls die von außen erreichbare Adresse der Nextcloud eintragen, oder in der `config.php` der Nextcloud `overwritehost`, `overwriteprotocol` und `overwrite.cli.url` auf die externe Adresse setzen; bereits erstellte Links ändern sich nicht rückwirkend, sie müssen neu erstellt werden. |
|
||||
| In „Dateien“ erscheint „Sie haben in kurzer Zeit viele Freigaben angelegt. Bitte warten Sie einige Minuten.“ | Tessera hat für den Benutzer 10 neue Freigaben innerhalb von 10 Minuten angelegt oder mehr als 40 Versuche in 10 Minuten gezählt (auch abgelehnte) | Einige Minuten abwarten (der Zähler läuft nach 10 Minuten ab); ein Neustart des `api`-Containers setzt den Zähler zurück, ist aber nur im Ausnahmefall nötig. |
|
||||
|
||||
@@ -942,3 +942,36 @@ Schlüssel der CAs werden nach der Erzeugung gelöscht). Dateinamen enden **nie*
|
||||
`-key-….der`. Die Specs lesen die Dateien mit `readFileSync` und rufen OpenSSL nie auf (die CI hat
|
||||
es nicht); die Live-Prüfung mit OpenSSL-Gegenprobe (`openssl verify`, `pkcs12 -info`,
|
||||
`pkcs7 -print_certs`, `pkey`) liegt im Skript `e2e-cert.sh` der Aufgabe.
|
||||
|
||||
*Fehlendes Zertifikat holen und der gemeinsame Adressschutz.* `POST fetch-issuer` (`cert-aia.ts`,
|
||||
`dto/cert-fetch-issuer.dto.ts`) nimmt **nur** `pem`; die Adresse liest der Server selbst aus dem
|
||||
Zertifikat (`toLegacyObject().infoAccess['CA Issuers - URI']`), der Browser nennt nie eine
|
||||
(die `ValidationPipe` mit `whitelist` entfernt jedes weitere Feld, das Controller-Spec prüft es).
|
||||
Es werden höchstens drei Adressen der Reihe nach versucht: nur `http`/`https`, ohne
|
||||
Zugangsdaten, höchstens 2048 Zeichen, nur der Standardport (ein Abruf auf `:8080` ist ein
|
||||
Portscanner). Die Schleife ist die von `nextcloud-status/nextcloud-logo-fetch.ts` (Adressschutz vor
|
||||
der ersten Anfrage und vor jeder Weiterleitung, `redirect: 'manual'`, höchstens drei Sprünge, ein
|
||||
Zeitlimit von 8 s mit Wette gegen den Abbruch, 256 KiB, `content-length` vorab und beim Lesen), dazu
|
||||
zwei Unterschiede: Erstens läuft der echte Abruf über einen eigenen `undici`-`Agent`, dessen
|
||||
`connect.lookup` (`createGuardedLookup`) jede aufgelöste Adresse prüft und bei einer nicht
|
||||
öffentlichen abbricht. Das schließt für diese Funktion das DNS-Rebinding-Fenster, das die anderen
|
||||
Nutzer des Adressschutzes bewusst offen lassen (Name wird vorher aufgelöst und beim Verbinden noch
|
||||
einmal). Zweitens wird eine Antwort nur angenommen, wenn `target.checkIssued(c)` **und**
|
||||
`target.verify(c.publicKey)` gelten (DER-Zertifikat, PKCS#7 als DER oder PEM, PEM-Text; sonst
|
||||
`aiaNotIssuer`). Der bewusst akzeptierte Rest steht im Kopfkommentar von `cert-aia.ts`: Jeder
|
||||
angemeldete Benutzer des Moduls kann den API-Server dazu bringen, einen einzigen GET an eine
|
||||
öffentliche Adresse zu senden, die in einem von ihm hochgeladenen Zertifikat steht. Die Oberfläche
|
||||
holt nie von selbst etwas (`ChainView`, Knopf), das geholte Zertifikat wird als Eintrag mit Herkunft
|
||||
`fetched` und Server (`working-set.ts`, `addFetched`) angehängt und beim nächsten Durchlauf mit allem
|
||||
anderen analysiert.
|
||||
|
||||
Der **gemeinsame Adressschutz** `common/public-url-guard.ts` (Favoriten-Symbole, Logo-Abruf von
|
||||
Nextcloud-Status und dieser Abruf) wurde dafür gehärtet: `isPrivateIpv6` zerlegt eine Adresse
|
||||
vollständig in acht Gruppen (`expandIpv6`) und erkennt damit auch versteckte Schreibweisen interner
|
||||
Adressen, nämlich IPv4-gemappt in Hex-Form (`::ffff:7f00:1`), IPv4-kompatibel (`::7f00:1`), NAT64
|
||||
(`64:ff9b::/96` nach eingebetteter IPv4, `64:ff9b:1::/48` immer), 6to4 (`2002::/16`), Teredo,
|
||||
Dokumentationsbereiche, `100::/64`, Unique-Local, Link-Local (auch mit Zonenkennung `%eth0`),
|
||||
Site-Local und Multicast; nicht lesbare Adressen bleiben gesperrt. `isPrivateIpAddress` ist jetzt
|
||||
exportiert, das neue Spec `public-url-guard.spec.ts` prüft jede dieser Schreibweisen und
|
||||
`isPublicHttpUrl` mit einer nachgebildeten Namensauflösung. Wer den Schutz erweitert, lässt die
|
||||
Specs von `favorites` und `nextcloud-status` mitlaufen.
|
||||
|
||||
Reference in New Issue
Block a user