Add explainable certificate path construction

This commit is contained in:
Sergey Chernov 2026-08-16 23:58:03 +04:00
parent 2d02590214
commit f0dbc19807
12 changed files with 319 additions and 2 deletions

View File

@ -93,6 +93,20 @@ certificate, recomputes every DER SHA-256 fingerprint, and rejects inconsistent
CA, Key Usage, critical-extension, or fingerprint claims. Recorded decisions CA, Key Usage, critical-extension, or fingerprint claims. Recorded decisions
remain evidence in the bundle and are not silently installed into local policy. remain evidence in the bundle and are not silently installed into local policy.
## Explainable path construction
Live probes build a candidate issuer graph independently of the order in which
OpenSSL returned certificates. Every possible certificate pair records issuer
and subject matching, signature verification, and AKI/SKI continuity. An edge
is accepted only when the issuer is a DER-confirmed CA, Key Usage permits
certificate signing, and no unsupported critical extension blocks its use.
TrustLab enumerates every acyclic candidate path and labels it `trusted`,
`untrusted`, `incomplete`, or `invalid`. Terminal explanations retain both the
structural outcome and validation failures, including validity periods and CA
path-length constraints. Trust anchors name their provider; a path is never
presented as simply “trusted” without attribution.
Authority scopes identify the exact DER-encoded CA certificate with Authority scopes identify the exact DER-encoded CA certificate with
`authorityCertificateSha256`. TrustLab verifies that it appears in the active `authorityCertificateSha256`. TrustLab verifies that it appears in the active
chain, has CA Basic Constraints in the supplied facts, and permits chain, has CA Basic Constraints in the supplied facts, and permits

View File

