Define strict TLS trust plugin API
This commit is contained in:
1 parent
f1fcef9b85
commit
1eeb8bac05
17 files changed
+834
-194
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user