Define strict TLS trust plugin API

This commit is contained in:
Sergey Chernov 2026-08-16 17:49:42 +04:00
parent f1fcef9b85
commit 1eeb8bac05
17 changed files with 834 additions and 194 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
dist/

View File

@ -67,6 +67,28 @@ community evidence.
Conventional global CA import may still be offered when global trust is exactly Conventional global CA import may still be offered when global trust is exactly
what the user intends. what the user intends.
## ADR-005: Protocol 0.1 is TLS-only and TypeScript-defined
The first plugin contract addresses substitution and restriction of TLS trust
authorities only. It deliberately excludes general browser automation,
announcements, peer-to-peer transport, timers, and post-navigation hooks. Those
may be layered on later without enlarging the authority of the TLS decision
surface.
The compiler-checked contract exposes three hooks:
- `collectEvidence` contributes attributed facts, warnings, and advisory votes;
- `onBeforeTlsAccept` synchronously accepts or rejects a normally valid TLS
connection using prepared local state;
- `onTlsFailure` asynchronously investigates a failed validation and may obtain
a browser-mediated user decision before a fresh connection attempt.
A plugin exports one registration object through `defineTrustPlugin`. Its static
manifest declares supported modes and requested capabilities; separate local
configuration grants its active mode and authority. Every terminal verdict is
Boolean and contains an explicit certificate-or-authority scope, lifetime, and
supporting journal references.
## Required Firefox additions ## Required Firefox additions
The exact patch boundaries will be established after TrustLab protocol v0 and a The exact patch boundaries will be established after TrustLab protocol v0 and a
@ -94,4 +116,3 @@ privileged-extension integration spike. Expected additions are:
- P2P transport and community governance; - P2P transport and community governance;
- release governance and threshold signing; - release governance and threshold signing;
- precise UI design and accessibility treatment. - precise UI design and accessibility treatment.

View File

@ -16,6 +16,8 @@ Run:
```sh ```sh
npm test npm test
npm run check
npm run build
npm run demo npm run demo
npm run ui npm run ui
``` ```
@ -34,3 +36,19 @@ This first slice intentionally omits persistence, package signatures, community
identities, networking, and real X.509 parsing. The included interactive UI is a identities, networking, and real X.509 parsing. The included interactive UI is a
security-surface prototype, not browser integration. security-surface prototype, not browser integration.
## TLS-only plugin API
The strict TypeScript contract is defined in [`sdk/plugin-api.ts`](sdk/plugin-api.ts).
A plugin exports one object created with `defineTrustPlugin`; local configuration
separately grants its active mode and scope.
The initial API intentionally exposes only:
- `collectEvidence` for attributed evidence, warnings, and advisory votes;
- `onBeforeTlsAccept` for a fast synchronous decision after normal validation;
- `onTlsFailure` for asynchronous investigation of a failed validation.
Every terminal result is Boolean and must include a discriminated certificate or
authority scope, an explicit lifetime, and the journal entries supporting it.
See [`examples/strict-plugin.ts`](examples/strict-plugin.ts) for a compiler-checked
registration example.

View File

@ -3,10 +3,15 @@ import { TrustRunner } from "../src/index.js";
const villagePlugin = { const villagePlugin = {
manifest: { manifest: {
manifestVersion: 1,
trustApiVersion: "0.1",
id: "community.village.trust", id: "community.village.trust",
name: "Village community trust", name: "Village community trust",
role: "decision-authority", version: "0.0.0",
supportedModes: ["decision-authority"],
capabilities: {},
}, },
hooks: {
collectEvidence({ facts }) { collectEvidence({ facts }) {
if (facts.hostname !== "library.village") return; if (facts.hostname !== "library.village") return;
return { return {
@ -19,24 +24,32 @@ const villagePlugin = {
], ],
}; };
}, },
decide({ facts, journal }) { async onTlsFailure({ facts, journal }) {
const known = journal.entries.some( const known = journal.entries.some(
(entry) => entry.code === "known-community-key", (entry) => entry.code === "known-community-key",
); );
if (!known) return; if (!known) return;
return { return {
trusted: true, trusted: true,
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 reasonEntryIds: journal.entries
.filter((entry) => entry.code === "known-community-key") .filter((entry) => entry.code === "known-community-key")
.map((entry) => entry.id), .map((entry) => entry.id),
}; };
}, },
},
}; };
const result = await new TrustRunner({ plugins: [villagePlugin] }).evaluate( const result = await new TrustRunner({
plugins: [{ plugin: villagePlugin, mode: "decision-authority" }],
}).evaluate(
unknownLocalAuthority, unknownLocalAuthority,
); );
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));

View File

