import tls from "node:tls"; import { createDeadline, remainingMs, timeoutError } from "./deadline"; import { validateResolvedAddresses } from "./policy"; export const TLS_TIMEOUT_MS = 5_000; export const TLS_TOTAL_TIMEOUT_MS = 8_000; export const TLS_MAX_ADDRESS_ATTEMPTS = 6; const DAY_MS = 86_400_000; type TlsError = { probe: "TLS"; message: string }; type TlsSocket = ReturnType; export type TlsConnectOptions = tls.ConnectionOptions; export type TlsConnector = (options: TlsConnectOptions) => TlsSocket; export interface TlsCollectionOptions { connect?: TlsConnector; now?: number; deadline?: number; } function failure(message: string, attempts: Array> = []): Record { const errors: TlsError[] = [{ probe: "TLS", message }]; return { error: message, attempts, _meta: { status: "failed", errors } }; } function errorMessage(error: unknown) { const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code ?? "") : ""; return code ? "TLS probe failed (" + code + ")." : "TLS probe failed."; } export function certificateDaysRemaining(validTo: string | null | undefined, now = Date.now()) { if (!validTo) return null; const timestamp = Date.parse(validTo); if (!Number.isFinite(timestamp)) return null; return Math.floor((timestamp - now) / DAY_MS); } function certificateNotYetValid(validFrom: string | null | undefined, now: number) { if (!validFrom) return null; const timestamp = Date.parse(validFrom); return Number.isFinite(timestamp) ? timestamp > now : null; } function certificateValidityDatesValid(validFrom: string | null | undefined, validTo: string | null | undefined) { if (!validFrom || !validTo) return false; const from = Date.parse(validFrom); const to = Date.parse(validTo); return Number.isFinite(from) && Number.isFinite(to) && from <= to; } function collectTlsCandidate(host: string, address: string, deadline: number, connect: TlsConnector, now: number): Promise> { const budget = Math.min(TLS_TIMEOUT_MS, remainingMs(deadline)); if (!budget) return Promise.reject(timeoutError("TLS handshake timed out.")); return new Promise((resolve, reject) => { let socket: TlsSocket | undefined; let settled = false; let timer: ReturnType | undefined; const done = (callback: () => void) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); socket?.destroy(); callback(); }; const fail = (error: unknown) => done(() => reject(error)); try { socket = connect({ host: address, port: 443, servername: host, rejectUnauthorized: false, timeout: budget, }); timer = setTimeout(() => fail(timeoutError("TLS handshake timed out.")), budget); } catch (error) { fail(error); return; } socket.once("secureConnect", () => { try { const cert = socket!.getPeerCertificate(true); if (!cert || !Object.keys(cert).length) { fail(new Error("TLS peer certificate was unavailable.")); return; } const hostnameError = tls.checkServerIdentity(host, cert); const validTo = cert.valid_to ?? null; const validFrom = cert.valid_from ?? null; const result = { address, protocol: socket!.getProtocol(), authorized: socket!.authorized, authorizationError: socket!.authorizationError ? String(socket!.authorizationError) : null, hostnameValid: !hostnameError, hostnameError: hostnameError?.message ?? null, subject: cert.subject ?? null, issuer: cert.issuer ?? null, subjectaltname: cert.subjectaltname ?? null, validFrom, validTo, validityDatesValid: certificateValidityDatesValid(validFrom, validTo), daysRemaining: certificateDaysRemaining(validTo, now), notYetValid: certificateNotYetValid(validFrom, now), fingerprint256: cert.fingerprint256 ?? null, _meta: { status: "ok", errors: [] }, }; done(() => resolve(result)); } catch (error) { fail(new Error(error instanceof Error ? "TLS certificate could not be parsed." : "TLS probe failed.")); } }); socket.once("timeout", () => fail(timeoutError("TLS handshake timed out."))); socket.once("error", (error) => fail(error)); }); } export async function collectTls(host: string, rawAddresses: string[] | string, options: TlsCollectionOptions = {}): Promise> { const addresses = validateResolvedAddresses(Array.isArray(rawAddresses) ? rawAddresses : [rawAddresses]); const deadline = options.deadline ?? createDeadline(TLS_TOTAL_TIMEOUT_MS); const connect = options.connect ?? ((connectionOptions) => tls.connect(connectionOptions)); const now = options.now ?? Date.now(); const attempts: Array> = []; for (const address of addresses.slice(0, TLS_MAX_ADDRESS_ATTEMPTS)) { if (!remainingMs(deadline)) break; try { return { ...(await collectTlsCandidate(host, address, deadline, connect, now)), attempts }; } catch (error) { attempts.push({ address, error: errorMessage(error) }); } } const message = remainingMs(deadline) ? "TLS probe failed for all resolved addresses." : "TLS probe deadline exhausted."; return failure(message, attempts); }