browsec/trustlab/src/path-analysis.js

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