@ -0,0 +1,49 @@
import {
defineTrustPlugin,
exactCertificateScope,
} from "../sdk/plugin-api.js";
export default defineTrustPlugin({
manifest: {
manifestVersion: 1,
trustApiVersion: "0.1",
id: "community.village.strict-example",
name: "Village strict example",
version: "1.0.0",
supportedModes: ["advisor", "decision-authority"],
capabilities: { interactiveUi: true },
},
hooks: {
collectEvidence({ facts }) {
if (facts.hostname !== "library.village") return undefined;
return {
entries: [
{
kind: "evidence",
code: "known-community-certificate",
message: "The configured community recognizes this certificate.",
},
],
};
},
async onTlsFailure({ facts, journal, ui }) {
const trusted = await ui.requestDecision({
title: "Unknown village authority",
summary: "Firefox does not recognize this authority. The community recognizes the exact certificate.",
proposedScope: exactCertificateScope(facts),
evidenceEntryIds: journal.entries.map((entry) => entry.id),
});
if (trusted === undefined) return undefined;
return {
trusted,
scope: exactCertificateScope(facts),
lifetime: { kind: "session" },
reasonEntryIds: journal.entries.map((entry) => entry.id),
};
},
},
});

View File

@ -0,0 +1,43 @@
import type {
PluginContribution,
TrustPluginHooks,
TrustVerdict,
} from "../sdk/plugin-api.js";
// These intentionally invalid declarations are compiler assertions: the build
// fails if TypeScript ever stops rejecting them.
const asynchronousFastHook: TrustPluginHooks = {
// @ts-expect-error onBeforeTlsAccept must never return a Promise.
async onBeforeTlsAccept() {
return undefined;
},
};
// @ts-expect-error Every terminal verdict requires an explicit lifetime.
const verdictWithoutLifetime: TrustVerdict = {
trusted: true,
scope: {
kind: "certificate-for-host",
hostname: "example.test",
port: 443,
certificateSha256: "leaf-sha256",
},
reasonEntryIds: [],
};
const voteWithoutBoolean: PluginContribution = {
entries: [
// @ts-expect-error A vote must state its Boolean recommendation.
{
kind: "vote",
code: "ambiguous-vote",
message: "This vote deliberately omits data.trusted.",
},
],
};
void asynchronousFastHook;
void verdictWithoutLifetime;
void voteWithoutBoolean;

32
trustlab/package-lock.json generated Normal file
View File

@ -0,0 +1,32 @@
{
"name": "@browsec/trustlab",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@browsec/trustlab",
"version": "0.0.0",
"devDependencies": {
"typescript": "^5.9.2"
},
"engines": {
"node": ">=22"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@ -5,11 +5,16 @@
"type": "module", "type": "module",
"description": "Browser-neutral reference runner for Browsec trust plugins", "description": "Browser-neutral reference runner for Browsec trust plugins",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit",
"test": "node --test --test-isolation=none", "test": "node --test --test-isolation=none",
"demo": "node examples/demo.js", "demo": "node examples/demo.js",
"ui": "node ui/dev-server.js" "ui": "node ui/dev-server.js"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"
},
"devDependencies": {
"typescript": "^5.9.2"
} }
} }

View File

