Implement scoped authority trust overlay
This commit is contained in:
1 parent
1eeb8bac05
commit
d479c459fc
17 files changed
+712
-65
No files matched your search
@@ -28,6 +28,7 @@ export function createBuiltinFinalHandler() {
|
||||
reasonEntryIds: journal.entries
|
||||
.filter((entry) => entry.kind === "warning" || entry.kind === "evidence")
|
||||
.map((entry) => entry.id),
|
||||
overriddenErrors: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}`)
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user