From 21b2836a8f0564475e39aa7c2c2bbec8adfdfc90 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 19 Jul 2026 00:33:17 -0400 Subject: [PATCH 1/2] feat(security): unblock public CA cluster deployments Signed-off-by: Yordis Prieto --- docs/security.md | 26 ++++ .../Utils/CertificateExtensions.cs | 7 +- .../Certificates/key_usages.cs | 2 + ...ode_certificate_authentication_provider.cs | 125 +++++++++++++++++- .../Configuration/ClusterVNodeOptionsTests.cs | 32 +++++ src/EventStore.Core/ClusterVNode.cs | 5 +- .../Configuration/ClusterVNodeOptions.cs | 5 + .../NodeCertificateAuthenticationProvider.cs | 9 +- 8 files changed, 203 insertions(+), 8 deletions(-) diff --git a/docs/security.md b/docs/security.md index a479cde382..6132a047e1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -165,6 +165,32 @@ If the domains are `node1.esdb.mycompany.org`, `node2.esdb.mycompany.org` and `n Server certificates **must** have the internal and external IP addresses (`ReplicationIp` and `NodeIp` respectively) or DNS names as subject alternative names. ::: +#### Node certificate Client Authentication usage + +Cluster nodes normally use the same certificate when accepting and initiating node-to-node connections. For HTTPS +cluster traffic, strict node authentication therefore requires both Server Authentication and Client Authentication usages +when an Extended Key Usage extension is present. + +Some public certificate authorities issue TLS server certificates without the Client Authentication usage. A node can +accept those certificates from other cluster members only when this compatibility policy is explicitly enabled: + +| Format | Syntax | +|:---------------------|:---------------------------------------------------------| +| Command line | `--allow-node-certificate-without-client-auth-eku` | +| YAML | `AllowNodeCertificateWithoutClientAuthEku` | +| Environment variable | `EVENTSTORE_ALLOW_NODE_CERTIFICATE_WITHOUT_CLIENT_AUTH_EKU` | + +**Default**: `false` + +::: warning +Enable this option only when your certificate authority cannot issue node certificates with both usages. Trusted chain, +validity period, common-name policy, subject alternative names, key usage, and Server Authentication usage remain +required. This option does not relax user certificate authentication. +::: + +This setting controls node identity authentication for HTTPS cluster traffic. It does not change legacy secure TCP +replication certificate handling. + #### Trusted root certificates When getting an incoming connection, the server needs to ensure if the certificate used for the connection can be trusted. For this to work, the server needs to know where trusted root certificates are located. diff --git a/src/EventStore.Common/Utils/CertificateExtensions.cs b/src/EventStore.Common/Utils/CertificateExtensions.cs index 466677db89..89d2bf9b5f 100644 --- a/src/EventStore.Common/Utils/CertificateExtensions.cs +++ b/src/EventStore.Common/Utils/CertificateExtensions.cs @@ -329,6 +329,10 @@ private static bool HasClientAuthExtendedKeyUsage(IEnumerable extendedKeyUs } public static bool IsServerCertificate(this X509Certificate2 certificate, out string failReason) + => certificate.IsServerCertificate(allowMissingClientAuthEku: false, out failReason); + + public static bool IsServerCertificate(this X509Certificate2 certificate, bool allowMissingClientAuthEku, + out string failReason) { if (!certificate.TryGetKeyUsages(out var keyUsages, out var hasExtKeyUsagesExtension, out var extKeyUsages, out failReason)) { @@ -352,8 +356,7 @@ public static bool IsServerCertificate(this X509Certificate2 certificate, out st return false; } - // historically, server certificates also have the clientAuth EKU - if (!HasClientAuthExtendedKeyUsage(extKeyUsages, out failReason)) + if (!allowMissingClientAuthEku && !HasClientAuthExtendedKeyUsage(extKeyUsages, out failReason)) { return false; } diff --git a/src/EventStore.Core.Tests/Certificates/key_usages.cs b/src/EventStore.Core.Tests/Certificates/key_usages.cs index 983851086c..d64ead233d 100644 --- a/src/EventStore.Core.Tests/Certificates/key_usages.cs +++ b/src/EventStore.Core.Tests/Certificates/key_usages.cs @@ -76,6 +76,7 @@ public void certificate_with_server_auth_eku_only() // historically, server certificates also have the clientAuth EKU Assert.False(sut.IsServerCertificate(out _)); + Assert.True(sut.IsServerCertificate(allowMissingClientAuthEku: true, out _)); Assert.False(sut.IsClientCertificate(out _)); } @@ -99,6 +100,7 @@ public void certificate_with_no_key_usage() Assert.False(sut.IsClientCertificate(out _)); Assert.False(sut.IsServerCertificate(out _)); + Assert.False(sut.IsServerCertificate(allowMissingClientAuthEku: true, out _)); } [Test] diff --git a/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs b/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs index 333412d49f..b3e008f728 100644 --- a/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs +++ b/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs @@ -1,6 +1,9 @@ using System; using System.Linq; using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; @@ -16,9 +19,127 @@ public class TestFixtureWithNodeCertificateHttpAuthenticationProvider { protected NodeCertificateAuthenticationProvider _provider; - protected void SetUpProvider() + protected void SetUpProvider(bool allowNodeCertificateWithoutClientAuthEku = false) { - _provider = new NodeCertificateAuthenticationProvider(() => "eventstoredb-node"); + _provider = new NodeCertificateAuthenticationProvider( + () => "eventstoredb-node", + allowNodeCertificateWithoutClientAuthEku); + } +} + +[TestFixture] +public class when_handling_a_node_certificate_without_the_client_auth_eku : + TestFixtureWithNodeCertificateHttpAuthenticationProvider +{ + [TestCase(false, false)] + [TestCase(true, true)] + public async Task follows_the_explicit_compatibility_policy(bool allowNodeCertificateWithoutClientAuthEku, + bool expectedResult) + { + SetUpProvider(allowNodeCertificateWithoutClientAuthEku); + var context = new DefaultHttpContext(); + using var certificate = CreateCertificate("eventstoredb-node"); + using var presentedCertificate = await PresentCertificateOverTls(certificate); + context.Connection.ClientCertificate = presentedCertificate; + + var result = _provider.Authenticate(context, out _); + + Assert.AreEqual(expectedResult, result); + } + + [Test] + public void does_not_relax_the_node_identity_policy() + { + SetUpProvider(allowNodeCertificateWithoutClientAuthEku: true); + var context = new DefaultHttpContext(); + using var certificate = CreateCertificate("another-node"); + context.Connection.ClientCertificate = certificate; + + var result = _provider.Authenticate(context, out _); + + Assert.False(result); + } + + private static X509Certificate2 CreateCertificate(string commonName) + { + using var rsa = RSA.Create(); + var request = new CertificateRequest( + $"CN={commonName}", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("localhost"); + request.CertificateExtensions.Add(sanBuilder.Build()); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + critical: false)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + [new Oid("1.3.6.1.5.5.7.3.1")], + critical: false)); + + return request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMonths(-1), + DateTimeOffset.UtcNow.AddMonths(1)); + } + + private static async Task PresentCertificateOverTls(X509Certificate2 clientCertificate) + { + using var serverCertificate = CreateCertificate("localhost"); + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + try + { + var serverTask = Task.Run(async () => + { + using var serverClient = await listener.AcceptTcpClientAsync(); + using var serverStream = new SslStream( + serverClient.GetStream(), + leaveInnerStreamOpen: false, + (_, certificate, chain, errors) => + { + var result = ClusterVNode.ValidateClientCertificate( + certificate, + chain, + errors, + () => null, + () => new X509Certificate2Collection(clientCertificate)); + return result.Item1; + }); + await serverStream.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = serverCertificate, + ClientCertificateRequired = true, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + EnabledSslProtocols = SslProtocols.None + }); + + return X509CertificateLoader.LoadCertificate( + serverStream.RemoteCertificate.Export(X509ContentType.Cert)); + }); + + using var client = new TcpClient(); + await client.ConnectAsync((IPEndPoint)listener.LocalEndpoint); + using var clientStream = new SslStream( + client.GetStream(), + leaveInnerStreamOpen: false, + (_, _, _, _) => true, + (_, _, _, _, _) => clientCertificate); + await clientStream.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = "localhost", + ClientCertificates = new X509CertificateCollection { clientCertificate }, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + EnabledSslProtocols = SslProtocols.None + }); + + return await serverTask.WaitAsync(TimeSpan.FromSeconds(10)); + } + finally + { + listener.Stop(); + } } } diff --git a/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsTests.cs b/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsTests.cs index f5a988ab10..58b55355b3 100644 --- a/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsTests.cs +++ b/src/EventStore.Core.XUnit.Tests/Configuration/ClusterVNodeOptionsTests.cs @@ -102,6 +102,38 @@ public void disable_tls_is_a_valid_parameter() Assert.Empty(options.Unknown.Options); } + [Fact] + public void node_certificate_client_auth_eku_validation_is_strict_by_default() + { + var configuration = EventStoreConfiguration.Build(Array.Empty()); + var options = ClusterVNodeOptions.FromConfiguration(configuration); + + options.Certificate.AllowNodeCertificateWithoutClientAuthEku.Should().BeFalse(); + } + + [Fact] + public void node_certificate_client_auth_eku_compatibility_is_explicitly_configurable() + { + var options = GetOptions("--allow-node-certificate-without-client-auth-eku true"); + + options.Certificate.AllowNodeCertificateWithoutClientAuthEku.Should().BeTrue(); + Assert.Empty(options.Unknown.Options); + } + + [Fact] + public void node_certificate_client_auth_eku_compatibility_can_be_configured_from_the_environment() + { + var configuration = new ConfigurationBuilder() + .AddEventStoreDefaultValues() + .AddEventStoreEnvironmentVariables(( + "EVENTSTORE_ALLOW_NODE_CERTIFICATE_WITHOUT_CLIENT_AUTH_EKU", + "true")) + .Build(); + var options = ClusterVNodeOptions.FromConfiguration(configuration); + + options.Certificate.AllowNodeCertificateWithoutClientAuthEku.Should().BeTrue(); + } + [Fact] public void insecure_disables_tls_and_auth() { diff --git a/src/EventStore.Core/ClusterVNode.cs b/src/EventStore.Core/ClusterVNode.cs index 48a32c7972..cad68af3dc 100644 --- a/src/EventStore.Core/ClusterVNode.cs +++ b/src/EventStore.Core/ClusterVNode.cs @@ -1077,8 +1077,9 @@ GossipAdvertiseInfo GetGossipAdvertiseInfo() if (!options.Application.TlsDisabled()) { //transport-level authentication providers - httpAuthenticationProviders.Add( - new NodeCertificateAuthenticationProvider(() => _certificateProvider.GetReservedNodeCommonName())); + httpAuthenticationProviders.Add(new NodeCertificateAuthenticationProvider( + getCertificateReservedNodeCommonName: () => _certificateProvider.GetReservedNodeCommonName(), + allowNodeCertificateWithoutClientAuthEku: options.Certificate.AllowNodeCertificateWithoutClientAuthEku)); } if (!options.Application.AuthDisabled() && options.Interface.EnableTrustedAuth) diff --git a/src/EventStore.Core/Configuration/ClusterVNodeOptions.cs b/src/EventStore.Core/Configuration/ClusterVNodeOptions.cs index f5e97800f2..dcb6e5115d 100644 --- a/src/EventStore.Core/Configuration/ClusterVNodeOptions.cs +++ b/src/EventStore.Core/Configuration/ClusterVNodeOptions.cs @@ -339,6 +339,11 @@ public record CertificateOptions [Description( "The pattern the CN (Common Name) of a connecting EventStoreDB node must match to be authenticated. A wildcard FQDN can be specified if using wildcard certificates or if the CN is not the same on all nodes. Leave empty to automatically use the CN of this node's certificate.")] public string CertificateReservedNodeCommonName { get; init; } = string.Empty; + + [Description( + "Allows an incoming node certificate to omit the Client Authentication extended key usage. " + + "All other node certificate validation remains enabled. Enable only when the certificate authority cannot issue node certificates with both Server Authentication and Client Authentication usages.")] + public bool AllowNodeCertificateWithoutClientAuthEku { get; init; } = false; } [Description("Certificate Options (from store)")] diff --git a/src/EventStore.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs b/src/EventStore.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs index 96d3dd3f19..88d4df2b14 100644 --- a/src/EventStore.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs +++ b/src/EventStore.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs @@ -15,12 +15,15 @@ namespace EventStore.Core.Services.Transport.Http.Authentication public class NodeCertificateAuthenticationProvider : IHttpAuthenticationProvider { private readonly Func _getCertificateReservedNodeCommonName; + private readonly bool _allowNodeCertificateWithoutClientAuthEku; public string Name => "node-certificate"; - public NodeCertificateAuthenticationProvider(Func getCertificateReservedNodeCommonName) + public NodeCertificateAuthenticationProvider(Func getCertificateReservedNodeCommonName, + bool allowNodeCertificateWithoutClientAuthEku = false) { _getCertificateReservedNodeCommonName = getCertificateReservedNodeCommonName; + _allowNodeCertificateWithoutClientAuthEku = allowNodeCertificateWithoutClientAuthEku; } public bool Authenticate(HttpContext context, out HttpAuthenticationRequest request) @@ -98,7 +101,9 @@ private static bool TrySetDictionaryValue(IDictionary dictionary private bool AuthenticateUncached(HttpContext context, X509Certificate2 clientCertificate) { var ip = context.Connection.RemoteIpAddress?.ToString() ?? ""; - var isServerCertificate = clientCertificate.IsServerCertificate(out var serverCertReason); + var isServerCertificate = clientCertificate.IsServerCertificate( + _allowNodeCertificateWithoutClientAuthEku, + out var serverCertReason); var reservedNodeCN = _getCertificateReservedNodeCommonName(); bool hasReservedNodeCN; From ded75103a47cf0f0d1022963fde2492dec1681c2 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 19 Jul 2026 00:59:34 -0400 Subject: [PATCH 2/2] fix(security): keep certificate policy aligned after reload Signed-off-by: Yordis Prieto --- ...ode_certificate_authentication_provider.cs | 19 +++++++++++++++++++ src/EventStore.Core/ClusterVNode.cs | 8 +++++++- .../NodeCertificateAuthenticationProvider.cs | 12 +++++++++--- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs b/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs index b3e008f728..6f56a94d5e 100644 --- a/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs +++ b/src/EventStore.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs @@ -60,6 +60,25 @@ public void does_not_relax_the_node_identity_policy() Assert.False(result); } + [Test] + public void observes_policy_changes_for_new_connections() + { + var allowNodeCertificateWithoutClientAuthEku = false; + _provider = new NodeCertificateAuthenticationProvider( + () => "eventstoredb-node", + () => allowNodeCertificateWithoutClientAuthEku); + using var certificate = CreateCertificate("eventstoredb-node"); + + var strictContext = new DefaultHttpContext(); + strictContext.Connection.ClientCertificate = certificate; + Assert.False(_provider.Authenticate(strictContext, out _)); + + allowNodeCertificateWithoutClientAuthEku = true; + var compatibleContext = new DefaultHttpContext(); + compatibleContext.Connection.ClientCertificate = certificate; + Assert.True(_provider.Authenticate(compatibleContext, out _)); + } + private static X509Certificate2 CreateCertificate(string commonName) { using var rsa = RSA.Create(); diff --git a/src/EventStore.Core/ClusterVNode.cs b/src/EventStore.Core/ClusterVNode.cs index cad68af3dc..c7ff3af7e1 100644 --- a/src/EventStore.Core/ClusterVNode.cs +++ b/src/EventStore.Core/ClusterVNode.cs @@ -212,6 +212,7 @@ internal MultiQueuedHandler WorkersHandler private int _systemInitPublished; private int _shutdownStarted; private int _reloadingConfig; + private int _allowNodeCertificateWithoutClientAuthEku; private PosixSignalRegistration _reloadConfigSignalRegistration; private readonly object _startupTaskGate = new(); private Task _startupTask = null!; @@ -1079,7 +1080,8 @@ GossipAdvertiseInfo GetGossipAdvertiseInfo() //transport-level authentication providers httpAuthenticationProviders.Add(new NodeCertificateAuthenticationProvider( getCertificateReservedNodeCommonName: () => _certificateProvider.GetReservedNodeCommonName(), - allowNodeCertificateWithoutClientAuthEku: options.Certificate.AllowNodeCertificateWithoutClientAuthEku)); + getAllowNodeCertificateWithoutClientAuthEku: () => + Volatile.Read(ref _allowNodeCertificateWithoutClientAuthEku) != 0)); } if (!options.Application.AuthDisabled() && options.Interface.EnableTrustedAuth) @@ -2248,6 +2250,10 @@ private void ReloadCertificates(ClusterVNodeOptions options) { throw new InvalidConfigurationException("Aborting certificate loading due to verification errors."); } + + Volatile.Write( + ref _allowNodeCertificateWithoutClientAuthEku, + options.Certificate.AllowNodeCertificateWithoutClientAuthEku ? 1 : 0); } public override string ToString() => diff --git a/src/EventStore.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs b/src/EventStore.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs index 88d4df2b14..8bf7e00713 100644 --- a/src/EventStore.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs +++ b/src/EventStore.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs @@ -15,15 +15,21 @@ namespace EventStore.Core.Services.Transport.Http.Authentication public class NodeCertificateAuthenticationProvider : IHttpAuthenticationProvider { private readonly Func _getCertificateReservedNodeCommonName; - private readonly bool _allowNodeCertificateWithoutClientAuthEku; + private readonly Func _getAllowNodeCertificateWithoutClientAuthEku; public string Name => "node-certificate"; public NodeCertificateAuthenticationProvider(Func getCertificateReservedNodeCommonName, bool allowNodeCertificateWithoutClientAuthEku = false) + : this(getCertificateReservedNodeCommonName, () => allowNodeCertificateWithoutClientAuthEku) + { + } + + public NodeCertificateAuthenticationProvider(Func getCertificateReservedNodeCommonName, + Func getAllowNodeCertificateWithoutClientAuthEku) { _getCertificateReservedNodeCommonName = getCertificateReservedNodeCommonName; - _allowNodeCertificateWithoutClientAuthEku = allowNodeCertificateWithoutClientAuthEku; + _getAllowNodeCertificateWithoutClientAuthEku = getAllowNodeCertificateWithoutClientAuthEku; } public bool Authenticate(HttpContext context, out HttpAuthenticationRequest request) @@ -102,7 +108,7 @@ private bool AuthenticateUncached(HttpContext context, X509Certificate2 clientCe { var ip = context.Connection.RemoteIpAddress?.ToString() ?? ""; var isServerCertificate = clientCertificate.IsServerCertificate( - _allowNodeCertificateWithoutClientAuthEku, + _getAllowNodeCertificateWithoutClientAuthEku(), out var serverCertReason); var reservedNodeCN = _getCertificateReservedNodeCommonName();