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; }