Implement scoped authority trust overlay

This commit is contained in:
sergeych committed 2026-08-16 19:42:53 +04:00
1 parent 1eeb8bac05
commit d479c459fc
17 files changed
+712 -65

No files matched your search

+1
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: [],
};
},
});
+1 -1
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";
+102
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}`)
);
}
+59 -1
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");