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 See [`examples/strict-plugin.ts`](examples/strict-plugin.ts) for a compiler-checked
registration example. 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 Authority scopes identify the exact DER-encoded CA certificate with
`authorityCertificateSha256`. TrustLab verifies that it appears in the active `authorityCertificateSha256`. TrustLab verifies that it appears in the active
chain, has CA Basic Constraints in the supplied facts, and permits 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", "check": "tsc -p tsconfig.json --noEmit",
"test": "node --test --test-isolation=none", "test": "node --test --test-isolation=none",
"demo": "node examples/demo.js", "demo": "node examples/demo.js",
"probe": "node examples/probe.js",
"ui": "node ui/dev-server.js" "ui": "node ui/dev-server.js"
}, },
"engines": { "engines": {

View File

@ -12,8 +12,10 @@ export type TlsValidation = "success" | "failure";
export interface CertificateFacts { export interface CertificateFacts {
readonly subject: string; readonly subject: string;
readonly issuer?: string;
readonly sha256: string; readonly sha256: string;
readonly spkiSha256?: string; readonly spkiSha256?: string;
readonly serialNumber?: string;
readonly dnsNames?: readonly string[]; readonly dnsNames?: readonly string[];
readonly validFrom?: string; readonly validFrom?: string;
readonly validUntil?: 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 { createTlsFacts, ENTRY_KINDS, PLUGIN_ROLES } from "./protocol.js";
export { TrustPolicyOverlay } from "./policy-overlay.js"; export { TrustPolicyOverlay } from "./policy-overlay.js";
export { TrustRunner } from "./runner.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/);
});