479 lines
16 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"),
);
if (certificate.der) item.append(renderDerExplorer(certificate));
item.setAttribute("aria-label", `${certificate.role}: ${certificate.name}. ${certificate.edge}`);
if (index < all.length - 1) item.dataset.linked = "true";
return item;
}),
);
}
function renderDerExplorer(certificate) {
const details = document.createElement("details");
details.className = "der-explorer";
const summary = document.createElement("summary");
summary.textContent = `Certificate internals · ${certificate.der.byteLength} DER bytes · ${certificate.der.extensions.length} extensions`;
details.append(summary);
const actions = document.createElement("div");
actions.className = "der-actions";
const download = document.createElement("a");
download.className = "button quiet";
download.textContent = "Save original .der";
download.download = `${certificate.sha256}.der`;
download.href = `data:application/pkix-cert;base64,${certificate.derBase64}`;
actions.append(download);
details.append(actions);
const derived = document.createElement("dl");
derived.className = "der-derived";
derived.append(
definition("Basic Constraints", certificate.der.derived.isCa
? `CA: true${certificate.der.derived.pathLength === undefined ? "" : `, path length: ${certificate.der.derived.pathLength}`}`
: "CA: false"),
definition("Key Usage", certificate.der.derived.keyUsages.join(", ") || "Not asserted"),
definition("Unsupported critical extensions", certificate.der.derived.unsupportedCriticalExtensions.join(", ") || "None"),
);
details.append(derived);
const extensions = document.createElement("div");
extensions.className = "der-extensions";
for (const extension of certificate.der.extensions) {
const row = document.createElement("article");
if (extension.critical) row.className = extension.supported ? "critical" : "critical unknown";
row.append(
node("strong", extension.name),
node("code", extension.oid),
node("span", `${extension.critical ? "critical" : "non-critical"} · bytes ${extension.offset}${extension.offset + extension.length - 1}`),
node("pre", extension.decoded ? JSON.stringify(extension.decoded, null, 2) : extension.valueHex),
);
extensions.append(row);
}
details.append(extensions);
const tree = document.createElement("details");
tree.className = "der-tree";
tree.append(node("summary", "ASN.1 structure and byte ranges"), renderDerNode(certificate.der.tree));
details.append(tree);
return details;
}
function renderDerNode(item) {
const list = document.createElement("ul");
const entry = document.createElement("li");
entry.append(node("code", `${item.type} · ${item.offset}${item.endOffset - 1} · value ${item.valueLength} B`));
if (item.children.length) entry.append(...item.children.map(renderDerNode));
list.append(entry);
return list;
}
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") ||
certificate.unsupportedCriticalExtensions?.length > 0 ||
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();