diff --git a/src/NATS.Server/NatsServer.cs b/src/NATS.Server/NatsServer.cs index 02a0734..b8dada6 100644 --- a/src/NATS.Server/NatsServer.cs +++ b/src/NATS.Server/NatsServer.cs @@ -276,6 +276,19 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable if (options.HasTls) { _sslOptions = TlsHelper.BuildServerAuthOptions(options); + + // OCSP stapling: build a certificate context so the runtime can + // fetch and cache a fresh OCSP response and staple it during the + // TLS handshake. offline:false tells the runtime to contact the + // OCSP responder; if the responder is unreachable we fall back to + // no stapling rather than refusing all connections. + var certContext = TlsHelper.BuildCertificateContext(options, offline: false); + if (certContext != null) + { + _sslOptions.ServerCertificateContext = certContext; + _logger.LogInformation("OCSP stapling enabled (mode: {OcspMode})", options.OcspConfig!.Mode); + } + _serverInfo.TlsRequired = !options.AllowNonTls; _serverInfo.TlsAvailable = options.AllowNonTls; _serverInfo.TlsVerify = options.TlsVerify; diff --git a/src/NATS.Server/Tls/TlsHelper.cs b/src/NATS.Server/Tls/TlsHelper.cs index cdc5ef6..efddc6e 100644 --- a/src/NATS.Server/Tls/TlsHelper.cs +++ b/src/NATS.Server/Tls/TlsHelper.cs @@ -33,6 +33,10 @@ public static class TlsHelper if (opts.TlsVerify && opts.TlsCaCert != null) { + var revocationMode = opts.OcspPeerVerify + ? X509RevocationMode.Online + : X509RevocationMode.NoCheck; + var caCerts = LoadCaCertificates(opts.TlsCaCert); authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) => { @@ -41,7 +45,19 @@ public static class TlsHelper chain2.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; foreach (var ca in caCerts) chain2.ChainPolicy.CustomTrustStore.Add(ca); - chain2.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain2.ChainPolicy.RevocationMode = revocationMode; + var cert2 = cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.GetRawCertData()); + return chain2.Build(cert2); + }; + } + else if (opts.OcspPeerVerify) + { + // No custom CA — still enable online revocation checking against the system store + authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) => + { + if (cert == null) return false; + using var chain2 = new X509Chain(); + chain2.ChainPolicy.RevocationMode = X509RevocationMode.Online; var cert2 = cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.GetRawCertData()); return chain2.Build(cert2); }; @@ -50,6 +66,25 @@ public static class TlsHelper return authOpts; } + /// + /// Builds an for OCSP stapling. + /// Returns null when TLS is not configured or OCSP mode is Never. + /// When is false the runtime will contact the + /// certificate's OCSP responder to obtain a fresh stapled response. + /// + public static SslStreamCertificateContext? BuildCertificateContext(NatsOptions opts, bool offline = false) + { + if (!opts.HasTls) return null; + if (opts.OcspConfig is null || opts.OcspConfig.Mode == OcspMode.Never) return null; + + var cert = LoadCertificate(opts.TlsCert!, opts.TlsKey); + var chain = new X509Certificate2Collection(); + if (!string.IsNullOrEmpty(opts.TlsCaCert)) + chain.ImportFromPemFile(opts.TlsCaCert); + + return SslStreamCertificateContext.Create(cert, chain, offline: offline); + } + public static string GetCertificateHash(X509Certificate2 cert) { var spki = cert.PublicKey.ExportSubjectPublicKeyInfo(); diff --git a/tests/NATS.Server.Tests/OcspStaplingTests.cs b/tests/NATS.Server.Tests/OcspStaplingTests.cs new file mode 100644 index 0000000..9c19dc0 --- /dev/null +++ b/tests/NATS.Server.Tests/OcspStaplingTests.cs @@ -0,0 +1,97 @@ +using NATS.Server.Tls; + +namespace NATS.Server.Tests; + +public class OcspStaplingTests +{ + [Fact] + public void OcspMode_Must_is_strictest() + { + var config = new OcspConfig { Mode = OcspMode.Must }; + config.Mode.ShouldBe(OcspMode.Must); + } + + [Fact] + public void OcspMode_Never_disables_all() + { + var config = new OcspConfig { Mode = OcspMode.Never }; + config.Mode.ShouldBe(OcspMode.Never); + } + + [Fact] + public void OcspPeerVerify_default_is_false() + { + var options = new NatsOptions(); + options.OcspPeerVerify.ShouldBeFalse(); + } + + [Fact] + public void OcspConfig_default_mode_is_Auto() + { + var config = new OcspConfig(); + config.Mode.ShouldBe(OcspMode.Auto); + } + + [Fact] + public void OcspConfig_default_OverrideUrls_is_empty() + { + var config = new OcspConfig(); + config.OverrideUrls.ShouldBeEmpty(); + } + + [Fact] + public void BuildCertificateContext_returns_null_when_no_tls() + { + var options = new NatsOptions + { + OcspConfig = new OcspConfig { Mode = OcspMode.Always }, + }; + // HasTls is false because TlsCert and TlsKey are not set + options.HasTls.ShouldBeFalse(); + var context = TlsHelper.BuildCertificateContext(options); + context.ShouldBeNull(); + } + + [Fact] + public void BuildCertificateContext_returns_null_when_mode_is_Never() + { + var options = new NatsOptions + { + TlsCert = "server.pem", + TlsKey = "server-key.pem", + OcspConfig = new OcspConfig { Mode = OcspMode.Never }, + }; + // OcspMode.Never must short-circuit even when TLS cert paths are set + var context = TlsHelper.BuildCertificateContext(options); + context.ShouldBeNull(); + } + + [Fact] + public void BuildCertificateContext_returns_null_when_OcspConfig_is_null() + { + var options = new NatsOptions + { + TlsCert = "server.pem", + TlsKey = "server-key.pem", + OcspConfig = null, + }; + var context = TlsHelper.BuildCertificateContext(options); + context.ShouldBeNull(); + } + + [Fact] + public void OcspPeerVerify_can_be_enabled() + { + var options = new NatsOptions { OcspPeerVerify = true }; + options.OcspPeerVerify.ShouldBeTrue(); + } + + [Fact] + public void OcspMode_values_have_correct_ordinals() + { + ((int)OcspMode.Auto).ShouldBe(0); + ((int)OcspMode.Always).ShouldBe(1); + ((int)OcspMode.Must).ShouldBe(2); + ((int)OcspMode.Never).ShouldBe(3); + } +}