using System.Net; using Org.BouncyCastle.Asn1; using Org.BouncyCastle.Asn1.X509; using Org.BouncyCastle.Pkix; using Org.BouncyCastle.Utilities.Collections; using Org.BouncyCastle.X509; using Org.BouncyCastle.X509.Store; namespace XFESystem.Tls; /// Managed PKIX validation against an explicit root store, with SAN identity and server usage checks. public sealed class TlsCertificateValidator { private readonly HashSet anchors; public TlsCertificateValidator(IEnumerable roots) { ArgumentNullException.ThrowIfNull(roots); anchors = roots.Select(root => new TrustAnchor(root, null)).ToHashSet(); if (anchors.Count is < 1 or > 512) throw new ArgumentException("HTTPS requires a bounded, nonempty root store.", nameof(roots)); if (anchors.Any(anchor => anchor.TrustedCert.GetBasicConstraints() < 0)) throw new ArgumentException("A trusted root must be a CA certificate.", nameof(roots)); foreach (var anchor in anchors) { TlsCertificatePolicy.CheckTrustStorePublicKey(anchor.TrustedCert); bool[]? usage = anchor.TrustedCert.GetKeyUsage(); if (usage is not null && (usage.Length <= 5 || !usage[5])) throw new ArgumentException("A trusted root must permit certificate signing.", nameof(roots)); } } public static TlsCertificateValidator FromPem(string pem) { ArgumentNullException.ThrowIfNull(pem); if (pem.Length > 1_048_576) throw new ArgumentException("The root store exceeds its bound.", nameof(pem)); var parsed = new X509CertificateParser().ReadCertificates(System.Text.Encoding.ASCII.GetBytes(pem)); return new(parsed); } public X509Certificate Validate(string host, IReadOnlyList encodedChain, DateTime utc) { ArgumentNullException.ThrowIfNull(host); ArgumentNullException.ThrowIfNull(encodedChain); if (utc.Kind != DateTimeKind.Utc) throw new ArgumentException("Certificate validation requires UTC.", nameof(utc)); if (encodedChain.Count is < 1 or > 16 || encodedChain.Sum(bytes => (long)bytes.Length) > 131_072) throw new InvalidOperationException("The TLS peer certificate chain exceeds its bound."); var parser = new X509CertificateParser(); var chain = encodedChain.Select(bytes => parser.ReadCertificate(bytes)).ToArray(); foreach (var certificate in chain) certificate.CheckValidity(utc); var leaf = chain[0]; MatchSubjectAlternativeName(leaf, host); if (leaf.GetBasicConstraints() >= 0) throw new InvalidOperationException("The HTTPS leaf certificate is a CA."); var usage = leaf.GetExtendedKeyUsage(); if (usage is not null && !usage.Contains(KeyPurposeID.id_kp_serverAuth)) throw new InvalidOperationException("The HTTPS certificate is not permitted for server authentication."); bool[]? keyUsage = leaf.GetKeyUsage(); if (keyUsage is not null && (keyUsage.Length == 0 || !keyUsage[0])) throw new InvalidOperationException("The HTTPS certificate is not permitted to sign a TLS handshake."); var parameters = new PkixBuilderParameters(anchors, new X509CertStoreSelector { Certificate = leaf }); parameters.Date = utc; // There is no OCSP/CRL fetcher yet. PKIX signature/constraints, trust, // validity, SAN identity, and key usages are still mandatory. parameters.IsRevocationEnabled = false; parameters.AddStoreCert(CollectionUtilities.CreateStore(chain)); var built = new PkixCertPathBuilder().Build(parameters); var anchor = built.TrustAnchor.TrustedCert; anchor.CheckValidity(utc); TlsCertificatePolicy.CheckPublicKey(anchor); TlsCertificatePolicy.CheckServerUsage(anchor); foreach (var certificate in built.CertPath.Certificates) { TlsCertificatePolicy.CheckPublicKey(certificate); TlsCertificatePolicy.CheckServerUsage(certificate); TlsCertificatePolicy.CheckSignatureAlgorithm(certificate); } TlsCertificatePolicy.CheckAnchorConstraints(anchor, built.CertPath.Certificates); return leaf; } private static void MatchSubjectAlternativeName(X509Certificate certificate, string host) { var extension = certificate.GetExtensionValue(X509Extensions.SubjectAlternativeName); if (extension is null) throw new InvalidOperationException("The HTTPS certificate has no subjectAltName."); var names = GeneralNames.GetInstance(Asn1Object.FromByteArray(extension.GetOctets())).GetNames(); if (IPAddress.TryParse(host, out var address)) { byte[] expected = address.GetAddressBytes(); if (names.Any(name => name.TagNo == GeneralName.IPAddress && Asn1OctetString.GetInstance(name.Name).GetOctets().AsSpan().SequenceEqual(expected))) return; } else { string expected = host.TrimEnd('.').ToLowerInvariant(); if (expected.Length == 0 || expected.Any(character => character > 127 || char.IsControl(character))) throw new ArgumentException("HTTPS requires a normalized ASCII DNS name.", nameof(host)); foreach (var name in names) { if (name.TagNo != GeneralName.DnsName) continue; string candidate = DerIA5String.GetInstance(name.Name).GetString().TrimEnd('.').ToLowerInvariant(); if (candidate == expected) return; // Wildcards match exactly one complete label. Restrict them to // at least a two-label suffix; no CN or partial-label fallback. if (!candidate.StartsWith("*.", StringComparison.Ordinal) || candidate[2..].Contains('*') || candidate[2..].Count(character => character == '.') < 1) continue; int separator = expected.IndexOf('.'); if (separator > 0 && !expected.StartsWith("xn--", StringComparison.Ordinal) && expected[(separator + 1)..] == candidate[2..]) return; } } throw new InvalidOperationException("The TLS certificate subjectAltName does not match the HTTPS host."); } }