330 lines
10 KiB
JavaScript
330 lines
10 KiB
JavaScript
import {
|
|
conflictingCommunityAdvice,
|
|
explicitlyDistrustedAuthority,
|
|
expiredLeafCertificate,
|
|
hostnameMismatch,
|
|
unknownLocalAuthority,
|
|
validPublicCertificate,
|
|
} from "../fixtures/tls.js";
|
|
import {
|
|
createCommunityAdvicePlugin,
|
|
createFirefoxValidationPlugin,
|
|
createUserDecisionPlugin,
|
|
createVillageCommunityPlugin,
|
|
} from "../plugins/demo-plugins.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": {
|
|
label: "Unknown village authority",
|
|
facts: unknownLocalAuthority,
|
|
},
|
|
"valid-public": {
|
|
label: "Valid conventional path",
|
|
facts: validPublicCertificate,
|
|
},
|
|
"expired-leaf": {
|
|
label: "Expired server certificate",
|
|
facts: expiredLeafCertificate,
|
|
},
|
|
"hostname-mismatch": {
|
|
label: "Hostname mismatch",
|
|
facts: hostnameMismatch,
|
|
},
|
|
"distrusted-authority": {
|
|
label: "Explicitly distrusted authority",
|
|
facts: explicitlyDistrustedAuthority,
|
|
},
|
|
"conflicting-advice": {
|
|
label: "Conflicting community advice",
|
|
facts: conflictingCommunityAdvice,
|
|
plugins: [
|
|
configure(createCommunityAdvicePlugin({
|
|
id: "community.archivists",
|
|
name: "Regional archivists",
|
|
trusted: true,
|
|
message: "The archivists recognize this exact certificate and recommend trust.",
|
|
}), "advisor"),
|
|
configure(createCommunityAdvicePlugin({
|
|
id: "community.network-watch",
|
|
name: "Independent network watch",
|
|
trusted: false,
|
|
message: "The network observers report an unexpected certificate change.",
|
|
}), "advisor"),
|
|
],
|
|
},
|
|
};
|
|
|
|
const state = {
|
|
scenario: "unknown-local",
|
|
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"),
|
|
};
|
|
|
|
for (const [value, scenario] of Object.entries(scenarios)) {
|
|
const option = document.createElement("option");
|
|
option.value = value;
|
|
option.textContent = scenario.label;
|
|
elements.scenario.append(option);
|
|
}
|
|
|
|
elements.scenario.addEventListener("change", () => {
|
|
state.scenario = elements.scenario.value;
|
|
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.pendingDecision = true;
|
|
render();
|
|
});
|
|
elements.reject.addEventListener("click", () => {
|
|
state.pendingDecision = false;
|
|
render();
|
|
});
|
|
elements.clear.addEventListener("click", () => {
|
|
state.pendingDecision = undefined;
|
|
policyOverlay.clear();
|
|
render();
|
|
});
|
|
|
|
async function render() {
|
|
const facts = scenarios[state.scenario].facts;
|
|
const evidencePlugins = [configure(createFirefoxValidationPlugin(), "advisor")];
|
|
if (state.communityEnabled) {
|
|
evidencePlugins.push(
|
|
...(scenarios[state.scenario].plugins ?? [
|
|
configure(createVillageCommunityPlugin(), "advisor"),
|
|
]),
|
|
);
|
|
}
|
|
|
|
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 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);
|
|
renderRules(policyOverlay.snapshot());
|
|
elements.clear.hidden = policyOverlay.snapshot().rules.length === 0;
|
|
}
|
|
|
|
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"),
|
|
node("h1", copy.title),
|
|
node("p", copy.detail, "status-detail"),
|
|
);
|
|
}
|
|
|
|
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),
|
|
definition("Port", String(result.facts.port)),
|
|
definition("Firefox result", result.facts.validation),
|
|
definition("TLS", result.facts.tls.version ?? "Unknown"),
|
|
);
|
|
}
|
|
|
|
function renderChain(result) {
|
|
elements.chain.replaceChildren(
|
|
...chainRows(result.facts).map((certificate, index, all) => {
|
|
const item = document.createElement("li");
|
|
item.className = certificate.failed ? "certificate failed" : "certificate";
|
|
item.append(
|
|
node("span", certificate.role, "certificate-role"),
|
|
node("strong", certificate.name),
|
|
node("code", certificate.sha256 ?? "No fingerprint"),
|
|
node("span", certificate.edge, "edge-label"),
|
|
);
|
|
item.setAttribute("aria-label", `${certificate.role}: ${certificate.name}. ${certificate.edge}`);
|
|
if (index < all.length - 1) item.dataset.linked = "true";
|
|
return item;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function renderJournal(result) {
|
|
if (result.journal.entries.length === 0) {
|
|
elements.journal.replaceChildren(node("p", "No plugin findings.", "empty"));
|
|
return;
|
|
}
|
|
|
|
elements.journal.replaceChildren(
|
|
...result.journal.entries.map((entry) => {
|
|
const article = document.createElement("article");
|
|
article.className = `journal-entry kind-${entry.kind}`;
|
|
article.append(
|
|
node("span", entry.kind, "entry-kind"),
|
|
node("h3", entry.pluginName),
|
|
node("p", entry.message ?? formatCode(entry.code)),
|
|
node("code", entry.code ?? entry.id),
|
|
);
|
|
return article;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function definition(term, value) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.append(node("dt", term), node("dd", value));
|
|
return wrapper;
|
|
}
|
|
|
|
function node(tag, text, className) {
|
|
const element = document.createElement(tag);
|
|
element.textContent = text;
|
|
if (className) element.className = className;
|
|
return element;
|
|
}
|
|
|
|
function formatCode(value = "") {
|
|
return value.replaceAll("-", " ");
|
|
}
|
|
|
|
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();
|