@ -22,6 +22,9 @@ export interface CertificateFacts {
readonly isCa: boolean; readonly isCa: boolean;
readonly keyUsages: readonly CertificateKeyUsage[]; readonly keyUsages: readonly CertificateKeyUsage[];
readonly unsupportedCriticalExtensions?: readonly string[]; readonly unsupportedCriticalExtensions?: readonly string[];
readonly pathLengthConstraint?: number;
readonly subjectKeyIdentifier?: string;
readonly authorityKeyIdentifier?: string;
readonly selfSigned: boolean; readonly selfSigned: boolean;
readonly derBase64?: string; readonly derBase64?: string;
readonly der?: Readonly<Record<string, unknown>>; readonly der?: Readonly<Record<string, unknown>>;

View File

@ -21,7 +21,9 @@ const KEY_USAGE_NAMES = [
"digitalSignature", "nonRepudiation", "keyEncipherment", "dataEncipherment", "digitalSignature", "nonRepudiation", "keyEncipherment", "dataEncipherment",
"keyAgreement", "keyCertSign", "crlSign", "encipherOnly", "decipherOnly", "keyAgreement", "keyCertSign", "crlSign", "encipherOnly", "decipherOnly",
]; ];
const SUPPORTED_EXTENSIONS = new Set(["2.5.29.14", "2.5.29.15", "2.5.29.19", "2.5.29.37"]); const SUPPORTED_EXTENSIONS = new Set([
"2.5.29.14", "2.5.29.15", "2.5.29.17", "2.5.29.19", "2.5.29.35", "2.5.29.37",
]);
export function exploreCertificateDer(input, limits = {}) { export function exploreCertificateDer(input, limits = {}) {
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
@ -44,6 +46,9 @@ export function exploreCertificateDer(input, limits = {}) {
isCa: basicConstraints?.ca === true, isCa: basicConstraints?.ca === true,
pathLength: basicConstraints?.pathLength, pathLength: basicConstraints?.pathLength,
keyUsages: keyUsage?.usages ?? [], keyUsages: keyUsage?.usages ?? [],
subjectKeyIdentifier: extensions.find((item) => item.oid === "2.5.29.14")?.decoded?.keyIdentifier,
authorityKeyIdentifier: extensions.find((item) => item.oid === "2.5.29.35")?.decoded?.keyIdentifier,
subjectAlternativeNames: extensions.find((item) => item.oid === "2.5.29.17")?.decoded?.names ?? [],
unsupportedCriticalExtensions: extensions unsupportedCriticalExtensions: extensions
.filter((item) => item.critical && !item.supported) .filter((item) => item.critical && !item.supported)
.map((item) => item.oid), .map((item) => item.oid),
@ -142,10 +147,44 @@ function decodeKnownExtension(oid, value) {
if (oid === "2.5.29.19") return decodeBasicConstraints(value); if (oid === "2.5.29.19") return decodeBasicConstraints(value);
if (oid === "2.5.29.15") return decodeKeyUsage(value); if (oid === "2.5.29.15") return decodeKeyUsage(value);
if (oid === "2.5.29.14") return { keyIdentifier: hex(readSingle(value, 4).value) }; if (oid === "2.5.29.14") return { keyIdentifier: hex(readSingle(value, 4).value) };
if (oid === "2.5.29.17") return decodeGeneralNames(value);
if (oid === "2.5.29.35") return decodeAuthorityKeyIdentifier(value);
if (oid === "2.5.29.37") return { purposes: readSingle(value, 16).children.map((node) => decodeOid(node.value)) }; if (oid === "2.5.29.37") return { purposes: readSingle(value, 16).children.map((node) => decodeOid(node.value)) };
return undefined; return undefined;
} }
function decodeGeneralNames(value) {
const sequence = readSingle(value, 16);
const labels = new Map([[1, "email"], [2, "dns"], [6, "uri"]]);
const names = sequence.children.map((node) => {
if (node.tagClass !== 2) return { type: `tag-${node.tagNumber}`, valueHex: hex(node.value) };
if (labels.has(node.tagNumber)) return { type: labels.get(node.tagNumber), value: new TextDecoder().decode(node.value) };
if (node.tagNumber === 7) return { type: "ip", value: decodeIpAddress(node.value), valueHex: hex(node.value) };
return { type: `general-name-${node.tagNumber}`, valueHex: hex(node.value) };
});
return { names };
}
function decodeAuthorityKeyIdentifier(value) {
const sequence = readSingle(value, 16);
const keyIdentifier = sequence.children.find((node) => node.tagClass === 2 && node.tagNumber === 0);
const serial = sequence.children.find((node) => node.tagClass === 2 && node.tagNumber === 2);
return {
...(keyIdentifier ? { keyIdentifier: hex(keyIdentifier.value) } : {}),
...(serial ? { authorityCertificateSerial: hex(serial.value) } : {}),
};
}
function decodeIpAddress(bytes) {
if (bytes.length === 4) return [...bytes].join(".");
if (bytes.length === 16) {
const groups = [];
for (let offset = 0; offset < 16; offset += 2) groups.push(((bytes[offset] << 8) | bytes[offset + 1]).toString(16));
return groups.join(":");
}
return "invalid IP address encoding";
}
function decodeBasicConstraints(value) { function decodeBasicConstraints(value) {
const sequence = readSingle(value, 16); const sequence = readSingle(value, 16);
let ca = false; let ca = false;

View File

@ -6,3 +6,4 @@ export { TrustRunner } from "./runner.js";
export { parseTlsTarget, probeTls } from "./tls-probe.js"; export { parseTlsTarget, probeTls } from "./tls-probe.js";
export { exploreCertificateDer } from "./der-explorer.js"; export { exploreCertificateDer } from "./der-explorer.js";
export { createInvestigationBundle, parseInvestigationBundle } from "./investigation-bundle.js"; export { createInvestigationBundle, parseInvestigationBundle } from "./investigation-bundle.js";
export { analyzeCertificatePaths } from "./path-analysis.js";

View File

@ -17,6 +17,7 @@ export function createInvestigationBundle(report, result, policySnapshot) {
facts: report.facts, facts: report.facts,
findings: report.findings ?? [], findings: report.findings ?? [],
caveats: report.caveats ?? [], caveats: report.caveats ?? [],
pathAnalysis: report.pathAnalysis,
}, },
decision: result ? { verdict: result.verdict, journal: result.journal } : undefined, decision: result ? { verdict: result.verdict, journal: result.journal } : undefined,
policySnapshot: policySnapshot ?? undefined, policySnapshot: policySnapshot ?? undefined,
@ -75,6 +76,12 @@ async function normalizeEmbeddedDer(certificate) {
isCa: explored.derived.isCa, isCa: explored.derived.isCa,
keyUsages: explored.derived.keyUsages, keyUsages: explored.derived.keyUsages,
unsupportedCriticalExtensions: explored.derived.unsupportedCriticalExtensions, unsupportedCriticalExtensions: explored.derived.unsupportedCriticalExtensions,
pathLengthConstraint: explored.derived.pathLength,
subjectKeyIdentifier: explored.derived.subjectKeyIdentifier,
authorityKeyIdentifier: explored.derived.authorityKeyIdentifier,
dnsNames: explored.derived.subjectAlternativeNames
.filter((name) => name.type === "dns")
.map((name) => name.value),
der: explored, der: explored,
}; };
} }

