Add read-only live TLS probe

This commit is contained in:
sergeych committed 2026-08-16 19:51:59 +04:00
1 parent d479c459fc
commit 676d1f2ec8
7 files changed
+270

No files matched your search

+1
View File
@@ -3,3 +3,4 @@ export { DecisionJournal } from "./journal.js";
export { createTlsFacts, ENTRY_KINDS, PLUGIN_ROLES } from "./protocol.js";
export { TrustPolicyOverlay } from "./policy-overlay.js";
export { TrustRunner } from "./runner.js";
export { parseTlsTarget, probeTls } from "./tls-probe.js";
+198
View File
@@ -0,0 +1,198 @@
import { createHash, randomUUID } from "node:crypto";
import { isIP } from "node:net";
import { connect } from "node:tls";
import { X509Certificate } from "node:crypto";
const DEFAULT_TIMEOUT_MS = 10_000;
export async function probeTls(target, options = {}) {
const { hostname, port } = parseTlsTarget(target, options.port);
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
const socket = connect({
host: hostname,
port,
servername: options.servername ?? hostname,
rejectUnauthorized: false,
});
const timer = setTimeout(() => {
socket.destroy(new Error(`TLS probe timed out after ${timeoutMs} ms`));
}, timeoutMs);
socket.once("secureConnect", () => {
try {
const detailedPeer = socket.getPeerCertificate(true);
if (!detailedPeer?.raw) throw new Error("The peer supplied no certificate");
const chain = certificateChain(detailedPeer);
const report = normalizeProbe({
hostname,
port,
chain,
authorized: socket.authorized,
authorizationError: socket.authorizationError,
protocol: socket.getProtocol(),
cipher: socket.getCipher(),
alpn: socket.alpnProtocol || undefined,
});
clearTimeout(timer);
socket.end();
resolve(report);
} catch (error) {
clearTimeout(timer);
socket.destroy();
reject(error);
}
});
socket.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
}
export function parseTlsTarget(target, explicitPort) {
if (typeof target !== "string" || target.trim() === "") {
throw new TypeError("TLS target must be a hostname or hostname:port");
}
let value = target.trim();
if (!value.includes("://")) value = `tls://${value}`;
const url = new URL(value);
if (!['tls:', 'https:'].includes(url.protocol) || url.username || url.password || !["", "/"].includes(url.pathname) || url.search || url.hash) {
throw new TypeError("TLS target must contain only a hostname and optional port");
}
const port = explicitPort ?? (url.port ? Number(url.port) : 443);
if (!url.hostname || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new TypeError("TLS target has an invalid hostname or port");
}
return { hostname: url.hostname, port };
}
function certificateChain(peer) {
const result = [];
const seen = new Set();
let current = peer;
while (current?.raw) {
const fingerprint = sha256(current.raw);
if (seen.has(fingerprint)) break;
seen.add(fingerprint);
result.push(new X509Certificate(current.raw));
if (!current.issuerCertificate || current.issuerCertificate === current) break;
current = current.issuerCertificate;
}
return result;
}
function normalizeProbe(input) {
const certificates = input.chain.map(certificateFacts);
const findings = [];
const now = Date.now();
const leaf = input.chain[0];
const matchedIdentity = isIP(input.hostname)
? leaf.checkIP(input.hostname)
: leaf.checkHost(input.hostname);
if (!matchedIdentity) {
findings.push(finding(
"hostname-mismatch",
"identity",
certificates[0].sha256,
`The leaf certificate does not identify ${input.hostname}.`,
));
}
input.chain.forEach((certificate, index) => {
if (Date.parse(certificate.validFrom) > now) {
findings.push(finding("not-yet-valid", "validity", certificates[index].sha256, `${certificate.subject} is not valid yet.`));
}
if (Date.parse(certificate.validTo) < now) {
findings.push(finding("expired", "validity", certificates[index].sha256, `${certificate.subject} has expired.`));
}
const issuer = input.chain[index + 1];
if (issuer && !certificate.verify(issuer.publicKey)) {
findings.push(finding("invalid-signature", "signature", certificates[index].sha256, `${certificate.subject} is not signed by the next certificate in the observed chain.`));
}
});
if (!input.authorized) {
const mapped = mapAuthorizationError(input.authorizationError);
if (!findings.some((item) => item.code === mapped.code)) {
findings.push(finding(mapped.code, mapped.check, mapped.certificateSha256 ?? certificates.at(-1)?.sha256, mapped.summary));
}
}
const errors = [...new Set(findings.map((item) => item.code))];
return {
facts: {
schemaVersion: 0,
connectionId: randomUUID(),
hostname: input.hostname,
port: input.port,
validation: findings.length === 0 && input.authorized ? "success" : "failure",
errors,
failure: findings[0],
presentedChain: certificates,
constructedChain: certificates,
tls: {
version: input.protocol,
alpn: input.alpn,
cipher: input.cipher?.name,
conventionalTrust: input.authorized,
conventionalTrustError: input.authorizationError || undefined,
chainSource: "node-openssl-peer-chain",
},
},
findings,
caveats: [
"Node/OpenSSL does not expose a reliable boundary between server-sent certificates and certificates added during path construction; both chain fields contain the observed peer chain in this probe version.",
],
};
}
function certificateFacts(certificate) {
return {
subject: certificate.subject,
issuer: certificate.issuer,
sha256: sha256(certificate.raw),
spkiSha256: sha256(certificate.publicKey.export({ type: "spki", format: "der" })),
serialNumber: certificate.serialNumber,
dnsNames: certificate.subjectAltName ? certificate.subjectAltName.split(", ") : [],
validFrom: new Date(certificate.validFrom).toISOString(),
validUntil: new Date(certificate.validTo).toISOString(),
isCa: certificate.ca,
keyUsages: normalizeKeyUsages(certificate.keyUsage),
selfSigned: certificate.checkIssued(certificate) && certificate.verify(certificate.publicKey),
};
}
function normalizeKeyUsages(usages = []) {
const mapping = new Map([
["Digital Signature", "digitalSignature"],
["Key Encipherment", "keyEncipherment"],
["Certificate Sign", "keyCertSign"],
["CRL Sign", "crlSign"],
]);
return usages.map((usage) => mapping.get(usage)).filter(Boolean);
}
function mapAuthorizationError(error) {
const code = String(error ?? "UNTRUSTED_ISSUER");
const table = {
CERT_HAS_EXPIRED: ["expired", "validity"],
CERT_NOT_YET_VALID: ["not-yet-valid", "validity"],
DEPTH_ZERO_SELF_SIGNED_CERT: ["self-signed-authority", "trust-anchor"],
SELF_SIGNED_CERT_IN_CHAIN: ["self-signed-authority", "trust-anchor"],
UNABLE_TO_GET_ISSUER_CERT_LOCALLY: ["unknown-issuer", "trust-anchor"],
UNABLE_TO_VERIFY_LEAF_SIGNATURE: ["unknown-issuer", "trust-anchor"],
CERT_SIGNATURE_FAILURE: ["invalid-signature", "signature"],
};
const [mappedCode, check] = table[code] ?? ["untrusted-issuer", "trust-anchor"];
return { code: mappedCode, check, summary: `Conventional verification failed: ${code}.` };
}
function finding(code, check, certificateSha256, summary) {
return { code, check, certificateSha256, summary };
}
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}