Add read-only live TLS probe

This commit is contained in:
Sergey Chernov 2026-08-16 19:51:59 +04:00
parent d479c459fc
commit 676d1f2ec8
7 changed files with 270 additions and 0 deletions

View File

@ -53,6 +53,22 @@ authority scope, an explicit lifetime, and the journal entries supporting it.
See [`examples/strict-plugin.ts`](examples/strict-plugin.ts) for a compiler-checked
registration example.
## Probe a real server
The read-only probe turns a live TLS connection into the same immutable facts
used by the simulator:
```sh
npm run probe -- example.com
npm run probe -- broken.example:8443 --json
```
It records DER and SPKI SHA-256 fingerprints and independently checks hostname,
validity periods, and adjacent certificate signatures. It never installs trust
or changes the operating system. The report labels its current chain-source
limitation rather than claiming that OpenSSL's peer chain is exactly what the
server transmitted.
Authority scopes identify the exact DER-encoded CA certificate with
`authorityCertificateSha256`. TrustLab verifies that it appears in the active
chain, has CA Basic Constraints in the supplied facts, and permits

View File

@ -0,0 +1,36 @@
#!/usr/bin/env node
import { probeTls } from "../src/index.js";
const args = process.argv.slice(2);
const json = args.includes("--json");
const target = args.find((argument) => !argument.startsWith("--"));
if (!target) {
console.error("Usage: npm run probe -- hostname[:port] [--json]");
process.exitCode = 2;
} else {
try {
const report = await probeTls(target);
if (json) console.log(JSON.stringify(report, null, 2));
else printReport(report);
} catch (error) {
console.error(`Probe failed: ${error.message}`);
process.exitCode = 1;
}
}
function printReport({ facts, findings, caveats }) {
console.log(`${facts.hostname}:${facts.port}${facts.validation.toUpperCase()}`);
console.log(`TLS ${facts.tls.version ?? "unknown"}; cipher ${facts.tls.cipher ?? "unknown"}`);
console.log(`Conventional trust: ${facts.tls.conventionalTrust ? "yes" : `no (${facts.tls.conventionalTrustError})`}`);
console.log("\nObserved certificate chain:");
facts.presentedChain.forEach((certificate, index) => {
console.log(`${index + 1}. ${certificate.subject}`);
console.log(` SHA-256 ${certificate.sha256}`);
console.log(` ${certificate.validFrom}${certificate.validUntil}${certificate.isCa ? " · CA" : ""}${certificate.selfSigned ? " · self-signed" : ""}`);
});
console.log("\nFindings:");
if (findings.length === 0) console.log("- No failure found by the current checks.");
else findings.forEach((item) => console.log(`- [${item.check}] ${item.summary}`));
console.log(`\nCaveat: ${caveats[0]}`);
}

View File

@ -9,6 +9,7 @@
"check": "tsc -p tsconfig.json --noEmit",
"test": "node --test --test-isolation=none",
"demo": "node examples/demo.js",
"probe": "node examples/probe.js",
"ui": "node ui/dev-server.js"
},
"engines": {

View File

@ -12,8 +12,10 @@ export type TlsValidation = "success" | "failure";
export interface CertificateFacts {
readonly subject: string;
readonly issuer?: string;
readonly sha256: string;
readonly spkiSha256?: string;
readonly serialNumber?: string;
readonly dnsNames?: readonly string[];
readonly validFrom?: string;
readonly validUntil?: string;

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
trustlab/src/tls-probe.js Normal file
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");
}

View File

@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseTlsTarget } from "../src/tls-probe.js";
test("TLS probe target parser accepts hostnames, ports, and HTTPS URLs", () => {
assert.deepEqual(parseTlsTarget("example.test"), { hostname: "example.test", port: 443 });
assert.deepEqual(parseTlsTarget("example.test:8443"), { hostname: "example.test", port: 8443 });
assert.deepEqual(parseTlsTarget("https://example.test:9443"), { hostname: "example.test", port: 9443 });
});
test("TLS probe target parser rejects credentials, paths, and invalid ports", () => {
assert.throws(() => parseTlsTarget("https://user@example.test"), /hostname and optional port/);
assert.throws(() => parseTlsTarget("https://example.test/path"), /hostname and optional port/);
assert.throws(() => parseTlsTarget("example.test:70000"), /Invalid URL|invalid hostname or port/);
});