View File

@ -0,0 +1,84 @@
export function analyzeCertificatePaths(certificates, relationships, options = {}) {
if (!Array.isArray(certificates) || certificates.length === 0) throw new TypeError("Path analysis requires a leaf certificate");
const byFingerprint = new Map(certificates.map((certificate) => [certificate.sha256, certificate]));
const trustAnchors = new Map([
...(options.trustedCertificateSha256 ?? []).map((sha256) => [sha256, "configured trust anchor"]),
...(options.trustAnchors ?? []).map((anchor) => [anchor.sha256, anchor.source]),
]);
const edges = relationships.map((relationship, index) => explainEdge({ ...relationship, index }, byFingerprint));
const acceptedByChild = new Map();
for (const edge of edges.filter((item) => item.accepted)) {
const list = acceptedByChild.get(edge.childSha256) ?? [];
list.push(edge);
acceptedByChild.set(edge.childSha256, list);
}
const paths = [];
walk([certificates[0].sha256], [], paths, acceptedByChild, byFingerprint, trustAnchors, Date.parse(options.validationTime ?? new Date().toISOString()));
return {
leafCertificateSha256: certificates[0].sha256,
trustAnchors: [...trustAnchors].map(([sha256, source]) => ({ sha256, source })),
edges,
paths,
};
}
function explainEdge(relationship, certificates) {
const issuer = certificates.get(relationship.issuerSha256);
const reasons = [];
if (!relationship.issuerNameMatches) reasons.push("issuer-subject-name-mismatch");
if (!relationship.signatureValid) reasons.push("signature-invalid");
if (relationship.authorityKeyMatches === false) reasons.push("authority-key-identifier-mismatch");
if (!issuer?.isCa) reasons.push("issuer-basic-constraints-not-ca");
if (!issuer?.keyUsages.includes("keyCertSign")) reasons.push("issuer-key-usage-forbids-certificate-signing");
if (issuer?.unsupportedCriticalExtensions?.length) reasons.push("issuer-has-unsupported-critical-extension");
return { ...relationship, accepted: reasons.length === 0, reasons };
}
function walk(path, pathEdges, paths, acceptedByChild, certificates, trustAnchors, validationTime) {
const current = path.at(-1);
const certificate = certificates.get(current);
const validationFailures = validatePath(path, certificates, validationTime);
if (trustAnchors.has(current)) {
paths.push(validationFailures.length
? { certificateSha256: path, edgeIndexes: pathEdges, status: "invalid", terminalReason: "path-validation-failed", structuralTerminalReason: "configured-trust-anchor", validationFailures, trustSource: trustAnchors.get(current) }
: { certificateSha256: path, edgeIndexes: pathEdges, status: "trusted", terminalReason: "configured-trust-anchor", validationFailures: [], trustSource: trustAnchors.get(current) });
return;
}
if (certificate?.selfSigned) {
paths.push({ certificateSha256: path, edgeIndexes: pathEdges, status: validationFailures.length ? "invalid" : "untrusted", terminalReason: validationFailures.length ? "path-validation-failed" : "self-signed-certificate-is-not-a-configured-anchor", structuralTerminalReason: "self-signed-certificate-is-not-a-configured-anchor", validationFailures });
return;
}
const outgoing = acceptedByChild.get(current) ?? [];
const acyclic = outgoing.filter((edge) => !path.includes(edge.issuerSha256));
if (acyclic.length === 0) {
const structuralTerminalReason = outgoing.length ? "issuer-cycle" : "no-acceptable-issuer";
paths.push({ certificateSha256: path, edgeIndexes: pathEdges, status: validationFailures.length ? "invalid" : "incomplete", terminalReason: validationFailures.length ? "path-validation-failed" : structuralTerminalReason, structuralTerminalReason, validationFailures });
return;
}
for (const edge of acyclic) {
walk(
[...path, edge.issuerSha256],
[...pathEdges, edge.index],
paths,
acceptedByChild,
certificates,
trustAnchors,
validationTime,
);
}
}
function validatePath(path, certificates, validationTime) {
const failures = [];
path.forEach((fingerprint, index) => {
const certificate = certificates.get(fingerprint);
if (certificate.validFrom && Date.parse(certificate.validFrom) > validationTime) failures.push({ code: "not-yet-valid", certificateSha256: fingerprint });
if (certificate.validUntil && Date.parse(certificate.validUntil) < validationTime) failures.push({ code: "expired", certificateSha256: fingerprint });
if (certificate.unsupportedCriticalExtensions?.length) failures.push({ code: "unsupported-critical-extension", certificateSha256: fingerprint, oids: certificate.unsupportedCriticalExtensions });
if (certificate.pathLengthConstraint !== undefined) {
const subordinateCaCount = path.slice(1, index).filter((sha256) => certificates.get(sha256)?.isCa).length;
if (subordinateCaCount > certificate.pathLengthConstraint) failures.push({ code: "path-length-constraint-exceeded", certificateSha256: fingerprint, permitted: certificate.pathLengthConstraint, observed: subordinateCaCount });
}
});
return failures;
}

