Connection
+Identity under examination
+Validation path
+Where trust succeeds or breaks
+Append-only journal
+diff --git a/trustlab/README.md b/trustlab/README.md index d48329c..0a17e77 100644 --- a/trustlab/README.md +++ b/trustlab/README.md @@ -17,14 +17,20 @@ Run: ```sh npm test npm run demo +npm run ui ``` +`npm run ui` serves the browser-hosted testbed at `http://127.0.0.1:4173`. +The page uses synthetic TLS records and the same portable TrustLab runner used +by the tests. It does not make real TLS decisions. + ## Current protocol boundary Plugins may return evidence, warnings, and a scoped Boolean trust verdict. They cannot access Node.js facilities through the protocol, mutate TLS facts or journal entries, or perform browser actions. -This first slice intentionally omits persistence, package signatures, -interactive UI, community identities, networking, and real X.509 parsing. +This first slice intentionally omits persistence, package signatures, community +identities, networking, and real X.509 parsing. The included interactive UI is a +security-surface prototype, not browser integration. diff --git a/trustlab/package.json b/trustlab/package.json index 73f2b27..407d783 100644 --- a/trustlab/package.json +++ b/trustlab/package.json @@ -6,7 +6,8 @@ "description": "Browser-neutral reference runner for Browsec trust plugins", "scripts": { "test": "node --test --test-isolation=none", - "demo": "node examples/demo.js" + "demo": "node examples/demo.js", + "ui": "node ui/dev-server.js" }, "engines": { "node": ">=22" diff --git a/trustlab/plugins/demo-plugins.js b/trustlab/plugins/demo-plugins.js new file mode 100644 index 0000000..e1ee104 --- /dev/null +++ b/trustlab/plugins/demo-plugins.js @@ -0,0 +1,90 @@ +export function createFirefoxValidationPlugin() { + return { + manifest: { + id: "org.browsec.firefox-validation", + name: "Firefox validation", + role: "advisor", + }, + collectEvidence({ facts }) { + if (facts.validation === "success") { + return { + entries: [ + { + kind: "vote", + code: "firefox-validation-succeeded", + message: "Firefox constructed a valid path to a configured trust anchor.", + }, + ], + }; + } + + return { + entries: facts.errors.map((error) => ({ + kind: "warning", + code: error, + message: explainValidationError(error), + })), + }; + }, + }; +} + +export function createVillageCommunityPlugin() { + return { + manifest: { + id: "community.village.observer", + name: "Village community observer", + role: "advisor", + }, + collectEvidence({ facts }) { + if (facts.hostname !== "library.village") return; + return { + entries: [ + { + kind: "evidence", + code: "community-key-continuity", + message: "This certificate has appeared in the synthetic community record for 184 days.", + data: { observers: 7, independentOperators: 3, ageDays: 184 }, + }, + { + kind: "vote", + code: "community-recommends-trust", + message: "The configured community recommends trusting this exact host certificate.", + }, + ], + }; + }, + }; +} + +export function createUserDecisionPlugin(decision) { + if (decision !== true && decision !== false) return undefined; + return { + manifest: { + id: "local.user.decision", + name: "Local user decision", + role: "decision-authority", + }, + decide({ facts, journal }) { + return { + trusted: decision, + scope: { hostname: facts.hostname, port: facts.port }, + reasonEntryIds: journal.entries + .filter((entry) => entry.kind === "evidence" || entry.kind === "warning") + .map((entry) => entry.id), + }; + }, + }; +} + +function explainValidationError(error) { + const explanations = { + "unknown-issuer": + "Firefox cannot construct a path from this certificate to a configured trust anchor.", + expired: "At least one certificate in the validation path is outside its validity period.", + "hostname-mismatch": + "The leaf certificate does not identify the requested hostname.", + }; + return explanations[error] ?? `Firefox reported certificate error: ${error}.`; +} + diff --git a/trustlab/test/ui.test.js b/trustlab/test/ui.test.js new file mode 100644 index 0000000..0f97477 --- /dev/null +++ b/trustlab/test/ui.test.js @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { unknownLocalAuthority } from "../fixtures/tls.js"; +import { + createFirefoxValidationPlugin, + createUserDecisionPlugin, + createVillageCommunityPlugin, +} from "../plugins/demo-plugins.js"; +import { TrustRunner } from "../src/index.js"; +import { chainRows, subjectName, verdictCopy } from "../ui/model.js"; + +test("UI model identifies the failed end of an unknown-authority chain", () => { + const rows = chainRows(unknownLocalAuthority); + assert.equal(rows.length, 2); + assert.equal(rows[0].name, "library.village"); + assert.equal(rows.at(-1).failed, true); + assert.match(rows.at(-1).edge, /Not anchored/); +}); + +test("certificate display names prefer the common name", () => { + assert.equal(subjectName("O=Village,CN=Library CA,C=GE"), "Library CA"); + assert.equal(subjectName("O=Nameless"), "O=Nameless"); +}); + +test("UI plugins produce attributed evidence and a local Boolean verdict", async () => { + const plugins = [ + createFirefoxValidationPlugin(), + createVillageCommunityPlugin(), + createUserDecisionPlugin(true), + ]; + const result = await new TrustRunner({ plugins }).evaluate(unknownLocalAuthority); + + assert.equal(result.verdict.trusted, true); + assert.ok(result.journal.entries.some((entry) => entry.code === "unknown-issuer")); + assert.ok( + result.journal.entries.some((entry) => entry.code === "community-key-continuity"), + ); + assert.equal(verdictCopy(result, true).title, "You trust this connection"); +}); + +test("security surface contains immutable-frame and simulation labels", async () => { + const html = await readFile(new URL("../ui/index.html", import.meta.url), "utf8"); + 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/); +}); + diff --git a/trustlab/ui/app.js b/trustlab/ui/app.js new file mode 100644 index 0000000..802902c --- /dev/null +++ b/trustlab/ui/app.js @@ -0,0 +1,159 @@ +import { unknownLocalAuthority, validPublicCertificate } from "../fixtures/tls.js"; +import { + createFirefoxValidationPlugin, + createUserDecisionPlugin, + createVillageCommunityPlugin, +} from "../plugins/demo-plugins.js"; +import { TrustRunner } from "../src/index.js"; +import { chainRows, verdictCopy } from "./model.js"; + +const scenarios = { + "unknown-local": { + label: "Unknown village authority", + facts: unknownLocalAuthority, + }, + "valid-public": { + label: "Valid conventional path", + facts: validPublicCertificate, + }, +}; + +const state = { + scenario: "unknown-local", + userDecision: undefined, + communityEnabled: true, +}; + +const elements = { + scenario: document.querySelector("#scenario"), + community: document.querySelector("#community-enabled"), + status: document.querySelector("#status"), + identity: document.querySelector("#identity"), + chain: document.querySelector("#chain"), + journal: document.querySelector("#journal"), + trust: document.querySelector("#trust"), + reject: document.querySelector("#reject"), + clear: document.querySelector("#clear-decision"), +}; + +for (const [value, scenario] of Object.entries(scenarios)) { + const option = document.createElement("option"); + option.value = value; + option.textContent = scenario.label; + elements.scenario.append(option); +} + +elements.scenario.addEventListener("change", () => { + state.scenario = elements.scenario.value; + state.userDecision = undefined; + render(); +}); +elements.community.addEventListener("change", () => { + state.communityEnabled = elements.community.checked; + render(); +}); +elements.trust.addEventListener("click", () => { + state.userDecision = true; + render(); +}); +elements.reject.addEventListener("click", () => { + state.userDecision = false; + render(); +}); +elements.clear.addEventListener("click", () => { + state.userDecision = undefined; + render(); +}); + +async function render() { + const facts = scenarios[state.scenario].facts; + const plugins = [createFirefoxValidationPlugin()]; + if (state.communityEnabled) plugins.push(createVillageCommunityPlugin()); + const userPlugin = createUserDecisionPlugin(state.userDecision); + if (userPlugin) plugins.push(userPlugin); + + const result = await new TrustRunner({ plugins }).evaluate(facts); + renderStatus(result); + renderIdentity(result); + renderChain(result); + renderJournal(result); + elements.clear.hidden = state.userDecision === undefined; +} + +function renderStatus(result) { + const copy = verdictCopy(result, state.userDecision !== undefined); + elements.status.dataset.state = result.verdict.trusted ? "trusted" : "not-trusted"; + elements.status.replaceChildren( + node("p", copy.eyebrow, "eyebrow"), + node("h1", copy.title), + node("p", copy.detail, "status-detail"), + ); +} + +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("TLS", result.facts.tls.version ?? "Unknown"), + ); +} + +function renderChain(result) { + elements.chain.replaceChildren( + ...chainRows(result.facts).map((certificate, index, all) => { + const item = document.createElement("li"); + item.className = certificate.failed ? "certificate failed" : "certificate"; + item.append( + node("span", certificate.role, "certificate-role"), + node("strong", certificate.name), + node("code", certificate.sha256 ?? "No fingerprint"), + node("span", certificate.edge, "edge-label"), + ); + item.setAttribute("aria-label", `${certificate.role}: ${certificate.name}. ${certificate.edge}`); + if (index < all.length - 1) item.dataset.linked = "true"; + return item; + }), + ); +} + +function renderJournal(result) { + if (result.journal.entries.length === 0) { + elements.journal.replaceChildren(node("p", "No plugin findings.", "empty")); + return; + } + + elements.journal.replaceChildren( + ...result.journal.entries.map((entry) => { + const article = document.createElement("article"); + article.className = `journal-entry kind-${entry.kind}`; + article.append( + node("span", entry.kind, "entry-kind"), + node("h3", entry.pluginName), + node("p", entry.message ?? formatCode(entry.code)), + node("code", entry.code ?? entry.id), + ); + return article; + }), + ); +} + +function definition(term, value) { + const wrapper = document.createElement("div"); + wrapper.append(node("dt", term), node("dd", value)); + return wrapper; +} + +function node(tag, text, className) { + const element = document.createElement(tag); + element.textContent = text; + if (className) element.className = className; + return element; +} + +function formatCode(value = "") { + return value.replaceAll("-", " "); +} + +render(); + diff --git a/trustlab/ui/dev-server.js b/trustlab/ui/dev-server.js new file mode 100644 index 0000000..ecc6143 --- /dev/null +++ b/trustlab/ui/dev-server.js @@ -0,0 +1,54 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const trustlabRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const host = "127.0.0.1"; +const port = Number.parseInt(process.env.TRUSTLAB_PORT ?? "4173", 10); +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", +}; + +const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", `http://${host}:${port}`); + const relativePath = url.pathname === "/" ? "ui/index.html" : url.pathname.slice(1); + const requestedPath = resolve(trustlabRoot, relativePath); + if (!requestedPath.startsWith(`${trustlabRoot}${sep}`)) { + respond(response, 403, "Forbidden"); + return; + } + + const body = await readFile(requestedPath); + response.writeHead(200, { + "Content-Type": contentTypes[extname(requestedPath)] ?? "application/octet-stream", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'", + }); + response.end(body); + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "EISDIR") { + respond(response, 404, "Not found"); + return; + } + respond(response, 500, "Internal server error"); + } +}); + +server.listen(port, host, () => { + console.log(`TrustLab UI: http://${host}:${port}`); +}); + +function respond(response, status, message) { + response.writeHead(status, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + response.end(message); +} + diff --git a/trustlab/ui/index.html b/trustlab/ui/index.html new file mode 100644 index 0000000..4273230 --- /dev/null +++ b/trustlab/ui/index.html @@ -0,0 +1,78 @@ + + +
+ + + +Connection
+Validation path
+Append-only journal
+