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 limitation rather than claiming that OpenSSL's peer chain is exactly what the
server transmitted. 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 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

@ -42,6 +42,7 @@ test("UI model locates leaf and root policy failures precisely", () => {
test("certificate display names prefer the common name", () => { 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,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"); 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, /Browsec security decision/);
assert.match(html, /Browser-owned test surface/); assert.match(html, /Browser-owned test surface/);
assert.match(html, /TRUSTLAB · SYNTHETIC/); 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, /Decision target/);
assert.match(html, /Effective and consumed local rules/); assert.match(html, /Effective and consumed local rules/);
}); });

View File

@ -61,12 +61,18 @@ const state = {
scenario: "unknown-local", scenario: "unknown-local",
pendingDecision: undefined, pendingDecision: undefined,
communityEnabled: true, communityEnabled: true,
liveReport: undefined,
}; };
const policyOverlay = new TrustPolicyOverlay(); const policyOverlay = new TrustPolicyOverlay();
const elements = { const elements = {
scenario: document.querySelector("#scenario"), 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"), community: document.querySelector("#community-enabled"),
target: document.querySelector("#trust-target"), target: document.querySelector("#trust-target"),
lifetime: document.querySelector("#trust-lifetime"), lifetime: document.querySelector("#trust-lifetime"),
@ -94,6 +100,43 @@ elements.scenario.addEventListener("change", () => {
renderTrustTargets(scenarios[state.scenario].facts); renderTrustTargets(scenarios[state.scenario].facts);
render(); 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.target.addEventListener("change", () => {
elements.includeSubdomains.disabled = !elements.target.value.startsWith("authority:"); elements.includeSubdomains.disabled = !elements.target.value.startsWith("authority:");
if (elements.includeSubdomains.disabled) elements.includeSubdomains.checked = false; if (elements.includeSubdomains.disabled) elements.includeSubdomains.checked = false;
@ -117,11 +160,14 @@ elements.clear.addEventListener("click", () => {
}); });
async function render() { async function render() {
const facts = scenarios[state.scenario].facts; const scenario = scenarios[state.scenario];
const evidencePlugins = [configure(createFirefoxValidationPlugin(), "advisor")]; const facts = scenario.facts;
const evidencePlugins = scenario.probePlugins
? [...scenario.probePlugins]
: [configure(createFirefoxValidationPlugin(), "advisor")];
if (state.communityEnabled) { if (state.communityEnabled) {
evidencePlugins.push( evidencePlugins.push(
...(scenarios[state.scenario].plugins ?? [ ...(scenario.plugins ?? [
configure(createVillageCommunityPlugin(), "advisor"), configure(createVillageCommunityPlugin(), "advisor"),
]), ]),
); );
@ -172,6 +218,9 @@ async function render() {
renderJournal(result); renderJournal(result);
renderRules(policyOverlay.snapshot()); renderRules(policyOverlay.snapshot());
elements.clear.hidden = policyOverlay.snapshot().rules.length === 0; elements.clear.hidden = policyOverlay.snapshot().rules.length === 0;
elements.frameSeal.textContent = state.scenario === "live"
? "TRUSTLAB · LIVE PROBE"
: "TRUSTLAB · SYNTHETIC";
} }
function renderStatus(result, hasLocalDecision) { function renderStatus(result, hasLocalDecision) {
@ -229,11 +278,50 @@ function renderIdentity(result) {
elements.identity.replaceChildren( elements.identity.replaceChildren(
definition("Requested host", result.facts.hostname), definition("Requested host", result.facts.hostname),
definition("Port", String(result.facts.port)), 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"), 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) { function renderChain(result) {
elements.chain.replaceChildren( elements.chain.replaceChildren(
...chainRows(result.facts).map((certificate, index, all) => { ...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 { readFile } from "node:fs/promises";
import { extname, resolve, sep } from "node:path"; import { extname, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { probeTls } from "../src/tls-probe.js";
const trustlabRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); const trustlabRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
const host = "127.0.0.1"; const host = "127.0.0.1";
@ -16,6 +17,10 @@ const contentTypes = {
const server = createServer(async (request, response) => { const server = createServer(async (request, response) => {
try { try {
const url = new URL(request.url ?? "/", `http://${host}:${port}`); 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 relativePath = url.pathname === "/" ? "ui/index.html" : url.pathname.slice(1);
const requestedPath = resolve(trustlabRoot, relativePath); const requestedPath = resolve(trustlabRoot, relativePath);
if (!requestedPath.startsWith(`${trustlabRoot}${sep}`)) { if (!requestedPath.startsWith(`${trustlabRoot}${sep}`)) {
@ -52,3 +57,43 @@ function respond(response, status, message) {
response.end(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> <strong>Browsec security decision</strong>
<span>Browser-owned test surface</span> <span>Browser-owned test surface</span>
</div> </div>
<div class="frame-seal">TRUSTLAB · SYNTHETIC</div> <div id="frame-seal" class="frame-seal">TRUSTLAB · SYNTHETIC</div>
</header> </header>
<main> <main>
<aside class="controls" aria-label="TrustLab controls"> <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> <label for="scenario">Certificate scenario</label>
<select id="scenario"></select> <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"> <label class="toggle">
<input id="community-enabled" type="checkbox" checked> <input id="community-enabled" type="checkbox" checked>
<span>Village community evidence</span> <span>Village community evidence</span>
@ -46,7 +55,7 @@
</label> </label>
<p class="simulation-note"> <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> </p>
</aside> </aside>
@ -90,7 +99,7 @@
<footer class="decision-bar"> <footer class="decision-bar">
<div> <div>
<strong>Your local decision</strong> <strong>Your local decision</strong>
<span>Exact host and port · current simulation</span> <span>Exact host and port · local TrustLab decision</span>
</div> </div>
<button id="clear-decision" class="button quiet" hidden>Clear decision</button> <button id="clear-decision" class="button quiet" hidden>Clear decision</button>
<button id="reject" class="button reject">Do not trust</button> <button id="reject" class="button reject">Do not trust</button>

View File

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

View File

@ -25,7 +25,7 @@ body {
#091014; #091014;
} }
button, select { font: inherit; } button, select, input { font: inherit; }
.browser-frame { .browser-frame {
position: sticky; position: sticky;
@ -100,6 +100,21 @@ select {
background: #142229; 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 { .toggle {
display: flex; display: flex;
gap: 0.65rem; gap: 0.65rem;