diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs index 14a6499..dec85b4 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0 using System.Globalization; +using System.Formats.Asn1; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.Json; @@ -15,6 +16,8 @@ namespace ZB.MOM.NatsNet.Server; internal static class OcspHandler { private const string CertPemLabel = "CERTIFICATE"; + private const string TlsFeaturesOid = "1.3.6.1.5.5.7.1.24"; + private const int StatusRequestExtension = 5; internal static (List? certificates, Exception? error) ParseCertPEM(string name) { @@ -50,6 +53,43 @@ internal static class OcspHandler } } + internal static bool HasOCSPStatusRequest(X509Certificate2 cert) + { + foreach (var extension in cert.Extensions) + { + if (!string.Equals(extension.Oid?.Value, TlsFeaturesOid, StringComparison.Ordinal)) + { + continue; + } + + try + { + var reader = new AsnReader(extension.RawData, AsnEncodingRules.DER); + var seq = reader.ReadSequence(); + while (seq.HasData) + { + if (seq.ReadInteger() == StatusRequestExtension) + { + return true; + } + } + + if (reader.HasData) + { + return false; + } + } + catch + { + return false; + } + + break; + } + + return false; + } + internal static (X509Certificate2? issuer, Exception? error) GetOCSPIssuerLocally( IReadOnlyList trustedCAs, IReadOnlyList certBundle) @@ -85,6 +125,11 @@ internal static class OcspHandler if (!chain.Build(leaf) || chain.ChainElements.Count < 2) { + if (string.Equals(leaf.Subject, leaf.Issuer, StringComparison.Ordinal)) + { + return (leaf, null); + } + return (null, null); } 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 7a078e8..bdec1f8 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspTypes.cs @@ -86,6 +86,9 @@ internal sealed class OcspMonitor /// Path to the TLS certificate file being monitored. public string? CertFile { get; set; } + /// Connection kind this monitor applies to (client/router/gateway/leaf). + public string Kind { get; set; } = string.Empty; + /// Path to the CA certificate file used to verify OCSP responses. public string? CaFile { get; set; } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs index b33f0a0..f48937d 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs @@ -411,8 +411,12 @@ public sealed partial class NatsServer // Assign leaf options. s._leafNodeEnabled = opts.LeafNode.Port != 0 || opts.LeafNode.Remotes.Count > 0; - // OCSP (stub — session 23). - // s.EnableOcsp() — deferred + var ocspError = s.EnableOCSP(); + if (ocspError != null) + { + s._mu.ExitWriteLock(); + return (null, ocspError); + } // Gateway (stub — session 16). // s.NewGateway(opts) — deferred diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs index dbf0a39..6f6bd3d 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Lifecycle.cs @@ -139,6 +139,22 @@ public sealed partial class NatsServer Noticef("Server Exiting.."); + var monitors = GetOcspMonitors(); + foreach (var monitor in monitors) + { + monitor.Stop(); + } + + _mu.EnterWriteLock(); + try + { + _ocsps = null; + } + finally + { + _mu.ExitWriteLock(); + } + if (_ocsprc != null) { /* stub — stop OCSP cache in session 23 */ } DisposeSignalHandlers(); diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs new file mode 100644 index 0000000..666465a --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs @@ -0,0 +1,400 @@ +// Copyright 2012-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 + +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider; +using ZB.MOM.NatsNet.Server.Auth.Ocsp; + +namespace ZB.MOM.NatsNet.Server; + +/// +/// TLS configuration slot used by OCSP wiring to apply wrapped TLS settings. +/// Mirrors Go tlsConfigKind shape used by configureOCSP. +/// +internal sealed class OcspTlsConfig +{ + public required string Kind { get; init; } + public required SslServerAuthenticationOptions TlsConfig { get; init; } + public required TlsConfigOpts? TlsOptions { get; init; } + public required Action Apply { get; init; } + public bool IsLeafSpoke { get; init; } +} + +public sealed partial class NatsServer +{ + private const string ClientKindName = "client"; + private const string RouterKindName = "router"; + private const string GatewayKindName = "gateway"; + private const string LeafKindName = "leaf"; + private const string DefaultOcspStoreDir = "ocsp"; + + internal OcspMonitor[] GetOcspMonitors() + { + _mu.EnterReadLock(); + try + { + return _ocsps is null ? [] : [.. _ocsps]; + } + finally + { + _mu.ExitReadLock(); + } + } + + internal Exception? SetupOCSPStapleStoreDir() + { + var storeDir = GetOpts().StoreDir; + if (string.IsNullOrEmpty(storeDir)) + { + return null; + } + + var ocspDir = Path.Combine(storeDir, DefaultOcspStoreDir); + try + { + if (!Directory.Exists(ocspDir)) + { + Directory.CreateDirectory(ocspDir); + } + else + { + var attributes = File.GetAttributes(ocspDir); + if ((attributes & FileAttributes.Directory) != FileAttributes.Directory) + { + return new InvalidOperationException("OCSP storage directory is not a directory"); + } + } + } + catch (Exception ex) + { + return new InvalidOperationException($"could not create OCSP storage directory - {ex.Message}", ex); + } + + return null; + } + + internal List ConfigureOCSP() + { + var opts = GetOpts(); + var configs = new List(); + + if (opts.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = ClientKindName, + TlsConfig = opts.TlsConfig, + TlsOptions = opts.TlsConfigOpts, + Apply = tls => opts.TlsConfig = tls, + }); + } + + if (opts.Websocket.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = ClientKindName, + TlsConfig = opts.Websocket.TlsConfig, + TlsOptions = opts.Websocket.TlsConfigOpts, + Apply = tls => opts.Websocket.TlsConfig = tls, + }); + } + + if (opts.Mqtt.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = ClientKindName, + TlsConfig = opts.Mqtt.TlsConfig, + TlsOptions = opts.Mqtt.TlsConfigOpts, + Apply = tls => opts.Mqtt.TlsConfig = tls, + }); + } + + if (opts.Cluster.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = RouterKindName, + TlsConfig = opts.Cluster.TlsConfig, + TlsOptions = opts.Cluster.TlsConfigOpts, + Apply = tls => opts.Cluster.TlsConfig = tls, + }); + } + + if (opts.LeafNode.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = LeafKindName, + TlsConfig = opts.LeafNode.TlsConfig, + TlsOptions = opts.LeafNode.TlsConfigOpts, + Apply = tls => opts.LeafNode.TlsConfig = tls, + }); + } + + foreach (var remote in opts.LeafNode.Remotes) + { + if (remote.TlsConfig == null) + { + continue; + } + + var capturedRemote = remote; + configs.Add(new OcspTlsConfig + { + Kind = LeafKindName, + TlsConfig = remote.TlsConfig, + TlsOptions = remote.TlsConfigOpts, + IsLeafSpoke = true, + Apply = tls => capturedRemote.TlsConfig = tls, + }); + } + + if (opts.Gateway.TlsConfig != null) + { + configs.Add(new OcspTlsConfig + { + Kind = GatewayKindName, + TlsConfig = opts.Gateway.TlsConfig, + TlsOptions = opts.Gateway.TlsConfigOpts, + Apply = tls => opts.Gateway.TlsConfig = tls, + }); + } + + foreach (var gateway in opts.Gateway.Gateways) + { + if (gateway.TlsConfig == null) + { + continue; + } + + var capturedGateway = gateway; + configs.Add(new OcspTlsConfig + { + Kind = GatewayKindName, + TlsConfig = gateway.TlsConfig, + TlsOptions = gateway.TlsConfigOpts, + Apply = tls => capturedGateway.TlsConfig = tls, + }); + } + + return configs; + } + + internal (SslServerAuthenticationOptions? tlsConfig, OcspMonitor? monitor, Exception? error) NewOCSPMonitor( + OcspTlsConfig config) + { + var opts = GetOpts(); + var ocspConfig = opts.OcspConfig; + + var certFile = config.TlsOptions?.CertFile ?? opts.TlsCert; + var caFile = config.TlsOptions?.CaFile ?? opts.TlsCaCert; + + if (config.TlsConfig.ServerCertificate is not X509Certificate2 leaf) + { + return (null, null, new InvalidOperationException("no certificate found")); + } + + var shutdownOnRevoke = false; + var mustStaple = OcspHandler.HasOCSPStatusRequest(leaf); + if (ocspConfig != null) + { + switch (ocspConfig.Mode) + { + case OcspMode.Never: + if (mustStaple) + { + Warnf("Certificate at '{0}' has MustStaple but OCSP is disabled", certFile); + } + return (config.TlsConfig, null, null); + case OcspMode.Always: + mustStaple = true; + shutdownOnRevoke = true; + break; + case OcspMode.Must when mustStaple: + shutdownOnRevoke = true; + break; + case OcspMode.Auto when !mustStaple: + return (config.TlsConfig, null, null); + } + } + + if (!mustStaple) + { + return (config.TlsConfig, null, null); + } + + var setupError = SetupOCSPStapleStoreDir(); + if (setupError != null) + { + return (null, null, setupError); + } + + var chain = new List { leaf.RawData }; + if (config.TlsConfig.ServerCertificateContext != null) + { + foreach (var intermediate in config.TlsConfig.ServerCertificateContext.IntermediateCertificates) + { + chain.Add(intermediate.RawData); + } + } + + var (issuer, issuerError) = OcspHandler.GetOCSPIssuer(caFile, chain); + if (issuerError != null || issuer == null) + { + return (null, null, issuerError ?? new InvalidOperationException("no issuers found")); + } + + var monitor = new OcspMonitor + { + Kind = config.Kind, + Server = this, + CertFile = certFile, + CaFile = caFile, + Leaf = leaf, + Issuer = issuer, + ShutdownOnRevoke = shutdownOnRevoke, + }; + + var (_, response, statusError) = monitor.GetStatus(); + if (statusError != null) + { + return (null, null, + new InvalidOperationException($"bad OCSP status update for certificate at '{certFile}': {statusError.Message}", statusError)); + } + + if (response != null && response.Status != OcspStatusAssertion.Good && shutdownOnRevoke) + { + return (null, null, + new InvalidOperationException( + $"found existing OCSP status for certificate at '{certFile}': {OcspHandler.OcspStatusString((int)response.Status)}")); + } + + return (config.TlsConfig, monitor, null); + } + + internal Exception? EnableOCSP() + { + var configs = ConfigureOCSP(); + var monitors = new List(configs.Count); + + foreach (var config in configs) + { + if (config.Kind != LeafKindName) + { + var (tlsConfig, monitor, error) = NewOCSPMonitor(config); + if (error != null) + { + return error; + } + + if (monitor != null && tlsConfig != null) + { + monitors.Add(monitor); + config.Apply(tlsConfig); + } + } + + // OCSP peer verification hook is implemented in batch 9 F4. + } + + _mu.EnterWriteLock(); + try + { + _ocsps = monitors.Count == 0 ? null : [.. monitors]; + } + finally + { + _mu.ExitWriteLock(); + } + + return null; + } + + internal void StartOCSPMonitoring() + { + OcspMonitor[]? monitors; + _mu.EnterReadLock(); + try + { + monitors = _ocsps; + } + finally + { + _mu.ExitReadLock(); + } + + if (monitors == null || monitors.Length == 0) + { + return; + } + + foreach (var monitor in monitors) + { + Noticef("OCSP Stapling enabled for {0} connections", monitor.Kind); + monitor.Start(); + StartGoRoutine(() => monitor.Run(_quitCts.Token).GetAwaiter().GetResult()); + } + } + + internal Exception? ReloadOCSP() + { + var setupError = SetupOCSPStapleStoreDir(); + if (setupError != null) + { + return setupError; + } + + var existingMonitors = GetOcspMonitors(); + foreach (var monitor in existingMonitors) + { + monitor.Stop(); + } + + var configs = ConfigureOCSP(); + var replacement = new List(configs.Count); + + _mu.EnterWriteLock(); + try + { + _ocspPeerVerify = false; + } + finally + { + _mu.ExitWriteLock(); + } + + foreach (var config in configs) + { + if (config.Kind != LeafKindName) + { + var (tlsConfig, monitor, error) = NewOCSPMonitor(config); + if (error != null) + { + return error; + } + + if (monitor != null && tlsConfig != null) + { + replacement.Add(monitor); + config.Apply(tlsConfig); + } + } + } + + _mu.EnterWriteLock(); + try + { + _ocsps = replacement.Count == 0 ? null : [.. replacement]; + } + finally + { + _mu.ExitWriteLock(); + } + + StartOCSPMonitoring(); + return null; + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/NatsServerOcspTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/NatsServerOcspTests.cs new file mode 100644 index 0000000..b040a11 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/NatsServerOcspTests.cs @@ -0,0 +1,189 @@ +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using Shouldly; +using ZB.MOM.NatsNet.Server; + +namespace ZB.MOM.NatsNet.Server.Tests; + +public sealed class NatsServerOcspTests : IDisposable +{ + private readonly List _tempDirs = []; + private readonly List _certs = []; + + [Fact] + public void SetupOCSPStapleStoreDir_WithStoreDir_CreatesDirectory() + { + var dir = MakeTempDir(); + var server = NewServer(new ServerOptions + { + StoreDir = dir, + }); + + var err = server.SetupOCSPStapleStoreDir(); + + err.ShouldBeNull(); + Directory.Exists(Path.Combine(dir, "ocsp")).ShouldBeTrue(); + } + + [Fact] + public void ConfigureOCSP_WithTlsConfig_ReturnsClientEntry() + { + var cert = CreateSelfSignedCertificate("CN=configure-ocsp"); + var server = NewServer(new ServerOptions + { + TlsConfig = new SslServerAuthenticationOptions { ServerCertificate = cert }, + }); + + var configs = server.ConfigureOCSP(); + + configs.Count.ShouldBe(1); + configs[0].Kind.ShouldBe("client"); + } + + [Fact] + public void NewOCSPMonitor_OcspNever_ReturnsNoMonitor() + { + var cert = CreateSelfSignedCertificate("CN=monitor-never"); + var server = NewServer(new ServerOptions + { + StoreDir = MakeTempDir(), + OcspConfig = new OcspConfig { Mode = ZB.MOM.NatsNet.Server.OcspMode.Never }, + }); + + var config = new OcspTlsConfig + { + Kind = "client", + TlsConfig = new SslServerAuthenticationOptions { ServerCertificate = cert }, + TlsOptions = null, + Apply = _ => { }, + }; + + var (_, monitor, err) = server.NewOCSPMonitor(config); + + err.ShouldBeNull(); + monitor.ShouldBeNull(); + } + + [Fact] + public void EnableOCSP_WithAlwaysMode_AddsMonitor() + { + var cert = CreateSelfSignedCertificate("CN=enable-ocsp"); + var storeDir = MakeTempDir(); + WriteLocalOcspStatus(storeDir, cert); + var server = NewServer(new ServerOptions + { + StoreDir = storeDir, + OcspConfig = new OcspConfig + { + Mode = ZB.MOM.NatsNet.Server.OcspMode.Always, + OverrideUrls = ["https://ocsp.example.test"], + }, + TlsConfig = new SslServerAuthenticationOptions { ServerCertificate = cert }, + }); + + var err = server.EnableOCSP(); + + err.ShouldBeNull(); + server.GetOcspMonitors().Length.ShouldBe(1); + } + + [Fact] + public void StartOCSPMonitoring_NoMonitors_DoesNotThrow() + { + var server = NewServer(new ServerOptions()); + + Should.NotThrow(() => server.StartOCSPMonitoring()); + } + + [Fact] + public void ReloadOCSP_WithConfiguredTls_ReplacesMonitors() + { + var cert = CreateSelfSignedCertificate("CN=reload-ocsp"); + var storeDir = MakeTempDir(); + WriteLocalOcspStatus(storeDir, cert); + var server = NewServer(new ServerOptions + { + StoreDir = storeDir, + OcspConfig = new OcspConfig + { + Mode = ZB.MOM.NatsNet.Server.OcspMode.Always, + OverrideUrls = ["https://ocsp.example.test"], + }, + TlsConfig = new SslServerAuthenticationOptions { ServerCertificate = cert }, + }); + server.EnableOCSP().ShouldBeNull(); + server.GetOcspMonitors().Length.ShouldBe(1); + + var err = server.ReloadOCSP(); + + err.ShouldBeNull(); + server.GetOcspMonitors().Length.ShouldBe(1); + } + + [Fact] + public void HasOCSPStatusRequest_CertificateWithoutExtension_ReturnsFalse() + { + var cert = CreateSelfSignedCertificate("CN=no-status-request"); + + OcspHandler.HasOCSPStatusRequest(cert).ShouldBeFalse(); + } + + 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 X509Certificate2 CreateSelfSignedCertificate(string subject) + { + var req = new CertificateRequest( + subject, + RSA.Create(2048), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false)); + + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(90)); + _certs.Add(cert); + return cert; + } + + private string MakeTempDir() + { + var path = Path.Combine(Path.GetTempPath(), "nats-ocsp-" + Path.GetRandomFileName()); + Directory.CreateDirectory(path); + _tempDirs.Add(path); + return path; + } + + private static void WriteLocalOcspStatus(string storeDir, X509Certificate2 cert) + { + var key = Convert.ToHexString(SHA256.HashData(cert.RawData)).ToLowerInvariant(); + var ocspDir = Path.Combine(storeDir, "ocsp"); + Directory.CreateDirectory(ocspDir); + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + Status = 0, + ThisUpdate = DateTime.UtcNow.AddMinutes(-5), + NextUpdate = DateTime.UtcNow.AddHours(6), + }); + File.WriteAllBytes(Path.Combine(ocspDir, key), payload); + } +} diff --git a/porting.db b/porting.db index 6aa7eea..7a380aa 100644 Binary files a/porting.db and b/porting.db differ