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
@@ -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: {},
|
||||
};
|
||||
}
|
||||
@@ -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] : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user