Connect live TLS probes to TrustLab UI

This commit is contained in:
Sergey Chernov 2026-08-16 20:30:31 +04:00
parent 676d1f2ec8
commit 7b136ea8b8
7 changed files with 179 additions and 15 deletions

View File

@ -69,6 +69,11 @@ 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.
Run `npm run ui`, open the displayed loopback URL, and use **Live TLS target**
to inspect a host in the diagnostic interface. The local endpoint accepts only
small JSON `POST` requests from its own browser origin and applies a ten-second
probe timeout.
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

@ -42,6 +42,7 @@ test("UI model locates leaf and root policy failures precisely", () => {
test("certificate display names prefer the common name", () => {
assert.equal(subjectName("O=Village,CN=Library CA,C=GE"), "Library CA");
assert.equal(subjectName("O=Village\nCN=Library CA\nC=GE"), "Library CA");
assert.equal(subjectName("O=Nameless"), "O=Nameless");
});
@ -95,7 +96,8 @@ test("security surface contains immutable-frame and simulation labels", async ()
assert.match(html, /Browsec security decision/);
assert.match(html, /Browser-owned test surface/);
assert.match(html, /TRUSTLAB · SYNTHETIC/);
assert.match(html, /It cannot alter browser trust/);
assert.match(html, /cannot alter browser or system trust/);
assert.match(html, /Live TLS target/);
assert.match(html, /Decision target/);
assert.match(html, /Effective and consumed local rules/);
});

View File

@ -61,12 +61,18 @@ const state = {
scenario: "unknown-local",
pendingDecision: undefined,
communityEnabled: true,
liveReport: undefined,
};
const policyOverlay = new TrustPolicyOverlay();
const elements = {
scenario: document.querySelector("#scenario"),
probeForm: document.querySelector("#probe-form"),
probeTarget: document.querySelector("#probe-target"),
probeSubmit: document.querySelector("#probe-submit"),
probeMessage: document.querySelector("#probe-message"),
frameSeal: document.querySelector("#frame-seal"),
community: document.querySelector("#community-enabled"),
target: document.querySelector("#trust-target"),
lifetime: document.querySelector("#trust-lifetime"),
@ -94,6 +100,43 @@ elements.scenario.addEventListener("change", () => {
renderTrustTargets(scenarios[state.scenario].facts);
render();
});
elements.probeForm.addEventListener("submit", async (event) => {
event.preventDefault();
elements.probeSubmit.disabled = true;
elements.probeMessage.className = "probe-message";
elements.probeMessage.textContent = "Opening a read-only TLS connection…";
try {
const response = await fetch("/api/probe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: elements.probeTarget.value }),
});
const report = await response.json();
if (!response.ok) throw new Error(report.error ?? `Probe failed (${response.status})`);
state.liveReport = report;
scenarios.live = {
label: `Live: ${report.facts.hostname}:${report.facts.port}`,
facts: report.facts,
probePlugins: [configure(createProbeEvidencePlugin(report), "advisor")],
};
let option = [...elements.scenario.options].find((item) => item.value === "live");
if (!option) {
option = new Option(scenarios.live.label, "live");
elements.scenario.prepend(option);
} else option.textContent = scenarios.live.label;
state.scenario = "live";
elements.scenario.value = "live";
state.pendingDecision = undefined;
renderTrustTargets(report.facts);
elements.probeMessage.textContent = `Received ${report.facts.presentedChain.length} certificate(s).`;
await render();
} catch (error) {
elements.probeMessage.className = "probe-message error";
elements.probeMessage.textContent = error.message;
} finally {
elements.probeSubmit.disabled = false;
}
});
elements.target.addEventListener("change", () => {
elements.includeSubdomains.disabled = !elements.target.value.startsWith("authority:");
if (elements.includeSubdomains.disabled) elements.includeSubdomains.checked = false;
@ -117,11 +160,14 @@ elements.clear.addEventListener("click", () => {
});
async function render() {
const facts = scenarios[state.scenario].facts;
const evidencePlugins = [configure(createFirefoxValidationPlugin(), "advisor")];
const scenario = scenarios[state.scenario];
const facts = scenario.facts;
const evidencePlugins = scenario.probePlugins
? [...scenario.probePlugins]
: [configure(createFirefoxValidationPlugin(), "advisor")];
if (state.communityEnabled) {
evidencePlugins.push(
...(scenarios[state.scenario].plugins ?? [
...(scenario.plugins ?? [
configure(createVillageCommunityPlugin(), "advisor"),
]),
);
@ -172,6 +218,9 @@ async function render() {
renderJournal(result);
renderRules(policyOverlay.snapshot());
elements.clear.hidden = policyOverlay.snapshot().rules.length === 0;
elements.frameSeal.textContent = state.scenario === "live"
? "TRUSTLAB · LIVE PROBE"
: "TRUSTLAB · SYNTHETIC";
}
function renderStatus(result, hasLocalDecision) {
@ -229,11 +278,50 @@ function renderIdentity(result) {
elements.identity.replaceChildren(
definition("Requested host", result.facts.hostname),
definition("Port", String(result.facts.port)),
definition("Firefox result", result.facts.validation),
definition("Conventional result", result.facts.validation),
definition("TLS", result.facts.tls.version ?? "Unknown"),
);
}
function createProbeEvidencePlugin(report) {
return {
manifest: {
manifestVersion: 1,
trustApiVersion: "0.1",
id: "org.browsec.live-probe",
name: "Live TLS probe",
version: "0.1.0",
supportedModes: ["advisor"],
capabilities: {},
},
hooks: {
collectEvidence() {
return {
entries: [
...(report.facts.validation === "success" ? [{
kind: "vote",
code: "conventional-validation-succeeded",
message: "The local OpenSSL validator accepted this certificate path.",
data: { trusted: true },
}] : []),
...report.findings.map((finding) => ({
kind: finding.check === "trust-anchor" ? "warning" : "evidence",
code: finding.code,
message: finding.summary,
data: { certificateSha256: finding.certificateSha256 },
})),
...report.caveats.map((message) => ({
kind: "warning",
code: "probe-chain-source-limitation",
message,
})),
],
};
},
},
};
}
function renderChain(result) {
elements.chain.replaceChildren(
...chainRows(result.facts).map((certificate, index, all) => {

View File

@ -2,6 +2,7 @@ import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { probeTls } from "../src/tls-probe.js";
const trustlabRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
const host = "127.0.0.1";
@ -16,6 +17,10 @@ const contentTypes = {
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", `http://${host}:${port}`);
if (url.pathname === "/api/probe") {
await handleProbe(request, response);
return;
}
const relativePath = url.pathname === "/" ? "ui/index.html" : url.pathname.slice(1);
const requestedPath = resolve(trustlabRoot, relativePath);
if (!requestedPath.startsWith(`${trustlabRoot}${sep}`)) {
@ -52,3 +57,43 @@ function respond(response, status, message) {
response.end(message);
}
async function handleProbe(request, response) {
if (request.method !== "POST") {
respond(response, 405, "Method not allowed");
return;
}
const expectedOrigin = `http://${host}:${port}`;
if (request.headers.origin && request.headers.origin !== expectedOrigin) {
respond(response, 403, "Origin not allowed");
return;
}
if (!request.headers["content-type"]?.startsWith("application/json")) {
respond(response, 415, "Expected application/json");
return;
}
try {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > 4096) throw new RangeError("Request body is too large");
chunks.push(chunk);
}
const input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const report = await probeTls(input.target, { timeoutMs: 10_000 });
respondJson(response, 200, report);
} catch (error) {
respondJson(response, error instanceof RangeError ? 413 : 400, {
error: error.message,
});
}
}
function respondJson(response, status, value) {
response.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
});
response.end(JSON.stringify(value));
}

View File

@ -14,15 +14,24 @@
<strong>Browsec security decision</strong>
<span>Browser-owned test surface</span>
</div>
<div class="frame-seal">TRUSTLAB · SYNTHETIC</div>
<div id="frame-seal" class="frame-seal">TRUSTLAB · SYNTHETIC</div>
</header>
<main>
<aside class="controls" aria-label="TrustLab controls">
<p class="eyebrow">Synthetic input</p>
<p class="eyebrow">Input source</p>
<label for="scenario">Certificate scenario</label>
<select id="scenario"></select>
<form id="probe-form" class="probe-form">
<label for="probe-target">Live TLS target</label>
<div class="probe-row">
<input id="probe-target" name="target" type="text" inputmode="url" placeholder="example.com:443" autocomplete="off" required>
<button id="probe-submit" class="button" type="submit">Probe</button>
</div>
<p id="probe-message" class="probe-message" aria-live="polite"></p>
</form>
<label class="toggle">
<input id="community-enabled" type="checkbox" checked>
<span>Village community evidence</span>
@ -46,7 +55,7 @@
</label>
<p class="simulation-note">
This page evaluates fixtures only. It cannot alter browser trust.
Live probes are read-only. This page cannot alter browser or system trust.
</p>
</aside>
@ -90,7 +99,7 @@
<footer class="decision-bar">
<div>
<strong>Your local decision</strong>
<span>Exact host and port · current simulation</span>
<span>Exact host and port · local TrustLab decision</span>
</div>
<button id="clear-decision" class="button quiet" hidden>Clear decision</button>
<button id="reject" class="button reject">Do not trust</button>

View File

@ -1,5 +1,5 @@
export function subjectName(subject = "") {
const commonName = /(?:^|,)CN=([^,]+)/.exec(subject)?.[1];
const commonName = /(?:^|[\n,])CN=([^\n,]+)/.exec(subject)?.[1];
return commonName ?? (subject || "Unnamed certificate");
}
@ -20,7 +20,7 @@ export function verdictCopy(result, hasUserDecision) {
if (result.facts.validation === "success") {
return {
eyebrow: "Firefox validation",
eyebrow: "Conventional validation",
title: "The conventional certificate path is valid",
detail: "Trust plugins may still add evidence, warnings, or a stricter local verdict.",
};
@ -28,7 +28,7 @@ export function verdictCopy(result, hasUserDecision) {
return {
eyebrow: "Decision required",
title: "Firefox could not verify this identity",
title: "The conventional validator could not verify this identity",
detail:
result.facts.failure?.summary ??
"Review the broken path and attributed plugin findings before deciding.",
@ -54,7 +54,7 @@ export function chainRows(facts) {
function edgeDescription(facts, certificate, index, chainLength) {
if (certificate.sha256 === facts.failure?.certificateSha256) {
const messages = {
"unknown-issuer": "Not anchored in current Firefox trust",
"unknown-issuer": "Not anchored in the current conventional trust store",
expired: "Certificate validity period has ended",
"hostname-mismatch": `Does not identify ${facts.hostname}`,
"explicitly-distrusted-authority": "Explicitly distrusted by local Browsec policy",
@ -63,6 +63,6 @@ function edgeDescription(facts, certificate, index, chainLength) {
}
return index === chainLength - 1
? "Trusted by current Firefox policy"
? "Accepted by current conventional policy"
: "Signature links to next issuer";
}

View File

@ -25,7 +25,7 @@ body {
#091014;
}
button, select { font: inherit; }
button, select, input { font: inherit; }
.browser-frame {
position: sticky;
@ -100,6 +100,21 @@ select {
background: #142229;
}
.probe-form { margin-top: 1rem; padding-top: 0.2rem; border-top: 1px solid var(--line); }
.probe-row { display: flex; gap: 0.45rem; }
.probe-row input {
min-width: 0;
width: 100%;
padding: 0.7rem;
border: 1px solid #3b515c;
border-radius: 0.3rem;
color: inherit;
background: #142229;
}
.probe-row .button { padding-inline: 0.8rem; }
.probe-message { min-height: 1.2em; margin: 0.45rem 0 0; color: var(--ink-muted); font-size: 0.72rem; }
.probe-message.error { color: var(--red); }
.toggle {
display: flex;
gap: 0.65rem;