Initialize Browsec architecture and TrustLab

This commit is contained in:
sergeych committed 2026-08-16 15:22:51 +04:00
commit a1e56c1d09
14 files changed
+1500

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# TrustLab
TrustLab is the browser-neutral executable model of Browsec's trust-plugin
protocol. It currently runs under Node.js without external dependencies, but
plugins receive only portable JavaScript values and TrustLab capabilities.
The initial runner separates two phases:
1. Evidence plugins independently inspect the same immutable TLS facts. Their
attributed results are appended deterministically to the journal.
2. Decision plugins inspect the sealed evidence view in configured order. The
first authorized Boolean verdict is terminal. Browsec's immutable built-in
handler finalizes that result or supplies the safe fallback.
Run:
```sh
npm test
npm run demo
```
## 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.
+42
View File
@@ -0,0 +1,42 @@
import { unknownLocalAuthority } from "../fixtures/tls.js";
import { TrustRunner } from "../src/index.js";
const villagePlugin = {
manifest: {
id: "community.village.trust",
name: "Village community trust",
role: "decision-authority",
},
collectEvidence({ facts }) {
if (facts.hostname !== "library.village") return;
return {
entries: [
{
kind: "evidence",
code: "known-community-key",
message: "The community has previously observed this certificate.",
},
],
};
},
decide({ facts, journal }) {
const known = journal.entries.some(
(entry) => entry.code === "known-community-key",
);
if (!known) return;
return {
trusted: true,
scope: { hostname: facts.hostname, port: facts.port },
reasonEntryIds: journal.entries
.filter((entry) => entry.code === "known-community-key")
.map((entry) => entry.id),
};
},
};
const result = await new TrustRunner({ plugins: [villagePlugin] }).evaluate(
unknownLocalAuthority,
);
console.log(JSON.stringify(result, null, 2));
+35
View File
@@ -0,0 +1,35 @@
export const validPublicCertificate = {
connectionId: "connection-valid-public",
hostname: "example.test",
port: 443,
validation: "success",
errors: [],
presentedChain: [
{ subject: "CN=example.test", sha256: "leaf-valid-public" },
{ subject: "CN=Example Intermediate", sha256: "intermediate-public" },
],
constructedChain: [
{ subject: "CN=example.test", sha256: "leaf-valid-public" },
{ subject: "CN=Example Intermediate", sha256: "intermediate-public" },
{ subject: "CN=Example Root", sha256: "root-public" },
],
tls: { version: "TLSv1.3", alpn: "h2" },
};
export const unknownLocalAuthority = {
connectionId: "connection-unknown-local",
hostname: "library.village",
port: 443,
validation: "failure",
errors: ["unknown-issuer"],
presentedChain: [
{ subject: "CN=library.village", sha256: "leaf-village-library" },
{ subject: "CN=Village Services CA", sha256: "ca-village-services" },
],
constructedChain: [
{ subject: "CN=library.village", sha256: "leaf-village-library" },
{ subject: "CN=Village Services CA", sha256: "ca-village-services" },
],
tls: { version: "TLSv1.3", alpn: "h2" },
};
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@browsec/trustlab",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Browser-neutral reference runner for Browsec trust plugins",
"scripts": {
"test": "node --test --test-isolation=none",
"demo": "node examples/demo.js"
},
"engines": {
"node": ">=22"
}
}
+24
View File
@@ -0,0 +1,24 @@
export const BUILTIN_HANDLER_ID = "org.browsec.builtin-final-handler";
export function createBuiltinFinalHandler() {
return Object.freeze({
manifest: {
id: BUILTIN_HANDLER_ID,
name: "Browsec built-in final handler",
role: "decision-authority",
immutable: true,
},
async finalize({ facts, journal, terminalVerdict }) {
if (terminalVerdict) return terminalVerdict;
return {
trusted: facts.validation === "success",
scope: { hostname: facts.hostname, port: facts.port },
reasonEntryIds: journal.entries
.filter((entry) => entry.kind === "warning" || entry.kind === "evidence")
.map((entry) => entry.id),
};
},
});
}
+17
View File
@@ -0,0 +1,17 @@
/** Return a deeply frozen clone suitable for crossing a plugin boundary. */
export function immutableClone(value) {
return deepFreeze(structuredClone(value));
}
function deepFreeze(value) {
if (value === null || typeof value !== "object" || Object.isFrozen(value)) {
return value;
}
for (const child of Object.values(value)) {
deepFreeze(child);
}
return Object.freeze(value);
}
+5
View File
@@ -0,0 +1,5 @@
export { BUILTIN_HANDLER_ID, createBuiltinFinalHandler } from "./builtin-final-handler.js";
export { DecisionJournal } from "./journal.js";
export { createTlsFacts, ENTRY_KINDS, PLUGIN_ROLES } from "./protocol.js";
export { TrustRunner } from "./runner.js";
+36
View File
@@ -0,0 +1,36 @@
import { immutableClone } from "./immutable.js";
export class DecisionJournal {
#entries = [];
#sealed = false;
append(plugin, entries) {
if (this.#sealed) throw new Error("Decision journal is sealed");
const appended = entries.map((entry) => {
const record = immutableClone({
id: `entry-${this.#entries.length + 1}`,
pluginId: plugin.id,
pluginName: plugin.name,
kind: entry.kind,
code: entry.code,
message: entry.message,
data: entry.data,
});
this.#entries.push(record);
return record;
});
return immutableClone(appended);
}
snapshot() {
return immutableClone({ entries: this.#entries });
}
seal() {
this.#sealed = true;
return this.snapshot();
}
}
+113
View File
@@ -0,0 +1,113 @@
import { immutableClone } from "./immutable.js";
export const ENTRY_KINDS = Object.freeze([
"evidence",
"warning",
"vote",
"resolution",
"error",
"abstention",
]);
export const PLUGIN_ROLES = Object.freeze([
"observer",
"advisor",
"decision-authority",
"veto-authority",
]);
export function createTlsFacts(input) {
requireString(input?.connectionId, "connectionId");
requireString(input?.hostname, "hostname");
requireInteger(input?.port, "port");
requireString(input?.validation, "validation");
return immutableClone({
schemaVersion: 0,
connectionId: input.connectionId,
hostname: input.hostname,
port: input.port,
validation: input.validation,
errors: input.errors ?? [],
presentedChain: input.presentedChain ?? [],
constructedChain: input.constructedChain ?? [],
tls: input.tls ?? {},
});
}
export function validateManifest(manifest) {
requireString(manifest?.id, "plugin manifest id");
requireString(manifest?.name, "plugin manifest name");
if (!PLUGIN_ROLES.includes(manifest.role)) {
throw new TypeError(`Unsupported plugin role: ${manifest.role}`);
}
return immutableClone(manifest);
}
export function validateContribution(contribution, role) {
if (contribution === undefined) return immutableClone({ entries: [] });
if (contribution === null || typeof contribution !== "object") {
throw new TypeError("Plugin contribution must be an object");
}
const entries = contribution.entries ?? [];
if (!Array.isArray(entries)) throw new TypeError("entries must be an array");
for (const entry of entries) {
if (!ENTRY_KINDS.includes(entry?.kind)) {
throw new TypeError(`Unsupported journal entry kind: ${entry?.kind}`);
}
if (entry.kind === "resolution") {
throw new TypeError("Plugins cannot append resolution entries directly");
}
if (role === "observer" && !["evidence", "warning"].includes(entry.kind)) {
throw new TypeError("Observer plugins may append evidence and warnings only");
}
if (role === "advisor" && entry.kind !== "vote" && !["evidence", "warning"].includes(entry.kind)) {
throw new TypeError("Advisor plugins may append evidence, warnings, and votes only");
}
}
return immutableClone({ entries });
}
export function validateVerdict(verdict, facts) {
if (verdict === undefined) return undefined;
if (verdict === null || typeof verdict !== "object") {
throw new TypeError("Trust verdict must be an object");
}
if (typeof verdict.trusted !== "boolean") {
throw new TypeError("Trust verdict must contain Boolean trusted");
}
const scope = verdict.scope ?? {};
if (scope.hostname !== facts.hostname || scope.port !== facts.port) {
throw new TypeError("Trust verdict scope does not match the connection");
}
return immutableClone({
trusted: verdict.trusted,
scope: { hostname: scope.hostname, port: scope.port },
reasonEntryIds: verdict.reasonEntryIds ?? [],
expiresAt: verdict.expiresAt,
});
}
export function validateVerdictAuthority(verdict, role) {
if (verdict === undefined) return;
if (role === "observer" || role === "advisor") {
throw new TypeError(`${role} plugin cannot issue a terminal verdict`);
}
if (role === "veto-authority" && verdict.trusted) {
throw new TypeError("Veto authority cannot issue a trusted verdict");
}
}
function requireString(value, name) {
if (typeof value !== "string" || value.length === 0) {
throw new TypeError(`${name} must be a non-empty string`);
}
}
function requireInteger(value, name) {
if (!Number.isInteger(value)) throw new TypeError(`${name} must be an integer`);
}
+137
View File
@@ -0,0 +1,137 @@
import { createBuiltinFinalHandler } from "./builtin-final-handler.js";
import { DecisionJournal } from "./journal.js";
import {
createTlsFacts,
validateContribution,
validateManifest,
validateVerdict,
validateVerdictAuthority,
} from "./protocol.js";
export class TrustRunner {
constructor({ plugins = [], finalHandler, timeoutMs = 25 } = {}) {
this.plugins = plugins.map(normalizePlugin);
this.finalHandler = normalizeFinalHandler(
finalHandler ?? createBuiltinFinalHandler(),
);
this.timeoutMs = timeoutMs;
}
async evaluate(inputFacts) {
const facts = createTlsFacts(inputFacts);
const journal = new DecisionJournal();
await this.#collectEvidence(facts, journal);
let terminalVerdict;
for (const plugin of this.plugins) {
if (!plugin.decide) continue;
try {
const candidate = await withTimeout(
Promise.resolve(plugin.decide({ facts, journal: journal.snapshot() })),
this.timeoutMs,
);
const verdict = validateVerdict(candidate, facts);
if (!verdict) {
journal.append(plugin.manifest, [{ kind: "abstention" }]);
continue;
}
validateVerdictAuthority(verdict, plugin.manifest.role);
terminalVerdict = verdict;
journal.append(plugin.manifest, [
{
kind: "resolution",
code: verdict.trusted ? "trusted" : "not-trusted",
data: verdict,
},
]);
break;
} catch (error) {
journal.append(plugin.manifest, [pluginError(error)]);
}
}
const sealedJournal = journal.seal();
const finalVerdict = validateVerdict(
await this.finalHandler.finalize({
facts,
journal: sealedJournal,
terminalVerdict,
}),
facts,
);
if (!finalVerdict) {
throw new Error("Built-in final handler must return a trust verdict");
}
return Object.freeze({ facts, journal: sealedJournal, verdict: finalVerdict });
}
async #collectEvidence(facts, journal) {
const collectors = this.plugins.map(async (plugin) => {
if (!plugin.collectEvidence) return { plugin, contribution: { entries: [] } };
try {
const result = await withTimeout(
Promise.resolve(plugin.collectEvidence({ facts })),
this.timeoutMs,
);
return {
plugin,
contribution: validateContribution(result, plugin.manifest.role),
};
} catch (error) {
return { plugin, contribution: { entries: [pluginError(error)] } };
}
});
// Collect independently, then append in configured order for reproducibility.
for (const { plugin, contribution } of await Promise.all(collectors)) {
journal.append(plugin.manifest, contribution.entries);
}
}
}
function normalizePlugin(plugin) {
return Object.freeze({
manifest: validateManifest(plugin.manifest),
collectEvidence: plugin.collectEvidence?.bind(plugin),
decide: plugin.decide?.bind(plugin),
});
}
function normalizeFinalHandler(handler) {
const normalized = normalizePlugin(handler);
if (handler.manifest.id !== "org.browsec.builtin-final-handler") {
throw new TypeError("Final handler must be Browsec's built-in handler");
}
if (typeof handler.finalize !== "function") {
throw new TypeError("Built-in final handler must implement finalize");
}
return Object.freeze({
...normalized,
finalize: handler.finalize.bind(handler),
});
}
function pluginError(error) {
return {
kind: "error",
code: error?.name === "TimeoutError" ? "plugin-timeout" : "plugin-error",
message: error instanceof Error ? error.message : String(error),
};
}
function withTimeout(promise, timeoutMs) {
let timeoutId;
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
const error = new Error(`Plugin exceeded ${timeoutMs} ms deadline`);
error.name = "TimeoutError";
reject(error);
}, timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId));
}
+181
View File
@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createBuiltinFinalHandler,
DecisionJournal,
TrustRunner,
} from "../src/index.js";
import {
unknownLocalAuthority,
validPublicCertificate,
} from "../fixtures/tls.js";
test("built-in handler rejects an unresolved certificate failure", async () => {
const result = await new TrustRunner().evaluate(unknownLocalAuthority);
assert.equal(result.verdict.trusted, false);
assert.equal(result.journal.entries.length, 0);
});
test("built-in handler preserves an ordinary successful validation", async () => {
const result = await new TrustRunner().evaluate(validPublicCertificate);
assert.equal(result.verdict.trusted, true);
});
test("a decision plugin can trust a narrowly scoped connection", async () => {
const plugin = pluginWith({
decide({ facts }) {
return {
trusted: true,
scope: { hostname: facts.hostname, port: facts.port },
};
},
});
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
unknownLocalAuthority,
);
assert.equal(result.verdict.trusted, true);
assert.equal(result.journal.entries.at(-1).kind, "resolution");
assert.equal(result.journal.entries.at(-1).pluginId, plugin.manifest.id);
});
test("evidence collection is independent but appended in plugin order", async () => {
const slowFirst = pluginWith({
id: "test.first",
async collectEvidence() {
await new Promise((resolve) => setTimeout(resolve, 5));
return { entries: [{ kind: "evidence", code: "first" }] };
},
});
const fastSecond = pluginWith({
id: "test.second",
collectEvidence() {
return { entries: [{ kind: "warning", code: "second" }] };
},
});
const result = await new TrustRunner({ plugins: [slowFirst, fastSecond] }).evaluate(
validPublicCertificate,
);
assert.deepEqual(
result.journal.entries.map((entry) => entry.code),
["first", "second"],
);
});
test("plugin timeout is recorded and falls through safely", async () => {
const plugin = pluginWith({
async decide() {
await new Promise((resolve) => setTimeout(resolve, 30));
return undefined;
},
});
const result = await new TrustRunner({ plugins: [plugin], timeoutMs: 5 }).evaluate(
unknownLocalAuthority,
);
assert.equal(result.verdict.trusted, false);
assert.equal(result.journal.entries.at(-1).code, "plugin-timeout");
});
test("a verdict cannot escape the active hostname and port", async () => {
const plugin = pluginWith({
decide() {
return {
trusted: true,
scope: { hostname: "different.test", port: 443 },
};
},
});
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
unknownLocalAuthority,
);
assert.equal(result.verdict.trusted, false);
assert.match(result.journal.entries.at(-1).message, /scope does not match/);
});
test("observer and advisor plugins cannot issue terminal verdicts", async () => {
for (const role of ["observer", "advisor"]) {
const plugin = pluginWith({
role,
decide({ facts }) {
return {
trusted: true,
scope: { hostname: facts.hostname, port: facts.port },
};
},
});
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
unknownLocalAuthority,
);
assert.equal(result.verdict.trusted, false);
assert.match(result.journal.entries.at(-1).message, /cannot issue/);
}
});
test("veto authority can reject but cannot trust", async () => {
const plugin = pluginWith({
role: "veto-authority",
decide({ facts }) {
return {
trusted: true,
scope: { hostname: facts.hostname, port: facts.port },
};
},
});
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
unknownLocalAuthority,
);
assert.equal(result.verdict.trusted, false);
assert.match(result.journal.entries.at(-1).message, /cannot issue a trusted/);
});
test("journal snapshots and entries are immutable", () => {
const journal = new DecisionJournal();
journal.append(
{ id: "test.plugin", name: "Test plugin" },
[{ kind: "evidence", data: { nested: true } }],
);
const snapshot = journal.snapshot();
assert.throws(() => snapshot.entries.push({}), TypeError);
assert.throws(() => {
snapshot.entries[0].data.nested = false;
}, TypeError);
});
test("only Browsec's immutable handler can occupy the final position", () => {
assert.throws(
() =>
new TrustRunner({
finalHandler: {
manifest: {
id: "test.impostor",
name: "Impostor",
role: "decision-authority",
},
finalize() {},
},
}),
/must be Browsec's built-in handler/,
);
assert.doesNotThrow(() =>
new TrustRunner({ finalHandler: createBuiltinFinalHandler() }),
);
});
function pluginWith({
id = "test.plugin",
role = "decision-authority",
collectEvidence,
decide,
}) {
return {
manifest: { id, name: id, role },
collectEvidence,
decide,
};
}