Fix E2E test gaps and add comprehensive E2E + parity test suites
- 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
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
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;
|
||||
@@ -7,6 +9,10 @@ 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)
|
||||
@@ -16,9 +22,48 @@ public static class TlsHelper
|
||||
|
||||
public static X509Certificate2Collection LoadCaCertificates(string caPath)
|
||||
{
|
||||
var collection = new X509Certificate2Collection();
|
||||
collection.ImportFromPemFile(caPath);
|
||||
return collection;
|
||||
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)
|
||||
@@ -92,9 +137,198 @@ public static class TlsHelper
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user