From 676d1f2ec806e469e17ba64e7aead5f92d26d009 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 16 Aug 2026 19:51:59 +0400 Subject: [PATCH] Add read-only live TLS probe --- trustlab/README.md | 16 +++ trustlab/examples/probe.js | 36 ++++++ trustlab/package.json | 1 + trustlab/sdk/plugin-api.ts | 2 + trustlab/src/index.js | 1 + trustlab/src/tls-probe.js | 198 ++++++++++++++++++++++++++++++++ trustlab/test/tls-probe.test.js | 16 +++ 7 files changed, 270 insertions(+) create mode 100644 trustlab/examples/probe.js create mode 100644 trustlab/src/tls-probe.js create mode 100644 trustlab/test/tls-probe.test.js diff --git a/trustlab/README.md b/trustlab/README.md index e850ac0..b798c0e 100644 --- a/trustlab/README.md +++ b/trustlab/README.md @@ -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 diff --git a/trustlab/examples/probe.js b/trustlab/examples/probe.js new file mode 100644 index 0000000..0710810 --- /dev/null +++ b/trustlab/examples/probe.js @@ -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]}`); +} diff --git a/trustlab/package.json b/trustlab/package.json index ded650f..af00ff5 100644 --- a/trustlab/package.json +++ b/trustlab/package.json @@ -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": { diff --git a/trustlab/sdk/plugin-api.ts b/trustlab/sdk/plugin-api.ts index 60ab580..853a466 100644 --- a/trustlab/sdk/plugin-api.ts +++ b/trustlab/sdk/plugin-api.ts @@ -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; diff --git a/trustlab/src/index.js b/trustlab/src/index.js index ac740a4..699ca23 100644 --- a/trustlab/src/index.js +++ b/trustlab/src/index.js @@ -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"; diff --git a/trustlab/src/tls-probe.js b/trustlab/src/tls-probe.js new file mode 100644 index 0000000..6f0db1e --- /dev/null +++ b/trustlab/src/tls-probe.js @@ -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"); +} diff --git a/trustlab/test/tls-probe.test.js b/trustlab/test/tls-probe.test.js new file mode 100644 index 0000000..931da09 --- /dev/null +++ b/trustlab/test/tls-probe.test.js @@ -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/); +});