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
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
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;
- release governance and threshold signing;
- precise UI design and accessibility treatment.

View File

@ -16,6 +16,8 @@ Run:
```sh
npm test
npm run check
npm run build
npm run demo
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
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,40 +3,53 @@ import { TrustRunner } from "../src/index.js";
const villagePlugin = {
manifest: {
manifestVersion: 1,
trustApiVersion: "0.1",
id: "community.village.trust",
name: "Village community trust",
role: "decision-authority",
version: "0.0.0",
supportedModes: ["decision-authority"],
capabilities: {},
},
collectEvidence({ facts }) {
if (facts.hostname !== "library.village") return;
return {
entries: [
{
kind: "evidence",
code: "known-community-key",
message: "The community has previously observed this certificate.",
hooks: {
collectEvidence({ facts }) {
if (facts.hostname !== "library.village") return;
return {
entries: [
{
kind: "evidence",
code: "known-community-key",
message: "The community has previously observed this certificate.",
},
],
};
},
async onTlsFailure({ facts, journal }) {
const known = journal.entries.some(
(entry) => entry.code === "known-community-key",
);
if (!known) return;
return {
trusted: true,
scope: {
kind: "certificate-for-host",
hostname: facts.hostname,
port: facts.port,
certificateSha256: facts.presentedChain[0].sha256,
},
],
};
},
decide({ facts, journal }) {
const known = journal.entries.some(
(entry) => entry.code === "known-community-key",
);
if (!known) return;
return {
trusted: true,
scope: { hostname: facts.hostname, port: facts.port },
reasonEntryIds: journal.entries
.filter((entry) => entry.code === "known-community-key")
.map((entry) => entry.id),
};
lifetime: { kind: "connection" },
reasonEntryIds: journal.entries
.filter((entry) => entry.code === "known-community-key")
.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,
);
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",
"description": "Browser-neutral reference runner for Browsec trust plugins",
"scripts": {
"build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit",
"test": "node --test --test-isolation=none",
"demo": "node examples/demo.js",
"ui": "node ui/dev-server.js"
},
"engines": {
"node": ">=22"
},
"devDependencies": {
"typescript": "^5.9.2"
}
}

View File

@ -1,58 +1,64 @@
export function createFirefoxValidationPlugin() {
return {
manifest: {
id: "org.browsec.firefox-validation",
name: "Firefox validation",
role: "advisor",
},
collectEvidence({ facts }) {
if (facts.validation === "success") {
return {
entries: [
{
kind: "vote",
code: "firefox-validation-succeeded",
message: "Firefox constructed a valid path to a configured trust anchor.",
},
],
};
}
manifest: pluginManifest(
"org.browsec.firefox-validation",
"Firefox validation",
["advisor"],
),
hooks: {
collectEvidence({ facts }) {
if (facts.validation === "success") {
return {
entries: [
{
kind: "vote",
code: "firefox-validation-succeeded",
message: "Firefox constructed a valid path to a configured trust anchor.",
data: { trusted: true },
},
],
};
}
return {
entries: facts.errors.map((error) => ({
kind: "warning",
code: error,
message: explainValidationError(error),
})),
};
return {
entries: facts.errors.map((error) => ({
kind: "warning",
code: error,
message: explainValidationError(error),
})),
};
},
},
};
}
export function createVillageCommunityPlugin() {
return {
manifest: {
id: "community.village.observer",
name: "Village community observer",
role: "advisor",
},
collectEvidence({ facts }) {
if (facts.hostname !== "library.village") return;
return {
entries: [
{
kind: "evidence",
code: "community-key-continuity",
message: "This certificate has appeared in the synthetic community record for 184 days.",
data: { observers: 7, independentOperators: 3, ageDays: 184 },
},
{
kind: "vote",
code: "community-recommends-trust",
message: "The configured community recommends trusting this exact host certificate.",
},
],
};
manifest: pluginManifest(
"community.village.observer",
"Village community observer",
["advisor"],
),
hooks: {
collectEvidence({ facts }) {
if (facts.hostname !== "library.village") return;
return {
entries: [
{
kind: "evidence",
code: "community-key-continuity",
message: "This certificate has appeared in the synthetic community record for 184 days.",
data: { observers: 7, independentOperators: 3, ageDays: 184 },
},
{
kind: "vote",
code: "community-recommends-trust",
message: "The configured community recommends trusting this exact host certificate.",
data: { trusted: true },
},
],
};
},
},
};
}
@ -64,42 +70,70 @@ export function createCommunityAdvicePlugin({
message,
}) {
return {
manifest: { id, name, role: "advisor" },
collectEvidence() {
return {
entries: [
{
kind: "vote",
code: trusted ? "community-votes-trusted" : "community-votes-not-trusted",
message,
data: { trusted },
},
],
};
manifest: pluginManifest(id, name, ["advisor"]),
hooks: {
collectEvidence() {
return {
entries: [
{
kind: "vote",
code: trusted ? "community-votes-trusted" : "community-votes-not-trusted",
message,
data: { trusted },
},
],
};
},
},
};
}
export function createUserDecisionPlugin(decision) {
if (decision !== true && decision !== false) return undefined;
const decide = ({ facts, journal }) => ({
trusted: decision,
scope: exactCertificateScope(facts),
lifetime: { kind: "connection" },
reasonEntryIds: journal.entries
.filter((entry) => entry.kind === "evidence" || entry.kind === "warning")
.map((entry) => entry.id),
});
return {
manifest: {
id: "local.user.decision",
name: "Local user decision",
role: "decision-authority",
},
decide({ facts, journal }) {
return {
trusted: decision,
scope: { hostname: facts.hostname, port: facts.port },
reasonEntryIds: journal.entries
.filter((entry) => entry.kind === "evidence" || entry.kind === "warning")
.map((entry) => entry.id),
};
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: {},
};
}
function explainValidationError(error) {
const explanations = {
"unknown-issuer":

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() {
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),

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") {

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",

View File

@ -14,6 +14,7 @@ import {
test("built-in handler rejects an unresolved certificate failure", async () => {
const result = await new TrustRunner().evaluate(unknownLocalAuthority);
assert.equal(result.verdict.trusted, false);
assert.equal(result.verdict.lifetime.kind, "connection");
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);
});
test("a decision plugin can trust a narrowly scoped connection", async () => {
const plugin = pluginWith({
decide({ facts }) {
return {
trusted: true,
scope: { hostname: facts.hostname, port: facts.port },
};
test("a failure hook can trust an exact certificate for a host", async () => {
const configured = configuredPlugin({
async onTlsFailure({ facts }) {
return verdictFor(facts, true);
},
});
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
unknownLocalAuthority,
);
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).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 () => {
const slowFirst = pluginWith({
const slowFirst = configuredPlugin({
id: "test.first",
mode: "observer",
async collectEvidence() {
await new Promise((resolve) => setTimeout(resolve, 5));
return { entries: [{ kind: "evidence", code: "first" }] };
},
});
const fastSecond = pluginWith({
const fastSecond = configuredPlugin({
id: "test.second",
mode: "observer",
collectEvidence() {
return { entries: [{ kind: "warning", code: "second" }] };
},
});
const result = await new TrustRunner({ plugins: [slowFirst, fastSecond] }).evaluate(
validPublicCertificate,
);
const result = await new TrustRunner({
plugins: [slowFirst, fastSecond],
}).evaluate(validPublicCertificate);
assert.deepEqual(
result.journal.entries.map((entry) => entry.code),
["first", "second"],
);
});
test("plugin timeout is recorded and falls through safely", async () => {
const plugin = pluginWith({
async decide() {
test("asynchronous failure hook timeout is recorded and falls through safely", async () => {
const configured = configuredPlugin({
async onTlsFailure() {
await new Promise((resolve) => setTimeout(resolve, 30));
return undefined;
},
});
const result = await new TrustRunner({ plugins: [plugin], timeoutMs: 5 }).evaluate(
unknownLocalAuthority,
);
const result = await new TrustRunner({
plugins: [configured],
timeoutMs: 5,
}).evaluate(unknownLocalAuthority);
assert.equal(result.verdict.trusted, false);
assert.equal(result.journal.entries.at(-1).code, "plugin-timeout");
});
test("a verdict cannot escape the active hostname and port", async () => {
const plugin = pluginWith({
decide() {
test("successful-path hook must return synchronously", async () => {
const configured = configuredPlugin({
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 {
trusted: true,
scope: { hostname: "different.test", port: 443 },
...verdictFor(facts, true),
scope: {
...exactScope(facts),
hostname: "different.test",
},
};
},
});
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
unknownLocalAuthority,
);
assert.equal(result.verdict.trusted, false);
assert.match(result.journal.entries.at(-1).message, /scope does not match/);
});
test("observer and advisor plugins cannot issue terminal verdicts", async () => {
for (const role of ["observer", "advisor"]) {
const plugin = pluginWith({
role,
decide({ facts }) {
return {
trusted: true,
scope: { hostname: facts.hostname, port: facts.port },
};
test("observer and advisor modes cannot issue terminal verdicts", async () => {
for (const mode of ["observer", "advisor"]) {
const configured = configuredPlugin({
mode,
async onTlsFailure({ facts }) {
return verdictFor(facts, true);
},
});
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
unknownLocalAuthority,
);
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 () => {
const plugin = pluginWith({
role: "veto-authority",
decide({ facts }) {
return {
trusted: true,
scope: { hostname: facts.hostname, port: facts.port },
};
const configured = configuredPlugin({
mode: "veto-authority",
async onTlsFailure({ facts }) {
return verdictFor(facts, true);
},
});
const result = await new TrustRunner({ plugins: [plugin] }).evaluate(
const result = await new TrustRunner({ plugins: [configured] }).evaluate(
unknownLocalAuthority,
);
assert.equal(result.verdict.trusted, false);
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", () => {
const journal = new DecisionJournal();
journal.append(
@ -151,11 +190,7 @@ test("only Browsec's immutable handler can occupy the final position", () => {
() =>
new TrustRunner({
finalHandler: {
manifest: {
id: "test.impostor",
name: "Impostor",
role: "decision-authority",
},
manifest: pluginManifest("test.impostor", ["decision-authority"]),
finalize() {},
},
}),
@ -167,15 +202,48 @@ test("only Browsec's immutable handler can occupy the final position", () => {
);
});
function pluginWith({
function configuredPlugin({
id = "test.plugin",
role = "decision-authority",
mode = "decision-authority",
collectEvidence,
decide,
onBeforeTlsAccept,
onTlsFailure,
}) {
return {
manifest: { id, name: id, role },
collectEvidence,
decide,
mode,
plugin: {
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 () => {
const plugins = [
createFirefoxValidationPlugin(),
createVillageCommunityPlugin(),
createUserDecisionPlugin(true),
configure(createFirefoxValidationPlugin(), "advisor"),
configure(createVillageCommunityPlugin(), "advisor"),
configure(createUserDecisionPlugin(true), "decision-authority"),
];
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 () => {
const plugins = [
createCommunityAdvicePlugin({
configure(createCommunityAdvicePlugin({
id: "community.yes",
name: "Community Yes",
trusted: true,
message: "Known key",
}),
createCommunityAdvicePlugin({
}), "advisor"),
configure(createCommunityAdvicePlugin({
id: "community.no",
name: "Community No",
trusted: false,
message: "Unexpected change",
}),
}), "advisor"),
];
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"));
});
function configure(plugin, mode) {
return { plugin, mode };
}
test("security surface contains immutable-frame and simulation labels", async () => {
const html = await readFile(new URL("../ui/index.html", import.meta.url), "utf8");
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",
facts: conflictingCommunityAdvice,
plugins: [
createCommunityAdvicePlugin({
configure(createCommunityAdvicePlugin({
id: "community.archivists",
name: "Regional archivists",
trusted: true,
message: "The archivists recognize this exact certificate and recommend trust.",
}),
createCommunityAdvicePlugin({
}), "advisor"),
configure(createCommunityAdvicePlugin({
id: "community.network-watch",
name: "Independent network watch",
trusted: false,
message: "The network observers report an unexpected certificate change.",
}),
}), "advisor"),
],
},
};
@ -105,14 +105,16 @@ elements.clear.addEventListener("click", () => {
async function render() {
const facts = scenarios[state.scenario].facts;
const plugins = [createFirefoxValidationPlugin()];
const plugins = [configure(createFirefoxValidationPlugin(), "advisor")];
if (state.communityEnabled) {
plugins.push(
...(scenarios[state.scenario].plugins ?? [createVillageCommunityPlugin()]),
...(scenarios[state.scenario].plugins ?? [
configure(createVillageCommunityPlugin(), "advisor"),
]),
);
}
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);
renderStatus(result);
@ -197,4 +199,8 @@ function formatCode(value = "") {
return value.replaceAll("-", " ");
}
function configure(plugin, mode) {
return { plugin, mode };
}
render();