Connect live TLS probes to TrustLab UI
This commit is contained in:
parent
676d1f2ec8
commit
7b136ea8b8
@ -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
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user