From d479c459fc86883d8c3d82ca07fefd92817a8d93 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 16 Aug 2026 19:42:53 +0400 Subject: [PATCH] Implement scoped authority trust overlay --- trustlab/README.md | 8 + trustlab/examples/demo.js | 1 + trustlab/examples/strict-plugin.ts | 9 +- trustlab/examples/type-contract.ts | 2 +- trustlab/fixtures/tls.js | 88 +++++---- trustlab/plugins/demo-plugins.js | 31 +++- trustlab/plugins/policy-overlay-plugin.js | 61 +++++++ trustlab/sdk/plugin-api.ts | 14 +- trustlab/src/builtin-final-handler.js | 1 + trustlab/src/index.js | 2 +- trustlab/src/policy-overlay.js | 102 +++++++++++ trustlab/src/protocol.js | 60 +++++- trustlab/test/policy-overlay.test.js | 213 ++++++++++++++++++++++ trustlab/test/runner.test.js | 4 +- trustlab/test/ui.test.js | 2 + trustlab/ui/app.js | 153 ++++++++++++++-- trustlab/ui/index.html | 26 ++- 17 files changed, 712 insertions(+), 65 deletions(-) create mode 100644 trustlab/plugins/policy-overlay-plugin.js create mode 100644 trustlab/src/policy-overlay.js create mode 100644 trustlab/test/policy-overlay.test.js diff --git a/trustlab/README.md b/trustlab/README.md index 1213f0f..e850ac0 100644 --- a/trustlab/README.md +++ b/trustlab/README.md @@ -52,3 +52,11 @@ 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. + +Authority scopes identify the exact DER-encoded CA certificate with +`authorityCertificateSha256`. TrustLab verifies that it appears in the active +chain, has CA Basic Constraints in the supplied facts, and permits +`keyCertSign`. The in-memory policy overlay applies that authority only to the +declared host namespace and participates in retries as an ordinary trust plugin. +An authority rule may override trust-anchor failures only; it cannot excuse an +expired certificate, a hostname mismatch, or another independent TLS failure. diff --git a/trustlab/examples/demo.js b/trustlab/examples/demo.js index f754bbc..010df2d 100644 --- a/trustlab/examples/demo.js +++ b/trustlab/examples/demo.js @@ -41,6 +41,7 @@ const villagePlugin = { reasonEntryIds: journal.entries .filter((entry) => entry.code === "known-community-key") .map((entry) => entry.id), + overriddenErrors: [...facts.errors], }; }, }, diff --git a/trustlab/examples/strict-plugin.ts b/trustlab/examples/strict-plugin.ts index 6d3b764..f5110b5 100644 --- a/trustlab/examples/strict-plugin.ts +++ b/trustlab/examples/strict-plugin.ts @@ -37,13 +37,14 @@ export default defineTrustPlugin({ }); if (trusted === undefined) return undefined; - return { - trusted, + const common = { scope: exactCertificateScope(facts), - lifetime: { kind: "session" }, + lifetime: { kind: "session" } as const, reasonEntryIds: journal.entries.map((entry) => entry.id), }; + return trusted + ? { ...common, trusted: true, overriddenErrors: [...facts.errors] } + : { ...common, trusted: false, overriddenErrors: [] }; }, }, }); - diff --git a/trustlab/examples/type-contract.ts b/trustlab/examples/type-contract.ts index 675790c..8a2903a 100644 --- a/trustlab/examples/type-contract.ts +++ b/trustlab/examples/type-contract.ts @@ -24,6 +24,7 @@ const verdictWithoutLifetime: TrustVerdict = { certificateSha256: "leaf-sha256", }, reasonEntryIds: [], + overriddenErrors: [], }; const voteWithoutBoolean: PluginContribution = { @@ -40,4 +41,3 @@ const voteWithoutBoolean: PluginContribution = { void asynchronousFastHook; void verdictWithoutLifetime; void voteWithoutBoolean; - diff --git a/trustlab/fixtures/tls.js b/trustlab/fixtures/tls.js index 27868f4..9109566 100644 --- a/trustlab/fixtures/tls.js +++ b/trustlab/fixtures/tls.js @@ -5,13 +5,13 @@ export const validPublicCertificate = { validation: "success", errors: [], presentedChain: [ - { subject: "CN=example.test", sha256: "leaf-valid-public" }, - { subject: "CN=Example Intermediate", sha256: "intermediate-public" }, + leaf("CN=example.test", "leaf-valid-public"), + ca("CN=Example Intermediate", "intermediate-public"), ], constructedChain: [ - { subject: "CN=example.test", sha256: "leaf-valid-public" }, - { subject: "CN=Example Intermediate", sha256: "intermediate-public" }, - { subject: "CN=Example Root", sha256: "root-public" }, + leaf("CN=example.test", "leaf-valid-public"), + ca("CN=Example Intermediate", "intermediate-public"), + ca("CN=Example Root", "root-public", { selfSigned: true }), ], tls: { version: "TLSv1.3", alpn: "h2" }, }; @@ -29,12 +29,12 @@ export const unknownLocalAuthority = { summary: "The candidate authority is not trusted by the current Firefox policy.", }, presentedChain: [ - { subject: "CN=library.village", sha256: "leaf-village-library" }, - { subject: "CN=Village Services CA", sha256: "ca-village-services" }, + leaf("CN=library.village", "leaf-village-library"), + ca("CN=Village Services CA", "ca-village-services", { selfSigned: true }), ], constructedChain: [ - { subject: "CN=library.village", sha256: "leaf-village-library" }, - { subject: "CN=Village Services CA", sha256: "ca-village-services" }, + leaf("CN=library.village", "leaf-village-library"), + ca("CN=Village Services CA", "ca-village-services", { selfSigned: true }), ], tls: { version: "TLSv1.3", alpn: "h2" }, }; @@ -52,23 +52,19 @@ export const expiredLeafCertificate = { summary: "The server certificate expired 46 days ago.", }, presentedChain: [ - { - subject: "CN=archive.village", - sha256: "leaf-expired-archive", + leaf("CN=archive.village", "leaf-expired-archive", { validFrom: "2025-06-01T00:00:00Z", validUntil: "2026-07-01T00:00:00Z", - }, - { subject: "CN=Village Public Services CA", sha256: "ca-village-public" }, + }), + ca("CN=Village Public Services CA", "ca-village-public"), ], constructedChain: [ - { - subject: "CN=archive.village", - sha256: "leaf-expired-archive", + leaf("CN=archive.village", "leaf-expired-archive", { validFrom: "2025-06-01T00:00:00Z", validUntil: "2026-07-01T00:00:00Z", - }, - { subject: "CN=Village Public Services CA", sha256: "ca-village-public" }, - { subject: "CN=Regional Root CA", sha256: "root-regional" }, + }), + ca("CN=Village Public Services CA", "ca-village-public"), + ca("CN=Regional Root CA", "root-regional", { selfSigned: true }), ], tls: { version: "TLSv1.3", alpn: "h2" }, }; @@ -86,21 +82,17 @@ export const hostnameMismatch = { summary: "The certificate identifies files.village, not records.village.", }, presentedChain: [ - { - subject: "CN=files.village", - sha256: "leaf-wrong-host", + leaf("CN=files.village", "leaf-wrong-host", { dnsNames: ["files.village"], - }, - { subject: "CN=Village Public Services CA", sha256: "ca-village-public" }, + }), + ca("CN=Village Public Services CA", "ca-village-public"), ], constructedChain: [ - { - subject: "CN=files.village", - sha256: "leaf-wrong-host", + leaf("CN=files.village", "leaf-wrong-host", { dnsNames: ["files.village"], - }, - { subject: "CN=Village Public Services CA", sha256: "ca-village-public" }, - { subject: "CN=Regional Root CA", sha256: "root-regional" }, + }), + ca("CN=Village Public Services CA", "ca-village-public"), + ca("CN=Regional Root CA", "root-regional", { selfSigned: true }), ], tls: { version: "TLSv1.3", alpn: "h2" }, }; @@ -118,13 +110,15 @@ export const explicitlyDistrustedAuthority = { summary: "A local Browsec rule explicitly distrusts this root authority.", }, presentedChain: [ - { subject: "CN=registry.example", sha256: "leaf-registry" }, - { subject: "CN=Commercial Issuing CA", sha256: "ca-commercial-issuing" }, + leaf("CN=registry.example", "leaf-registry"), + ca("CN=Commercial Issuing CA", "ca-commercial-issuing"), ], constructedChain: [ - { subject: "CN=registry.example", sha256: "leaf-registry" }, - { subject: "CN=Commercial Issuing CA", sha256: "ca-commercial-issuing" }, - { subject: "CN=Globally Trusted but Locally Rejected Root", sha256: "root-distrusted" }, + leaf("CN=registry.example", "leaf-registry"), + ca("CN=Commercial Issuing CA", "ca-commercial-issuing"), + ca("CN=Globally Trusted but Locally Rejected Root", "root-distrusted", { + selfSigned: true, + }), ], tls: { version: "TLSv1.3", alpn: "h2" }, }; @@ -133,3 +127,25 @@ export const conflictingCommunityAdvice = { ...unknownLocalAuthority, connectionId: "connection-conflicting-community-advice", }; + +function leaf(subject, sha256, extra = {}) { + return { + subject, + sha256, + isCa: false, + keyUsages: ["digitalSignature", "keyEncipherment"], + selfSigned: false, + ...extra, + }; +} + +function ca(subject, sha256, extra = {}) { + return { + subject, + sha256, + isCa: true, + keyUsages: ["keyCertSign", "crlSign"], + selfSigned: false, + ...extra, + }; +} diff --git a/trustlab/plugins/demo-plugins.js b/trustlab/plugins/demo-plugins.js index 407d464..5ee2664 100644 --- a/trustlab/plugins/demo-plugins.js +++ b/trustlab/plugins/demo-plugins.js @@ -88,15 +88,19 @@ export function createCommunityAdvicePlugin({ }; } -export function createUserDecisionPlugin(decision) { +export function createUserDecisionPlugin(decision, options = {}) { if (decision !== true && decision !== false) return undefined; const decide = ({ facts, journal }) => ({ trusted: decision, - scope: exactCertificateScope(facts), - lifetime: { kind: "connection" }, + scope: + options.target === "authority" + ? authorityForHostScope(facts, options) + : exactCertificateScope(facts), + lifetime: { kind: options.lifetime ?? "connection" }, reasonEntryIds: journal.entries .filter((entry) => entry.kind === "evidence" || entry.kind === "warning") .map((entry) => entry.id), + overriddenErrors: decision ? [...facts.errors] : [], }); return { manifest: pluginManifest( @@ -113,6 +117,27 @@ export function createUserDecisionPlugin(decision) { }; } +function authorityForHostScope(facts, options) { + const authorities = [...facts.constructedChain].filter( + (certificate) => + certificate.isCa && certificate.keyUsages.includes("keyCertSign"), + ); + const authority = options.authorityCertificateSha256 + ? authorities.find( + (certificate) => + certificate.sha256 === options.authorityCertificateSha256, + ) + : authorities.at(-1); + if (!authority) throw new TypeError("No usable CA certificate is present"); + return { + kind: "authority-for-host", + hostname: facts.hostname, + port: "any", + authorityCertificateSha256: authority.sha256, + includeSubdomains: Boolean(options.includeSubdomains), + }; +} + function exactCertificateScope(facts) { return { kind: "certificate-for-host", diff --git a/trustlab/plugins/policy-overlay-plugin.js b/trustlab/plugins/policy-overlay-plugin.js new file mode 100644 index 0000000..f4570af --- /dev/null +++ b/trustlab/plugins/policy-overlay-plugin.js @@ -0,0 +1,61 @@ +export function createPolicyOverlayPlugin(overlay) { + const evidenceByConnection = new Map(); + + return { + manifest: { + manifestVersion: 1, + trustApiVersion: "0.1", + id: "org.browsec.local-policy-overlay", + name: "Browsec local trust policy", + version: "0.1.0", + supportedModes: ["decision-authority"], + capabilities: {}, + }, + hooks: { + collectEvidence({ facts }) { + const rule = overlay.match(facts); + if (!rule) return undefined; + evidenceByConnection.set(facts.connectionId, rule.id); + return { + entries: [ + { + kind: "evidence", + code: "local-policy-rule-matched", + message: `${rule.sourcePluginName} stored a matching ${rule.trusted ? "trusted" : "not-trusted"} rule.`, + data: { ruleId: rule.id, trusted: rule.trusted }, + }, + ], + }; + }, + + onBeforeTlsAccept(context) { + return decideFromOverlay(context); + }, + + async onTlsFailure(context) { + return decideFromOverlay(context); + }, + }, + }; + + function decideFromOverlay({ facts, journal }) { + const expectedRuleId = evidenceByConnection.get(facts.connectionId); + const rule = overlay.take(facts); + evidenceByConnection.delete(facts.connectionId); + if (!rule || rule.id !== expectedRuleId) return undefined; + + return { + trusted: rule.trusted, + scope: rule.scope, + lifetime: rule.lifetime, + overriddenErrors: rule.overriddenErrors, + reasonEntryIds: journal.entries + .filter( + (entry) => + entry.pluginId === "org.browsec.local-policy-overlay" && + entry.data?.ruleId === rule.id, + ) + .map((entry) => entry.id), + }; + } +} diff --git a/trustlab/sdk/plugin-api.ts b/trustlab/sdk/plugin-api.ts index 5a8a63d..60ab580 100644 --- a/trustlab/sdk/plugin-api.ts +++ b/trustlab/sdk/plugin-api.ts @@ -17,8 +17,17 @@ export interface CertificateFacts { readonly dnsNames?: readonly string[]; readonly validFrom?: string; readonly validUntil?: string; + readonly isCa: boolean; + readonly keyUsages: readonly CertificateKeyUsage[]; + readonly selfSigned: boolean; } +export type CertificateKeyUsage = + | "digitalSignature" + | "keyEncipherment" + | "keyCertSign" + | "crlSign"; + export interface TlsFailure { readonly code: string; readonly certificateSha256?: string; @@ -88,7 +97,7 @@ export interface AuthorityForHostScope { readonly kind: "authority-for-host"; readonly hostname: string; readonly port: number | "any"; - readonly authoritySha256: string; + readonly authorityCertificateSha256: string; readonly includeSubdomains: boolean; } @@ -103,6 +112,7 @@ export interface TrustedVerdict { readonly scope: TrustScope; readonly lifetime: TrustLifetime; readonly reasonEntryIds: readonly string[]; + readonly overriddenErrors: readonly string[]; } export interface NotTrustedVerdict { @@ -110,6 +120,7 @@ export interface NotTrustedVerdict { readonly scope: TrustScope; readonly lifetime: TrustLifetime; readonly reasonEntryIds: readonly string[]; + readonly overriddenErrors: readonly []; } export type TrustVerdict = TrustedVerdict | NotTrustedVerdict; @@ -191,4 +202,3 @@ export function exactCertificateScope(facts: TlsFacts): CertificateForHostScope certificateSha256: leaf.sha256, }); } - diff --git a/trustlab/src/builtin-final-handler.js b/trustlab/src/builtin-final-handler.js index 5a7e010..97d43e4 100644 --- a/trustlab/src/builtin-final-handler.js +++ b/trustlab/src/builtin-final-handler.js @@ -28,6 +28,7 @@ export function createBuiltinFinalHandler() { reasonEntryIds: journal.entries .filter((entry) => entry.kind === "warning" || entry.kind === "evidence") .map((entry) => entry.id), + overriddenErrors: [], }; }, }); diff --git a/trustlab/src/index.js b/trustlab/src/index.js index f00e1f4..ac740a4 100644 --- a/trustlab/src/index.js +++ b/trustlab/src/index.js @@ -1,5 +1,5 @@ export { BUILTIN_HANDLER_ID, createBuiltinFinalHandler } from "./builtin-final-handler.js"; export { DecisionJournal } from "./journal.js"; export { createTlsFacts, ENTRY_KINDS, PLUGIN_ROLES } from "./protocol.js"; +export { TrustPolicyOverlay } from "./policy-overlay.js"; export { TrustRunner } from "./runner.js"; - diff --git a/trustlab/src/policy-overlay.js b/trustlab/src/policy-overlay.js new file mode 100644 index 0000000..3bdaaff --- /dev/null +++ b/trustlab/src/policy-overlay.js @@ -0,0 +1,102 @@ +import { immutableClone } from "./immutable.js"; + +export class TrustPolicyOverlay { + #rules = []; + #history = []; + #nextId = 1; + + remember(verdict, source = {}) { + if (typeof verdict?.trusted !== "boolean") { + throw new TypeError("Policy overlay requires a Boolean trust verdict"); + } + const rule = immutableClone({ + id: `rule-${this.#nextId++}`, + trusted: verdict.trusted, + scope: verdict.scope, + lifetime: verdict.lifetime, + overriddenErrors: verdict.overriddenErrors, + createdAt: new Date().toISOString(), + sourcePluginId: source.pluginId ?? "unknown", + sourcePluginName: source.pluginName ?? source.pluginId ?? "Unknown source", + }); + this.#rules.push(rule); + this.#history.push(rule); + return rule; + } + + match(facts, now = new Date()) { + this.#discardExpired(now); + const matching = this.#rules.filter( + (rule) => + scopeMatches(rule.scope, facts) && + (!rule.trusted || + facts.errors.every((error) => rule.overriddenErrors.includes(error))), + ); + const denied = matching.filter((rule) => !rule.trusted); + return denied.at(-1) ?? matching.at(-1); + } + + take(facts, now = new Date()) { + const rule = this.match(facts, now); + if (!rule) return undefined; + if (rule.lifetime.kind === "connection") this.remove(rule.id); + return rule; + } + + remove(ruleId) { + const previousLength = this.#rules.length; + this.#rules = this.#rules.filter((rule) => rule.id !== ruleId); + return this.#rules.length !== previousLength; + } + + clearSession() { + this.#rules = this.#rules.filter( + (rule) => !["connection", "session"].includes(rule.lifetime.kind), + ); + } + + clear() { + this.#rules = []; + } + + snapshot(now = new Date()) { + this.#discardExpired(now); + return immutableClone({ rules: this.#rules, history: this.#history }); + } + + #discardExpired(now) { + this.#rules = this.#rules.filter((rule) => { + if (rule.lifetime.kind !== "until") return true; + return Date.parse(rule.lifetime.expiresAt) > now.getTime(); + }); + } +} + +function scopeMatches(scope, facts) { + if (!hostnameMatches(scope, facts.hostname)) return false; + if (scope.port !== "any" && scope.port !== facts.port) return false; + + if (scope.kind === "certificate-for-host") { + return facts.presentedChain[0]?.sha256 === scope.certificateSha256; + } + + if (scope.kind === "authority-for-host") { + return [...facts.presentedChain, ...facts.constructedChain].some( + (certificate) => + certificate.sha256 === scope.authorityCertificateSha256 && + certificate.isCa && + certificate.keyUsages.includes("keyCertSign"), + ); + } + + return false; +} + +function hostnameMatches(scope, hostname) { + if (hostname === scope.hostname) return true; + return ( + scope.kind === "authority-for-host" && + scope.includeSubdomains && + hostname.endsWith(`.${scope.hostname}`) + ); +} diff --git a/trustlab/src/protocol.js b/trustlab/src/protocol.js index b315fd8..5bd9f38 100644 --- a/trustlab/src/protocol.js +++ b/trustlab/src/protocol.js @@ -21,6 +21,15 @@ export function createTlsFacts(input) { requireString(input?.hostname, "hostname"); requireInteger(input?.port, "port"); requireString(input?.validation, "validation"); + if (!Array.isArray(input.presentedChain) || input.presentedChain.length === 0) { + throw new TypeError("TLS facts must contain a presented leaf certificate"); + } + for (const certificate of [ + ...input.presentedChain, + ...(input.constructedChain ?? []), + ]) { + validateCertificateFacts(certificate); + } return immutableClone({ schemaVersion: 0, @@ -110,15 +119,40 @@ export function validateVerdict(verdict, facts) { if (!Array.isArray(verdict.reasonEntryIds)) { throw new TypeError("Trust verdict reasonEntryIds must be an array"); } + if (!Array.isArray(verdict.overriddenErrors)) { + throw new TypeError("Trust verdict overriddenErrors must be an array"); + } + if (!verdict.trusted && verdict.overriddenErrors.length !== 0) { + throw new TypeError("A not-trusted verdict cannot override TLS errors"); + } + if (verdict.trusted) { + const missing = facts.errors.filter( + (error) => !verdict.overriddenErrors.includes(error), + ); + if (missing.length > 0) { + throw new TypeError(`Trusted verdict does not override errors: ${missing.join(", ")}`); + } + } + if ( + scope.kind === "authority-for-host" && + verdict.overriddenErrors.some((error) => !isAuthorityTrustError(error)) + ) { + throw new TypeError("Authority trust may override trust-anchor errors only"); + } return immutableClone({ trusted: verdict.trusted, scope, lifetime, reasonEntryIds: verdict.reasonEntryIds, + overriddenErrors: verdict.overriddenErrors, }); } +function isAuthorityTrustError(error) { + return ["unknown-issuer", "untrusted-issuer", "self-signed-authority"].includes(error); +} + function validateScope(scope, facts) { if (scope === null || typeof scope !== "object") { throw new TypeError("Trust verdict must contain a scope"); @@ -145,15 +179,39 @@ function validateScope(scope, facts) { throw new TypeError("Authority trust scope must specify includeSubdomains"); } const chain = [...facts.presentedChain, ...facts.constructedChain]; - if (!chain.some((certificate) => certificate.sha256 === scope.authoritySha256)) { + const authority = chain.find( + (certificate) => + certificate.sha256 === scope.authorityCertificateSha256, + ); + if (!authority) { throw new TypeError("Authority trust scope does not match the certificate chain"); } + if (!authority.isCa) { + throw new TypeError("Authority trust scope targets a non-CA certificate"); + } + if (!authority.keyUsages.includes("keyCertSign")) { + throw new TypeError("Authority trust scope targets a certificate without keyCertSign"); + } return scope; } throw new TypeError(`Unsupported trust scope kind: ${scope.kind}`); } +function validateCertificateFacts(certificate) { + requireString(certificate?.subject, "certificate subject"); + requireString(certificate?.sha256, "certificate sha256"); + if (typeof certificate.isCa !== "boolean") { + throw new TypeError("certificate isCa must be Boolean"); + } + if (!Array.isArray(certificate.keyUsages)) { + throw new TypeError("certificate keyUsages must be an array"); + } + if (typeof certificate.selfSigned !== "boolean") { + throw new TypeError("certificate selfSigned must be Boolean"); + } +} + function validateLifetime(lifetime) { if (lifetime === null || typeof lifetime !== "object") { throw new TypeError("Trust verdict must contain a lifetime"); diff --git a/trustlab/test/policy-overlay.test.js b/trustlab/test/policy-overlay.test.js new file mode 100644 index 0000000..af1442a --- /dev/null +++ b/trustlab/test/policy-overlay.test.js @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + expiredLeafCertificate, + unknownLocalAuthority, +} from "../fixtures/tls.js"; +import { createUserDecisionPlugin } from "../plugins/demo-plugins.js"; +import { createPolicyOverlayPlugin } from "../plugins/policy-overlay-plugin.js"; +import { TrustPolicyOverlay, TrustRunner } from "../src/index.js"; + +test("authority decision becomes a scoped overlay rule used on retry", async () => { + const userPlugin = createUserDecisionPlugin(true, { + target: "authority", + lifetime: "session", + }); + const first = await evaluateWith(userPlugin, "decision-authority"); + assert.equal(first.verdict.scope.kind, "authority-for-host"); + assert.equal( + first.verdict.scope.authorityCertificateSha256, + "ca-village-services", + ); + + const overlay = new TrustPolicyOverlay(); + overlay.remember(first.verdict, { + pluginId: userPlugin.manifest.id, + pluginName: userPlugin.manifest.name, + }); + + const retry = await evaluateWith( + createPolicyOverlayPlugin(overlay), + "decision-authority", + ); + assert.equal(retry.verdict.trusted, true); + assert.ok( + retry.journal.entries.some( + (entry) => entry.code === "local-policy-rule-matched", + ), + ); + assert.equal(overlay.snapshot().rules.length, 1); +}); + +test("authority decision binds the explicitly selected CA certificate", async () => { + const unknownAuthorityFacts = structuredClone(expiredLeafCertificate); + unknownAuthorityFacts.connectionId = "connection-selected-unknown-authority"; + unknownAuthorityFacts.errors = ["unknown-issuer"]; + unknownAuthorityFacts.failure = { + code: "unknown-issuer", + certificateSha256: "ca-village-public", + check: "trust-anchor", + summary: "The selected authority is not locally trusted.", + }; + const plugin = createUserDecisionPlugin(true, { + target: "authority", + authorityCertificateSha256: "ca-village-public", + lifetime: "session", + }); + const result = await new TrustRunner({ + plugins: [{ plugin, mode: "decision-authority" }], + }).evaluate(unknownAuthorityFacts); + + assert.equal(result.verdict.scope.kind, "authority-for-host"); + assert.equal( + result.verdict.scope.authorityCertificateSha256, + "ca-village-public", + ); +}); + +test("authority trust cannot override an expired leaf certificate", async () => { + const plugin = createUserDecisionPlugin(true, { + target: "authority", + authorityCertificateSha256: "ca-village-public", + lifetime: "session", + }); + const result = await new TrustRunner({ + plugins: [{ plugin, mode: "decision-authority" }], + }).evaluate(expiredLeafCertificate); + + assert.equal(result.verdict.trusted, false); + assert.match( + result.journal.entries.at(-1).message, + /trust-anchor errors only/, + ); +}); + +test("connection-lifetime rule is consumed by exactly one retry", async () => { + const userPlugin = createUserDecisionPlugin(true, { + target: "certificate", + lifetime: "connection", + }); + const first = await evaluateWith(userPlugin, "decision-authority"); + const overlay = new TrustPolicyOverlay(); + overlay.remember(first.verdict, { pluginId: userPlugin.manifest.id }); + const policyPlugin = createPolicyOverlayPlugin(overlay); + + const accepted = await evaluateWith(policyPlugin, "decision-authority"); + const rejected = await evaluateWith(policyPlugin, "decision-authority"); + + assert.equal(accepted.verdict.trusted, true); + assert.equal(rejected.verdict.trusted, false); + assert.equal(overlay.snapshot().rules.length, 0); + assert.equal(overlay.snapshot().history.length, 1); +}); + +test("local distrust takes precedence over a matching trust rule", () => { + const overlay = new TrustPolicyOverlay(); + const scope = { + kind: "certificate-for-host", + hostname: unknownLocalAuthority.hostname, + port: unknownLocalAuthority.port, + certificateSha256: unknownLocalAuthority.presentedChain[0].sha256, + }; + overlay.remember({ + trusted: true, + scope, + lifetime: { kind: "session" }, + reasonEntryIds: [], + overriddenErrors: [...unknownLocalAuthority.errors], + }); + overlay.remember({ + trusted: false, + scope, + lifetime: { kind: "session" }, + reasonEntryIds: [], + overriddenErrors: [], + }); + + assert.equal(overlay.match(unknownLocalAuthority).trusted, false); +}); + +test("authority scope cannot target the leaf certificate", async () => { + const maliciousPlugin = { + manifest: manifest("test.non-ca-authority", ["decision-authority"]), + hooks: { + async onTlsFailure({ facts }) { + return { + trusted: true, + scope: { + kind: "authority-for-host", + hostname: facts.hostname, + port: "any", + authorityCertificateSha256: facts.presentedChain[0].sha256, + includeSubdomains: false, + }, + lifetime: { kind: "session" }, + reasonEntryIds: [], + overriddenErrors: [...facts.errors], + }; + }, + }, + }; + + const result = await evaluateWith(maliciousPlugin, "decision-authority"); + assert.equal(result.verdict.trusted, false); + assert.match(result.journal.entries.at(-1).message, /non-CA certificate/); +}); + +test("authority scope requires keyCertSign usage", async () => { + const brokenAuthorityFacts = structuredClone(unknownLocalAuthority); + for (const chain of [ + brokenAuthorityFacts.presentedChain, + brokenAuthorityFacts.constructedChain, + ]) { + const authority = chain.find( + (certificate) => certificate.sha256 === "ca-village-services", + ); + authority.keyUsages = ["crlSign"]; + } + const plugin = { + manifest: manifest("test.non-signing-authority", ["decision-authority"]), + hooks: { + async onTlsFailure({ facts }) { + return { + trusted: true, + scope: { + kind: "authority-for-host", + hostname: facts.hostname, + port: "any", + authorityCertificateSha256: "ca-village-services", + includeSubdomains: false, + }, + lifetime: { kind: "session" }, + reasonEntryIds: [], + overriddenErrors: [...facts.errors], + }; + }, + }, + }; + const result = await new TrustRunner({ + plugins: [{ plugin, mode: "decision-authority" }], + }).evaluate(brokenAuthorityFacts); + + assert.equal(result.verdict.trusted, false); + assert.match(result.journal.entries.at(-1).message, /without keyCertSign/); +}); + +function evaluateWith(plugin, mode) { + return new TrustRunner({ plugins: [{ plugin, mode }] }).evaluate( + unknownLocalAuthority, + ); +} + +function manifest(id, supportedModes) { + return { + manifestVersion: 1, + trustApiVersion: "0.1", + id, + name: id, + version: "0.0.0", + supportedModes, + capabilities: {}, + }; +} diff --git a/trustlab/test/runner.test.js b/trustlab/test/runner.test.js index f83a94f..dee329b 100644 --- a/trustlab/test/runner.test.js +++ b/trustlab/test/runner.test.js @@ -156,11 +156,12 @@ test("authority scope must identify a certificate in the active chain", async () kind: "authority-for-host", hostname: facts.hostname, port: facts.port, - authoritySha256: "not-in-this-chain", + authorityCertificateSha256: "not-in-this-chain", includeSubdomains: false, }, lifetime: { kind: "session" }, reasonEntryIds: [], + overriddenErrors: [...facts.errors], }; }, }); @@ -236,6 +237,7 @@ function verdictFor(facts, trusted) { scope: exactScope(facts), lifetime: { kind: "connection" }, reasonEntryIds: [], + overriddenErrors: trusted ? [...facts.errors] : [], }; } diff --git a/trustlab/test/ui.test.js b/trustlab/test/ui.test.js index 2286fe9..3fac645 100644 --- a/trustlab/test/ui.test.js +++ b/trustlab/test/ui.test.js @@ -96,4 +96,6 @@ test("security surface contains immutable-frame and simulation labels", async () assert.match(html, /Browser-owned test surface/); assert.match(html, /TRUSTLAB · SYNTHETIC/); assert.match(html, /It cannot alter browser trust/); + assert.match(html, /Decision target/); + assert.match(html, /Effective and consumed local rules/); }); diff --git a/trustlab/ui/app.js b/trustlab/ui/app.js index b6511b6..df2d0aa 100644 --- a/trustlab/ui/app.js +++ b/trustlab/ui/app.js @@ -12,8 +12,9 @@ import { createUserDecisionPlugin, createVillageCommunityPlugin, } from "../plugins/demo-plugins.js"; -import { TrustRunner } from "../src/index.js"; -import { chainRows, verdictCopy } from "./model.js"; +import { createPolicyOverlayPlugin } from "../plugins/policy-overlay-plugin.js"; +import { TrustPolicyOverlay, TrustRunner } from "../src/index.js"; +import { chainRows, subjectName, verdictCopy } from "./model.js"; const scenarios = { "unknown-local": { @@ -58,17 +59,23 @@ const scenarios = { const state = { scenario: "unknown-local", - userDecision: undefined, + pendingDecision: undefined, communityEnabled: true, }; +const policyOverlay = new TrustPolicyOverlay(); + const elements = { scenario: document.querySelector("#scenario"), community: document.querySelector("#community-enabled"), + target: document.querySelector("#trust-target"), + lifetime: document.querySelector("#trust-lifetime"), + includeSubdomains: document.querySelector("#include-subdomains"), status: document.querySelector("#status"), identity: document.querySelector("#identity"), chain: document.querySelector("#chain"), journal: document.querySelector("#journal"), + rules: document.querySelector("#rules"), trust: document.querySelector("#trust"), reject: document.querySelector("#reject"), clear: document.querySelector("#clear-decision"), @@ -83,49 +90,92 @@ for (const [value, scenario] of Object.entries(scenarios)) { elements.scenario.addEventListener("change", () => { state.scenario = elements.scenario.value; - state.userDecision = undefined; + state.pendingDecision = undefined; + renderTrustTargets(scenarios[state.scenario].facts); render(); }); +elements.target.addEventListener("change", () => { + elements.includeSubdomains.disabled = !elements.target.value.startsWith("authority:"); + if (elements.includeSubdomains.disabled) elements.includeSubdomains.checked = false; +}); elements.community.addEventListener("change", () => { state.communityEnabled = elements.community.checked; render(); }); elements.trust.addEventListener("click", () => { - state.userDecision = true; + state.pendingDecision = true; render(); }); elements.reject.addEventListener("click", () => { - state.userDecision = false; + state.pendingDecision = false; render(); }); elements.clear.addEventListener("click", () => { - state.userDecision = undefined; + state.pendingDecision = undefined; + policyOverlay.clear(); render(); }); async function render() { const facts = scenarios[state.scenario].facts; - const plugins = [configure(createFirefoxValidationPlugin(), "advisor")]; + const evidencePlugins = [configure(createFirefoxValidationPlugin(), "advisor")]; if (state.communityEnabled) { - plugins.push( + evidencePlugins.push( ...(scenarios[state.scenario].plugins ?? [ configure(createVillageCommunityPlugin(), "advisor"), ]), ); } - const userPlugin = createUserDecisionPlugin(state.userDecision); - if (userPlugin) plugins.push(configure(userPlugin, "decision-authority")); + let decisionApplied = false; + if (state.pendingDecision !== undefined) { + policyOverlay.clear(); + const userPlugin = createUserDecisionPlugin(state.pendingDecision, { + target: elements.target.value.startsWith("authority:") + ? "authority" + : "certificate", + authorityCertificateSha256: elements.target.value.startsWith("authority:") + ? elements.target.value.slice("authority:".length) + : undefined, + lifetime: elements.lifetime.value, + includeSubdomains: elements.includeSubdomains.checked, + }); + const decisionResult = await new TrustRunner({ + plugins: [ + ...evidencePlugins, + configure(userPlugin, "decision-authority"), + ], + }).evaluate(facts); + policyOverlay.remember(decisionResult.verdict, { + pluginId: userPlugin.manifest.id, + pluginName: userPlugin.manifest.name, + }); + state.pendingDecision = undefined; + decisionApplied = true; + } + + const plugins = [...evidencePlugins]; + if (policyOverlay.match(facts)) { + plugins.push( + configure(createPolicyOverlayPlugin(policyOverlay), "decision-authority"), + ); + } const result = await new TrustRunner({ plugins }).evaluate(facts); - renderStatus(result); + const decidedByOverlay = result.journal.entries.some( + (entry) => + entry.pluginId === "org.browsec.local-policy-overlay" && + entry.kind === "resolution", + ); + renderStatus(result, decisionApplied || decidedByOverlay); renderIdentity(result); renderChain(result); renderJournal(result); - elements.clear.hidden = state.userDecision === undefined; + renderRules(policyOverlay.snapshot()); + elements.clear.hidden = policyOverlay.snapshot().rules.length === 0; } -function renderStatus(result) { - const copy = verdictCopy(result, state.userDecision !== undefined); +function renderStatus(result, hasLocalDecision) { + const copy = verdictCopy(result, hasLocalDecision); elements.status.dataset.state = result.verdict.trusted ? "trusted" : "not-trusted"; elements.status.replaceChildren( node("p", copy.eyebrow, "eyebrow"), @@ -134,6 +184,47 @@ function renderStatus(result) { ); } +function renderRules(snapshot) { + if (snapshot.history.length === 0) { + elements.rules.replaceChildren(node("p", "No local trust rules.", "empty")); + return; + } + const activeIds = new Set(snapshot.rules.map((rule) => rule.id)); + elements.rules.replaceChildren( + ...snapshot.history.map((rule) => { + const article = document.createElement("article"); + article.className = "journal-entry kind-resolution"; + const status = activeIds.has(rule.id) ? "active" : "inactive"; + article.append( + node("span", status, "entry-kind"), + node("h3", describeScope(rule.scope)), + node( + "p", + `${rule.trusted ? "Trusted" : "Not trusted"} · ${describeLifetime(rule.lifetime)} · ${rule.sourcePluginName}`, + ), + node("code", rule.id), + ); + return article; + }), + ); +} + +function describeScope(scope) { + return scope.kind === "certificate-for-host" + ? `Exact certificate ${shortFingerprint(scope.certificateSha256)} for ${scope.hostname}:${scope.port}` + : `${scope.includeSubdomains ? "Authority for namespace" : "Authority for host"} ${scope.hostname} · ${shortFingerprint(scope.authorityCertificateSha256)}`; +} + +function describeLifetime(lifetime) { + const labels = { + connection: "next connection", + session: "browser session", + persistent: "until revoked", + until: `until ${lifetime.expiresAt}`, + }; + return labels[lifetime.kind]; +} + function renderIdentity(result) { elements.identity.replaceChildren( definition("Requested host", result.facts.hostname), @@ -203,4 +294,36 @@ function configure(plugin, mode) { return { plugin, mode }; } +function renderTrustTargets(facts) { + const current = elements.target.value; + const options = [new Option("Exact certificate for this host", "certificate")]; + const seen = new Set(); + for (const certificate of facts.constructedChain) { + if ( + !certificate.isCa || + !certificate.keyUsages.includes("keyCertSign") || + seen.has(certificate.sha256) + ) { + continue; + } + seen.add(certificate.sha256); + options.push( + new Option( + `Authority: ${subjectName(certificate.subject)} · ${shortFingerprint(certificate.sha256)}`, + `authority:${certificate.sha256}`, + ), + ); + } + elements.target.replaceChildren(...options); + if (options.some((option) => option.value === current)) elements.target.value = current; + elements.includeSubdomains.disabled = !elements.target.value.startsWith("authority:"); +} + +function shortFingerprint(fingerprint) { + return fingerprint.length > 18 + ? `${fingerprint.slice(0, 8)}…${fingerprint.slice(-8)}` + : fingerprint; +} + +renderTrustTargets(scenarios[state.scenario].facts); render(); diff --git a/trustlab/ui/index.html b/trustlab/ui/index.html index 4273230..449294b 100644 --- a/trustlab/ui/index.html +++ b/trustlab/ui/index.html @@ -28,6 +28,23 @@ Village community evidence + + + + + + + +

This page evaluates fixtures only. It cannot alter browser trust.

@@ -59,6 +76,14 @@
+ +
+
+

Policy overlay

+

Effective and consumed local rules

+
+
+
@@ -75,4 +100,3 @@ -