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
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
`authorityCertificateSha256`. TrustLab verifies that it appears in the active
chain, has CA Basic Constraints in the supplied facts, and permits

View File

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

View File

@ -21,7 +21,9 @@ const KEY_USAGE_NAMES = [
"digitalSignature", "nonRepudiation", "keyEncipherment", "dataEncipherment",
"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 = {}) {
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
@ -44,6 +46,9 @@ export function exploreCertificateDer(input, limits = {}) {
isCa: basicConstraints?.ca === true,
pathLength: basicConstraints?.pathLength,
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
.filter((item) => item.critical && !item.supported)
.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.15") return decodeKeyUsage(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)) };
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) {
const sequence = readSingle(value, 16);
let ca = false;

View File

@ -6,3 +6,4 @@ export { TrustRunner } from "./runner.js";
export { parseTlsTarget, probeTls } from "./tls-probe.js";
export { exploreCertificateDer } from "./der-explorer.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,
findings: report.findings ?? [],
caveats: report.caveats ?? [],
pathAnalysis: report.pathAnalysis,
},
decision: result ? { verdict: result.verdict, journal: result.journal } : undefined,
policySnapshot: policySnapshot ?? undefined,
@ -75,6 +76,12 @@ async function normalizeEmbeddedDer(certificate) {
isCa: explored.derived.isCa,
keyUsages: explored.derived.keyUsages,
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,
};
}

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 { X509Certificate } from "node:crypto";
import { exploreCertificateDer } from "./der-explorer.js";
import { analyzeCertificatePaths } from "./path-analysis.js";
const DEFAULT_TIMEOUT_MS = 10_000;
@ -86,6 +87,7 @@ function certificateChain(peer) {
function normalizeProbe(input) {
const certificates = input.chain.map(certificateFacts);
const relationships = certificateRelationships(input.chain, certificates);
const findings = [];
const now = Date.now();
const leaf = input.chain[0];
@ -124,6 +126,13 @@ function normalizeProbe(input) {
const errors = [...new Set(findings.map((item) => item.code))];
return {
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: {
schemaVersion: 0,
connectionId: randomUUID(),
@ -158,11 +167,16 @@ function certificateFacts(certificate) {
sha256: sha256(certificate.raw),
spkiSha256: sha256(certificate.publicKey.export({ type: "spki", format: "der" })),
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(),
validUntil: new Date(certificate.validTo).toISOString(),
isCa: der.derived.isCa,
keyUsages: der.derived.keyUsages,
pathLengthConstraint: der.derived.pathLength,
subjectKeyIdentifier: der.derived.subjectKeyIdentifier,
authorityKeyIdentifier: der.derived.authorityKeyIdentifier,
unsupportedCriticalExtensions: der.derived.unsupportedCriticalExtensions,
selfSigned: certificate.checkIssued(certificate) && certificate.verify(certificate.publicKey),
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) {
const code = String(error ?? "UNTRUSTED_ISSUER");
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"]);
});
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) {
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"),
identity: document.querySelector("#identity"),
chain: document.querySelector("#chain"),
pathPanel: document.querySelector("#path-panel"),
paths: document.querySelector("#paths"),
journal: document.querySelector("#journal"),
rules: document.querySelector("#rules"),
trust: document.querySelector("#trust"),
@ -154,6 +156,7 @@ elements.importBundle.addEventListener("change", async () => {
facts: bundle.evidence.facts,
findings: bundle.evidence.findings,
caveats: bundle.evidence.caveats,
pathAnalysis: bundle.evidence.pathAnalysis,
};
policyOverlay.clear();
activateReport(report, "offline");
@ -246,6 +249,7 @@ async function render() {
renderStatus(result, decisionApplied || decidedByOverlay);
renderIdentity(result);
renderChain(result);
renderPathAnalysis(state.scenario === "live" ? state.liveReport?.pathAnalysis : undefined, facts);
renderJournal(result);
renderRules(policyOverlay.snapshot());
elements.clear.hidden = policyOverlay.snapshot().rules.length === 0;
@ -255,6 +259,47 @@ async function render() {
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) {
state.liveReport = report;
state.source = source;

View File

@ -86,6 +86,14 @@
<ol id="chain" class="chain"></ol>
</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">
<div class="section-heading">
<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; }
.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 {
position: fixed;
z-index: 20;