141 lines
5.4 KiB
TypeScript
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);
|
|
}
|