Implement scoped authority trust overlay
This commit is contained in:
parent
1eeb8bac05
commit
d479c459fc
@ -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.
|
authority scope, an explicit lifetime, and the journal entries supporting it.
|
||||||
See [`examples/strict-plugin.ts`](examples/strict-plugin.ts) for a compiler-checked
|
See [`examples/strict-plugin.ts`](examples/strict-plugin.ts) for a compiler-checked
|
||||||
registration example.
|
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.
|
||||||
|
|||||||
@ -41,6 +41,7 @@ const villagePlugin = {
|
|||||||
reasonEntryIds: journal.entries
|
reasonEntryIds: journal.entries
|
||||||
.filter((entry) => entry.code === "known-community-key")
|
.filter((entry) => entry.code === "known-community-key")
|
||||||
.map((entry) => entry.id),
|
.map((entry) => entry.id),
|
||||||
|
overriddenErrors: [...facts.errors],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -37,13 +37,14 @@ export default defineTrustPlugin({
|
|||||||
});
|
});
|
||||||
if (trusted === undefined) return undefined;
|
if (trusted === undefined) return undefined;
|
||||||
|
|
||||||
return {
|
const common = {
|
||||||
trusted,
|
|
||||||
scope: exactCertificateScope(facts),
|
scope: exactCertificateScope(facts),
|
||||||
lifetime: { kind: "session" },
|
lifetime: { kind: "session" } as const,
|
||||||
reasonEntryIds: journal.entries.map((entry) => entry.id),
|
reasonEntryIds: journal.entries.map((entry) => entry.id),
|
||||||
};
|
};
|
||||||
|
return trusted
|
||||||
|
? { ...common, trusted: true, overriddenErrors: [...facts.errors] }
|
||||||
|
: { ...common, trusted: false, overriddenErrors: [] };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const verdictWithoutLifetime: TrustVerdict = {
|
|||||||
certificateSha256: "leaf-sha256",
|
certificateSha256: "leaf-sha256",
|
||||||
},
|
},
|
||||||
reasonEntryIds: [],
|
reasonEntryIds: [],
|
||||||
|
overriddenErrors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const voteWithoutBoolean: PluginContribution = {
|
const voteWithoutBoolean: PluginContribution = {
|
||||||
@ -40,4 +41,3 @@ const voteWithoutBoolean: PluginContribution = {
|
|||||||
void asynchronousFastHook;
|
void asynchronousFastHook;
|
||||||
void verdictWithoutLifetime;
|
void verdictWithoutLifetime;
|
||||||
void voteWithoutBoolean;
|
void voteWithoutBoolean;
|
||||||
|
|
||||||
|
|||||||
@ -5,13 +5,13 @@ export const validPublicCertificate = {
|
|||||||
validation: "success",
|
validation: "success",
|
||||||
errors: [],
|
errors: [],
|
||||||
presentedChain: [
|
presentedChain: [
|
||||||
{ subject: "CN=example.test", sha256: "leaf-valid-public" },
|
leaf("CN=example.test", "leaf-valid-public"),
|
||||||
{ subject: "CN=Example Intermediate", sha256: "intermediate-public" },
|
ca("CN=Example Intermediate", "intermediate-public"),
|
||||||
],
|
],
|
||||||
constructedChain: [
|
constructedChain: [
|
||||||
{ subject: "CN=example.test", sha256: "leaf-valid-public" },
|
leaf("CN=example.test", "leaf-valid-public"),
|
||||||
{ subject: "CN=Example Intermediate", sha256: "intermediate-public" },
|
ca("CN=Example Intermediate", "intermediate-public"),
|
||||||
{ subject: "CN=Example Root", sha256: "root-public" },
|
ca("CN=Example Root", "root-public", { selfSigned: true }),
|
||||||
],
|
],
|
||||||
tls: { version: "TLSv1.3", alpn: "h2" },
|
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.",
|
summary: "The candidate authority is not trusted by the current Firefox policy.",
|
||||||
},
|
},
|
||||||
presentedChain: [
|
presentedChain: [
|
||||||
{ subject: "CN=library.village", sha256: "leaf-village-library" },
|
leaf("CN=library.village", "leaf-village-library"),
|
||||||
{ subject: "CN=Village Services CA", sha256: "ca-village-services" },
|
ca("CN=Village Services CA", "ca-village-services", { selfSigned: true }),
|
||||||
],
|
],
|
||||||
constructedChain: [
|
constructedChain: [
|
||||||
{ subject: "CN=library.village", sha256: "leaf-village-library" },
|
leaf("CN=library.village", "leaf-village-library"),
|
||||||
{ subject: "CN=Village Services CA", sha256: "ca-village-services" },
|
ca("CN=Village Services CA", "ca-village-services", { selfSigned: true }),
|
||||||
],
|
],
|
||||||
tls: { version: "TLSv1.3", alpn: "h2" },
|
tls: { version: "TLSv1.3", alpn: "h2" },
|
||||||
};
|
};
|
||||||
@ -52,23 +52,19 @@ export const expiredLeafCertificate = {
|
|||||||
summary: "The server certificate expired 46 days ago.",
|
summary: "The server certificate expired 46 days ago.",
|
||||||
},
|
},
|
||||||
presentedChain: [
|
presentedChain: [
|
||||||
{
|
leaf("CN=archive.village", "leaf-expired-archive", {
|
||||||
subject: "CN=archive.village",
|
|
||||||
sha256: "leaf-expired-archive",
|
|
||||||
validFrom: "2025-06-01T00:00:00Z",
|
validFrom: "2025-06-01T00:00:00Z",
|
||||||
validUntil: "2026-07-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: [
|
constructedChain: [
|
||||||
{
|
leaf("CN=archive.village", "leaf-expired-archive", {
|
||||||
subject: "CN=archive.village",
|
|
||||||
sha256: "leaf-expired-archive",
|
|
||||||
validFrom: "2025-06-01T00:00:00Z",
|
validFrom: "2025-06-01T00:00:00Z",
|
||||||
validUntil: "2026-07-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"),
|
||||||
{ subject: "CN=Regional Root CA", sha256: "root-regional" },
|
ca("CN=Regional Root CA", "root-regional", { selfSigned: true }),
|
||||||
],
|
],
|
||||||
tls: { version: "TLSv1.3", alpn: "h2" },
|
tls: { version: "TLSv1.3", alpn: "h2" },
|
||||||
};
|
};
|
||||||
@ -86,21 +82,17 @@ export const hostnameMismatch = {
|
|||||||
summary: "The certificate identifies files.village, not records.village.",
|
summary: "The certificate identifies files.village, not records.village.",
|
||||||
},
|
},
|
||||||
presentedChain: [
|
presentedChain: [
|
||||||
{
|
leaf("CN=files.village", "leaf-wrong-host", {
|
||||||
subject: "CN=files.village",
|
|
||||||
sha256: "leaf-wrong-host",
|
|
||||||
dnsNames: ["files.village"],
|
dnsNames: ["files.village"],
|
||||||
},
|
}),
|
||||||
{ subject: "CN=Village Public Services CA", sha256: "ca-village-public" },
|
ca("CN=Village Public Services CA", "ca-village-public"),
|
||||||
],
|
],
|
||||||
constructedChain: [
|
constructedChain: [
|
||||||
{
|
leaf("CN=files.village", "leaf-wrong-host", {
|
||||||
subject: "CN=files.village",
|
|
||||||
sha256: "leaf-wrong-host",
|
|
||||||
dnsNames: ["files.village"],
|
dnsNames: ["files.village"],
|
||||||
},
|
}),
|
||||||
{ subject: "CN=Village Public Services CA", sha256: "ca-village-public" },
|
ca("CN=Village Public Services CA", "ca-village-public"),
|
||||||
{ subject: "CN=Regional Root CA", sha256: "root-regional" },
|
ca("CN=Regional Root CA", "root-regional", { selfSigned: true }),
|
||||||
],
|
],
|
||||||
tls: { version: "TLSv1.3", alpn: "h2" },
|
tls: { version: "TLSv1.3", alpn: "h2" },
|
||||||
};
|
};
|
||||||
@ -118,13 +110,15 @@ export const explicitlyDistrustedAuthority = {
|
|||||||
summary: "A local Browsec rule explicitly distrusts this root authority.",
|
summary: "A local Browsec rule explicitly distrusts this root authority.",
|
||||||
},
|
},
|
||||||
presentedChain: [
|
presentedChain: [
|
||||||
{ subject: "CN=registry.example", sha256: "leaf-registry" },
|
leaf("CN=registry.example", "leaf-registry"),
|
||||||
{ subject: "CN=Commercial Issuing CA", sha256: "ca-commercial-issuing" },
|
ca("CN=Commercial Issuing CA", "ca-commercial-issuing"),
|
||||||
],
|
],
|
||||||
constructedChain: [
|
constructedChain: [
|
||||||
{ subject: "CN=registry.example", sha256: "leaf-registry" },
|
leaf("CN=registry.example", "leaf-registry"),
|
||||||
{ subject: "CN=Commercial Issuing CA", sha256: "ca-commercial-issuing" },
|
ca("CN=Commercial Issuing CA", "ca-commercial-issuing"),
|
||||||
{ subject: "CN=Globally Trusted but Locally Rejected Root", sha256: "root-distrusted" },
|
ca("CN=Globally Trusted but Locally Rejected Root", "root-distrusted", {
|
||||||
|
selfSigned: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
tls: { version: "TLSv1.3", alpn: "h2" },
|
tls: { version: "TLSv1.3", alpn: "h2" },
|
||||||
};
|
};
|
||||||
@ -133,3 +127,25 @@ export const conflictingCommunityAdvice = {
|
|||||||
...unknownLocalAuthority,
|
...unknownLocalAuthority,
|
||||||
connectionId: "connection-conflicting-community-advice",
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -88,15 +88,19 @@ export function createCommunityAdvicePlugin({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUserDecisionPlugin(decision) {
|
export function createUserDecisionPlugin(decision, options = {}) {
|
||||||
if (decision !== true && decision !== false) return undefined;
|
if (decision !== true && decision !== false) return undefined;
|
||||||
const decide = ({ facts, journal }) => ({
|
const decide = ({ facts, journal }) => ({
|
||||||
trusted: decision,
|
trusted: decision,
|
||||||
scope: exactCertificateScope(facts),
|
scope:
|
||||||
lifetime: { kind: "connection" },
|
options.target === "authority"
|
||||||
|
? authorityForHostScope(facts, options)
|
||||||
|
: exactCertificateScope(facts),
|
||||||
|
lifetime: { kind: options.lifetime ?? "connection" },
|
||||||
reasonEntryIds: journal.entries
|
reasonEntryIds: journal.entries
|
||||||
.filter((entry) => entry.kind === "evidence" || entry.kind === "warning")
|
.filter((entry) => entry.kind === "evidence" || entry.kind === "warning")
|
||||||
.map((entry) => entry.id),
|
.map((entry) => entry.id),
|
||||||
|
overriddenErrors: decision ? [...facts.errors] : [],
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
manifest: pluginManifest(
|
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) {
|
function exactCertificateScope(facts) {
|
||||||
return {
|
return {
|
||||||
kind: "certificate-for-host",
|
kind: "certificate-for-host",
|
||||||
|
|||||||
61
trustlab/plugins/policy-overlay-plugin.js
Normal file
61
trustlab/plugins/policy-overlay-plugin.js
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,8 +17,17 @@ export interface CertificateFacts {
|
|||||||
readonly dnsNames?: readonly string[];
|
readonly dnsNames?: readonly string[];
|
||||||
readonly validFrom?: string;
|
readonly validFrom?: string;
|
||||||
readonly validUntil?: string;
|
readonly validUntil?: string;
|
||||||
|
readonly isCa: boolean;
|
||||||
|
readonly keyUsages: readonly CertificateKeyUsage[];
|
||||||
|
readonly selfSigned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CertificateKeyUsage =
|
||||||
|
| "digitalSignature"
|
||||||
|
| "keyEncipherment"
|
||||||
|
| "keyCertSign"
|
||||||
|
| "crlSign";
|
||||||
|
|
||||||
export interface TlsFailure {
|
export interface TlsFailure {
|
||||||
readonly code: string;
|
readonly code: string;
|
||||||
readonly certificateSha256?: string;
|
readonly certificateSha256?: string;
|
||||||
@ -88,7 +97,7 @@ export interface AuthorityForHostScope {
|
|||||||
readonly kind: "authority-for-host";
|
readonly kind: "authority-for-host";
|
||||||
readonly hostname: string;
|
readonly hostname: string;
|
||||||
readonly port: number | "any";
|
readonly port: number | "any";
|
||||||
readonly authoritySha256: string;
|
readonly authorityCertificateSha256: string;
|
||||||
readonly includeSubdomains: boolean;
|
readonly includeSubdomains: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,6 +112,7 @@ export interface TrustedVerdict {
|
|||||||
readonly scope: TrustScope;
|
readonly scope: TrustScope;
|
||||||
readonly lifetime: TrustLifetime;
|
readonly lifetime: TrustLifetime;
|
||||||
readonly reasonEntryIds: readonly string[];
|
readonly reasonEntryIds: readonly string[];
|
||||||
|
readonly overriddenErrors: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotTrustedVerdict {
|
export interface NotTrustedVerdict {
|
||||||
@ -110,6 +120,7 @@ export interface NotTrustedVerdict {
|
|||||||
readonly scope: TrustScope;
|
readonly scope: TrustScope;
|
||||||
readonly lifetime: TrustLifetime;
|
readonly lifetime: TrustLifetime;
|
||||||
readonly reasonEntryIds: readonly string[];
|
readonly reasonEntryIds: readonly string[];
|
||||||
|
readonly overriddenErrors: readonly [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TrustVerdict = TrustedVerdict | NotTrustedVerdict;
|
export type TrustVerdict = TrustedVerdict | NotTrustedVerdict;
|
||||||
@ -191,4 +202,3 @@ export function exactCertificateScope(facts: TlsFacts): CertificateForHostScope
|
|||||||
certificateSha256: leaf.sha256,
|
certificateSha256: leaf.sha256,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export function createBuiltinFinalHandler() {
|
|||||||
reasonEntryIds: journal.entries
|
reasonEntryIds: journal.entries
|
||||||
.filter((entry) => entry.kind === "warning" || entry.kind === "evidence")
|
.filter((entry) => entry.kind === "warning" || entry.kind === "evidence")
|
||||||
.map((entry) => entry.id),
|
.map((entry) => entry.id),
|
||||||
|
overriddenErrors: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export { BUILTIN_HANDLER_ID, createBuiltinFinalHandler } from "./builtin-final-handler.js";
|
export { BUILTIN_HANDLER_ID, createBuiltinFinalHandler } from "./builtin-final-handler.js";
|
||||||
export { DecisionJournal } from "./journal.js";
|
export { DecisionJournal } from "./journal.js";
|
||||||
export { createTlsFacts, ENTRY_KINDS, PLUGIN_ROLES } from "./protocol.js";
|
export { createTlsFacts, ENTRY_KINDS, PLUGIN_ROLES } from "./protocol.js";
|
||||||
|
export { TrustPolicyOverlay } from "./policy-overlay.js";
|
||||||
export { TrustRunner } from "./runner.js";
|
export { TrustRunner } from "./runner.js";
|
||||||
|
|
||||||
|
|||||||
102
trustlab/src/policy-overlay.js
Normal file
102
trustlab/src/policy-overlay.js
Normal 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}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -21,6 +21,15 @@ export function createTlsFacts(input) {
|
|||||||
requireString(input?.hostname, "hostname");
|
requireString(input?.hostname, "hostname");
|
||||||
requireInteger(input?.port, "port");
|
requireInteger(input?.port, "port");
|
||||||
requireString(input?.validation, "validation");
|
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({
|
return immutableClone({
|
||||||
schemaVersion: 0,
|
schemaVersion: 0,
|
||||||
@ -110,15 +119,40 @@ export function validateVerdict(verdict, facts) {
|
|||||||
if (!Array.isArray(verdict.reasonEntryIds)) {
|
if (!Array.isArray(verdict.reasonEntryIds)) {
|
||||||
throw new TypeError("Trust verdict reasonEntryIds must be an array");
|
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({
|
return immutableClone({
|
||||||
trusted: verdict.trusted,
|
trusted: verdict.trusted,
|
||||||
scope,
|
scope,
|
||||||
lifetime,
|
lifetime,
|
||||||
reasonEntryIds: verdict.reasonEntryIds,
|
reasonEntryIds: verdict.reasonEntryIds,
|
||||||
|
overriddenErrors: verdict.overriddenErrors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAuthorityTrustError(error) {
|
||||||
|
return ["unknown-issuer", "untrusted-issuer", "self-signed-authority"].includes(error);
|
||||||
|
}
|
||||||
|
|
||||||
function validateScope(scope, facts) {
|
function validateScope(scope, facts) {
|
||||||
if (scope === null || typeof scope !== "object") {
|
if (scope === null || typeof scope !== "object") {
|
||||||
throw new TypeError("Trust verdict must contain a scope");
|
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");
|
throw new TypeError("Authority trust scope must specify includeSubdomains");
|
||||||
}
|
}
|
||||||
const chain = [...facts.presentedChain, ...facts.constructedChain];
|
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");
|
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;
|
return scope;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new TypeError(`Unsupported trust scope kind: ${scope.kind}`);
|
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) {
|
function validateLifetime(lifetime) {
|
||||||
if (lifetime === null || typeof lifetime !== "object") {
|
if (lifetime === null || typeof lifetime !== "object") {
|
||||||
throw new TypeError("Trust verdict must contain a lifetime");
|
throw new TypeError("Trust verdict must contain a lifetime");
|
||||||
|
|||||||
213
trustlab/test/policy-overlay.test.js
Normal file
213
trustlab/test/policy-overlay.test.js
Normal 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: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -156,11 +156,12 @@ test("authority scope must identify a certificate in the active chain", async ()
|
|||||||
kind: "authority-for-host",
|
kind: "authority-for-host",
|
||||||
hostname: facts.hostname,
|
hostname: facts.hostname,
|
||||||
port: facts.port,
|
port: facts.port,
|
||||||
authoritySha256: "not-in-this-chain",
|
authorityCertificateSha256: "not-in-this-chain",
|
||||||
includeSubdomains: false,
|
includeSubdomains: false,
|
||||||
},
|
},
|
||||||
lifetime: { kind: "session" },
|
lifetime: { kind: "session" },
|
||||||
reasonEntryIds: [],
|
reasonEntryIds: [],
|
||||||
|
overriddenErrors: [...facts.errors],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -236,6 +237,7 @@ function verdictFor(facts, trusted) {
|
|||||||
scope: exactScope(facts),
|
scope: exactScope(facts),
|
||||||
lifetime: { kind: "connection" },
|
lifetime: { kind: "connection" },
|
||||||
reasonEntryIds: [],
|
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, /Browser-owned test surface/);
|
||||||
assert.match(html, /TRUSTLAB · SYNTHETIC/);
|
assert.match(html, /TRUSTLAB · SYNTHETIC/);
|
||||||
assert.match(html, /It cannot alter browser trust/);
|
assert.match(html, /It cannot alter browser trust/);
|
||||||
|
assert.match(html, /Decision target/);
|
||||||
|
assert.match(html, /Effective and consumed local rules/);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,8 +12,9 @@ import {
|
|||||||
createUserDecisionPlugin,
|
createUserDecisionPlugin,
|
||||||
createVillageCommunityPlugin,
|
createVillageCommunityPlugin,
|
||||||
} from "../plugins/demo-plugins.js";
|
} from "../plugins/demo-plugins.js";
|
||||||
import { TrustRunner } from "../src/index.js";
|
import { createPolicyOverlayPlugin } from "../plugins/policy-overlay-plugin.js";
|
||||||
import { chainRows, verdictCopy } from "./model.js";
|
import { TrustPolicyOverlay, TrustRunner } from "../src/index.js";
|
||||||
|
import { chainRows, subjectName, verdictCopy } from "./model.js";
|
||||||
|
|
||||||
const scenarios = {
|
const scenarios = {
|
||||||
"unknown-local": {
|
"unknown-local": {
|
||||||
@ -58,17 +59,23 @@ const scenarios = {
|
|||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
scenario: "unknown-local",
|
scenario: "unknown-local",
|
||||||
userDecision: undefined,
|
pendingDecision: undefined,
|
||||||
communityEnabled: true,
|
communityEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const policyOverlay = new TrustPolicyOverlay();
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
scenario: document.querySelector("#scenario"),
|
scenario: document.querySelector("#scenario"),
|
||||||
community: document.querySelector("#community-enabled"),
|
community: document.querySelector("#community-enabled"),
|
||||||
|
target: document.querySelector("#trust-target"),
|
||||||
|
lifetime: document.querySelector("#trust-lifetime"),
|
||||||
|
includeSubdomains: document.querySelector("#include-subdomains"),
|
||||||
status: document.querySelector("#status"),
|
status: document.querySelector("#status"),
|
||||||
identity: document.querySelector("#identity"),
|
identity: document.querySelector("#identity"),
|
||||||
chain: document.querySelector("#chain"),
|
chain: document.querySelector("#chain"),
|
||||||
journal: document.querySelector("#journal"),
|
journal: document.querySelector("#journal"),
|
||||||
|
rules: document.querySelector("#rules"),
|
||||||
trust: document.querySelector("#trust"),
|
trust: document.querySelector("#trust"),
|
||||||
reject: document.querySelector("#reject"),
|
reject: document.querySelector("#reject"),
|
||||||
clear: document.querySelector("#clear-decision"),
|
clear: document.querySelector("#clear-decision"),
|
||||||
@ -83,49 +90,92 @@ for (const [value, scenario] of Object.entries(scenarios)) {
|
|||||||
|
|
||||||
elements.scenario.addEventListener("change", () => {
|
elements.scenario.addEventListener("change", () => {
|
||||||
state.scenario = elements.scenario.value;
|
state.scenario = elements.scenario.value;
|
||||||
state.userDecision = undefined;
|
state.pendingDecision = undefined;
|
||||||
|
renderTrustTargets(scenarios[state.scenario].facts);
|
||||||
render();
|
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", () => {
|
elements.community.addEventListener("change", () => {
|
||||||
state.communityEnabled = elements.community.checked;
|
state.communityEnabled = elements.community.checked;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
elements.trust.addEventListener("click", () => {
|
elements.trust.addEventListener("click", () => {
|
||||||
state.userDecision = true;
|
state.pendingDecision = true;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
elements.reject.addEventListener("click", () => {
|
elements.reject.addEventListener("click", () => {
|
||||||
state.userDecision = false;
|
state.pendingDecision = false;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
elements.clear.addEventListener("click", () => {
|
elements.clear.addEventListener("click", () => {
|
||||||
state.userDecision = undefined;
|
state.pendingDecision = undefined;
|
||||||
|
policyOverlay.clear();
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function render() {
|
async function render() {
|
||||||
const facts = scenarios[state.scenario].facts;
|
const facts = scenarios[state.scenario].facts;
|
||||||
const plugins = [configure(createFirefoxValidationPlugin(), "advisor")];
|
const evidencePlugins = [configure(createFirefoxValidationPlugin(), "advisor")];
|
||||||
if (state.communityEnabled) {
|
if (state.communityEnabled) {
|
||||||
plugins.push(
|
evidencePlugins.push(
|
||||||
...(scenarios[state.scenario].plugins ?? [
|
...(scenarios[state.scenario].plugins ?? [
|
||||||
configure(createVillageCommunityPlugin(), "advisor"),
|
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);
|
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);
|
renderIdentity(result);
|
||||||
renderChain(result);
|
renderChain(result);
|
||||||
renderJournal(result);
|
renderJournal(result);
|
||||||
elements.clear.hidden = state.userDecision === undefined;
|
renderRules(policyOverlay.snapshot());
|
||||||
|
elements.clear.hidden = policyOverlay.snapshot().rules.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(result) {
|
function renderStatus(result, hasLocalDecision) {
|
||||||
const copy = verdictCopy(result, state.userDecision !== undefined);
|
const copy = verdictCopy(result, hasLocalDecision);
|
||||||
elements.status.dataset.state = result.verdict.trusted ? "trusted" : "not-trusted";
|
elements.status.dataset.state = result.verdict.trusted ? "trusted" : "not-trusted";
|
||||||
elements.status.replaceChildren(
|
elements.status.replaceChildren(
|
||||||
node("p", copy.eyebrow, "eyebrow"),
|
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) {
|
function renderIdentity(result) {
|
||||||
elements.identity.replaceChildren(
|
elements.identity.replaceChildren(
|
||||||
definition("Requested host", result.facts.hostname),
|
definition("Requested host", result.facts.hostname),
|
||||||
@ -203,4 +294,36 @@ function configure(plugin, mode) {
|
|||||||
return { 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();
|
render();
|
||||||
|
|||||||
@ -28,6 +28,23 @@
|
|||||||
<span>Village community evidence</span>
|
<span>Village community evidence</span>
|
||||||
</label>
|
</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">
|
<p class="simulation-note">
|
||||||
This page evaluates fixtures only. It cannot alter browser trust.
|
This page evaluates fixtures only. It cannot alter browser trust.
|
||||||
</p>
|
</p>
|
||||||
@ -59,6 +76,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="journal" class="journal"></div>
|
<div id="journal" class="journal"></div>
|
||||||
</section>
|
</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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ -75,4 +100,3 @@
|
|||||||
<script type="module" src="/ui/app.js"></script>
|
<script type="module" src="/ui/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user