Define strict TLS trust plugin API
This commit is contained in:
parent
f1fcef9b85
commit
1eeb8bac05
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -3,40 +3,53 @@ 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: {},
|
||||||
},
|
},
|
||||||
collectEvidence({ facts }) {
|
hooks: {
|
||||||
if (facts.hostname !== "library.village") return;
|
collectEvidence({ facts }) {
|
||||||
return {
|
if (facts.hostname !== "library.village") return;
|
||||||
entries: [
|
return {
|
||||||
{
|
entries: [
|
||||||
kind: "evidence",
|
{
|
||||||
code: "known-community-key",
|
kind: "evidence",
|
||||||
message: "The community has previously observed this certificate.",
|
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,
|
||||||
},
|
},
|
||||||
],
|
lifetime: { kind: "connection" },
|
||||||
};
|
reasonEntryIds: journal.entries
|
||||||
},
|
.filter((entry) => entry.code === "known-community-key")
|
||||||
decide({ facts, journal }) {
|
.map((entry) => entry.id),
|
||||||
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),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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));
|
||||||
|
|
||||||
|
|||||||
49
trustlab/examples/strict-plugin.ts
Normal file
49
trustlab/examples/strict-plugin.ts
Normal 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),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
43
trustlab/examples/type-contract.ts
Normal file
43
trustlab/examples/type-contract.ts
Normal 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
32
trustlab/package-lock.json
generated
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,58 +1,64 @@
|
|||||||
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"],
|
||||||
},
|
),
|
||||||
collectEvidence({ facts }) {
|
hooks: {
|
||||||
if (facts.validation === "success") {
|
collectEvidence({ facts }) {
|
||||||
return {
|
if (facts.validation === "success") {
|
||||||
entries: [
|
return {
|
||||||
{
|
entries: [
|
||||||
kind: "vote",
|
{
|
||||||
code: "firefox-validation-succeeded",
|
kind: "vote",
|
||||||
message: "Firefox constructed a valid path to a configured trust anchor.",
|
code: "firefox-validation-succeeded",
|
||||||
},
|
message: "Firefox constructed a valid path to a configured trust anchor.",
|
||||||
],
|
data: { trusted: true },
|
||||||
};
|
},
|
||||||
}
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entries: facts.errors.map((error) => ({
|
entries: facts.errors.map((error) => ({
|
||||||
kind: "warning",
|
kind: "warning",
|
||||||
code: error,
|
code: error,
|
||||||
message: explainValidationError(error),
|
message: explainValidationError(error),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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"],
|
||||||
},
|
),
|
||||||
collectEvidence({ facts }) {
|
hooks: {
|
||||||
if (facts.hostname !== "library.village") return;
|
collectEvidence({ facts }) {
|
||||||
return {
|
if (facts.hostname !== "library.village") return;
|
||||||
entries: [
|
return {
|
||||||
{
|
entries: [
|
||||||
kind: "evidence",
|
{
|
||||||
code: "community-key-continuity",
|
kind: "evidence",
|
||||||
message: "This certificate has appeared in the synthetic community record for 184 days.",
|
code: "community-key-continuity",
|
||||||
data: { observers: 7, independentOperators: 3, ageDays: 184 },
|
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",
|
kind: "vote",
|
||||||
message: "The configured community recommends trusting this exact host certificate.",
|
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,
|
message,
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
manifest: { id, name, role: "advisor" },
|
manifest: pluginManifest(id, name, ["advisor"]),
|
||||||
collectEvidence() {
|
hooks: {
|
||||||
return {
|
collectEvidence() {
|
||||||
entries: [
|
return {
|
||||||
{
|
entries: [
|
||||||
kind: "vote",
|
{
|
||||||
code: trusted ? "community-votes-trusted" : "community-votes-not-trusted",
|
kind: "vote",
|
||||||
message,
|
code: trusted ? "community-votes-trusted" : "community-votes-not-trusted",
|
||||||
data: { trusted },
|
message,
|
||||||
},
|
data: { trusted },
|
||||||
],
|
},
|
||||||
};
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUserDecisionPlugin(decision) {
|
export function createUserDecisionPlugin(decision) {
|
||||||
if (decision !== true && decision !== false) return undefined;
|
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 {
|
return {
|
||||||
manifest: {
|
manifest: pluginManifest(
|
||||||
id: "local.user.decision",
|
"local.user.decision",
|
||||||
name: "Local user decision",
|
"Local user decision",
|
||||||
role: "decision-authority",
|
["decision-authority"],
|
||||||
},
|
),
|
||||||
decide({ facts, journal }) {
|
hooks: {
|
||||||
return {
|
onBeforeTlsAccept: decide,
|
||||||
trusted: decision,
|
async onTlsFailure(context) {
|
||||||
scope: { hostname: facts.hostname, port: facts.port },
|
return decide(context);
|
||||||
reasonEntryIds: journal.entries
|
},
|
||||||
.filter((entry) => entry.kind === "evidence" || entry.kind === "warning")
|
|
||||||
.map((entry) => entry.id),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function explainValidationError(error) {
|
||||||
const explanations = {
|
const explanations = {
|
||||||
"unknown-issuer":
|
"unknown-issuer":
|
||||||
|
|||||||
194
trustlab/sdk/plugin-api.ts
Normal file
194
trustlab/sdk/plugin-api.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
22
trustlab/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user