@ -1,10 +1,11 @@
export function createFirefoxValidationPlugin() { export function createFirefoxValidationPlugin() {
return { return {
manifest: { manifest: pluginManifest(
id: "org.browsec.firefox-validation", "org.browsec.firefox-validation",
name: "Firefox validation", "Firefox validation",
role: "advisor", ["advisor"],
}, ),
hooks: {
collectEvidence({ facts }) { collectEvidence({ facts }) {
if (facts.validation === "success") { if (facts.validation === "success") {
return { return {
@ -13,6 +14,7 @@ export function createFirefoxValidationPlugin() {
kind: "vote", kind: "vote",
code: "firefox-validation-succeeded", code: "firefox-validation-succeeded",
message: "Firefox constructed a valid path to a configured trust anchor.", message: "Firefox constructed a valid path to a configured trust anchor.",
data: { trusted: true },
}, },
], ],
}; };
@ -26,16 +28,18 @@ export function createFirefoxValidationPlugin() {
})), })),
}; };
}, },
},
}; };
} }
export function createVillageCommunityPlugin() { export function createVillageCommunityPlugin() {
return { return {
manifest: { manifest: pluginManifest(
id: "community.village.observer", "community.village.observer",
name: "Village community observer", "Village community observer",
role: "advisor", ["advisor"],
}, ),
hooks: {
collectEvidence({ facts }) { collectEvidence({ facts }) {
if (facts.hostname !== "library.village") return; if (facts.hostname !== "library.village") return;
return { return {
@ -50,10 +54,12 @@ export function createVillageCommunityPlugin() {
kind: "vote", kind: "vote",
code: "community-recommends-trust", code: "community-recommends-trust",
message: "The configured community recommends trusting this exact host certificate.", message: "The configured community recommends trusting this exact host certificate.",
data: { trusted: true },
}, },
], ],
}; };
}, },
},
}; };
} }
@ -64,7 +70,8 @@ export function createCommunityAdvicePlugin({
message, message,
}) { }) {
return { return {
manifest: { id, name, role: "advisor" }, manifest: pluginManifest(id, name, ["advisor"]),
hooks: {
collectEvidence() { collectEvidence() {
return { return {
entries: [ entries: [
@ -77,26 +84,53 @@ export function createCommunityAdvicePlugin({
], ],
}; };
}, },
},
}; };
} }
export function createUserDecisionPlugin(decision) { export function createUserDecisionPlugin(decision) {
if (decision !== true && decision !== false) return undefined; if (decision !== true && decision !== false) return undefined;
return { const decide = ({ facts, journal }) => ({
manifest: {
id: "local.user.decision",
name: "Local user decision",
role: "decision-authority",
},
decide({ facts, journal }) {
return {
trusted: decision, trusted: decision,
scope: { hostname: facts.hostname, port: facts.port }, scope: exactCertificateScope(facts),
lifetime: { kind: "connection" },
reasonEntryIds: journal.entries reasonEntryIds: journal.entries
.filter((entry) => entry.kind === "evidence" || entry.kind === "warning") .filter((entry) => entry.kind === "evidence" || entry.kind === "warning")
.map((entry) => entry.id), .map((entry) => entry.id),
}; });
return {
manifest: pluginManifest(
"local.user.decision",
"Local user decision",
["decision-authority"],
),
hooks: {
onBeforeTlsAccept: decide,
async onTlsFailure(context) {
return decide(context);
}, },
},
};
}
function exactCertificateScope(facts) {
return {
kind: "certificate-for-host",
hostname: facts.hostname,
port: facts.port,
certificateSha256: facts.presentedChain[0].sha256,
};
}
function pluginManifest(id, name, supportedModes) {
return {
manifestVersion: 1,
trustApiVersion: "0.1",
id,
name,
version: "0.0.0",
supportedModes,
capabilities: {},
}; };
} }

194
trustlab/sdk/plugin-api.ts Normal file
View File

@ -0,0 +1,194 @@
/** Version of the first TLS-only trust-plugin API. */
export const TRUST_API_VERSION = "0.1" as const;
export type TrustApiVersion = typeof TRUST_API_VERSION;
export type PluginMode =
| "observer"
| "advisor"
| "decision-authority"
| "veto-authority";
export type TlsValidation = "success" | "failure";
export interface CertificateFacts {
readonly subject: string;
readonly sha256: string;
readonly spkiSha256?: string;
readonly dnsNames?: readonly string[];
readonly validFrom?: string;
readonly validUntil?: string;
}
export interface TlsFailure {
readonly code: string;
readonly certificateSha256?: string;
readonly check: "identity" | "validity" | "signature" | "trust-anchor" | "local-policy";
readonly summary: string;
}
export interface TlsFacts {
readonly schemaVersion: 0;
readonly connectionId: string;
readonly hostname: string;
readonly port: number;
readonly validation: TlsValidation;
readonly errors: readonly string[];
readonly failure?: TlsFailure;
readonly presentedChain: readonly CertificateFacts[];
readonly constructedChain: readonly CertificateFacts[];
readonly tls: Readonly<Record<string, unknown>>;
}
export type PluginEntry = EvidenceEntry | WarningEntry | VoteEntry;
interface EntryBase {
readonly code: string;
readonly message: string;
readonly data?: Readonly<Record<string, unknown>>;
}
export interface EvidenceEntry extends EntryBase {
readonly kind: "evidence";
}
export interface WarningEntry extends EntryBase {
readonly kind: "warning";
}
export interface VoteEntry extends EntryBase {
readonly kind: "vote";
readonly data: Readonly<{ trusted: boolean } & Record<string, unknown>>;
}
export interface JournalEntry extends EntryBase {
readonly id: string;
readonly pluginId: string;
readonly pluginName: string;
readonly kind: PluginEntry["kind"] | "resolution" | "error" | "abstention";
}
export interface ReadonlyDecisionJournal {
readonly entries: readonly JournalEntry[];
}
export interface PluginContribution {
readonly entries: readonly PluginEntry[];
}
export type TrustScope = CertificateForHostScope | AuthorityForHostScope;
export interface CertificateForHostScope {
readonly kind: "certificate-for-host";
readonly hostname: string;
readonly port: number;
readonly certificateSha256: string;
}
export interface AuthorityForHostScope {
readonly kind: "authority-for-host";
readonly hostname: string;
readonly port: number | "any";
readonly authoritySha256: string;
readonly includeSubdomains: boolean;
}
export type TrustLifetime =
| { readonly kind: "connection" }
| { readonly kind: "session" }
| { readonly kind: "until"; readonly expiresAt: string }
| { readonly kind: "persistent" };
export interface TrustedVerdict {
readonly trusted: true;
readonly scope: TrustScope;
readonly lifetime: TrustLifetime;
readonly reasonEntryIds: readonly string[];
}
export interface NotTrustedVerdict {
readonly trusted: false;
readonly scope: TrustScope;
readonly lifetime: TrustLifetime;
readonly reasonEntryIds: readonly string[];
}
export type TrustVerdict = TrustedVerdict | NotTrustedVerdict;
export interface EvidenceContext {
readonly facts: TlsFacts;
readonly signal: AbortSignal;
}
export interface DecisionContext {
readonly facts: TlsFacts;
readonly journal: ReadonlyDecisionJournal;
}
export interface FailureDecisionContext extends DecisionContext {
readonly ui: TrustDecisionUi;
readonly signal: AbortSignal;
}
export interface TrustDecisionRequest {
readonly title: string;
readonly summary: string;
readonly proposedScope: TrustScope;
readonly evidenceEntryIds: readonly string[];
}
export interface TrustDecisionUi {
requestDecision(request: TrustDecisionRequest): Promise<boolean | undefined>;
}
export interface TrustPluginHooks {
collectEvidence?(
context: EvidenceContext,
): PluginContribution | undefined | Promise<PluginContribution | undefined>;
/** Fast and deliberately synchronous. Network and UI are unavailable. */
onBeforeTlsAccept?(context: DecisionContext): TrustVerdict | undefined;
/** Failed navigation is investigated asynchronously before a fresh retry. */
onTlsFailure?(
context: FailureDecisionContext,
): Promise<TrustVerdict | undefined>;
}
export interface TrustPluginManifest {
readonly manifestVersion: 1;
readonly trustApiVersion: TrustApiVersion;
readonly id: string;
readonly name: string;
readonly version: string;
readonly supportedModes: readonly PluginMode[];
readonly capabilities: {
readonly interactiveUi?: boolean;
};
}
export interface TrustPluginRegistration {
readonly manifest: TrustPluginManifest;
readonly hooks: TrustPluginHooks;
}
/**
* Defines the module's single registration object. Browsec still validates the
* installed package manifest and local capability grants before activation.
*/
export function defineTrustPlugin<const T extends TrustPluginRegistration>(
registration: T,
): T {
return Object.freeze(registration);
}
export function exactCertificateScope(facts: TlsFacts): CertificateForHostScope {
const leaf = facts.presentedChain[0];
if (!leaf) throw new TypeError("TLS facts do not contain a leaf certificate");
return Object.freeze({
kind: "certificate-for-host",
hostname: facts.hostname,
port: facts.port,
certificateSha256: leaf.sha256,
});
}

View File

@ -3,9 +3,13 @@ export const BUILTIN_HANDLER_ID = "org.browsec.builtin-final-handler";
export function createBuiltinFinalHandler() { export function createBuiltinFinalHandler() {
return Object.freeze({ return Object.freeze({
manifest: { manifest: {
manifestVersion: 1,
trustApiVersion: "0.1",
id: BUILTIN_HANDLER_ID, id: BUILTIN_HANDLER_ID,
name: "Browsec built-in final handler", name: "Browsec built-in final handler",
role: "decision-authority", version: "0.0.0",
supportedModes: ["decision-authority"],
capabilities: { interactiveUi: true },
immutable: true, immutable: true,
}, },
@ -14,7 +18,13 @@ export function createBuiltinFinalHandler() {
return { return {
trusted: facts.validation === "success", 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 reasonEntryIds: journal.entries
.filter((entry) => entry.kind === "warning" || entry.kind === "evidence") .filter((entry) => entry.kind === "warning" || entry.kind === "evidence")
.map((entry) => entry.id), .map((entry) => entry.id),

View File

@ -37,14 +37,39 @@ export function createTlsFacts(input) {
} }
export function validateManifest(manifest) { 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?.id, "plugin manifest id");
requireString(manifest?.name, "plugin manifest name"); requireString(manifest?.name, "plugin manifest name");
if (!PLUGIN_ROLES.includes(manifest.role)) { requireString(manifest?.version, "plugin manifest version");
throw new TypeError(`Unsupported plugin role: ${manifest.role}`); 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); 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) { export function validateContribution(contribution, role) {
if (contribution === undefined) return immutableClone({ entries: [] }); if (contribution === undefined) return immutableClone({ entries: [] });
if (contribution === null || typeof contribution !== "object") { if (contribution === null || typeof contribution !== "object") {
@ -80,19 +105,68 @@ export function validateVerdict(verdict, facts) {
throw new TypeError("Trust verdict must contain Boolean trusted"); throw new TypeError("Trust verdict must contain Boolean trusted");
} }
const scope = verdict.scope ?? {}; const scope = validateScope(verdict.scope, facts);
if (scope.hostname !== facts.hostname || scope.port !== facts.port) { const lifetime = validateLifetime(verdict.lifetime);
throw new TypeError("Trust verdict scope does not match the connection"); if (!Array.isArray(verdict.reasonEntryIds)) {
throw new TypeError("Trust verdict reasonEntryIds must be an array");
} }
return immutableClone({ return immutableClone({
trusted: verdict.trusted, trusted: verdict.trusted,
scope: { hostname: scope.hostname, port: scope.port }, scope,
reasonEntryIds: verdict.reasonEntryIds ?? [], lifetime,
expiresAt: verdict.expiresAt, 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) { export function validateVerdictAuthority(verdict, role) {
if (verdict === undefined) return; if (verdict === undefined) return;
if (role === "observer" || role === "advisor") { if (role === "observer" || role === "advisor") {

View File

@ -4,17 +4,19 @@ import {
createTlsFacts, createTlsFacts,
validateContribution, validateContribution,
validateManifest, validateManifest,
validatePluginMode,
validateVerdict, validateVerdict,
validateVerdictAuthority, validateVerdictAuthority,
} from "./protocol.js"; } from "./protocol.js";
export class TrustRunner { export class TrustRunner {
constructor({ plugins = [], finalHandler, timeoutMs = 25 } = {}) { constructor({ plugins = [], finalHandler, timeoutMs = 25, ui } = {}) {
this.plugins = plugins.map(normalizePlugin); this.plugins = plugins.map(normalizeConfiguredPlugin);
this.finalHandler = normalizeFinalHandler( this.finalHandler = normalizeFinalHandler(
finalHandler ?? createBuiltinFinalHandler(), finalHandler ?? createBuiltinFinalHandler(),
); );
this.timeoutMs = timeoutMs; this.timeoutMs = timeoutMs;
this.ui = ui ?? { async requestDecision() { return undefined; } };
} }
async evaluate(inputFacts) { async evaluate(inputFacts) {
@ -25,18 +27,38 @@ export class TrustRunner {
let terminalVerdict; let terminalVerdict;
for (const plugin of this.plugins) { 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 { try {
const candidate = await withTimeout( const context = {
Promise.resolve(plugin.decide({ facts, journal: journal.snapshot() })), facts,
this.timeoutMs, 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); const verdict = validateVerdict(candidate, facts);
if (!verdict) { if (!verdict) {
journal.append(plugin.manifest, [{ kind: "abstention" }]); journal.append(plugin.manifest, [{ kind: "abstention" }]);
continue; continue;
} }
validateVerdictAuthority(verdict, plugin.manifest.role); validateVerdictAuthority(verdict, plugin.mode);
terminalVerdict = verdict; terminalVerdict = verdict;
journal.append(plugin.manifest, [ journal.append(plugin.manifest, [
@ -71,15 +93,22 @@ export class TrustRunner {
async #collectEvidence(facts, journal) { async #collectEvidence(facts, journal) {
const collectors = this.plugins.map(async (plugin) => { const collectors = this.plugins.map(async (plugin) => {
if (!plugin.collectEvidence) return { plugin, contribution: { entries: [] } }; if (!plugin.hooks.collectEvidence) {
return { plugin, contribution: { entries: [] } };
}
try { try {
const result = await withTimeout( const result = await withTimeout(
Promise.resolve(plugin.collectEvidence({ facts })), Promise.resolve(
plugin.hooks.collectEvidence({
facts,
signal: AbortSignal.timeout(this.timeoutMs),
}),
),
this.timeoutMs, this.timeoutMs,
); );
return { return {
plugin, plugin,
contribution: validateContribution(result, plugin.manifest.role), contribution: validateContribution(result, plugin.mode),
}; };
} catch (error) { } catch (error) {
return { plugin, contribution: { entries: [pluginError(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({ return Object.freeze({
manifest: validateManifest(plugin.manifest), manifest,
collectEvidence: plugin.collectEvidence?.bind(plugin), mode: validatePluginMode(manifest, configured.mode),
decide: plugin.decide?.bind(plugin), hooks: Object.freeze({ ...configured.plugin.hooks }),
}); });
} }
function normalizeFinalHandler(handler) { function normalizeFinalHandler(handler) {
const normalized = normalizePlugin(handler);
if (handler.manifest.id !== "org.browsec.builtin-final-handler") { if (handler.manifest.id !== "org.browsec.builtin-final-handler") {
throw new TypeError("Final handler must be Browsec's built-in 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"); throw new TypeError("Built-in final handler must implement finalize");
} }
return Object.freeze({ return Object.freeze({
...normalized, manifest: validateManifest(handler.manifest),
finalize: handler.finalize.bind(handler), 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) { function pluginError(error) {
return { return {
kind: "error", kind: "error",

View File

@ -14,6 +14,7 @@ import {
test("built-in handler rejects an unresolved certificate failure", async () => { test("built-in handler rejects an unresolved certificate failure", async () => {
const result = await new TrustRunner().evaluate(unknownLocalAuthority); const result = await new TrustRunner().evaluate(unknownLocalAuthority);
assert.equal(result.verdict.trusted, false); assert.equal(result.verdict.trusted, false);
assert.equal(result.verdict.lifetime.kind, "connection");
assert.equal(result.journal.entries.length, 0); assert.equal(result.journal.entries.length, 0);
}); });
@ -22,92 +23,109 @@ test("built-in handler preserves an ordinary successful validation", async () =>
assert.equal(result.verdict.trusted, true); assert.equal(result.verdict.trusted, true);
}); });
test("a decision plugin can trust a narrowly scoped connection", async () => { test("a failure hook can trust an exact certificate for a host", async () => {
const plugin = pluginWith({ const configured = configuredPlugin({
decide({ facts }) { async onTlsFailure({ facts }) {
return { return verdictFor(facts, true);
trusted: true,
scope: { hostname: facts.hostname, port: facts.port },
};
}, },
}); });
const result = await new TrustRunner({ plugins: [plugin] }).evaluate( const result = await new TrustRunner({ plugins: [configured] }).evaluate(
unknownLocalAuthority, unknownLocalAuthority,
); );
assert.equal(result.verdict.trusted, true); assert.equal(result.verdict.trusted, true);
assert.equal(result.verdict.scope.kind, "certificate-for-host");
assert.equal(result.journal.entries.at(-1).kind, "resolution"); assert.equal(result.journal.entries.at(-1).kind, "resolution");
assert.equal(result.journal.entries.at(-1).pluginId, plugin.manifest.id); assert.equal(
result.journal.entries.at(-1).pluginId,
configured.plugin.manifest.id,
);
}); });
test("evidence collection is independent but appended in plugin order", async () => { test("evidence collection is independent but appended in plugin order", async () => {
const slowFirst = pluginWith({ const slowFirst = configuredPlugin({
id: "test.first", id: "test.first",
mode: "observer",
async collectEvidence() { async collectEvidence() {
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
return { entries: [{ kind: "evidence", code: "first" }] }; return { entries: [{ kind: "evidence", code: "first" }] };
}, },
}); });
const fastSecond = pluginWith({ const fastSecond = configuredPlugin({
id: "test.second", id: "test.second",
mode: "observer",
collectEvidence() { collectEvidence() {
return { entries: [{ kind: "warning", code: "second" }] }; return { entries: [{ kind: "warning", code: "second" }] };
}, },
}); });
const result = await new TrustRunner({ plugins: [slowFirst, fastSecond] }).evaluate( const result = await new TrustRunner({
validPublicCertificate, plugins: [slowFirst, fastSecond],
); }).evaluate(validPublicCertificate);
assert.deepEqual( assert.deepEqual(
result.journal.entries.map((entry) => entry.code), result.journal.entries.map((entry) => entry.code),
["first", "second"], ["first", "second"],
); );
}); });
test("plugin timeout is recorded and falls through safely", async () => { test("asynchronous failure hook timeout is recorded and falls through safely", async () => {
const plugin = pluginWith({ const configured = configuredPlugin({
async decide() { async onTlsFailure() {
await new Promise((resolve) => setTimeout(resolve, 30)); await new Promise((resolve) => setTimeout(resolve, 30));
return undefined; return undefined;
}, },
}); });
const result = await new TrustRunner({ plugins: [plugin], timeoutMs: 5 }).evaluate( const result = await new TrustRunner({
unknownLocalAuthority, plugins: [configured],
); timeoutMs: 5,
}).evaluate(unknownLocalAuthority);
assert.equal(result.verdict.trusted, false); assert.equal(result.verdict.trusted, false);
assert.equal(result.journal.entries.at(-1).code, "plugin-timeout"); assert.equal(result.journal.entries.at(-1).code, "plugin-timeout");
}); });
test("a verdict cannot escape the active hostname and port", async () => { test("successful-path hook must return synchronously", async () => {
const plugin = pluginWith({ const configured = configuredPlugin({
decide() { async onBeforeTlsAccept() {
return undefined;
},
});
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
validPublicCertificate,
);
assert.equal(result.verdict.trusted, true);
assert.match(result.journal.entries.at(-1).message, /must return synchronously/);
});
test("a verdict cannot escape the active hostname, port, or certificate", async () => {
const configured = configuredPlugin({
async onTlsFailure({ facts }) {
return { return {
trusted: true, ...verdictFor(facts, true),
scope: { hostname: "different.test", port: 443 }, scope: {
...exactScope(facts),
hostname: "different.test",
},
}; };
}, },
}); });
const result = await new TrustRunner({ plugins: [plugin] }).evaluate( const result = await new TrustRunner({ plugins: [configured] }).evaluate(
unknownLocalAuthority, unknownLocalAuthority,
); );
assert.equal(result.verdict.trusted, false); assert.equal(result.verdict.trusted, false);
assert.match(result.journal.entries.at(-1).message, /scope does not match/); assert.match(result.journal.entries.at(-1).message, /scope does not match/);
}); });
test("observer and advisor plugins cannot issue terminal verdicts", async () => { test("observer and advisor modes cannot issue terminal verdicts", async () => {
for (const role of ["observer", "advisor"]) { for (const mode of ["observer", "advisor"]) {
const plugin = pluginWith({ const configured = configuredPlugin({
role, mode,
decide({ facts }) { async onTlsFailure({ facts }) {
return { return verdictFor(facts, true);
trusted: true,
scope: { hostname: facts.hostname, port: facts.port },
};
}, },
}); });
const result = await new TrustRunner({ plugins: [plugin] }).evaluate( const result = await new TrustRunner({ plugins: [configured] }).evaluate(
unknownLocalAuthority, unknownLocalAuthority,
); );
assert.equal(result.verdict.trusted, false); assert.equal(result.verdict.trusted, false);
@ -116,22 +134,43 @@ test("observer and advisor plugins cannot issue terminal verdicts", async () =>
}); });
test("veto authority can reject but cannot trust", async () => { test("veto authority can reject but cannot trust", async () => {
const plugin = pluginWith({ const configured = configuredPlugin({
role: "veto-authority", mode: "veto-authority",
decide({ facts }) { async onTlsFailure({ facts }) {
return { return verdictFor(facts, true);
trusted: true,
scope: { hostname: facts.hostname, port: facts.port },
};
}, },
}); });
const result = await new TrustRunner({ plugins: [plugin] }).evaluate( const result = await new TrustRunner({ plugins: [configured] }).evaluate(
unknownLocalAuthority, unknownLocalAuthority,
); );
assert.equal(result.verdict.trusted, false); assert.equal(result.verdict.trusted, false);
assert.match(result.journal.entries.at(-1).message, /cannot issue a trusted/); assert.match(result.journal.entries.at(-1).message, /cannot issue a trusted/);
}); });
test("authority scope must identify a certificate in the active chain", async () => {
const configured = configuredPlugin({
async onTlsFailure({ facts }) {
return {
trusted: true,
scope: {
kind: "authority-for-host",
hostname: facts.hostname,
port: facts.port,
authoritySha256: "not-in-this-chain",
includeSubdomains: false,
},
lifetime: { kind: "session" },
reasonEntryIds: [],
};
},
});
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
unknownLocalAuthority,
);
assert.equal(result.verdict.trusted, false);
assert.match(result.journal.entries.at(-1).message, /does not match the certificate chain/);
});
test("journal snapshots and entries are immutable", () => { test("journal snapshots and entries are immutable", () => {
const journal = new DecisionJournal(); const journal = new DecisionJournal();
journal.append( journal.append(
@ -151,11 +190,7 @@ test("only Browsec's immutable handler can occupy the final position", () => {
() => () =>
new TrustRunner({ new TrustRunner({
finalHandler: { finalHandler: {
manifest: { manifest: pluginManifest("test.impostor", ["decision-authority"]),
id: "test.impostor",
name: "Impostor",
role: "decision-authority",
},
finalize() {}, finalize() {},
}, },
}), }),
@ -167,15 +202,48 @@ test("only Browsec's immutable handler can occupy the final position", () => {
); );
}); });
function pluginWith({ function configuredPlugin({
id = "test.plugin", id = "test.plugin",
role = "decision-authority", mode = "decision-authority",
collectEvidence, collectEvidence,
decide, onBeforeTlsAccept,
onTlsFailure,
}) { }) {
return { return {
manifest: { id, name: id, role }, mode,
collectEvidence, plugin: {
decide, manifest: pluginManifest(id, [mode]),
hooks: { collectEvidence, onBeforeTlsAccept, onTlsFailure },
},
};
}
function pluginManifest(id, supportedModes) {
return {
manifestVersion: 1,
trustApiVersion: "0.1",
id,
name: id,
version: "0.0.0",
supportedModes,
capabilities: {},
};
}
function verdictFor(facts, trusted) {
return {
trusted,
scope: exactScope(facts),
lifetime: { kind: "connection" },
reasonEntryIds: [],
};
}
function exactScope(facts) {
return {
kind: "certificate-for-host",
hostname: facts.hostname,
port: facts.port,
certificateSha256: facts.presentedChain[0].sha256,
}; };
} }

View File

@ -47,9 +47,9 @@ test("certificate display names prefer the common name", () => {
test("UI plugins produce attributed evidence and a local Boolean verdict", async () => { test("UI plugins produce attributed evidence and a local Boolean verdict", async () => {
const plugins = [ const plugins = [
createFirefoxValidationPlugin(), configure(createFirefoxValidationPlugin(), "advisor"),
createVillageCommunityPlugin(), configure(createVillageCommunityPlugin(), "advisor"),
createUserDecisionPlugin(true), configure(createUserDecisionPlugin(true), "decision-authority"),
]; ];
const result = await new TrustRunner({ plugins }).evaluate(unknownLocalAuthority); const result = await new TrustRunner({ plugins }).evaluate(unknownLocalAuthority);
@ -63,18 +63,18 @@ test("UI plugins produce attributed evidence and a local Boolean verdict", async
test("conflicting community advice remains visible without becoming a verdict", async () => { test("conflicting community advice remains visible without becoming a verdict", async () => {
const plugins = [ const plugins = [
createCommunityAdvicePlugin({ configure(createCommunityAdvicePlugin({
id: "community.yes", id: "community.yes",
name: "Community Yes", name: "Community Yes",
trusted: true, trusted: true,
message: "Known key", message: "Known key",
}), }), "advisor"),
createCommunityAdvicePlugin({ configure(createCommunityAdvicePlugin({
id: "community.no", id: "community.no",
name: "Community No", name: "Community No",
trusted: false, trusted: false,
message: "Unexpected change", message: "Unexpected change",
}), }), "advisor"),
]; ];
const result = await new TrustRunner({ plugins }).evaluate(unknownLocalAuthority); const result = await new TrustRunner({ plugins }).evaluate(unknownLocalAuthority);
@ -86,6 +86,10 @@ test("conflicting community advice remains visible without becoming a verdict",
assert.ok(result.journal.entries.every((entry) => entry.kind === "vote")); assert.ok(result.journal.entries.every((entry) => entry.kind === "vote"));
}); });
function configure(plugin, mode) {
return { plugin, mode };
}
test("security surface contains immutable-frame and simulation labels", async () => { test("security surface contains immutable-frame and simulation labels", async () => {
const html = await readFile(new URL("../ui/index.html", import.meta.url), "utf8"); const html = await readFile(new URL("../ui/index.html", import.meta.url), "utf8");
assert.match(html, /Browsec security decision/); assert.match(html, /Browsec security decision/);

22
trustlab/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noEmitOnError": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": ".",
"skipLibCheck": true
},
"include": [
"sdk/**/*.ts",
"examples/**/*.ts"
]
}

View File

@ -40,18 +40,18 @@ const scenarios = {
label: "Conflicting community advice", label: "Conflicting community advice",
facts: conflictingCommunityAdvice, facts: conflictingCommunityAdvice,
plugins: [ plugins: [
createCommunityAdvicePlugin({ configure(createCommunityAdvicePlugin({
id: "community.archivists", id: "community.archivists",
name: "Regional archivists", name: "Regional archivists",
trusted: true, trusted: true,
message: "The archivists recognize this exact certificate and recommend trust.", message: "The archivists recognize this exact certificate and recommend trust.",
}), }), "advisor"),
createCommunityAdvicePlugin({ configure(createCommunityAdvicePlugin({
id: "community.network-watch", id: "community.network-watch",
name: "Independent network watch", name: "Independent network watch",
trusted: false, trusted: false,
message: "The network observers report an unexpected certificate change.", message: "The network observers report an unexpected certificate change.",
}), }), "advisor"),
], ],
}, },
}; };
@ -105,14 +105,16 @@ elements.clear.addEventListener("click", () => {
async function render() { async function render() {
const facts = scenarios[state.scenario].facts; const facts = scenarios[state.scenario].facts;
const plugins = [createFirefoxValidationPlugin()]; const plugins = [configure(createFirefoxValidationPlugin(), "advisor")];
if (state.communityEnabled) { if (state.communityEnabled) {
plugins.push( plugins.push(
...(scenarios[state.scenario].plugins ?? [createVillageCommunityPlugin()]), ...(scenarios[state.scenario].plugins ?? [
configure(createVillageCommunityPlugin(), "advisor"),
]),
); );
} }
const userPlugin = createUserDecisionPlugin(state.userDecision); const userPlugin = createUserDecisionPlugin(state.userDecision);
if (userPlugin) plugins.push(userPlugin); if (userPlugin) plugins.push(configure(userPlugin, "decision-authority"));
const result = await new TrustRunner({ plugins }).evaluate(facts); const result = await new TrustRunner({ plugins }).evaluate(facts);
renderStatus(result); renderStatus(result);
@ -197,4 +199,8 @@ function formatCode(value = "") {
return value.replaceAll("-", " "); return value.replaceAll("-", " ");
} }
function configure(plugin, mode) {
return { plugin, mode };
}
render(); render();