Define strict TLS trust plugin API

This commit is contained in:
sergeych committed 2026-08-16 17:49:42 +04:00
1 parent f1fcef9b85
commit 1eeb8bac05
17 files changed
+834 -194

No files matched your search

+12 -2
View File
@@ -3,9 +3,13 @@ export const BUILTIN_HANDLER_ID = "org.browsec.builtin-final-handler";
export function createBuiltinFinalHandler() {
return Object.freeze({
manifest: {
manifestVersion: 1,
trustApiVersion: "0.1",
id: BUILTIN_HANDLER_ID,
name: "Browsec built-in final handler",
role: "decision-authority",
version: "0.0.0",
supportedModes: ["decision-authority"],
capabilities: { interactiveUi: true },
immutable: true,
},
@@ -14,7 +18,13 @@ export function createBuiltinFinalHandler() {
return {
trusted: facts.validation === "success",
scope: { hostname: facts.hostname, port: facts.port },
scope: {
kind: "certificate-for-host",
hostname: facts.hostname,
port: facts.port,
certificateSha256: facts.presentedChain[0].sha256,
},
lifetime: { kind: "connection" },
reasonEntryIds: journal.entries
.filter((entry) => entry.kind === "warning" || entry.kind === "evidence")
.map((entry) => entry.id),
+82 -8
View File
@@ -37,14 +37,39 @@ export function createTlsFacts(input) {
}
export function validateManifest(manifest) {
if (manifest?.manifestVersion !== 1) {
throw new TypeError(`Unsupported plugin manifest version: ${manifest?.manifestVersion}`);
}
if (manifest?.trustApiVersion !== "0.1") {
throw new TypeError(`Unsupported trust API version: ${manifest?.trustApiVersion}`);
}
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}`);
requireString(manifest?.version, "plugin manifest version");
if (manifest.capabilities === null || typeof manifest.capabilities !== "object") {
throw new TypeError("Plugin manifest capabilities must be an object");
}
if (!Array.isArray(manifest.supportedModes) || manifest.supportedModes.length === 0) {
throw new TypeError("Plugin manifest must declare supportedModes");
}
for (const mode of manifest.supportedModes) {
if (!PLUGIN_ROLES.includes(mode)) {
throw new TypeError(`Unsupported plugin mode: ${mode}`);
}
}
return immutableClone(manifest);
}
export function validatePluginMode(manifest, mode) {
if (!PLUGIN_ROLES.includes(mode)) {
throw new TypeError(`Unsupported configured plugin mode: ${mode}`);
}
if (!manifest.supportedModes.includes(mode)) {
throw new TypeError(`Plugin ${manifest.id} does not support mode ${mode}`);
}
return mode;
}
export function validateContribution(contribution, role) {
if (contribution === undefined) return immutableClone({ entries: [] });
if (contribution === null || typeof contribution !== "object") {
@@ -80,19 +105,68 @@ export function validateVerdict(verdict, facts) {
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");
const scope = validateScope(verdict.scope, facts);
const lifetime = validateLifetime(verdict.lifetime);
if (!Array.isArray(verdict.reasonEntryIds)) {
throw new TypeError("Trust verdict reasonEntryIds must be an array");
}
return immutableClone({
trusted: verdict.trusted,
scope: { hostname: scope.hostname, port: scope.port },
reasonEntryIds: verdict.reasonEntryIds ?? [],
expiresAt: verdict.expiresAt,
scope,
lifetime,
reasonEntryIds: verdict.reasonEntryIds,
});
}
function validateScope(scope, facts) {
if (scope === null || typeof scope !== "object") {
throw new TypeError("Trust verdict must contain a scope");
}
if (scope.hostname !== facts.hostname) {
throw new TypeError("Trust verdict scope does not match the connection");
}
if (scope.kind === "certificate-for-host") {
if (scope.port !== facts.port) {
throw new TypeError("Certificate trust scope port does not match the connection");
}
if (scope.certificateSha256 !== facts.presentedChain[0]?.sha256) {
throw new TypeError("Certificate trust scope does not match the presented leaf");
}
return scope;
}
if (scope.kind === "authority-for-host") {
if (scope.port !== "any" && scope.port !== facts.port) {
throw new TypeError("Authority trust scope port does not match the connection");
}
if (typeof scope.includeSubdomains !== "boolean") {
throw new TypeError("Authority trust scope must specify includeSubdomains");
}
const chain = [...facts.presentedChain, ...facts.constructedChain];
if (!chain.some((certificate) => certificate.sha256 === scope.authoritySha256)) {
throw new TypeError("Authority trust scope does not match the certificate chain");
}
return scope;
}
throw new TypeError(`Unsupported trust scope kind: ${scope.kind}`);
}
function validateLifetime(lifetime) {
if (lifetime === null || typeof lifetime !== "object") {
throw new TypeError("Trust verdict must contain a lifetime");
}
if (!["connection", "session", "until", "persistent"].includes(lifetime.kind)) {
throw new TypeError(`Unsupported trust lifetime: ${lifetime.kind}`);
}
if (lifetime.kind === "until" && Number.isNaN(Date.parse(lifetime.expiresAt))) {
throw new TypeError("Until lifetime must contain a valid expiresAt timestamp");
}
return lifetime;
}
export function validateVerdictAuthority(verdict, role) {
if (verdict === undefined) return;
if (role === "observer" || role === "advisor") {
+61 -17
View File
@@ -4,17 +4,19 @@ import {
createTlsFacts,
validateContribution,
validateManifest,
validatePluginMode,
validateVerdict,
validateVerdictAuthority,
} from "./protocol.js";
export class TrustRunner {
constructor({ plugins = [], finalHandler, timeoutMs = 25 } = {}) {
this.plugins = plugins.map(normalizePlugin);
constructor({ plugins = [], finalHandler, timeoutMs = 25, ui } = {}) {
this.plugins = plugins.map(normalizeConfiguredPlugin);
this.finalHandler = normalizeFinalHandler(
finalHandler ?? createBuiltinFinalHandler(),
);
this.timeoutMs = timeoutMs;
this.ui = ui ?? { async requestDecision() { return undefined; } };
}
async evaluate(inputFacts) {
@@ -25,18 +27,38 @@ export class TrustRunner {
let terminalVerdict;
for (const plugin of this.plugins) {
if (!plugin.decide) continue;
const hook =
facts.validation === "success"
? plugin.hooks.onBeforeTlsAccept
: plugin.hooks.onTlsFailure;
if (!hook) continue;
try {
const candidate = await withTimeout(
Promise.resolve(plugin.decide({ facts, journal: journal.snapshot() })),
this.timeoutMs,
);
const context = {
facts,
journal: journal.snapshot(),
...(facts.validation === "failure"
? {
ui: plugin.manifest.capabilities.interactiveUi
? this.ui
: deniedUi(plugin.manifest.id),
signal: AbortSignal.timeout(this.timeoutMs),
}
: {}),
};
const rawCandidate = hook(context);
if (facts.validation === "success" && isPromiseLike(rawCandidate)) {
throw new TypeError("onBeforeTlsAccept must return synchronously");
}
const candidate =
facts.validation === "failure"
? await withTimeout(Promise.resolve(rawCandidate), this.timeoutMs)
: rawCandidate;
const verdict = validateVerdict(candidate, facts);
if (!verdict) {
journal.append(plugin.manifest, [{ kind: "abstention" }]);
continue;
}
validateVerdictAuthority(verdict, plugin.manifest.role);
validateVerdictAuthority(verdict, plugin.mode);
terminalVerdict = verdict;
journal.append(plugin.manifest, [
@@ -71,15 +93,22 @@ export class TrustRunner {
async #collectEvidence(facts, journal) {
const collectors = this.plugins.map(async (plugin) => {
if (!plugin.collectEvidence) return { plugin, contribution: { entries: [] } };
if (!plugin.hooks.collectEvidence) {
return { plugin, contribution: { entries: [] } };
}
try {
const result = await withTimeout(
Promise.resolve(plugin.collectEvidence({ facts })),
Promise.resolve(
plugin.hooks.collectEvidence({
facts,
signal: AbortSignal.timeout(this.timeoutMs),
}),
),
this.timeoutMs,
);
return {
plugin,
contribution: validateContribution(result, plugin.manifest.role),
contribution: validateContribution(result, plugin.mode),
};
} catch (error) {
return { plugin, contribution: { entries: [pluginError(error)] } };
@@ -93,16 +122,19 @@ export class TrustRunner {
}
}
function normalizePlugin(plugin) {
function normalizeConfiguredPlugin(configured) {
if (!configured?.plugin) {
throw new TypeError("Configured plugin must contain a plugin registration");
}
const manifest = validateManifest(configured.plugin.manifest);
return Object.freeze({
manifest: validateManifest(plugin.manifest),
collectEvidence: plugin.collectEvidence?.bind(plugin),
decide: plugin.decide?.bind(plugin),
manifest,
mode: validatePluginMode(manifest, configured.mode),
hooks: Object.freeze({ ...configured.plugin.hooks }),
});
}
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");
}
@@ -110,11 +142,23 @@ function normalizeFinalHandler(handler) {
throw new TypeError("Built-in final handler must implement finalize");
}
return Object.freeze({
...normalized,
manifest: validateManifest(handler.manifest),
finalize: handler.finalize.bind(handler),
});
}
function isPromiseLike(value) {
return value !== null && typeof value === "object" && typeof value.then === "function";
}
function deniedUi(pluginId) {
return Object.freeze({
async requestDecision() {
throw new Error(`Plugin ${pluginId} has no interactiveUi capability`);
},
});
}
function pluginError(error) {
return {
kind: "error",