diff --git a/trustlab/README.md b/trustlab/README.md index b798c0e..65c8cb6 100644 --- a/trustlab/README.md +++ b/trustlab/README.md @@ -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 diff --git a/trustlab/test/ui.test.js b/trustlab/test/ui.test.js index 3fac645..a4430d7 100644 --- a/trustlab/test/ui.test.js +++ b/trustlab/test/ui.test.js @@ -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/); }); diff --git a/trustlab/ui/app.js b/trustlab/ui/app.js index df2d0aa..371d730 100644 --- a/trustlab/ui/app.js +++ b/trustlab/ui/app.js @@ -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) => { diff --git a/trustlab/ui/dev-server.js b/trustlab/ui/dev-server.js index ecc6143..fceba16 100644 --- a/trustlab/ui/dev-server.js +++ b/trustlab/ui/dev-server.js @@ -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)); +} diff --git a/trustlab/ui/index.html b/trustlab/ui/index.html index 449294b..b7c6b2a 100644 --- a/trustlab/ui/index.html +++ b/trustlab/ui/index.html @@ -14,15 +14,24 @@ Browsec security decision Browser-owned test surface -