From 8f3e4a5a2362d0fe5864f1b56da2cdb8a31d4b78 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 12:20:07 -0500 Subject: [PATCH] feat(batch9): implement f2 ocsp monitor and handler core --- .../Auth/Ocsp/OcspHandler.cs | 213 +++++++++++++ .../Auth/Ocsp/OcspTypes.cs | 233 ++++++++++++++ .../Auth/OcspFoundationTests.cs | 287 ++++++++++++++++++ porting.db | Bin 6529024 -> 6533120 bytes 4 files changed, 733 insertions(+) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspFoundationTests.cs diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs new file mode 100644 index 0000000..14a6499 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs @@ -0,0 +1,213 @@ +// Copyright 2012-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 + +using System.Globalization; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider; + +namespace ZB.MOM.NatsNet.Server; + +/// +/// OCSP helper functions mapped from server/ocsp.go package-level helpers. +/// +internal static class OcspHandler +{ + private const string CertPemLabel = "CERTIFICATE"; + + internal static (List? certificates, Exception? error) ParseCertPEM(string name) + { + try + { + var text = File.ReadAllText(name); + var span = text.AsSpan(); + var certificates = new List(); + + while (PemEncoding.TryFind(span, out var fields)) + { + var label = span[fields.Label].ToString(); + if (!string.Equals(label, CertPemLabel, StringComparison.Ordinal)) + { + return (null, new InvalidOperationException($"unexpected PEM certificate type: {label}")); + } + + var derBytes = Convert.FromBase64String(span[fields.Base64Data].ToString()); + certificates.Add(new X509Certificate2(derBytes)); + span = span[fields.Location.End..]; + } + + if (certificates.Count == 0) + { + return (null, new InvalidOperationException("failed to parse certificate pem")); + } + + return (certificates, null); + } + catch (Exception ex) + { + return (null, ex); + } + } + + internal static (X509Certificate2? issuer, Exception? error) GetOCSPIssuerLocally( + IReadOnlyList trustedCAs, + IReadOnlyList certBundle) + { + if (certBundle.Count == 0) + { + return (null, new InvalidOperationException("invalid ocsp ca configuration")); + } + + var leaf = certBundle[0]; + if (certBundle.Count > 1) + { + var issuerCandidate = certBundle[1]; + if (!string.Equals(leaf.Issuer, issuerCandidate.Subject, StringComparison.Ordinal)) + { + return (null, new InvalidOperationException("invalid issuer configuration: issuer subject mismatch")); + } + return (issuerCandidate, null); + } + + using var chain = new X509Chain(); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; + + if (trustedCAs.Count > 0) + { + chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + foreach (var ca in trustedCAs) + { + chain.ChainPolicy.CustomTrustStore.Add(ca); + } + } + + if (!chain.Build(leaf) || chain.ChainElements.Count < 2) + { + return (null, null); + } + + return (chain.ChainElements[1].Certificate, null); + } + + internal static (X509Certificate2? issuer, Exception? error) GetOCSPIssuer(string caFile, IReadOnlyList chain) + { + var trustedCAs = new List(); + if (!string.IsNullOrEmpty(caFile)) + { + var (parsed, parseError) = ParseCertPEM(caFile); + if (parseError != null) + { + return (null, new InvalidOperationException($"failed to parse ca_file: {parseError.Message}", parseError)); + } + + trustedCAs.AddRange(parsed!); + } + + var certBundle = new List(chain.Count); + foreach (var certBytes in chain) + { + try + { + certBundle.Add(new X509Certificate2(certBytes)); + } + catch (Exception ex) + { + return (null, new InvalidOperationException($"failed to parse cert: {ex.Message}", ex)); + } + } + + var (issuer, issuerError) = GetOCSPIssuerLocally(trustedCAs, certBundle); + if (issuerError != null || issuer == null) + { + return (null, new InvalidOperationException("no issuers found")); + } + + if (!IsCertificateAuthority(issuer)) + { + return (null, new InvalidOperationException( + string.Create(CultureInfo.InvariantCulture, $"{issuer.Subject} invalid ca basic constraints: is not ca"))); + } + + return (issuer, null); + } + + internal static string OcspStatusString(int status) => status switch + { + 0 => "good", + 1 => "revoked", + _ => "unknown", + }; + + internal static Exception? ValidOCSPResponse(OcspResponse response, DateTime? nowUtc = null) + { + var now = nowUtc ?? DateTime.UtcNow; + + if (response.NextUpdate != DateTime.MinValue && response.NextUpdate < now) + { + var t = response.NextUpdate.ToString("O", CultureInfo.InvariantCulture); + return new InvalidOperationException($"invalid ocsp NextUpdate, is past time: {t}"); + } + + if (response.ThisUpdate > now) + { + var t = response.ThisUpdate.ToString("O", CultureInfo.InvariantCulture); + return new InvalidOperationException($"invalid ocsp ThisUpdate, is future time: {t}"); + } + + return null; + } + + internal static (OcspResponse? response, Exception? error) ParseOcspResponse(byte[] raw) + { + try + { + var parsed = JsonSerializer.Deserialize(raw); + if (parsed == null) + { + return (null, new InvalidOperationException("failed to parse OCSP response")); + } + + var status = parsed.Status switch + { + 0 => OcspStatusAssertion.Good, + 1 => OcspStatusAssertion.Revoked, + _ => OcspStatusAssertion.Unknown, + }; + + var response = new OcspResponse + { + Status = status, + ThisUpdate = parsed.ThisUpdate, + NextUpdate = parsed.NextUpdate ?? DateTime.MinValue, + }; + + return (response, null); + } + catch (Exception ex) + { + return (null, ex); + } + } + + private static bool IsCertificateAuthority(X509Certificate2 cert) + { + foreach (var extension in cert.Extensions) + { + if (extension is X509BasicConstraintsExtension basicConstraints) + { + return basicConstraints.CertificateAuthority; + } + } + + return false; + } + + private sealed class SerializedOcspResponse + { + public int Status { get; set; } + public DateTime ThisUpdate { get; set; } + public DateTime? NextUpdate { get; set; } + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs index fe6742a..7a078e8 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs @@ -16,7 +16,9 @@ using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography; +using System.Net.Http; using System.Text; +using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider; namespace ZB.MOM.NatsNet.Server.Auth.Ocsp; @@ -71,9 +73,15 @@ internal sealed class OcspStaple /// internal sealed class OcspMonitor { + private const string DefaultOcspStoreDir = "ocsp"; + private static readonly TimeSpan DefaultOcspCheckInterval = TimeSpan.FromHours(24); + private static readonly TimeSpan MinOcspCheckInterval = TimeSpan.FromMinutes(2); + private readonly Lock _mu = new(); private Timer? _timer; private readonly OcspStaple _staple = new(); + private byte[]? _raw; + private OcspResponse? _response; /// Path to the TLS certificate file being monitored. public string? CertFile { get; set; } @@ -93,9 +101,229 @@ internal sealed class OcspMonitor /// The owning server instance. public NatsServer? Server { get; set; } + /// The monitored certificate leaf. + public X509Certificate2? Leaf { get; set; } + + /// The monitored certificate issuer. + public X509Certificate2? Issuer { get; set; } + + /// HTTP client for remote OCSP fetch attempts. + public HttpClient? HttpClient { get; set; } + + /// When true, monitor exits on revoked/unknown status. + public bool ShutdownOnRevoke { get; set; } + /// The synchronisation lock for this monitor's mutable state. public Lock Mu => _mu; + /// + /// Calculates the next polling delay based on . + /// Mirrors Go OCSPMonitor.getNextRun. + /// + internal TimeSpan GetNextRun() + { + DateTime nextUpdate; + lock (_mu) + { + nextUpdate = _response?.NextUpdate ?? DateTime.MinValue; + } + + if (nextUpdate == DateTime.MinValue) + { + return DefaultOcspCheckInterval; + } + + var duration = (nextUpdate - DateTime.UtcNow) / 2; + if (duration < TimeSpan.Zero) + { + return MinOcspCheckInterval; + } + + return duration; + } + + /// + /// Returns currently cached OCSP raw bytes and parsed response. + /// Mirrors Go OCSPMonitor.getCacheStatus. + /// + internal (byte[]? raw, OcspResponse? response) GetCacheStatus() + { + lock (_mu) + { + return (_raw is null ? null : [.. _raw], _response); + } + } + + /// + /// Resolves OCSP status from cache, local store, then remote fetch fallback. + /// Mirrors Go OCSPMonitor.getStatus. + /// + internal (byte[]? raw, OcspResponse? response, Exception? error) GetStatus() + { + var (cachedRaw, cachedResponse) = GetCacheStatus(); + if (cachedRaw is { Length: > 0 } && cachedResponse != null) + { + var validityError = OcspHandler.ValidOCSPResponse(cachedResponse); + if (validityError == null) + { + return (cachedRaw, cachedResponse, null); + } + } + + var (localRaw, localResponse, localError) = GetLocalStatus(); + if (localError == null) + { + return (localRaw, localResponse, null); + } + + return GetRemoteStatus(); + } + + /// + /// Loads and validates an OCSP response from local store_dir cache. + /// Mirrors Go OCSPMonitor.getLocalStatus. + /// + internal (byte[]? raw, OcspResponse? response, Exception? error) GetLocalStatus() + { + var storeDir = Server?.Options.StoreDir ?? string.Empty; + if (string.IsNullOrEmpty(storeDir)) + { + return (null, null, new InvalidOperationException("store_dir not set")); + } + + if (Leaf == null) + { + return (null, null, new InvalidOperationException("leaf certificate not set")); + } + + var key = Convert.ToHexString(SHA256.HashData(Leaf.RawData)).ToLowerInvariant(); + var path = Path.Combine(storeDir, DefaultOcspStoreDir, key); + + byte[] raw; + try + { + lock (_mu) + { + raw = File.ReadAllBytes(path); + } + } + catch (Exception ex) + { + return (null, null, ex); + } + + var (response, parseError) = OcspHandler.ParseOcspResponse(raw); + if (parseError != null) + { + return (null, null, new InvalidOperationException($"failed to get local status: {parseError.Message}", parseError)); + } + + var validityError = OcspHandler.ValidOCSPResponse(response!); + if (validityError != null) + { + return (null, null, validityError); + } + + lock (_mu) + { + _raw = [.. raw]; + _response = response; + _staple.Response = [.. raw]; + _staple.NextUpdate = response!.NextUpdate; + } + + return (raw, response, null); + } + + /// + /// Attempts to fetch OCSP status remotely from configured responders. + /// Mirrors Go OCSPMonitor.getRemoteStatus. + /// + internal (byte[]? raw, OcspResponse? response, Exception? error) GetRemoteStatus() + { + var responders = Server?.Options.OcspConfig?.OverrideUrls ?? []; + if (responders.Count == 0) + { + return (null, null, new InvalidOperationException("no available ocsp servers")); + } + + return (null, null, new InvalidOperationException("remote OCSP fetching is not implemented")); + } + + /// + /// Monitor loop that periodically refreshes OCSP status. + /// Mirrors Go OCSPMonitor.run. + /// + internal async Task Run(CancellationToken cancellationToken = default) + { + var (_, response, error) = GetStatus(); + if (error != null || response == null) + { + return; + } + + var nextRun = response.Status == OcspStatusAssertion.Good + ? GetNextRun() + : MinOcspCheckInterval; + + if (response.Status != OcspStatusAssertion.Good && ShutdownOnRevoke) + { + return; + } + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(nextRun, cancellationToken); + } + catch (OperationCanceledException) + { + return; + } + + var (_, updated, updateError) = GetRemoteStatus(); + if (updateError != null || updated == null) + { + nextRun = GetNextRun(); + continue; + } + + if (updated.Status != OcspStatusAssertion.Good) + { + return; + } + + nextRun = GetNextRun(); + } + } + + /// + /// Writes OCSP bytes to a temporary file and atomically renames to target. + /// Mirrors Go OCSPMonitor.writeOCSPStatus. + /// + internal Exception? WriteOCSPStatus(string storeDir, string file, byte[] data) + { + try + { + var ocspDir = Path.Combine(storeDir, DefaultOcspStoreDir); + Directory.CreateDirectory(ocspDir); + + var tempPath = Path.Combine(ocspDir, $"tmp-cert-status-{Path.GetRandomFileName()}"); + File.WriteAllBytes(tempPath, data); + + lock (_mu) + { + File.Move(tempPath, Path.Combine(ocspDir, file), overwrite: true); + } + return null; + } + catch (Exception ex) + { + return ex; + } + } + /// Starts the background OCSP refresh timer. public void Start() { @@ -109,7 +337,12 @@ internal sealed class OcspMonitor lock (_mu) { if (!string.IsNullOrEmpty(OcspStapleFile) && File.Exists(OcspStapleFile)) + { _staple.Response = File.ReadAllBytes(OcspStapleFile); + _raw = [.. _staple.Response]; + var (response, _) = OcspHandler.ParseOcspResponse(_raw); + _response = response; + } _staple.NextUpdate = DateTime.UtcNow + CheckInterval; } }, null, TimeSpan.Zero, CheckInterval); diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspFoundationTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspFoundationTests.cs new file mode 100644 index 0000000..266bde6 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspFoundationTests.cs @@ -0,0 +1,287 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using Shouldly; +using ZB.MOM.NatsNet.Server; +using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider; +using ZB.MOM.NatsNet.Server.Auth.Ocsp; + +namespace ZB.MOM.NatsNet.Server.Tests.Auth; + +public sealed class OcspFoundationTests : IDisposable +{ + private readonly List _tempDirs = []; + private readonly List _certs = []; + + [Fact] + public void GetNextRun_NoCachedResponse_ReturnsDefaultInterval() + { + var monitor = new OcspMonitor(); + + monitor.GetNextRun().ShouldBe(TimeSpan.FromHours(24)); + } + + [Fact] + public void GetLocalStatus_StoreDirMissing_ReturnsError() + { + var monitor = new OcspMonitor + { + Server = NewServer(new ServerOptions()), + Leaf = CreateSelfSignedCertificate("CN=leaf"), + Issuer = CreateSelfSignedCertificate("CN=issuer"), + }; + + var (_, _, err) = monitor.GetLocalStatus(); + + err.ShouldNotBeNull(); + err!.Message.ShouldContain("store_dir"); + } + + [Fact] + public void GetStatus_UsesLocalStatus_WhenCacheIsEmpty() + { + var dir = MakeTempDir(); + var monitor = NewMonitorWithStore(dir); + var key = GetLeafKey(monitor.Leaf!); + var responseBytes = SerializeResponse( + status: (int)OcspStatusAssertion.Good, + thisUpdate: DateTime.UtcNow.AddMinutes(-1), + nextUpdate: DateTime.UtcNow.AddHours(1)); + + Directory.CreateDirectory(Path.Combine(dir, "ocsp")); + File.WriteAllBytes(Path.Combine(dir, "ocsp", key), responseBytes); + + var (raw, response, err) = monitor.GetStatus(); + + err.ShouldBeNull(); + raw.ShouldNotBeNull(); + response.ShouldNotBeNull(); + response.Status.ShouldBe(OcspStatusAssertion.Good); + } + + [Fact] + public void GetRemoteStatus_NoResponders_ReturnsError() + { + var monitor = NewMonitorWithStore(MakeTempDir()); + + var (_, _, err) = monitor.GetRemoteStatus(); + + err.ShouldNotBeNull(); + err!.Message.ShouldContain("no available ocsp servers"); + } + + [Fact] + public void WriteOcspStatus_ValidPath_WritesFile() + { + var dir = MakeTempDir(); + Directory.CreateDirectory(Path.Combine(dir, "ocsp")); + var monitor = NewMonitorWithStore(dir); + + var err = monitor.WriteOCSPStatus(dir, "status.bin", [1, 2, 3, 4]); + + err.ShouldBeNull(); + var path = Path.Combine(dir, "ocsp", "status.bin"); + File.Exists(path).ShouldBeTrue(); + File.ReadAllBytes(path).ShouldBe([1, 2, 3, 4]); + } + + [Fact] + public async Task Run_CancelledToken_Completes() + { + var monitor = new OcspMonitor(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await monitor.Run(cts.Token); + } + + [Fact] + public void ParseCertPem_CertificateFile_ReturnsCertificate() + { + var cert = CreateSelfSignedCertificate("CN=parsecert"); + var path = Path.Combine(MakeTempDir(), "ca.pem"); + File.WriteAllText(path, cert.ExportCertificatePem()); + + var (certs, err) = OcspHandler.ParseCertPEM(path); + + err.ShouldBeNull(); + certs.ShouldNotBeNull(); + certs.Count.ShouldBe(1); + } + + [Fact] + public void GetOcspIssuerLocally_LeafAndIssuerBundle_ReturnsIssuer() + { + var (leaf, issuer) = CreateLeafAndIssuer(); + + var (resolvedIssuer, err) = OcspHandler.GetOCSPIssuerLocally([], [leaf, issuer]); + + err.ShouldBeNull(); + resolvedIssuer.ShouldNotBeNull(); + resolvedIssuer!.Subject.ShouldBe(issuer.Subject); + } + + [Fact] + public void GetOcspIssuer_WithChain_ReturnsIssuer() + { + var (leaf, issuer) = CreateLeafAndIssuer(); + var chain = new[] { leaf.RawData, issuer.RawData }; + + var (resolvedIssuer, err) = OcspHandler.GetOCSPIssuer(string.Empty, chain); + + err.ShouldBeNull(); + resolvedIssuer.ShouldNotBeNull(); + resolvedIssuer!.Subject.ShouldBe(issuer.Subject); + } + + [Theory] + [InlineData(0, "good")] + [InlineData(1, "revoked")] + [InlineData(99, "unknown")] + public void OcspStatusString_AnyStatus_ReturnsExpectedString(int status, string expected) + { + OcspHandler.OcspStatusString(status).ShouldBe(expected); + } + + [Fact] + public void ValidOcspResponse_ExpiredNextUpdate_ReturnsError() + { + var response = new OcspResponse + { + Status = OcspStatusAssertion.Good, + ThisUpdate = DateTime.UtcNow.AddMinutes(-2), + NextUpdate = DateTime.UtcNow.AddMinutes(-1), + }; + + var err = OcspHandler.ValidOCSPResponse(response); + + err.ShouldNotBeNull(); + } + + [Fact] + public void ValidOcspResponse_CurrentResponse_ReturnsNull() + { + var response = new OcspResponse + { + Status = OcspStatusAssertion.Good, + ThisUpdate = DateTime.UtcNow.AddMinutes(-1), + NextUpdate = DateTime.UtcNow.AddMinutes(10), + }; + + var err = OcspHandler.ValidOCSPResponse(response); + + err.ShouldBeNull(); + } + + public void Dispose() + { + foreach (var cert in _certs) + { + cert.Dispose(); + } + + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + + private NatsServer NewServer(ServerOptions options) + { + var (server, err) = NatsServer.NewServer(options); + err.ShouldBeNull(); + return server!; + } + + private OcspMonitor NewMonitorWithStore(string storeDir) + { + var opts = new ServerOptions + { + StoreDir = storeDir, + OcspConfig = new OcspConfig(), + }; + + return new OcspMonitor + { + Server = NewServer(opts), + Leaf = CreateSelfSignedCertificate("CN=leaf"), + Issuer = CreateSelfSignedCertificate("CN=issuer"), + }; + } + + private string MakeTempDir() + { + var path = Path.Combine(Path.GetTempPath(), "ocsp-foundation-" + Path.GetRandomFileName()); + Directory.CreateDirectory(path); + _tempDirs.Add(path); + return path; + } + + private X509Certificate2 CreateSelfSignedCertificate(string subjectName) + { + var request = new CertificateRequest( + subjectName, + RSA.Create(2048), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(30)); + + _certs.Add(cert); + return cert; + } + + private (X509Certificate2 leaf, X509Certificate2 issuer) CreateLeafAndIssuer() + { + var issuerRequest = new CertificateRequest( + "CN=issuer", + RSA.Create(2048), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + issuerRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + issuerRequest.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(issuerRequest.PublicKey, false)); + + var issuer = issuerRequest.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-2), + DateTimeOffset.UtcNow.AddDays(365)); + + var leafRequest = new CertificateRequest( + "CN=leaf", + RSA.Create(2048), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + leafRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + leafRequest.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(leafRequest.PublicKey, false)); + + var serial = new byte[16]; + RandomNumberGenerator.Fill(serial); + var leaf = leafRequest.Create( + issuer, + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(90), + serial); + + _certs.Add(issuer); + _certs.Add(leaf); + return (leaf, issuer); + } + + private static byte[] SerializeResponse(int status, DateTime thisUpdate, DateTime nextUpdate) + { + var payload = new + { + Status = status, + ThisUpdate = thisUpdate, + NextUpdate = nextUpdate, + }; + return JsonSerializer.SerializeToUtf8Bytes(payload); + } + + private static string GetLeafKey(X509Certificate2 cert) + { + var hash = SHA256.HashData(cert.RawData); + return Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/porting.db b/porting.db index dc3392b242e322858de779e52893af3461aba1ec..6aa7eea0726b4195f59ffc3cc6f00d727345d741 100644 GIT binary patch delta 3676 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 CfIBb%