From 1eeb8bac051ca7faf2e5e100e25110b19d8c1e27 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 16 Aug 2026 17:49:42 +0400 Subject: [PATCH] Define strict TLS trust plugin API --- .gitignore | 3 + ARCHITECTURE.md | 23 ++- trustlab/README.md | 18 +++ trustlab/examples/demo.js | 65 +++++---- trustlab/examples/strict-plugin.ts | 49 +++++++ trustlab/examples/type-contract.ts | 43 ++++++ trustlab/package-lock.json | 32 +++++ trustlab/package.json | 5 + trustlab/plugins/demo-plugins.js | 176 +++++++++++++---------- trustlab/sdk/plugin-api.ts | 194 ++++++++++++++++++++++++++ trustlab/src/builtin-final-handler.js | 14 +- trustlab/src/protocol.js | 90 ++++++++++-- trustlab/src/runner.js | 78 ++++++++--- trustlab/test/runner.test.js | 178 +++++++++++++++-------- trustlab/test/ui.test.js | 18 ++- trustlab/tsconfig.json | 22 +++ trustlab/ui/app.js | 20 ++- 17 files changed, 834 insertions(+), 194 deletions(-) create mode 100644 .gitignore create mode 100644 trustlab/examples/strict-plugin.ts create mode 100644 trustlab/examples/type-contract.ts create mode 100644 trustlab/package-lock.json create mode 100644 trustlab/sdk/plugin-api.ts create mode 100644 trustlab/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08dc7b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ec45e4e..64071c2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. - diff --git a/trustlab/README.md b/trustlab/README.md index 0a17e77..1213f0f 100644 --- a/trustlab/README.md +++ b/trustlab/README.md @@ -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. diff --git a/trustlab/examples/demo.js b/trustlab/examples/demo.js index 98480d0..f754bbc 100644 --- a/trustlab/examples/demo.js +++ b/trustlab/examples/demo.js @@ -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)); - diff --git a/trustlab/examples/strict-plugin.ts b/trustlab/examples/strict-plugin.ts new file mode 100644 index 0000000..6d3b764 --- /dev/null +++ b/trustlab/examples/strict-plugin.ts @@ -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), + }; + }, + }, +}); + diff --git a/trustlab/examples/type-contract.ts b/trustlab/examples/type-contract.ts new file mode 100644 index 0000000..675790c --- /dev/null +++ b/trustlab/examples/type-contract.ts @@ -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; + diff --git a/trustlab/package-lock.json b/trustlab/package-lock.json new file mode 100644 index 0000000..14b96c2 --- /dev/null +++ b/trustlab/package-lock.json @@ -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" + } + } + } +} diff --git a/trustlab/package.json b/trustlab/package.json index 407d783..ded650f 100644 --- a/trustlab/package.json +++ b/trustlab/package.json @@ -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" } } diff --git a/trustlab/plugins/demo-plugins.js b/trustlab/plugins/demo-plugins.js index 541e77f..407d464 100644 --- a/trustlab/plugins/demo-plugins.js +++ b/trustlab/plugins/demo-plugins.js @@ -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": diff --git a/trustlab/sdk/plugin-api.ts b/trustlab/sdk/plugin-api.ts new file mode 100644 index 0000000..5a8a63d --- /dev/null +++ b/trustlab/sdk/plugin-api.ts @@ -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>; +} + +export type PluginEntry = EvidenceEntry | WarningEntry | VoteEntry; + +interface EntryBase { + readonly code: string; + readonly message: string; + readonly data?: Readonly>; +} + +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>; +} + +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; +} + +export interface TrustPluginHooks { + collectEvidence?( + context: EvidenceContext, + ): PluginContribution | undefined | Promise; + + /** 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; +} + +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( + 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, + }); +} + diff --git a/trustlab/src/builtin-final-handler.js b/trustlab/src/builtin-final-handler.js index bdb94ee..5a7e010 100644 --- a/trustlab/src/builtin-final-handler.js +++ b/trustlab/src/builtin-final-handler.js @@ -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), diff --git a/trustlab/src/protocol.js b/trustlab/src/protocol.js index a8f0c13..b315fd8 100644 --- a/trustlab/src/protocol.js +++ b/trustlab/src/protocol.js @@ -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") { diff --git a/trustlab/src/runner.js b/trustlab/src/runner.js index 4353b03..f9bb744 100644 --- a/trustlab/src/runner.js +++ b/trustlab/src/runner.js @@ -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", diff --git a/trustlab/test/runner.test.js b/trustlab/test/runner.test.js index 1b96662..f83a94f 100644 --- a/trustlab/test/runner.test.js +++ b/trustlab/test/runner.test.js @@ -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, }; } diff --git a/trustlab/test/ui.test.js b/trustlab/test/ui.test.js index a0b8e26..2286fe9 100644 --- a/trustlab/test/ui.test.js +++ b/trustlab/test/ui.test.js @@ -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/); diff --git a/trustlab/tsconfig.json b/trustlab/tsconfig.json new file mode 100644 index 0000000..1ca7d48 --- /dev/null +++ b/trustlab/tsconfig.json @@ -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" + ] +} diff --git a/trustlab/ui/app.js b/trustlab/ui/app.js index 6e3c0f8..b6511b6 100644 --- a/trustlab/ui/app.js +++ b/trustlab/ui/app.js @@ -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();