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

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