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;
/// <summary>Managed PKIX validation against an explicit root store, with SAN identity and server usage checks.</summary>
public sealed class TlsCertificateValidator
{
private readonly HashSet<TrustAnchor> anchors;
public TlsCertificateValidator(IEnumerable<X509Certificate> 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<byte[]> 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.");
}
}
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;
/// <summary>Managed PKIX validation against an explicit root store, with SAN identity and server usage checks.</summary>
public sealed class TlsCertificateValidator
{
private readonly HashSet<TrustAnchor> anchors;
public TlsCertificateValidator(IEnumerable<X509Certificate> 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<byte[]> 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.");
}
}