using System.Net.Security; using System.Formats.Asn1; using System.Text.RegularExpressions; using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; namespace NATS.Server.Tls; public static class TlsHelper { private const string AuthorityInfoAccessOid = "1.3.6.1.5.5.7.1.1"; private const string OcspAccessMethodOid = "1.3.6.1.5.5.7.48.1"; private const string OcspSigningEkuOid = "1.3.6.1.5.5.7.3.9"; public static X509Certificate2 LoadCertificate(string certPath, string? keyPath) { if (keyPath != null) return X509Certificate2.CreateFromPemFile(certPath, keyPath); return X509CertificateLoader.LoadCertificateFromFile(certPath); } public static X509Certificate2Collection LoadCaCertificates(string caPath) { var pem = File.ReadAllText(caPath); return ParseCertPem(pem); } /// /// Parses one or more PEM blocks and requires all blocks to be CERTIFICATE. /// Mirrors Go parseCertPEM behavior by rejecting unexpected block types. /// public static X509Certificate2Collection ParseCertPem(string pemData) { if (string.IsNullOrWhiteSpace(pemData)) throw new InvalidDataException("PEM data is empty."); var beginMatches = Regex.Matches(pemData, "-----BEGIN ([^-]+)-----"); if (beginMatches.Count == 0) throw new InvalidDataException("No PEM certificate block found."); foreach (Match match in beginMatches) { var label = match.Groups[1].Value; if (!string.Equals(label, "CERTIFICATE", StringComparison.Ordinal)) throw new InvalidDataException($"unexpected PEM certificate type: {label}"); } var certs = new X509Certificate2Collection(); var certMatches = Regex.Matches( pemData, "-----BEGIN CERTIFICATE-----\\s*(?[A-Za-z0-9+/=\\r\\n]+?)\\s*-----END CERTIFICATE-----", RegexOptions.Singleline); foreach (Match certMatch in certMatches) { var body = certMatch.Groups["body"].Value; var normalized = Regex.Replace(body, "\\s+", "", RegexOptions.Singleline); var der = Convert.FromBase64String(normalized); certs.Add(X509CertificateLoader.LoadCertificate(der)); } if (certs.Count == 0) throw new InvalidDataException("No PEM certificate block found."); return certs; } public static SslServerAuthenticationOptions BuildServerAuthOptions(NatsOptions opts) { var cert = LoadCertificate(opts.TlsCert!, opts.TlsKey); var authOpts = new SslServerAuthenticationOptions { ServerCertificate = cert, EnabledSslProtocols = opts.TlsMinVersion, ClientCertificateRequired = opts.TlsVerify, }; if (opts.TlsVerify && opts.TlsCaCert != null) { var revocationMode = opts.OcspPeerVerify ? X509RevocationMode.Online : X509RevocationMode.NoCheck; var caCerts = LoadCaCertificates(opts.TlsCaCert); authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) => { if (cert == null) return false; using var chain2 = new X509Chain(); chain2.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; foreach (var ca in caCerts) chain2.ChainPolicy.CustomTrustStore.Add(ca); chain2.ChainPolicy.RevocationMode = revocationMode; var cert2 = cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.GetRawCertData()); return chain2.Build(cert2); }; } else if (opts.OcspPeerVerify) { // No custom CA — still enable online revocation checking against the system store authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) => { if (cert == null) return false; using var chain2 = new X509Chain(); chain2.ChainPolicy.RevocationMode = X509RevocationMode.Online; var cert2 = cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.GetRawCertData()); return chain2.Build(cert2); }; } return authOpts; } /// /// Builds an for OCSP stapling. /// Returns null when TLS is not configured or OCSP mode is Never. /// When is false the runtime will contact the /// certificate's OCSP responder to obtain a fresh stapled response. /// public static SslStreamCertificateContext? BuildCertificateContext(NatsOptions opts, bool offline = false) { if (!opts.HasTls) return null; if (opts.OcspConfig is null || opts.OcspConfig.Mode == OcspMode.Never) return null; var cert = LoadCertificate(opts.TlsCert!, opts.TlsKey); var chain = new X509Certificate2Collection(); if (!string.IsNullOrEmpty(opts.TlsCaCert)) chain.ImportFromPemFile(opts.TlsCaCert); return SslStreamCertificateContext.Create(cert, chain, offline: offline); } public static string GetCertificateHash(X509Certificate2 cert) { var spki = cert.PublicKey.ExportSubjectPublicKeyInfo(); var hash = SHA256.HashData(spki); return Convert.ToHexStringLower(hash); } public static string GenerateFingerprint(X509Certificate2 cert) { var hash = SHA256.HashData(cert.RawData); return Convert.ToBase64String(hash); } public static IReadOnlyList GetWebEndpoints(IEnumerable uris) { var urls = new List(); foreach (var uri in uris) { if (!Uri.TryCreate(uri, UriKind.Absolute, out var endpoint)) continue; if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && !string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) continue; urls.Add(endpoint); } return urls; } public static string GetSubjectDNForm(X509Certificate2? cert) { return cert?.SubjectName.Name ?? string.Empty; } public static string GetIssuerDNForm(X509Certificate2? cert) { return cert?.IssuerName.Name ?? string.Empty; } public static bool MatchesPinnedCert(X509Certificate2 cert, HashSet pinned) { var hash = GetCertificateHash(cert); return pinned.Contains(hash); } /// /// Checks if a chain link is eligible for OCSP validation by ensuring the leaf /// certificate includes at least one valid HTTP(S) OCSP AIA endpoint. /// public static bool CertOCSPEligible(ChainLink? link) { if (link?.Leaf is null) return false; if (link.Leaf.RawData is null || link.Leaf.RawData.Length == 0) return false; var aiaUris = GetOcspResponderUris(link.Leaf); if (aiaUris.Count == 0) return false; var urls = GetWebEndpoints(aiaUris); if (urls.Count == 0) return false; link.OCSPWebEndpoints = urls; return true; } /// /// Returns the positional issuer certificate for a leaf in a verified chain. /// public static X509Certificate2? GetLeafIssuerCert(IReadOnlyList? chain, int leafPos) { if (chain is null || chain.Count == 0 || leafPos < 0 || leafPos >= chain.Count - 1) return null; return chain[leafPos + 1]; } /// /// Equivalent to Go certstore.GetLeafIssuer: verifies the leaf against the /// supplied trust root and returns the first issuer in the verified chain. /// public static X509Certificate2? GetLeafIssuer(X509Certificate2 leaf, X509Certificate2 trustedRoot) { using var chain = new X509Chain(); chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; chain.ChainPolicy.CustomTrustStore.Add(trustedRoot); chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag; if (!chain.Build(leaf) || chain.ChainElements.Count < 2) return null; return X509CertificateLoader.LoadCertificate(chain.ChainElements[1].Certificate.RawData); } /// /// Checks OCSP response currency semantics with clock skew and fallback TTL. /// public static bool OcspResponseCurrent(OcspResponseInfo response, OCSPPeerConfig opts) { var skew = TimeSpan.FromSeconds(opts.ClockSkew); if (skew < TimeSpan.Zero) skew = OCSPPeerConfig.DefaultAllowedClockSkew; var now = DateTime.UtcNow; if (response.NextUpdate.HasValue && response.NextUpdate.Value < now - skew) return false; if (!response.NextUpdate.HasValue) { var ttl = TimeSpan.FromSeconds(opts.TTLUnsetNextUpdate); if (ttl < TimeSpan.Zero) ttl = OCSPPeerConfig.DefaultTTLUnsetNextUpdate; if (response.ThisUpdate + ttl < now - skew) return false; } if (response.ThisUpdate > now + skew) return false; return true; } /// /// Validates OCSP delegated signer semantics. Direct issuer signatures are valid; /// delegated certificates must include id-kp-OCSPSigning EKU. /// public static bool ValidDelegationCheck(X509Certificate2? issuer, X509Certificate2? responderCertificate) { if (issuer is null) return false; if (responderCertificate is null) return true; if (responderCertificate.Thumbprint == issuer.Thumbprint) return true; foreach (var extension in responderCertificate.Extensions) { if (extension is not X509EnhancedKeyUsageExtension eku) continue; foreach (var oid in eku.EnhancedKeyUsages) { if (oid.Value == OcspSigningEkuOid) return true; } } return false; } [SlopwatchSuppress("SW003", "AsnContentException on a malformed AIA extension is intentionally swallowed; invalid extension shape means no usable OCSP URI")] private static IReadOnlyList GetOcspResponderUris(X509Certificate2 cert) { var uris = new List(); foreach (var extension in cert.Extensions) { if (!string.Equals(extension.Oid?.Value, AuthorityInfoAccessOid, StringComparison.Ordinal)) continue; try { var reader = new AsnReader(extension.RawData, AsnEncodingRules.DER); var seq = reader.ReadSequence(); while (seq.HasData) { var accessDescription = seq.ReadSequence(); var accessMethod = accessDescription.ReadObjectIdentifier(); if (!string.Equals(accessMethod, OcspAccessMethodOid, StringComparison.Ordinal)) { accessDescription.ThrowIfNotEmpty(); continue; } var uri = accessDescription.ReadCharacterString( UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6)); accessDescription.ThrowIfNotEmpty(); if (!string.IsNullOrWhiteSpace(uri)) uris.Add(uri); } seq.ThrowIfNotEmpty(); reader.ThrowIfNotEmpty(); } catch (AsnContentException ex) { // Invalid AIA extension shape should behave as "no usable OCSP URI" — swallow is intentional. _ = ex.Message; } } return uris; } }