Files

141 lines
5.4 KiB
TypeScript

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<typeof tls.connect>;
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<string, unknown>> = []): Record<string, unknown> {
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<Record<string, unknown>> {
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<typeof setTimeout> | 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<Record<string, unknown>> {
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<Record<string, unknown>> = [];
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);
}