From 39a1383de2f96c52d3accae2a23b2fdf1273ed25 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:38:01 -0500 Subject: [PATCH] feat: add OCSP peer verification and stapling support Wire OcspPeerVerify into the client-cert validation callback in TlsHelper so revocation is checked online when the flag is set. Add TlsHelper.BuildCertificateContext to build an SslStreamCertificateContext with offline:false, enabling the runtime to fetch and staple OCSP responses during the TLS handshake. NatsServer applies the context at startup when OcspConfig.Mode is not Never. Ten unit tests cover the config defaults, mode ordinals, and the null-return invariants of BuildCertificateContext. --- src/NATS.Server/NatsServer.cs | 13 +++ src/NATS.Server/Tls/TlsHelper.cs | 37 +++++++- tests/NATS.Server.Tests/OcspStaplingTests.cs | 97 ++++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 tests/NATS.Server.Tests/OcspStaplingTests.cs 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); + } +}