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; 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 } | 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 }).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 }; /** Die Rangfolge, wenn alle Adressen scheitern: die Meldung mit dem meisten Fortschritt gewinnt. */ const FAILURE_RANK: Record = { aiaNotIssuer: 4, aiaTooLarge: 3, aiaUnreachable: 2, aiaInternal: 1, }; const FAILURE_STATUS: Record = { aiaNotIssuer: 422, aiaInternal: 422, aiaTooLarge: 502, aiaUnreachable: 502, }; const FAILURE_TEXT: Record = { 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 { 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 => { 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 => { let current = first; for (let hop = 0; ; hop++) { if (!hasDefaultPort(current) || !(await isPublic(current))) { return { ok: false, code: 'aiaInternal' }; } let response: Awaited>; 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 }).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]); }