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 dc3392b..6aa7eea 100644 Binary files a/porting.db and b/porting.db differ