Add read-only live TLS probe
This commit is contained in:
parent
d479c459fc
commit
676d1f2ec8
@ -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
|
||||
|
||||
36
trustlab/examples/probe.js
Normal file
36
trustlab/examples/probe.js
Normal 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]}`);
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
198
trustlab/src/tls-probe.js
Normal 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");
|
||||
}
|
||||
16
trustlab/test/tls-probe.test.js
Normal file
16
trustlab/test/tls-probe.test.js
Normal 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/);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user