Initialize Browsec architecture and TrustLab
This commit is contained in:
commit
a1e56c1d09
14 files changed
+1500
No files matched your search
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user