View File

@ -3,6 +3,7 @@ import { isIP } from "node:net";
import { connect } from "node:tls"; import { connect } from "node:tls";
import { X509Certificate } from "node:crypto"; import { X509Certificate } from "node:crypto";
import { exploreCertificateDer } from "./der-explorer.js"; import { exploreCertificateDer } from "./der-explorer.js";
import { analyzeCertificatePaths } from "./path-analysis.js";
const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_TIMEOUT_MS = 10_000;
@ -86,6 +87,7 @@ function certificateChain(peer) {
function normalizeProbe(input) { function normalizeProbe(input) {
const certificates = input.chain.map(certificateFacts); const certificates = input.chain.map(certificateFacts);
const relationships = certificateRelationships(input.chain, certificates);
const findings = []; const findings = [];
const now = Date.now(); const now = Date.now();
const leaf = input.chain[0]; const leaf = input.chain[0];
@ -124,6 +126,13 @@ function normalizeProbe(input) {
const errors = [...new Set(findings.map((item) => item.code))]; const errors = [...new Set(findings.map((item) => item.code))];
return { return {
observedAt: new Date().toISOString(), observedAt: new Date().toISOString(),
pathAnalysis: analyzeCertificatePaths(certificates, relationships, {
trustAnchors: input.authorized ? [{
sha256: certificates.at(-1).sha256,
source: "Node/OpenSSL conventional validation terminus",
}] : [],
validationTime: new Date().toISOString(),
}),
facts: { facts: {
schemaVersion: 0, schemaVersion: 0,
connectionId: randomUUID(), connectionId: randomUUID(),
@ -158,11 +167,16 @@ function certificateFacts(certificate) {
sha256: sha256(certificate.raw), sha256: sha256(certificate.raw),
spkiSha256: sha256(certificate.publicKey.export({ type: "spki", format: "der" })), spkiSha256: sha256(certificate.publicKey.export({ type: "spki", format: "der" })),
serialNumber: certificate.serialNumber, serialNumber: certificate.serialNumber,
dnsNames: certificate.subjectAltName ? certificate.subjectAltName.split(", ") : [], dnsNames: der.derived.subjectAlternativeNames
.filter((name) => name.type === "dns")
.map((name) => name.value),
validFrom: new Date(certificate.validFrom).toISOString(), validFrom: new Date(certificate.validFrom).toISOString(),
validUntil: new Date(certificate.validTo).toISOString(), validUntil: new Date(certificate.validTo).toISOString(),
isCa: der.derived.isCa, isCa: der.derived.isCa,
keyUsages: der.derived.keyUsages, keyUsages: der.derived.keyUsages,
pathLengthConstraint: der.derived.pathLength,
subjectKeyIdentifier: der.derived.subjectKeyIdentifier,
authorityKeyIdentifier: der.derived.authorityKeyIdentifier,
unsupportedCriticalExtensions: der.derived.unsupportedCriticalExtensions, unsupportedCriticalExtensions: der.derived.unsupportedCriticalExtensions,
selfSigned: certificate.checkIssued(certificate) && certificate.verify(certificate.publicKey), selfSigned: certificate.checkIssued(certificate) && certificate.verify(certificate.publicKey),
derBase64: Buffer.from(certificate.raw).toString("base64"), derBase64: Buffer.from(certificate.raw).toString("base64"),
@ -170,6 +184,29 @@ function certificateFacts(certificate) {
}; };
} }
function certificateRelationships(chain, facts) {
const relationships = [];
chain.forEach((child, childIndex) => {
chain.forEach((issuer, issuerIndex) => {
if (childIndex === issuerIndex) return;
const authorityKeyIdentifier = facts[childIndex].authorityKeyIdentifier;
const subjectKeyIdentifier = facts[issuerIndex].subjectKeyIdentifier;
let signatureValid = false;
try { signatureValid = child.verify(issuer.publicKey); } catch {}
relationships.push({
childSha256: facts[childIndex].sha256,
issuerSha256: facts[issuerIndex].sha256,
issuerNameMatches: child.issuer === issuer.subject,
signatureValid,
authorityKeyMatches: authorityKeyIdentifier && subjectKeyIdentifier
? authorityKeyIdentifier === subjectKeyIdentifier
: undefined,
});
});
});
return relationships;
}
function mapAuthorizationError(error) { function mapAuthorizationError(error) {
const code = String(error ?? "UNTRUSTED_ISSUER"); const code = String(error ?? "UNTRUSTED_ISSUER");
const table = { const table = {

View File

@ -38,6 +38,20 @@ test("an understood name does not make an unsupported critical extension safe",
assert.deepEqual(result.derived.unsupportedCriticalExtensions, ["2.5.29.32"]); assert.deepEqual(result.derived.unsupportedCriticalExtensions, ["2.5.29.32"]);
}); });
test("DER explorer decodes SAN and Authority Key Identifier relationships", () => {
const sanNames = tlv("30", "820b6578616d706c652e636f6d8704c0000201");
const authorityKey = tlv("30", "800401020304");
const extensions = tlv("30", extension("551d11", sanNames) + extension("551d23", authorityKey));
const certificate = bytes(tlv("30", tlv("30", tlv("a3", extensions))));
const result = exploreCertificateDer(certificate);
assert.deepEqual(result.derived.subjectAlternativeNames, [
{ type: "dns", value: "example.com" },
{ type: "ip", value: "192.0.2.1", valueHex: "c0000201" },
]);
assert.equal(result.derived.authorityKeyIdentifier, "01020304");
});
function extension(oidHex, valueHex, critical = false) { function extension(oidHex, valueHex, critical = false) {
return tlv("30", tlv("06", oidHex) + (critical ? "0101ff" : "") + tlv("04", valueHex)); return tlv("30", tlv("06", oidHex) + (critical ? "0101ff" : "") + tlv("04", valueHex));
} }

View File

@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import { analyzeCertificatePaths } from "../src/path-analysis.js";
test("path analysis enumerates two valid issuer alternatives with attributed trust", () => {
const certificates = [cert("leaf", false), cert("intermediate-a"), cert("intermediate-b"), cert("root-a", true), cert("root-b", true)];
const relationships = [
link("leaf", "intermediate-a"), link("leaf", "intermediate-b"),
link("intermediate-a", "root-a"), link("intermediate-b", "root-b"),
];
const result = analyzeCertificatePaths(certificates, relationships, {
trustedCertificateSha256: ["root-a"],
});
assert.equal(result.paths.length, 2);
assert.equal(result.paths.find((path) => path.certificateSha256.includes("root-a")).status, "trusted");
assert.equal(result.paths.find((path) => path.certificateSha256.includes("root-b")).status, "untrusted");
});
test("path analysis explains rejected and missing issuer paths", () => {
const certificates = [cert("leaf", false), cert("wrong-issuer")];
const relationships = [{ ...link("leaf", "wrong-issuer"), signatureValid: false }];
const result = analyzeCertificatePaths(certificates, relationships);
assert.equal(result.paths[0].status, "incomplete");
assert.equal(result.paths[0].terminalReason, "no-acceptable-issuer");
assert.deepEqual(result.edges[0].reasons, ["signature-invalid"]);
});
test("path analysis rejects issuer certificates that cannot sign certificates", () => {
const issuer = { ...cert("issuer"), keyUsages: ["digitalSignature"] };
const result = analyzeCertificatePaths([cert("leaf", false), issuer], [link("leaf", "issuer")]);
assert.equal(result.edges[0].accepted, false);
assert.match(result.edges[0].reasons.join(" "), /key-usage/);
});
test("path validation explains an exceeded CA path-length constraint", () => {
const intermediate = cert("intermediate");
const root = { ...cert("root", true), pathLengthConstraint: 0 };
const result = analyzeCertificatePaths(
[cert("leaf", false), intermediate, root],
[link("leaf", "intermediate"), link("intermediate", "root")],
{ trustedCertificateSha256: ["root"] },
);
assert.equal(result.paths[0].status, "invalid");
assert.deepEqual(result.paths[0].validationFailures.map((failure) => failure.code), ["path-length-constraint-exceeded"]);
});
function cert(sha256, selfSigned = false) {
return { sha256, subject: sha256, isCa: sha256 !== "leaf", keyUsages: ["keyCertSign"], selfSigned, unsupportedCriticalExtensions: [] };
}
function link(childSha256, issuerSha256) {
return { childSha256, issuerSha256, issuerNameMatches: true, signatureValid: true, authorityKeyMatches: true };
}

View File

@ -85,6 +85,8 @@ const elements = {
status: document.querySelector("#status"), status: document.querySelector("#status"),
identity: document.querySelector("#identity"), identity: document.querySelector("#identity"),
chain: document.querySelector("#chain"), chain: document.querySelector("#chain"),
pathPanel: document.querySelector("#path-panel"),
paths: document.querySelector("#paths"),
journal: document.querySelector("#journal"), journal: document.querySelector("#journal"),
rules: document.querySelector("#rules"), rules: document.querySelector("#rules"),
trust: document.querySelector("#trust"), trust: document.querySelector("#trust"),
@ -154,6 +156,7 @@ elements.importBundle.addEventListener("change", async () => {
facts: bundle.evidence.facts, facts: bundle.evidence.facts,
findings: bundle.evidence.findings, findings: bundle.evidence.findings,
caveats: bundle.evidence.caveats, caveats: bundle.evidence.caveats,
pathAnalysis: bundle.evidence.pathAnalysis,
}; };
policyOverlay.clear(); policyOverlay.clear();
activateReport(report, "offline"); activateReport(report, "offline");
@ -246,6 +249,7 @@ async function render() {
renderStatus(result, decisionApplied || decidedByOverlay); renderStatus(result, decisionApplied || decidedByOverlay);
renderIdentity(result); renderIdentity(result);
renderChain(result); renderChain(result);
renderPathAnalysis(state.scenario === "live" ? state.liveReport?.pathAnalysis : undefined, facts);
renderJournal(result); renderJournal(result);
renderRules(policyOverlay.snapshot()); renderRules(policyOverlay.snapshot());
elements.clear.hidden = policyOverlay.snapshot().rules.length === 0; elements.clear.hidden = policyOverlay.snapshot().rules.length === 0;
@ -255,6 +259,47 @@ async function render() {
elements.exportBundle.hidden = state.scenario !== "live"; elements.exportBundle.hidden = state.scenario !== "live";
} }
function renderPathAnalysis(analysis, facts) {
elements.pathPanel.hidden = !analysis;
if (!analysis) return;
const certificates = new Map(
[...facts.presentedChain, ...facts.constructedChain].map((certificate) => [certificate.sha256, certificate]),
);
const pathCards = analysis.paths.map((path, index) => {
const article = document.createElement("article");
article.className = `path-card status-${path.status}`;
article.append(
node("span", path.status, "entry-kind"),
node("h3", `Candidate path ${index + 1}`),
node("p", path.certificateSha256
.map((fingerprint) => subjectName(certificates.get(fingerprint)?.subject))
.join(" → ")),
node("code", [formatCode(path.terminalReason), path.structuralTerminalReason && path.structuralTerminalReason !== path.terminalReason ? `ends at: ${formatCode(path.structuralTerminalReason)}` : undefined, path.trustSource].filter(Boolean).join(" · ")),
);
if (path.validationFailures?.length) {
article.append(node("p", `Validation: ${path.validationFailures.map((failure) => formatCode(failure.code)).join("; ")}`));
}
return article;
});
const rejected = analysis.edges.filter((edge) =>
!edge.accepted && (edge.issuerNameMatches || edge.authorityKeyMatches === true || edge.signatureValid),
);
if (rejected.length) {
const heading = node("h3", "Rejected issuer edges", "path-subheading");
pathCards.push(heading, ...rejected.map((edge) => {
const article = document.createElement("article");
article.className = "path-card status-rejected";
article.append(
node("span", "rejected", "entry-kind"),
node("h3", `${subjectName(certificates.get(edge.childSha256)?.subject)}${subjectName(certificates.get(edge.issuerSha256)?.subject)}`),
node("p", edge.reasons.map(formatCode).join("; ")),
);
return article;
}));
}
elements.paths.replaceChildren(...pathCards);
}
function activateReport(report, source) { function activateReport(report, source) {
state.liveReport = report; state.liveReport = report;
state.source = source; state.source = source;

View File

@ -86,6 +86,14 @@
<ol id="chain" class="chain"></ol> <ol id="chain" class="chain"></ol>
</section> </section>
<section id="path-panel" class="panel" hidden>
<div class="section-heading">
<p class="eyebrow">Candidate construction</p>
<h2>Every route to trust—and where it ends</h2>
</div>
<div id="paths" class="path-analysis"></div>
</section>
<section class="panel"> <section class="panel">
<div class="section-heading"> <div class="section-heading">
<p class="eyebrow">Append-only journal</p> <p class="eyebrow">Append-only journal</p>

View File

@ -296,6 +296,16 @@ select {
.entry-kind { color: var(--cyan); font: 700 0.62rem ui-monospace, monospace; text-transform: uppercase; } .entry-kind { color: var(--cyan); font: 700 0.62rem ui-monospace, monospace; text-transform: uppercase; }
.empty { color: var(--ink-muted); } .empty { color: var(--ink-muted); }
.path-analysis { display: grid; gap: 0.65rem; }
.path-card { display: grid; grid-template-columns: 5rem 1fr auto; gap: 0.2rem 0.8rem; padding: 0.9rem 1rem; border-left: 3px solid var(--amber); background: #0c161b; }
.path-card.status-trusted { border-color: var(--green); }
.path-card.status-untrusted, .path-card.status-rejected { border-color: var(--red); }
.path-card h3, .path-card p { margin: 0; }
.path-card h3 { font-size: 0.86rem; }
.path-card p { grid-column: 2 / -1; color: var(--ink-muted); font-size: 0.78rem; overflow-wrap: anywhere; }
.path-card code { color: #78919b; font-size: 0.68rem; }
.path-subheading { margin: 1rem 0 0.2rem; font-size: 0.85rem; }
.decision-bar { .decision-bar {
position: fixed; position: fixed;
z-index: 20; z-index: 20;