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 dec85b4..73a0782 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/OcspHandler.cs @@ -53,6 +53,110 @@ internal static class OcspHandler } } + internal static (OcspPeerConfig? config, Exception? error) ParseOCSPPeer(object? value) + { + if (value is not IDictionary map) + { + return (null, new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrIllegalPeerOptsConfig, value ?? "null"))); + } + + var config = OcspPeerConfig.Create(); + foreach (var (key, raw) in map) + { + switch (key.ToLowerInvariant()) + { + case "verify": + if (raw is not bool verify) + { + return (null, new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingPeerOptFieldGeneric, key))); + } + config.Verify = verify; + break; + + case "allowed_clockskew": + var (clockSkew, skewError) = ParsePeerDurationValue(raw); + if (skewError != null) + { + return (null, skewError); + } + if (clockSkew >= 0) + { + config.ClockSkew = clockSkew; + } + break; + + case "ca_timeout": + var (timeout, timeoutError) = ParsePeerDurationValue(raw); + if (timeoutError != null) + { + return (null, timeoutError); + } + if (timeout >= 0) + { + config.Timeout = timeout; + } + break; + + case "cache_ttl_when_next_update_unset": + var (ttl, ttlError) = ParsePeerDurationValue(raw); + if (ttlError != null) + { + return (null, ttlError); + } + if (ttl >= 0) + { + config.TTLUnsetNextUpdate = ttl; + } + break; + + case "warn_only": + if (raw is not bool warnOnly) + { + return (null, new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingPeerOptFieldGeneric, key))); + } + config.WarnOnly = warnOnly; + break; + + case "unknown_is_good": + if (raw is not bool unknownIsGood) + { + return (null, new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingPeerOptFieldGeneric, key))); + } + config.UnknownIsGood = unknownIsGood; + break; + + case "allow_when_ca_unreachable": + if (raw is not bool allowWhenCaUnreachable) + { + return (null, new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingPeerOptFieldGeneric, key))); + } + config.AllowWhenCAUnreachable = allowWhenCaUnreachable; + break; + + default: + return (null, new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingPeerOptFieldGeneric, key))); + } + } + + return (config, null); + } + + internal static X509Certificate2? PeerFromVerifiedChains(X509Certificate2[][] chains) + { + if (chains.Length == 0 || chains[0].Length == 0) + { + return null; + } + + return chains[0][0]; + } + internal static bool HasOCSPStatusRequest(X509Certificate2 cert) { foreach (var extension in cert.Extensions) @@ -249,6 +353,33 @@ internal static class OcspHandler return false; } + private static (double value, Exception? error) ParsePeerDurationValue(object? value) + { + return value switch + { + null => (0, new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingPeerOptFieldTypeConversion, "unexpected type"))), + int i => (i, null), + long l => (l, null), + float f => (f, null), + double d => (d, null), + string s => ParseDurationSeconds(s), + _ => (0, new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingPeerOptFieldTypeConversion, "unexpected type"))), + }; + } + + private static (double value, Exception? error) ParseDurationSeconds(string duration) + { + if (TimeSpan.TryParse(duration, CultureInfo.InvariantCulture, out var parsed)) + { + return (parsed.TotalSeconds, null); + } + + return (0, new InvalidOperationException( + string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingPeerOptFieldTypeConversion, "unexpected type"))); + } + private sealed class SerializedOcspResponse { public int Status { get; set; } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs index 666465a..a8e3597 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Ocsp.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0 using System.Net.Security; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider; using ZB.MOM.NatsNet.Server.Auth.Ocsp; @@ -397,4 +398,236 @@ public sealed partial class NatsServer StartOCSPMonitoring(); return null; } + + internal (SslServerAuthenticationOptions? tlsConfig, bool plugged, Exception? error) PlugTLSOCSPPeer(OcspTlsConfig? config) + { + if (config == null || config.TlsConfig == null) + { + return (null, false, new InvalidOperationException(OcspMessages.ErrUnableToPlugTLSEmptyConfig)); + } + + var kind = config.Kind; + var isSpoke = config.IsLeafSpoke; + var tlsOptions = config.TlsOptions; + if (tlsOptions?.OcspPeerConfig == null || !tlsOptions.OcspPeerConfig.Verify) + { + return (config.TlsConfig, false, null); + } + + Debugf(OcspMessages.DbgPlugTLSForKind, config.Kind); + + if (kind == ClientKindName || (kind == LeafKindName && !isSpoke)) + { + if (!tlsOptions.Verify) + { + return (null, false, new InvalidOperationException(OcspMessages.ErrMTLSRequired)); + } + + return PlugClientTLSOCSPPeer(config); + } + + if (kind == LeafKindName && isSpoke) + { + return PlugServerTLSOCSPPeer(config); + } + + return (config.TlsConfig, false, null); + } + + internal (SslServerAuthenticationOptions? tlsConfig, bool plugged, Exception? error) PlugClientTLSOCSPPeer(OcspTlsConfig? config) + { + if (config?.TlsConfig == null || config.TlsOptions == null) + { + return (null, false, new InvalidOperationException(OcspMessages.ErrUnableToPlugTLSClient)); + } + + var tlsConfig = config.TlsConfig; + var tlsOptions = config.TlsOptions; + if (tlsOptions.OcspPeerConfig == null || !tlsOptions.OcspPeerConfig.Verify) + { + return (tlsConfig, false, null); + } + + tlsConfig.RemoteCertificateValidationCallback = (_, _, chain, _) => + { + if (chain?.ChainElements == null || chain.ChainElements.Count == 0) + { + return false; + } + + var converted = chain.ChainElements + .Select(e => e.Certificate) + .OfType() + .ToArray(); + return TlsClientOCSPValid([converted], tlsOptions.OcspPeerConfig); + }; + + return (tlsConfig, true, null); + } + + internal (SslServerAuthenticationOptions? tlsConfig, bool plugged, Exception? error) PlugServerTLSOCSPPeer(OcspTlsConfig? config) + { + if (config?.TlsConfig == null || config.TlsOptions == null) + { + return (null, false, new InvalidOperationException(OcspMessages.ErrUnableToPlugTLSServer)); + } + + var tlsConfig = config.TlsConfig; + var tlsOptions = config.TlsOptions; + if (tlsOptions.OcspPeerConfig == null || !tlsOptions.OcspPeerConfig.Verify) + { + return (tlsConfig, false, null); + } + + tlsConfig.RemoteCertificateValidationCallback = (_, _, chain, _) => + { + if (chain?.ChainElements == null || chain.ChainElements.Count == 0) + { + return false; + } + + var converted = chain.ChainElements + .Select(e => e.Certificate) + .OfType() + .ToArray(); + return TlsServerOCSPValid([converted], tlsOptions.OcspPeerConfig); + }; + + return (tlsConfig, true, null); + } + + internal bool TlsServerOCSPValid(X509Certificate2[][] chains, OcspPeerConfig options) + { + Debugf(OcspMessages.DbgNumServerChains, chains.Length); + return PeerOCSPValid(chains, options); + } + + internal bool TlsClientOCSPValid(X509Certificate2[][] chains, OcspPeerConfig options) + { + Debugf(OcspMessages.DbgNumClientChains, chains.Length); + return PeerOCSPValid(chains, options); + } + + internal bool PeerOCSPValid(X509Certificate2[][] chains, OcspPeerConfig options) + { + var peer = OcspHandler.PeerFromVerifiedChains(chains); + if (peer == null) + { + Errorf(OcspMessages.ErrPeerEmptyAutoReject); + return false; + } + + for (var chainIndex = 0; chainIndex < chains.Length; chainIndex++) + { + var chain = chains[chainIndex]; + Debugf(OcspMessages.DbgLinksInChain, chainIndex, chain.Length); + + if (chain.Length == 1) + { + Debugf(OcspMessages.DbgSelfSignedValid, chainIndex); + return true; + } + + var chainEligible = false; + var eligibleLinks = new List(); + for (var linkPos = 0; linkPos < chain.Length - 1; linkPos++) + { + var cert = chain[linkPos]; + var link = new ChainLink { Leaf = cert }; + if (OcspUtilities.CertOCSPEligible(link)) + { + chainEligible = true; + link.Issuer = chain[linkPos + 1]; + eligibleLinks.Add(link); + } + } + + if (!chainEligible) + { + Debugf(OcspMessages.DbgValidNonOCSPChain, chainIndex); + return true; + } + + Debugf(OcspMessages.DbgChainIsOCSPEligible, chainIndex, eligibleLinks.Count); + var chainValid = true; + foreach (var link in eligibleLinks) + { + var (reason, good) = CertOCSPGood(link, options); + if (good) + { + continue; + } + + Debugf(reason); + chainValid = false; + break; + } + + if (chainValid) + { + Debugf(OcspMessages.DbgChainIsOCSPValid, chainIndex); + return true; + } + } + + Debugf(OcspMessages.DbgNoOCSPValidChains); + return false; + } + + internal (string reason, bool good) CertOCSPGood(ChainLink? link, OcspPeerConfig options) + { + if (link?.Leaf == null || link.Issuer == null || link.OcspWebEndpoints == null || link.OcspWebEndpoints.Count == 0) + { + return ("Empty chainlink found", false); + } + + var log = new OcspLog + { + Debugf = (fmt, args) => Debugf(fmt, args), + Noticef = (fmt, args) => Noticef(fmt, args), + Warnf = (fmt, args) => Warnf(fmt, args), + Errorf = (fmt, args) => Errorf(fmt, args), + }; + + var fingerprint = Convert.ToHexString(SHA256.HashData(link.Leaf.RawData)).ToLowerInvariant(); + var cached = _ocsprc?.Get(fingerprint); + if (cached is { Length: > 0 }) + { + var (parsed, parseError) = OcspHandler.ParseOcspResponse(cached); + if (parseError == null && parsed != null && OcspUtilities.OCSPResponseCurrent(parsed, options, log)) + { + if (parsed.Status == OcspStatusAssertion.Revoked || + (parsed.Status == OcspStatusAssertion.Unknown && !options.UnknownIsGood)) + { + if (options.WarnOnly) + { + Warnf("allowing OCSP peer due warn_only for [{0}]", link.Leaf.Subject); + return (string.Empty, true); + } + + return ($"OCSP response invalid status: {OcspStatusAssertionExtensions.GetStatusAssertionStr((int)parsed.Status)}", false); + } + + Debugf(OcspMessages.DbgOCSPValidPeerLink, link.Leaf.Subject); + return (string.Empty, true); + } + } + + if (options.AllowWhenCAUnreachable || options.WarnOnly) + { + if (options.WarnOnly) + { + Warnf("allowing OCSP peer due warn_only for [{0}]", link.Leaf.Subject); + } + + if (options.AllowWhenCAUnreachable) + { + Warnf("allowing OCSP peer due unreachable CA for [{0}]", link.Leaf.Subject); + } + + return (string.Empty, true); + } + + return ("failed to fetch OCSP response", false); + } } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs index a1a87da..f382669 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs @@ -118,6 +118,7 @@ public class TlsConfigOpts public List CaCertsMatch { get; set; } = []; public List Certificates { get; set; } = []; public SslProtocols MinVersion { get; set; } + public Auth.CertificateIdentityProvider.OcspPeerConfig? OcspPeerConfig { get; set; } } /// diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/CertificateIdentityProvider/CertificateIdentityProviderTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/CertificateIdentityProvider/CertificateIdentityProviderTests.cs index a2f240e..b9226f8 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/CertificateIdentityProvider/CertificateIdentityProviderTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/CertificateIdentityProvider/CertificateIdentityProviderTests.cs @@ -1,4 +1,5 @@ using Shouldly; +using ZB.MOM.NatsNet.Server; using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider; namespace ZB.MOM.NatsNet.Server.Tests.Auth.CertificateIdentityProvider; @@ -36,4 +37,18 @@ public sealed class CertificateIdentityProviderTests var decoded = Convert.FromBase64String(unescaped); decoded.ShouldBe(data); } + + [Fact] + public void ParseOCSPPeer_UnknownField_ReturnsError() + { + Dictionary map = new() + { + ["unexpected"] = true, + }; + + var (config, err) = OcspHandler.ParseOCSPPeer(map); + + config.ShouldBeNull(); + err.ShouldNotBeNull(); + } } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspPeerValidationTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspPeerValidationTests.cs new file mode 100644 index 0000000..e6dad5b --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/OcspPeerValidationTests.cs @@ -0,0 +1,193 @@ +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Shouldly; +using ZB.MOM.NatsNet.Server; +using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider; + +namespace ZB.MOM.NatsNet.Server.Tests.Auth; + +public sealed class OcspPeerValidationTests : IDisposable +{ + private readonly List _certs = []; + + [Fact] + public void ParseOCSPPeer_ValidMap_ReturnsConfig() + { + Dictionary map = new() + { + ["verify"] = true, + ["allowed_clockskew"] = 30.0, + ["ca_timeout"] = 5.0, + ["cache_ttl_when_next_update_unset"] = 120.0, + ["warn_only"] = true, + ["unknown_is_good"] = true, + ["allow_when_ca_unreachable"] = true, + }; + + var (config, err) = OcspHandler.ParseOCSPPeer(map); + + err.ShouldBeNull(); + config.ShouldNotBeNull(); + config.Verify.ShouldBeTrue(); + config.ClockSkew.ShouldBe(30.0); + config.Timeout.ShouldBe(5.0); + config.TTLUnsetNextUpdate.ShouldBe(120.0); + config.WarnOnly.ShouldBeTrue(); + config.UnknownIsGood.ShouldBeTrue(); + config.AllowWhenCAUnreachable.ShouldBeTrue(); + } + + [Fact] + public void PeerFromVerifiedChains_EmptyChains_ReturnsNull() + { + OcspHandler.PeerFromVerifiedChains([]).ShouldBeNull(); + } + + [Fact] + public void PeerFromVerifiedChains_NonEmptyChains_ReturnsFirstLeaf() + { + var cert = CreateSelfSignedCertificate("CN=peer-first"); + + var result = OcspHandler.PeerFromVerifiedChains([[cert]]); + + result.ShouldNotBeNull(); + result!.Subject.ShouldBe(cert.Subject); + } + + [Fact] + public void PlugTLSOCSPPeer_ClientWithoutVerify_ReturnsError() + { + var server = NewServer(); + var cert = CreateSelfSignedCertificate("CN=client-no-verify"); + var config = new OcspTlsConfig + { + Kind = "client", + TlsConfig = new SslServerAuthenticationOptions { ServerCertificate = cert }, + TlsOptions = new TlsConfigOpts + { + Verify = false, + OcspPeerConfig = new OcspPeerConfig { Verify = true }, + }, + Apply = _ => { }, + }; + + var (_, plugged, err) = server.PlugTLSOCSPPeer(config); + + plugged.ShouldBeFalse(); + err.ShouldNotBeNull(); + err!.Message.ShouldContain("mTLS"); + } + + [Fact] + public void PlugTLSOCSPPeer_ClientWithVerify_ReturnsPlugged() + { + var server = NewServer(); + var cert = CreateSelfSignedCertificate("CN=client-verify"); + var config = new OcspTlsConfig + { + Kind = "client", + TlsConfig = new SslServerAuthenticationOptions { ServerCertificate = cert }, + TlsOptions = new TlsConfigOpts + { + Verify = true, + OcspPeerConfig = new OcspPeerConfig { Verify = true }, + }, + Apply = _ => { }, + }; + + var (tlsConfig, plugged, err) = server.PlugTLSOCSPPeer(config); + + err.ShouldBeNull(); + plugged.ShouldBeTrue(); + tlsConfig.ShouldNotBeNull(); + tlsConfig!.RemoteCertificateValidationCallback.ShouldNotBeNull(); + } + + [Fact] + public void PlugTLSOCSPPeer_LeafSpoke_ReturnsPlugged() + { + var server = NewServer(); + var cert = CreateSelfSignedCertificate("CN=leaf-spoke"); + var config = new OcspTlsConfig + { + Kind = "leaf", + IsLeafSpoke = true, + TlsConfig = new SslServerAuthenticationOptions { ServerCertificate = cert }, + TlsOptions = new TlsConfigOpts + { + Verify = true, + OcspPeerConfig = new OcspPeerConfig { Verify = true }, + }, + Apply = _ => { }, + }; + + var (_, plugged, err) = server.PlugTLSOCSPPeer(config); + + err.ShouldBeNull(); + plugged.ShouldBeTrue(); + } + + [Fact] + public void TlsServerOCSPValid_SelfSignedChain_ReturnsTrue() + { + var server = NewServer(); + var cert = CreateSelfSignedCertificate("CN=selfsigned"); + var opts = new OcspPeerConfig { Verify = true }; + + var valid = server.TlsServerOCSPValid([[cert]], opts); + + valid.ShouldBeTrue(); + } + + [Fact] + public void TlsClientOCSPValid_EmptyChains_ReturnsFalse() + { + var server = NewServer(); + var opts = new OcspPeerConfig { Verify = true }; + + var valid = server.TlsClientOCSPValid([], opts); + + valid.ShouldBeFalse(); + } + + [Fact] + public void CertOCSPGood_EmptyChainLink_ReturnsFalse() + { + var server = NewServer(); + var (reason, good) = server.CertOCSPGood(new ChainLink(), new OcspPeerConfig()); + + good.ShouldBeFalse(); + reason.ShouldContain("Empty chainlink"); + } + + public void Dispose() + { + foreach (var cert in _certs) + { + cert.Dispose(); + } + } + + private NatsServer NewServer() + { + var (server, err) = NatsServer.NewServer(new ServerOptions()); + err.ShouldBeNull(); + return server!; + } + + private X509Certificate2 CreateSelfSignedCertificate(string subject) + { + var request = new CertificateRequest( + subject, + RSA.Create(2048), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + var cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30)); + _certs.Add(cert); + return cert; + } +} diff --git a/porting.db b/porting.db index 7a380aa..1486120 100644 Binary files a/porting.db and b/porting.db differ