418 lines
14 KiB
JavaScript
418 lines
14 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,
|
|
liveReport: undefined,
|
|
};
|
|
|
|
const policyOverlay = new TrustPolicyOverlay();
|
|
|
|
const elements = {
|
|
scenario: document.querySelector("#scenario"),
|
|
probeForm: document.querySelector("#probe-form"),
|
|
probeTarget: document.querySelector("#probe-target"),
|
|
probeSubmit: document.querySelector("#probe-submit"),
|
|
probeMessage: document.querySelector("#probe-message"),
|
|
frameSeal: document.querySelector("#frame-seal"),
|
|
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.probeForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
elements.probeSubmit.disabled = true;
|
|
elements.probeMessage.className = "probe-message";
|
|
elements.probeMessage.textContent = "Opening a read-only TLS connection…";
|
|
try {
|
|
const response = await fetch("/api/probe", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ target: elements.probeTarget.value }),
|
|
});
|
|
const report = await response.json();
|
|
if (!response.ok) throw new Error(report.error ?? `Probe failed (${response.status})`);
|
|
state.liveReport = report;
|
|
scenarios.live = {
|
|
label: `Live: ${report.facts.hostname}:${report.facts.port}`,
|
|
facts: report.facts,
|
|
probePlugins: [configure(createProbeEvidencePlugin(report), "advisor")],
|
|
};
|
|
let option = [...elements.scenario.options].find((item) => item.value === "live");
|
|
if (!option) {
|
|
option = new Option(scenarios.live.label, "live");
|
|
elements.scenario.prepend(option);
|
|
} else option.textContent = scenarios.live.label;
|
|
state.scenario = "live";
|
|
elements.scenario.value = "live";
|
|
state.pendingDecision = undefined;
|
|
renderTrustTargets(report.facts);
|
|
elements.probeMessage.textContent = `Received ${report.facts.presentedChain.length} certificate(s).`;
|
|
await render();
|
|
} catch (error) {
|
|
elements.probeMessage.className = "probe-message error";
|
|
elements.probeMessage.textContent = error.message;
|
|
} finally {
|
|
elements.probeSubmit.disabled = false;
|
|
}
|
|
});
|
|
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 scenario = scenarios[state.scenario];
|
|
const facts = scenario.facts;
|
|
const evidencePlugins = scenario.probePlugins
|
|
? [...scenario.probePlugins]
|
|
: [configure(createFirefoxValidationPlugin(), "advisor")];
|
|
if (state.communityEnabled) {
|
|
evidencePlugins.push(
|
|
...(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;
|
|
elements.frameSeal.textContent = state.scenario === "live"
|
|
? "TRUSTLAB · LIVE PROBE"
|
|
: "TRUSTLAB · SYNTHETIC";
|
|
}
|
|
|
|
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("Conventional result", result.facts.validation),
|
|
definition("TLS", result.facts.tls.version ?? "Unknown"),
|
|
);
|
|
}
|
|
|
|
function createProbeEvidencePlugin(report) {
|
|
return {
|
|
manifest: {
|
|
manifestVersion: 1,
|
|
trustApiVersion: "0.1",
|
|
id: "org.browsec.live-probe",
|
|
name: "Live TLS probe",
|
|
version: "0.1.0",
|
|
supportedModes: ["advisor"],
|
|
capabilities: {},
|
|
},
|
|
hooks: {
|
|
collectEvidence() {
|
|
return {
|
|
entries: [
|
|
...(report.facts.validation === "success" ? [{
|
|
kind: "vote",
|
|
code: "conventional-validation-succeeded",
|
|
message: "The local OpenSSL validator accepted this certificate path.",
|
|
data: { trusted: true },
|
|
}] : []),
|
|
...report.findings.map((finding) => ({
|
|
kind: finding.check === "trust-anchor" ? "warning" : "evidence",
|
|
code: finding.code,
|
|
message: finding.summary,
|
|
data: { certificateSha256: finding.certificateSha256 },
|
|
})),
|
|
...report.caveats.map((message) => ({
|
|
kind: "warning",
|
|
code: "probe-chain-source-limitation",
|
|
message,
|
|
})),
|
|
],
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
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();
|