- Fix pull consumer fetch: send original stream subject in HMSG (not inbox) so NATS client distinguishes data messages from control messages - Fix MaxAge expiry: add background timer in StreamManager for periodic pruning - Fix JetStream wire format: Go-compatible anonymous objects with string enums, proper offset-based pagination for stream/consumer list APIs - Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream) - Add ~1000 parity tests across all subsystems (gaps closure) - Update gap inventory docs to reflect implementation status
335 lines
12 KiB
C#
335 lines
12 KiB
C#
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses one or more PEM blocks and requires all blocks to be CERTIFICATE.
|
|
/// Mirrors Go parseCertPEM behavior by rejecting unexpected block types.
|
|
/// </summary>
|
|
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*(?<body>[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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds an <see cref="SslStreamCertificateContext"/> for OCSP stapling.
|
|
/// Returns null when TLS is not configured or OCSP mode is Never.
|
|
/// When <paramref name="offline"/> is false the runtime will contact the
|
|
/// certificate's OCSP responder to obtain a fresh stapled response.
|
|
/// </summary>
|
|
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<Uri> GetWebEndpoints(IEnumerable<string> uris)
|
|
{
|
|
var urls = new List<Uri>();
|
|
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<string> pinned)
|
|
{
|
|
var hash = GetCertificateHash(cert);
|
|
return pinned.Contains(hash);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the positional issuer certificate for a leaf in a verified chain.
|
|
/// </summary>
|
|
public static X509Certificate2? GetLeafIssuerCert(IReadOnlyList<X509Certificate2>? chain, int leafPos)
|
|
{
|
|
if (chain is null || chain.Count == 0 || leafPos < 0 || leafPos >= chain.Count - 1)
|
|
return null;
|
|
return chain[leafPos + 1];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Equivalent to Go certstore.GetLeafIssuer: verifies the leaf against the
|
|
/// supplied trust root and returns the first issuer in the verified chain.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks OCSP response currency semantics with clock skew and fallback TTL.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates OCSP delegated signer semantics. Direct issuer signatures are valid;
|
|
/// delegated certificates must include id-kp-OCSPSigning EKU.
|
|
/// </summary>
|
|
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<string> GetOcspResponderUris(X509Certificate2 cert)
|
|
{
|
|
var uris = new List<string>();
|
|
|
|
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;
|
|
}
|
|
}
|