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
|
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
|
||||||
|
|||||||
@ -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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user