Add browser-hosted TrustLab security surface

This commit is contained in:
Sergey Chernov 2026-08-16 15:57:03 +04:00
parent 1b0f178f37
commit 9830f444ac
9 changed files with 782 additions and 3 deletions

View File

@ -17,14 +17,20 @@ Run:
```sh ```sh
npm test npm test
npm run demo 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 ## Current protocol boundary
Plugins may return evidence, warnings, and a scoped Boolean trust verdict. They Plugins may return evidence, warnings, and a scoped Boolean trust verdict. They
cannot access Node.js facilities through the protocol, mutate TLS facts or cannot access Node.js facilities through the protocol, mutate TLS facts or
journal entries, or perform browser actions. journal entries, or perform browser actions.
This first slice intentionally omits persistence, package signatures, This first slice intentionally omits persistence, package signatures, community
interactive UI, community identities, networking, and real X.509 parsing. identities, networking, and real X.509 parsing. The included interactive UI is a
security-surface prototype, not browser integration.

View File

@ -6,7 +6,8 @@
"description": "Browser-neutral reference runner for Browsec trust plugins", "description": "Browser-neutral reference runner for Browsec trust plugins",
"scripts": { "scripts": {
"test": "node --test --test-isolation=none", "test": "node --test --test-isolation=none",
"demo": "node examples/demo.js" "demo": "node examples/demo.js",
"ui": "node ui/dev-server.js"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"

View File

@ -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}.`;
}

50
trustlab/test/ui.test.js Normal file
View File

@ -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/);
});

159
trustlab/ui/app.js Normal file
View File

@ -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();

54
trustlab/ui/dev-server.js Normal file
View File

@ -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);
}

78
trustlab/ui/index.html Normal file
View File

@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Browsec TrustLab</title>
<link rel="stylesheet" href="/ui/styles.css">
</head>
<body>
<header class="browser-frame">
<div class="brand-mark" aria-hidden="true">B</div>
<div>
<strong>Browsec security decision</strong>
<span>Browser-owned test surface</span>
</div>
<div class="frame-seal">TRUSTLAB · SYNTHETIC</div>
</header>
<main>
<aside class="controls" aria-label="TrustLab controls">
<p class="eyebrow">Synthetic input</p>
<label for="scenario">Certificate scenario</label>
<select id="scenario"></select>
<label class="toggle">
<input id="community-enabled" type="checkbox" checked>
<span>Village community evidence</span>
</label>
<p class="simulation-note">
This page evaluates fixtures only. It cannot alter browser trust.
</p>
</aside>
<section class="workspace">
<section id="status" class="status-card" aria-live="polite"></section>
<section class="panel identity-panel">
<div class="section-heading">
<p class="eyebrow">Connection</p>
<h2>Identity under examination</h2>
</div>
<dl id="identity" class="identity-grid"></dl>
</section>
<section class="panel">
<div class="section-heading">
<p class="eyebrow">Validation path</p>
<h2>Where trust succeeds or breaks</h2>
</div>
<ol id="chain" class="chain"></ol>
</section>
<section class="panel">
<div class="section-heading">
<p class="eyebrow">Append-only journal</p>
<h2>Attributed findings</h2>
</div>
<div id="journal" class="journal"></div>
</section>
</section>
</main>
<footer class="decision-bar">
<div>
<strong>Your local decision</strong>
<span>Exact host and port · current simulation</span>
</div>
<button id="clear-decision" class="button quiet" hidden>Clear decision</button>
<button id="reject" class="button reject">Do not trust</button>
<button id="trust" class="button trust">Trust this connection</button>
</footer>
<script type="module" src="/ui/app.js"></script>
</body>
</html>

55
trustlab/ui/model.js Normal file
View File

@ -0,0 +1,55 @@
export function subjectName(subject = "") {
const commonName = /(?:^|,)CN=([^,]+)/.exec(subject)?.[1];
return commonName ?? (subject || "Unnamed certificate");
}
export function verdictCopy(result, hasUserDecision) {
if (hasUserDecision) {
return result.verdict.trusted
? {
eyebrow: "Local decision",
title: "You trust this connection",
detail: "The decision is limited to the displayed host and port in this simulation.",
}
: {
eyebrow: "Local decision",
title: "You do not trust this connection",
detail: "The connection remains blocked by your explicit decision.",
};
}
if (result.facts.validation === "success") {
return {
eyebrow: "Firefox validation",
title: "The conventional certificate path is valid",
detail: "Trust plugins may still add evidence, warnings, or a stricter local verdict.",
};
}
return {
eyebrow: "Decision required",
title: "Firefox could not verify this identity",
detail: "Review the broken path and attributed plugin findings before deciding.",
};
}
export function chainRows(facts) {
const chain = facts.constructedChain.length
? facts.constructedChain
: facts.presentedChain;
const failureAtEnd = facts.validation === "failure";
return chain.map((certificate, index) => ({
...certificate,
name: subjectName(certificate.subject),
role:
index === 0 ? "Leaf certificate" : index === chain.length - 1 ? "Root candidate" : "Intermediate CA",
edge:
index === chain.length - 1
? failureAtEnd
? "Not anchored in current Firefox trust"
: "Trusted by current Firefox policy"
: "Signature links to next issuer",
failed: index === chain.length - 1 && failureAtEnd,
}));
}

286
trustlab/ui/styles.css Normal file
View File

@ -0,0 +1,286 @@
:root {
color: #eef4f7;
background: #091014;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
--ink-muted: #9baeb8;
--panel: #111c22;
--panel-raised: #17252d;
--line: #2c3c45;
--cyan: #75d9e9;
--cyan-dark: #123d48;
--amber: #f0b85b;
--red: #ff8178;
--green: #7bddad;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at 75% -20%, #173843 0, transparent 42rem),
#091014;
}
button, select { font: inherit; }
.browser-frame {
position: sticky;
z-index: 10;
top: 0;
display: flex;
align-items: center;
gap: 0.85rem;
min-height: 4.4rem;
padding: 0.75rem 1.25rem;
border-bottom: 3px solid var(--cyan);
background: #071319f2;
box-shadow: 0 0.5rem 2rem #0008;
backdrop-filter: blur(12px);
}
.browser-frame strong, .browser-frame span { display: block; }
.browser-frame span { color: var(--ink-muted); font-size: 0.78rem; }
.brand-mark {
display: grid;
width: 2.45rem;
height: 2.45rem;
place-items: center;
border: 1px solid var(--cyan);
border-radius: 50%;
color: #071319;
background: var(--cyan);
font-weight: 900;
}
.frame-seal {
margin-left: auto;
padding: 0.38rem 0.6rem;
border: 1px solid #3d6975;
border-radius: 0.25rem;
color: var(--cyan);
font: 700 0.66rem/1.1 ui-monospace, monospace;
letter-spacing: 0.12em;
}
main {
display: grid;
grid-template-columns: minmax(14rem, 19rem) minmax(0, 60rem);
gap: clamp(1rem, 4vw, 4rem);
max-width: 88rem;
margin: 0 auto;
padding: clamp(1.5rem, 4vw, 4rem) clamp(1rem, 3vw, 3rem) 8rem;
}
.controls {
align-self: start;
padding: 1.2rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: #0c171c;
}
.controls label:not(.toggle) {
display: block;
margin: 0.8rem 0 0.4rem;
color: var(--ink-muted);
font-size: 0.78rem;
}
select {
width: 100%;
padding: 0.7rem;
border: 1px solid #3b515c;
border-radius: 0.3rem;
color: inherit;
background: #142229;
}
.toggle {
display: flex;
gap: 0.65rem;
align-items: center;
margin-top: 1.2rem;
color: #cfdae0;
font-size: 0.85rem;
}
.toggle input { accent-color: var(--cyan); }
.simulation-note {
margin: 1.5rem 0 0;
padding-top: 1rem;
border-top: 1px solid var(--line);
color: var(--ink-muted);
font-size: 0.75rem;
line-height: 1.55;
}
.workspace { min-width: 0; }
.status-card, .panel {
margin-bottom: 1rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: linear-gradient(145deg, #142129, #0f191e);
box-shadow: 0 1.1rem 2.5rem #0003;
}
.status-card {
position: relative;
overflow: hidden;
padding: clamp(1.5rem, 4vw, 2.6rem);
}
.status-card::before {
position: absolute;
inset: 0 auto 0 0;
width: 0.35rem;
background: var(--amber);
content: "";
}
.status-card[data-state="trusted"]::before { background: var(--green); }
.status-card[data-state="not-trusted"]::before { background: var(--red); }
.status-card h1 {
max-width: 22ch;
margin: 0.3rem 0 0.65rem;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(1.8rem, 4vw, 3.2rem);
font-weight: 500;
line-height: 1.04;
}
.status-detail { max-width: 62ch; color: var(--ink-muted); line-height: 1.55; }
.panel { padding: clamp(1.1rem, 3vw, 2rem); }
.section-heading h2 { margin: 0.2rem 0 1.3rem; font-size: 1.15rem; }
.eyebrow {
margin: 0;
color: var(--cyan);
font: 700 0.68rem/1.3 ui-monospace, monospace;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.identity-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
border-top: 1px solid var(--line);
}
.identity-grid div { padding: 1rem 1rem 0 0; }
.identity-grid dt { color: var(--ink-muted); font-size: 0.72rem; }
.identity-grid dd { margin: 0.25rem 0 0; overflow-wrap: anywhere; }
.chain { margin: 0; padding: 0; list-style: none; }
.certificate {
position: relative;
display: grid;
grid-template-columns: minmax(9rem, 0.7fr) minmax(12rem, 1fr) minmax(10rem, 1fr);
gap: 0.35rem 1rem;
padding: 1rem;
border: 1px solid #31434d;
border-radius: 0.35rem;
background: var(--panel-raised);
}
.certificate + .certificate { margin-top: 1.8rem; }
.certificate + .certificate::before {
position: absolute;
top: -1.85rem;
left: 2rem;
width: 1px;
height: 1.8rem;
background: #54707c;
content: "";
}
.certificate + .certificate::after {
position: absolute;
top: -0.4rem;
left: 1.78rem;
color: #78919b;
content: "▾";
}
.certificate.failed { border-color: #a45250; background: #2b1c1d; }
.certificate-role { grid-column: 1; color: var(--ink-muted); font-size: 0.72rem; }
.certificate strong { grid-column: 1; }
.certificate code { grid-column: 2; color: #afc1ca; overflow-wrap: anywhere; }
.edge-label { grid-column: 3; grid-row: 1 / span 2; align-self: center; color: var(--green); font-size: 0.78rem; }
.failed .edge-label { color: var(--red); }
.journal { display: grid; gap: 0.65rem; }
.journal-entry {
display: grid;
grid-template-columns: 5rem 1fr auto;
gap: 0.15rem 0.9rem;
align-items: baseline;
padding: 0.9rem 1rem;
border-left: 3px solid #54707c;
background: #0c161b;
}
.journal-entry.kind-warning { border-color: var(--amber); }
.journal-entry.kind-resolution { border-color: var(--cyan); }
.journal-entry h3, .journal-entry p { margin: 0; }
.journal-entry h3 { font-size: 0.86rem; }
.journal-entry p { grid-column: 2; color: var(--ink-muted); font-size: 0.8rem; }
.journal-entry code { grid-column: 3; grid-row: 1; color: #78919b; font-size: 0.68rem; }
.entry-kind { color: var(--cyan); font: 700 0.62rem ui-monospace, monospace; text-transform: uppercase; }
.empty { color: var(--ink-muted); }
.decision-bar {
position: fixed;
z-index: 20;
right: 0;
bottom: 0;
left: 0;
display: flex;
gap: 0.7rem;
align-items: center;
min-height: 5rem;
padding: 0.85rem clamp(1rem, 3vw, 3rem);
border-top: 1px solid #38525e;
background: #081116f5;
box-shadow: 0 -1rem 3rem #0008;
backdrop-filter: blur(14px);
}
.decision-bar div { margin-right: auto; }
.decision-bar strong, .decision-bar span { display: block; }
.decision-bar span { margin-top: 0.2rem; color: var(--ink-muted); font-size: 0.72rem; }
.button {
padding: 0.72rem 1rem;
border: 1px solid transparent;
border-radius: 0.3rem;
color: #eef4f7;
background: #26363e;
cursor: pointer;
}
.button:hover { filter: brightness(1.13); }
.button:focus-visible { outline: 3px solid white; outline-offset: 2px; }
.button.trust { color: #071319; background: var(--green); font-weight: 800; }
.button.reject { border-color: #985653; background: #321e1e; }
.button.quiet { border-color: #3d515b; background: transparent; }
@media (max-width: 760px) {
main { grid-template-columns: 1fr; }
.controls { position: static; }
.identity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.certificate { grid-template-columns: 1fr; }
.certificate code, .edge-label { grid-column: 1; grid-row: auto; }
.journal-entry { grid-template-columns: 4.5rem 1fr; }
.journal-entry code { display: none; }
.decision-bar { flex-wrap: wrap; }
.decision-bar div { flex-basis: 100%; }
.button { flex: 1; }
}