Add explainable certificate path construction
This commit is contained in:
parent
2d02590214
commit
f0dbc19807
@ -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
|
||||||
|
|||||||
@ -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>>;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
84
trustlab/src/path-analysis.js
Normal file
84
trustlab/src/path-analysis.js
Normal 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;
|
||||||
|
}
|
||||||
@ -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 = {
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
55
trustlab/test/path-analysis.test.js
Normal file
55
trustlab/test/path-analysis.test.js
Normal 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 };
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user