Implement scoped authority trust overlay

This commit is contained in:
Sergey Chernov 2026-08-16 19:42:53 +04:00
parent 1eeb8bac05
commit d479c459fc
17 changed files with 712 additions and 65 deletions

View File

@ -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.

View File

@ -41,6 +41,7 @@ const villagePlugin = {
reasonEntryIds: journal.entries
.filter((entry) => entry.code === "known-community-key")
.map((entry) => entry.id),
overriddenErrors: [...facts.errors],
};
},
},

View File

@ -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: [] };
},
},
});

View File

@ -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;

View File

@ -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,
};
}

View File

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

View File

@ -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),
};
}
}

View File

@ -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,
});
}

View File

@ -28,6 +28,7 @@ export function createBuiltinFinalHandler() {
reasonEntryIds: journal.entries
.filter((entry) => entry.kind === "warning" || entry.kind === "evidence")
.map((entry) => entry.id),
overriddenErrors: [],
};
},
});

View File

@ -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";

View File

@ -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}`)
);
}

View File

@ -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");

View File

@ -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: {},
};
}

View File

@ -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] : [],
};
}

View File

@ -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/);
});

View File

@ -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();

View File

@ -28,6 +28,23 @@
<span>Village community evidence</span>
</label>
<label for="trust-target">Decision target</label>
<select id="trust-target">
<option value="certificate">Exact certificate for this host</option>
</select>
<label for="trust-lifetime">Decision lifetime</label>
<select id="trust-lifetime">
<option value="connection">Next connection</option>
<option value="session" selected>Browser session</option>
<option value="persistent">Until revoked</option>
</select>
<label class="toggle">
<input id="include-subdomains" type="checkbox">
<span>Include subdomains for authority rules</span>
</label>
<p class="simulation-note">
This page evaluates fixtures only. It cannot alter browser trust.
</p>
@ -59,6 +76,14 @@
</div>
<div id="journal" class="journal"></div>
</section>
<section class="panel">
<div class="section-heading">
<p class="eyebrow">Policy overlay</p>
<h2>Effective and consumed local rules</h2>
</div>
<div id="rules" class="journal"></div>
</section>
</section>
</main>
@ -75,4 +100,3 @@
<script type="module" src="/ui/app.js"></script>
</body>
</html>