85 lines
5.0 KiB
JavaScript
85 lines
5.0 KiB
JavaScript
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;
|
|
}
|