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:
Joseph Doherty
2026-03-12 14:09:23 -04:00
parent 79c1ee8776
commit c30e67a69d
226 changed files with 17801 additions and 709 deletions

View File

@@ -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;
}
}