From fc77abd876b6c2d103c587784f46c98f2811647e Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Thu, 9 Apr 2026 14:36:37 +0100 Subject: [PATCH 01/15] remove (nearly) duplicate code that can now be referenced directly in the server was originally duplicated when this plugin lived in a separate repo --- .../CertificateExtensionsTests.cs | 112 --------------- .../CertificateExtensions.cs | 127 ------------------ .../KurrentDB.Auth.UserCertificates.csproj | 3 + .../UserCertificateAuthenticationProvider.cs | 3 +- 4 files changed, 5 insertions(+), 240 deletions(-) delete mode 100644 src/KurrentDB.Auth.UserCertificates.Tests/CertificateExtensionsTests.cs delete mode 100644 src/KurrentDB.Auth.UserCertificates/CertificateExtensions.cs diff --git a/src/KurrentDB.Auth.UserCertificates.Tests/CertificateExtensionsTests.cs b/src/KurrentDB.Auth.UserCertificates.Tests/CertificateExtensionsTests.cs deleted file mode 100644 index 72995718e75..00000000000 --- a/src/KurrentDB.Auth.UserCertificates.Tests/CertificateExtensionsTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. -// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). - -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Xunit; - -namespace KurrentDB.Auth.UserCertificates.Tests; - -public class CertificateExtensionsTests { - private readonly Oid _serverAuth = Oid.FromOidValue("1.3.6.1.5.5.7.3.1", OidGroup.EnhancedKeyUsage); - private readonly Oid _clientAuth = Oid.FromOidValue("1.3.6.1.5.5.7.3.2", OidGroup.EnhancedKeyUsage); - private const X509KeyUsageFlags DefaultKeyUsages = X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment; - - private static X509Certificate2 GenSut(X509KeyUsageFlags keyUsages, OidCollection extendedKeyUsages) { - using (RSA rsa = RSA.Create()) { - var certReq = - new CertificateRequest("CN=hello", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - var keyUsageExtension = new X509KeyUsageExtension(keyUsages, false); - var extendedKeyUsageExtension = new X509EnhancedKeyUsageExtension(extendedKeyUsages, false); - - certReq.CertificateExtensions.Add(keyUsageExtension); - - if (extendedKeyUsages.Count != 0) - certReq.CertificateExtensions.Add(extendedKeyUsageExtension); - - return certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddMonths(-1), - DateTimeOffset.UtcNow.AddMonths(1)); - } - } - - private static OidCollection GenOids(params Oid[] oids) { - var oidCollection = new OidCollection(); - foreach (var oid in oids) { - oidCollection.Add(oid); - } - - return oidCollection; - } - - [Fact] - public void certificate_with_client_auth_eku() { - var sut = GenSut( - keyUsages: DefaultKeyUsages, - extendedKeyUsages: GenOids(_clientAuth)); - - Assert.True(sut.IsClientCertificate(out _)); - Assert.False(sut.IsServerCertificate(out _)); - } - - [Fact] - public void certificate_with_client_and_server_auth_eku() { - var sut = GenSut( - keyUsages: DefaultKeyUsages, - extendedKeyUsages: GenOids(_clientAuth, _serverAuth)); - - Assert.True(sut.IsServerCertificate(out _)); - Assert.True(sut.IsClientCertificate(out _)); - } - - [Fact] - public void certificate_with_server_auth_eku_only() { - var sut = GenSut( - keyUsages: DefaultKeyUsages, - extendedKeyUsages: GenOids(_serverAuth)); - - // historically, server certificates also have the clientAuth EKU - Assert.False(sut.IsServerCertificate(out _)); - Assert.False(sut.IsClientCertificate(out _)); - } - - [Fact] - public void certificate_with_no_ekus() { - var sut = GenSut( - keyUsages: DefaultKeyUsages, - extendedKeyUsages: GenOids()); - - Assert.True(sut.IsServerCertificate(out _)); - Assert.False(sut.IsClientCertificate(out _)); - } - - [Fact] - public void certificate_with_no_key_usage() { - var sut = GenSut( - keyUsages: X509KeyUsageFlags.None, - extendedKeyUsages: GenOids(_clientAuth, _serverAuth)); - - Assert.False(sut.IsClientCertificate(out _)); - Assert.False(sut.IsServerCertificate(out _)); - } - - [Fact] - public void certificate_with_missing_key_usage() { - var sut = GenSut( - keyUsages: X509KeyUsageFlags.KeyEncipherment, - extendedKeyUsages: GenOids(_clientAuth, _serverAuth)); - - Assert.False(sut.IsClientCertificate(out _)); - Assert.False(sut.IsServerCertificate(out _)); - } - - [Fact] - public void certificate_with_key_agreement_instead_of_key_encipherment_key_usage() { - var sut = GenSut( - keyUsages: X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyAgreement, - extendedKeyUsages: GenOids(_clientAuth, _serverAuth)); - - Assert.True(sut.IsClientCertificate(out _)); - Assert.True(sut.IsServerCertificate(out _)); - } -} diff --git a/src/KurrentDB.Auth.UserCertificates/CertificateExtensions.cs b/src/KurrentDB.Auth.UserCertificates/CertificateExtensions.cs deleted file mode 100644 index eb1207f48f1..00000000000 --- a/src/KurrentDB.Auth.UserCertificates/CertificateExtensions.cs +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. -// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). - -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; - -namespace KurrentDB.Auth.UserCertificates; - -public static class CertificateExtensions { - private static bool TryGetKeyUsages( - this X509Certificate2 certificate, - out X509KeyUsageFlags keyUsages, - out bool hasExtendedKeyUsage, - out Oid[] extKeyUsages, - out string failReason) { - - keyUsages = X509KeyUsageFlags.None; - hasExtendedKeyUsage = false; - extKeyUsages = []; - failReason = ""; - - X509ExtensionCollection extensions; - try { - extensions = certificate.Extensions; - } catch (CryptographicException ex) { - failReason = ex.Message; - return false; - } - - foreach (var extension in extensions) { - switch (extension.Oid?.Value) { - case "2.5.29.15": // Oid for Key Usage extension - var keyUsageExt = (X509KeyUsageExtension)extension; - keyUsages |= keyUsageExt.KeyUsages; - break; - case "2.5.29.37": // Oid for Extended Key Usage extension - hasExtendedKeyUsage = true; - var enhancedKeyUsageExt = (X509EnhancedKeyUsageExtension)extension; - extKeyUsages = new Oid[enhancedKeyUsageExt.EnhancedKeyUsages.Count]; - if (extKeyUsages.Length > 0) - enhancedKeyUsageExt.EnhancedKeyUsages.CopyTo(extKeyUsages, 0); - break; - } - } - - return true; - } - - private static bool HasCorrectKeyUsages(X509KeyUsageFlags keyUsageFlags, out string failReason) { - if (!keyUsageFlags.HasFlag(X509KeyUsageFlags.DigitalSignature)) { - failReason = "Missing key usage: Digital Signature"; - return false; - } - - if (!keyUsageFlags.HasFlag(X509KeyUsageFlags.KeyEncipherment) && - !keyUsageFlags.HasFlag(X509KeyUsageFlags.KeyAgreement)) { - failReason = "Missing key usage: Key Encipherment and/or Key Agreement"; - return false; - } - - failReason = string.Empty; - return true; - } - - private static bool HasServerAuthExtendedKeyUsage(IEnumerable extendedKeyUsages, out string failReason) { - if (extendedKeyUsages.All(oid => oid.Value != "1.3.6.1.5.5.7.3.1")) { // serverAuth - failReason = "Missing extended key usage: Server Authentication"; - return false; - } - - failReason = string.Empty; - return true; - } - - private static bool HasClientAuthExtendedKeyUsage(IEnumerable extendedKeyUsages, out string failReason) { - if (extendedKeyUsages.All(oid => oid.Value != "1.3.6.1.5.5.7.3.2")) { // clientAuth - failReason = "Missing extended key usage: Client Authentication"; - return false; - } - - failReason = string.Empty; - return true; - } - - public static bool IsServerCertificate(this X509Certificate2 certificate, out string failReason) { - if (!certificate.TryGetKeyUsages(out var keyUsages, out var hasExtKeyUsagesExtension, out var extKeyUsages, out failReason)) - return false; - - if (!HasCorrectKeyUsages(keyUsages, out failReason)) - return false; - - // rfc5280 section-4.2.1.12: extended key usages (EKUs) only have to be enforced - // if the extension is present at all. here, we don't enforce them for server - // certificates for backwards compatibility. however, this also implies that we - // _need_ the EKUs to be present for other types of certificates (e.g user certificates) - // as otherwise it would cause ambiguity when trying to determine the certificate type. - if (hasExtKeyUsagesExtension) { - if (!HasServerAuthExtendedKeyUsage(extKeyUsages, out failReason)) - return false; - - // historically, server certificates also have the clientAuth EKU - if (!HasClientAuthExtendedKeyUsage(extKeyUsages, out failReason)) - return false; - } - - failReason = string.Empty; - return true; - } - - public static bool IsClientCertificate(this X509Certificate2 certificate, out string failReason) { - if (!certificate.TryGetKeyUsages(out var keyUsages, out _, out var extKeyUsages, out failReason)) - return false; - - if (!HasCorrectKeyUsages(keyUsages, out failReason)) - return false; - - if (!HasClientAuthExtendedKeyUsage(extKeyUsages, out failReason)) - return false; - - failReason = string.Empty; - return true; - } - - public static string GetCommonName(this X509Certificate2 certificate) => certificate.GetNameInfo(X509NameType.SimpleName, false); -} diff --git a/src/KurrentDB.Auth.UserCertificates/KurrentDB.Auth.UserCertificates.csproj b/src/KurrentDB.Auth.UserCertificates/KurrentDB.Auth.UserCertificates.csproj index 520bd7c4bd2..f225aac272d 100644 --- a/src/KurrentDB.Auth.UserCertificates/KurrentDB.Auth.UserCertificates.csproj +++ b/src/KurrentDB.Auth.UserCertificates/KurrentDB.Auth.UserCertificates.csproj @@ -8,6 +8,9 @@ true + + + diff --git a/src/KurrentDB.Auth.UserCertificates/UserCertificateAuthenticationProvider.cs b/src/KurrentDB.Auth.UserCertificates/UserCertificateAuthenticationProvider.cs index 3ea144e8d69..d5b1c942c96 100644 --- a/src/KurrentDB.Auth.UserCertificates/UserCertificateAuthenticationProvider.cs +++ b/src/KurrentDB.Auth.UserCertificates/UserCertificateAuthenticationProvider.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Security.Cryptography.X509Certificates; using EventStore.Plugins.Authentication; +using KurrentDB.Common.Utils; using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -109,7 +110,7 @@ private bool AuthenticateUncached(HttpContext context, X509Certificate2 clientCe if (!clientCertificate.IsClientCertificate(out _)) return false; - if (clientCertificate.IsServerCertificate(out _)) + if (clientCertificate.IsServerCertificate(disableClientAuthEkuValidation: false, out _)) return false; userId = clientCertificate.GetCommonName(); From c6bdaec7b3977c4e0427863041a4387df86037ab Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Wed, 15 Apr 2026 09:12:29 +0100 Subject: [PATCH 02/15] refactor out HasIpOrDnsSan helper --- .../Utils/CertificateExtensions.cs | 8 ++++++ .../Certificates/subject_alternative_names.cs | 26 +++++++++++++++++++ .../NodeCertificateAuthenticationProvider.cs | 4 +-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/KurrentDB.Common/Utils/CertificateExtensions.cs b/src/KurrentDB.Common/Utils/CertificateExtensions.cs index bf314fbdea2..b06a30eee7a 100644 --- a/src/KurrentDB.Common/Utils/CertificateExtensions.cs +++ b/src/KurrentDB.Common/Utils/CertificateExtensions.cs @@ -57,6 +57,14 @@ public static class CertificateExtensions { return sans; } + public static bool HasIpOrDnsSan(this X509Certificate2 certificate) => + certificate + .GetSubjectAlternativeNames() + .Where(x => x.type + is CertificateNameType.DnsName + or CertificateNameType.IpAddress) + .IsNotEmpty(); + public static bool MatchesName(this X509Certificate2 certificate, string name) { // Implemented based on RFC 6125 (https://datatracker.ietf.org/doc/html/rfc6125) with the following changes: // - Does not support SRV-ID and URI-ID identifier types yet diff --git a/src/KurrentDB.Core.Tests/Certificates/subject_alternative_names.cs b/src/KurrentDB.Core.Tests/Certificates/subject_alternative_names.cs index 8a385d5b8a0..412605bc0db 100644 --- a/src/KurrentDB.Core.Tests/Certificates/subject_alternative_names.cs +++ b/src/KurrentDB.Core.Tests/Certificates/subject_alternative_names.cs @@ -104,4 +104,30 @@ public void can_read_multiple_ips_and_dns_names_from_san_with_other_name_types() .OrderBy(x => x).ToArray()); } + [Test] + public void has_ip_or_dns_san_returns_true_with_ip() { + var sut = GenSut([("127.0.0.1", CertificateNameType.IpAddress)]); + Assert.True(sut.HasIpOrDnsSan()); + } + + [Test] + public void has_ip_or_dns_san_returns_true_with_dns() { + var sut = GenSut([("hello.world", CertificateNameType.DnsName)]); + Assert.True(sut.HasIpOrDnsSan()); + } + + [Test] + public void has_ip_or_dns_san_returns_false_with_only_email() { + var sut = GenSut([("test@test.com", "email")]); + Assert.False(sut.HasIpOrDnsSan()); + } + + [Test] + public void has_ip_or_dns_san_returns_true_with_mixed_san_types() { + var sut = GenSut([ + ("test@test.com", "email"), + ("127.0.0.1", CertificateNameType.IpAddress), + ]); + Assert.True(sut.HasIpOrDnsSan()); + } } diff --git a/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs b/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs index 69f82f50c56..e559dc237f2 100644 --- a/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs +++ b/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs @@ -92,9 +92,7 @@ private bool AuthenticateUncached(HttpContext context, X509Certificate2 clientCe return false; } - bool hasIpOrDnsSan = clientCertificate.GetSubjectAlternativeNames() - .Where(x => x.type is CertificateNameType.DnsName or CertificateNameType.IpAddress) - .IsNotEmpty(); + bool hasIpOrDnsSan = clientCertificate.HasIpOrDnsSan(); if (!isServerCertificate && !hasReservedNodeCN && !hasIpOrDnsSan) { // We are sure that this is not a misconfigured node certificate with incorrect EKUs, missing SANs, etc. It could be a user certificate. From d54a758fabaf5414000b94e120e40567782b642a Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Wed, 15 Apr 2026 17:55:21 +0100 Subject: [PATCH 03/15] add inbound certificate classifier - classifies inbound ceriticates into node/user/unclassified based on EKUs - used by both certificate authentication providers to guarantee consistent classification (can't match both providers) --- .../UserCertificateAuthenticationProvider.cs | 6 +- .../Utils/CertificateClassification.cs | 13 ++ .../Utils/CertificateExtensions.cs | 74 ++++++----- .../Certificates/key_usages.cs | 116 ------------------ .../inbound_certificate_classification.cs | 72 +++++++++++ .../NodeCertificateAuthenticationProvider.cs | 67 +++++----- 6 files changed, 162 insertions(+), 186 deletions(-) create mode 100644 src/KurrentDB.Common/Utils/CertificateClassification.cs delete mode 100644 src/KurrentDB.Core.Tests/Certificates/key_usages.cs create mode 100644 src/KurrentDB.Core.XUnit.Tests/Certificates/inbound_certificate_classification.cs diff --git a/src/KurrentDB.Auth.UserCertificates/UserCertificateAuthenticationProvider.cs b/src/KurrentDB.Auth.UserCertificates/UserCertificateAuthenticationProvider.cs index d5b1c942c96..9420173b702 100644 --- a/src/KurrentDB.Auth.UserCertificates/UserCertificateAuthenticationProvider.cs +++ b/src/KurrentDB.Auth.UserCertificates/UserCertificateAuthenticationProvider.cs @@ -107,10 +107,8 @@ private static bool TrySetDictionaryValue(IDictionary dictionary private bool AuthenticateUncached(HttpContext context, X509Certificate2 clientCertificate, out string userId) { userId = null; - if (!clientCertificate.IsClientCertificate(out _)) - return false; - - if (clientCertificate.IsServerCertificate(disableClientAuthEkuValidation: false, out _)) + var profile = clientCertificate.ClassifyInboundCertificate(disableClientAuthEkuValidation: false, out _); + if (profile is not CertificateClassification.User) return false; userId = clientCertificate.GetCommonName(); diff --git a/src/KurrentDB.Common/Utils/CertificateClassification.cs b/src/KurrentDB.Common/Utils/CertificateClassification.cs new file mode 100644 index 00000000000..826f666abc6 --- /dev/null +++ b/src/KurrentDB.Common/Utils/CertificateClassification.cs @@ -0,0 +1,13 @@ +// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. +// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). + +namespace KurrentDB.Common.Utils; + +// The certificate profile distinguishes whether a certificate is configured to authenticate a node or a user. +// It does not imply that the certificate is valid (trusted, non expired etc). +// It does not imply that the user/node is authenticated. +public enum CertificateClassification { + Unclassified, + Node, + User, +} diff --git a/src/KurrentDB.Common/Utils/CertificateExtensions.cs b/src/KurrentDB.Common/Utils/CertificateExtensions.cs index b06a30eee7a..48c7263248a 100644 --- a/src/KurrentDB.Common/Utils/CertificateExtensions.cs +++ b/src/KurrentDB.Common/Utils/CertificateExtensions.cs @@ -24,7 +24,7 @@ public static class CertificateExtensions { try { extensions = certificate.Extensions; } catch (CryptographicException) { - return null; + return []; } var sans = new List<(string, string)>(); @@ -280,45 +280,51 @@ private static bool HasClientAuthExtendedKeyUsage(IEnumerable extendedKeyUs return true; } - public static bool IsServerCertificate(this X509Certificate2 certificate, bool disableClientAuthEkuValidation, out string failReason) { - if (!certificate.TryGetKeyUsages(out var keyUsages, out var hasExtKeyUsagesExtension, out var extKeyUsages, out failReason)) - return false; + /// + /// Classifies an inbound certificate as a node certificate, user certificate, or unclassified, + /// based on its EKU profile. + /// + /// + /// Does not check the CN. + /// Does not validate the certificate. + /// + public static CertificateClassification ClassifyInboundCertificate( + this X509Certificate2 certificate, + bool disableClientAuthEkuValidation, + out string error) { - if (!HasCorrectKeyUsages(keyUsages, out failReason)) - return false; + if (!certificate.TryGetKeyUsages(out var keyUsages, out var hasEkuExtension, out var ekus, out error)) + return CertificateClassification.Unclassified; - // rfc5280 section-4.2.1.12: extended key usages (EKUs) only have to be enforced - // if the extension is present at all. we are allowed to require the extension, but do not for server - // certificates for backwards compatibility. however, this also implies that we - // _need_ the extension to be present for other types of certificates (e.g user certificates) - // as otherwise it would cause ambiguity when trying to determine the certificate type. - if (hasExtKeyUsagesExtension) { - if (!HasServerAuthExtendedKeyUsage(extKeyUsages, out failReason)) - return false; - - if (!disableClientAuthEkuValidation && !HasClientAuthExtendedKeyUsage(extKeyUsages, out failReason)) { - failReason += - ". If you are using a certificate from a public CA that does not include the clientAuth EKU, " + - "please see the documentation for the DisableClientAuthEkuValidation configuration option."; - return false; - } - } + if (!HasCorrectKeyUsages(keyUsages, out error)) + return CertificateClassification.Unclassified; - failReason = string.Empty; - return true; - } + var hasServerAuthEku = HasServerAuthExtendedKeyUsage(ekus, out _); + var hasClientAuthEku = HasClientAuthExtendedKeyUsage(ekus, out _); - public static bool IsClientCertificate(this X509Certificate2 certificate, out string failReason) { - if (!certificate.TryGetKeyUsages(out var keyUsages, out _, out var extKeyUsages, out failReason)) - return false; + // User Cert: clientAuthEku; no serverAuthEku; + if (hasClientAuthEku && !hasServerAuthEku) { + error = ""; + return CertificateClassification.User; + } - if (!HasCorrectKeyUsages(keyUsages, out failReason)) - return false; + // Node Cert: + // rfc5280 section-4.2.1.12: EKUs only have to be enforced if the extension is present. + // We don't require the extension for server certificates for backwards compatibility, + // but we do require it for user certificates to avoid ambiguity. + if (!hasEkuExtension || hasServerAuthEku && (hasClientAuthEku || disableClientAuthEkuValidation)) { + error = ""; + return CertificateClassification.Node; + } - if (!HasClientAuthExtendedKeyUsage(extKeyUsages, out failReason)) - return false; + // Unclassified Cert: + error = "Certificate is not a user certificate. "; + error += hasServerAuthEku + ? "Certificate has the serverAuth EKU but not the clientAuth EKU. " + + "If you are using a certificate from a public CA that does not include the clientAuth EKU, " + + "please see the documentation for the DisableClientAuthEkuValidation configuration option." + : "Certificate has the EKU extension but does not have the serverAuth EKU."; - failReason = string.Empty; - return true; + return CertificateClassification.Unclassified; } } diff --git a/src/KurrentDB.Core.Tests/Certificates/key_usages.cs b/src/KurrentDB.Core.Tests/Certificates/key_usages.cs deleted file mode 100644 index 01457811d4e..00000000000 --- a/src/KurrentDB.Core.Tests/Certificates/key_usages.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. -// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). - -using System; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using KurrentDB.Common.Utils; -using NUnit.Framework; - -namespace KurrentDB.Core.Tests.Certificates; - -public class key_usages { - private readonly Oid _serverAuth = Oid.FromOidValue("1.3.6.1.5.5.7.3.1", OidGroup.EnhancedKeyUsage); - private readonly Oid _clientAuth = Oid.FromOidValue("1.3.6.1.5.5.7.3.2", OidGroup.EnhancedKeyUsage); - private const X509KeyUsageFlags DefaultKeyUsages = X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment; - - private static X509Certificate2 GenSut(X509KeyUsageFlags keyUsages, OidCollection extendedKeyUsages) { - using (RSA rsa = RSA.Create()) { - var certReq = - new CertificateRequest("CN=hello", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - var keyUsageExtension = new X509KeyUsageExtension(keyUsages, false); - var extendedKeyUsageExtension = new X509EnhancedKeyUsageExtension(extendedKeyUsages, false); - - certReq.CertificateExtensions.Add(keyUsageExtension); - - if (extendedKeyUsages.Count != 0) - certReq.CertificateExtensions.Add(extendedKeyUsageExtension); - - return certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddMonths(-1), - DateTimeOffset.UtcNow.AddMonths(1)); - } - } - - private static OidCollection GenOids(params Oid[] oids) { - var oidCollection = new OidCollection(); - foreach (var oid in oids) { - oidCollection.Add(oid); - } - - return oidCollection; - } - - [Test] - public void certificate_with_client_auth_eku() { - var sut = GenSut( - keyUsages: DefaultKeyUsages, - extendedKeyUsages: GenOids(_clientAuth)); - - Assert.True(sut.IsClientCertificate(out _)); - Assert.False(sut.IsServerCertificate(false, out _)); - } - - [Test] - public void certificate_with_client_and_server_auth_eku() { - var sut = GenSut( - keyUsages: DefaultKeyUsages, - extendedKeyUsages: GenOids(_clientAuth, _serverAuth)); - - Assert.True(sut.IsServerCertificate(false, out _)); - Assert.True(sut.IsClientCertificate(out _)); - } - - [TestCase(false, false)] - [TestCase(true, true)] - public void certificate_with_server_auth_eku_only(bool disableClientAuthEkuValidation, bool expectedIsServer) { - var sut = GenSut( - keyUsages: DefaultKeyUsages, - extendedKeyUsages: GenOids(_serverAuth)); - - Assert.AreEqual(expectedIsServer, sut.IsServerCertificate(disableClientAuthEkuValidation, out var failReason)); - if (!expectedIsServer) - Assert.That(failReason, Does.Contain(nameof(ClusterVNodeOptions.Certificate.DisableClientAuthEkuValidation))); - Assert.False(sut.IsClientCertificate(out _)); - } - - [Test] - public void certificate_with_no_ekus() { - var sut = GenSut( - keyUsages: DefaultKeyUsages, - extendedKeyUsages: GenOids()); - - Assert.True(sut.IsServerCertificate(false, out _)); - Assert.False(sut.IsClientCertificate(out _)); - } - - [Test] - public void certificate_with_no_key_usage() { - var sut = GenSut( - keyUsages: X509KeyUsageFlags.None, - extendedKeyUsages: GenOids(_clientAuth, _serverAuth)); - - Assert.False(sut.IsClientCertificate(out _)); - Assert.False(sut.IsServerCertificate(false, out _)); - } - - [Test] - public void certificate_with_missing_key_usage() { - var sut = GenSut( - keyUsages: X509KeyUsageFlags.KeyEncipherment, - extendedKeyUsages: GenOids(_clientAuth, _serverAuth)); - - Assert.False(sut.IsClientCertificate(out _)); - Assert.False(sut.IsServerCertificate(false, out _)); - } - - [Test] - public void certificate_with_key_agreement_instead_of_key_encipherment_key_usage() { - var sut = GenSut( - keyUsages: X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyAgreement, - extendedKeyUsages: GenOids(_clientAuth, _serverAuth)); - - Assert.True(sut.IsClientCertificate(out _)); - Assert.True(sut.IsServerCertificate(false, out _)); - } -} diff --git a/src/KurrentDB.Core.XUnit.Tests/Certificates/inbound_certificate_classification.cs b/src/KurrentDB.Core.XUnit.Tests/Certificates/inbound_certificate_classification.cs new file mode 100644 index 00000000000..f399a408e31 --- /dev/null +++ b/src/KurrentDB.Core.XUnit.Tests/Certificates/inbound_certificate_classification.cs @@ -0,0 +1,72 @@ +// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. +// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). + +using System; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using KurrentDB.Common.Utils; +using Xunit; + +namespace KurrentDB.Core.XUnit.Tests.Certificates; + +public class inbound_certificate_classification { + private static readonly X509KeyUsageFlags DefaultKeyUsages = X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment; + + private static X509Certificate2 GenCert( + bool serverAuth, bool clientAuth, + X509KeyUsageFlags? keyUsages = null) { + + using var rsa = RSA.Create(); + var certReq = new CertificateRequest("CN=test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + certReq.CertificateExtensions.Add(new X509KeyUsageExtension(keyUsages ?? DefaultKeyUsages, critical: false)); + + if (serverAuth || clientAuth) { + var oids = new OidCollection(); + if (serverAuth) oids.Add(new Oid("1.3.6.1.5.5.7.3.1")); + if (clientAuth) oids.Add(new Oid("1.3.6.1.5.5.7.3.2")); + certReq.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(oids, critical: false)); + } + + return certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddMonths(-1), DateTimeOffset.UtcNow.AddMonths(1)); + } + + // serverAuth and clientAuth + [InlineData(true, true, false, CertificateClassification.Node, "")] + // serverAuth only (e.g. public CA) + [InlineData(true, false, true, CertificateClassification.Node, "")] + [InlineData(true, false, false, CertificateClassification.Unclassified, "Certificate is not a user certificate. Certificate has the serverAuth EKU but not the clientAuth EKU. If you are using a certificate from a public CA that does not include the clientAuth EKU, please see the documentation for the DisableClientAuthEkuValidation configuration option.")] + // clientAuth only + [InlineData(false, true, false, CertificateClassification.User, "")] + // No EKU extension + [InlineData(false, false, false, CertificateClassification.Node, "")] + [Theory] + public void classify( + bool serverAuth, bool clientAuth, bool disableClientAuthEkuValidation, + CertificateClassification expectedProfile, string expectedDescription) { + + var cert = GenCert(serverAuth, clientAuth); + var profile = cert.ClassifyInboundCertificate(disableClientAuthEkuValidation, out var description); + Assert.Equal(expectedProfile, profile); + Assert.Equal(expectedDescription, description); + } + + [InlineData(X509KeyUsageFlags.None)] + [InlineData(X509KeyUsageFlags.KeyEncipherment)] + [InlineData(X509KeyUsageFlags.DigitalSignature)] + [Theory] + public void bad_key_usages_are_unknown(X509KeyUsageFlags keyUsages) { + var cert = GenCert(serverAuth: true, clientAuth: true, keyUsages: keyUsages); + Assert.Equal(CertificateClassification.Unclassified, + cert.ClassifyInboundCertificate(disableClientAuthEkuValidation: false, out _)); + } + + [Fact] + public void key_agreement_instead_of_key_encipherment_is_valid() { + var cert = GenCert(serverAuth: true, clientAuth: true, + keyUsages: X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyAgreement); + Assert.Equal(CertificateClassification.Node, + cert.ClassifyInboundCertificate(disableClientAuthEkuValidation: false, out _)); + } +} diff --git a/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs b/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs index e559dc237f2..4090902062e 100644 --- a/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs +++ b/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using EventStore.Plugins.Authentication; @@ -80,37 +79,41 @@ private static bool TrySetDictionaryValue(IDictionary dictionary private bool AuthenticateUncached(HttpContext context, X509Certificate2 clientCertificate) { var ip = context.Connection.RemoteIpAddress?.ToString() ?? ""; - var isServerCertificate = clientCertificate.IsServerCertificate(disableClientAuthEkuValidation, out var serverCertReason); - - var reservedNodeCN = getCertificateReservedNodeCommonName(); - bool hasReservedNodeCN; - try { - hasReservedNodeCN = clientCertificate.ClientCertificateMatchesName(reservedNodeCN); - } catch (CryptographicException) { - return false; - } catch (NullReferenceException) { - return false; + switch (clientCertificate.ClassifyInboundCertificate(disableClientAuthEkuValidation, out var serverCertReason)) { + case CertificateClassification.Node: { + var reservedNodeCN = getCertificateReservedNodeCommonName(); + bool hasReservedNodeCN; + try { + hasReservedNodeCN = clientCertificate.ClientCertificateMatchesName(reservedNodeCN); + } catch (CryptographicException) { + return false; + } catch (NullReferenceException) { + return false; + } + + if (!hasReservedNodeCN) { + var clientCertificateCN = clientCertificate.GetCommonName(); + Log.Error( + "Connection from node: {ip} was denied because its CN: {clientCertificateCN} does not match with the reserved node CN: {reservedNodeCN}", + ip, clientCertificateCN, reservedNodeCN); + } + + bool hasIpOrDnsSan = clientCertificate.HasIpOrDnsSan(); + if (!hasIpOrDnsSan) { + Log.Error("Connection from node: {ip} was denied because its certificate does not have any IP or DNS Subject Alternative Names (SAN).", ip); + } + + return hasReservedNodeCN && hasIpOrDnsSan; + } + + case CertificateClassification.User: { + return false; + } + + default: { + Log.Error("Connection from {ip} was denied because its certificate was not recognized as a node or user certificate: {failReason}", ip, serverCertReason); + return false; + } } - - bool hasIpOrDnsSan = clientCertificate.HasIpOrDnsSan(); - - if (!isServerCertificate && !hasReservedNodeCN && !hasIpOrDnsSan) { - // We are sure that this is not a misconfigured node certificate with incorrect EKUs, missing SANs, etc. It could be a user certificate. - return false; - } - if (!hasReservedNodeCN) { - var clientCertificateCN = clientCertificate.GetCommonName(); - Log.Error( - "Connection from node: {ip} was denied because its CN: {clientCertificateCN} does not match with the reserved node CN: {reservedNodeCN}", - ip, clientCertificateCN, reservedNodeCN); - } - if (!hasIpOrDnsSan) { - Log.Error("Connection from node: {ip} was denied because its certificate does not have any IP or DNS Subject Alternative Names (SAN).", ip); - } - if (!isServerCertificate) { - Log.Error("Connection from node: {ip} was denied because it is not configured as a server certificate: {failReason}", ip, serverCertReason); - } - - return hasReservedNodeCN && hasIpOrDnsSan && isServerCertificate; } } From 85fa777f9f9f325f3750d7a1aad470239f889025 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Fri, 17 Apr 2026 14:53:50 +0100 Subject: [PATCH 04/15] add options for client cluster certificate. when provided this certificate will be used for outgoing connections to other nodes --- .../Configuration/ClusterVNodeOptions.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs index e52df7fb2f2..08a1e3d66cf 100644 --- a/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs +++ b/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs @@ -41,6 +41,8 @@ public partial record ClusterVNodeOptions { [OptionGroup] public CertificateOptions Certificate { get; init; } = new(); [OptionGroup] public CertificateFileOptions CertificateFile { get; init; } = new(); [OptionGroup] public CertificateStoreOptions CertificateStore { get; init; } = new(); + [OptionGroup] public ClientClusterCertificateFileOptions ClientClusterCertificateFile { get; init; } = new(); + [OptionGroup] public ClientClusterCertificateStoreOptions ClientClusterCertificateStore { get; init; } = new(); [OptionGroup] public ClusterOptions Cluster { get; init; } = new(); [OptionGroup] public DatabaseOptions Database { get; init; } = new(); [OptionGroup] public GrpcOptions Grpc { get; init; } = new(); @@ -79,6 +81,8 @@ public static ClusterVNodeOptions FromConfiguration(IConfigurationRoot configura Certificate = configuration.BindOptions(), CertificateFile = configuration.BindOptions(), CertificateStore = configuration.BindOptions(), + ClientClusterCertificateFile = configuration.BindOptions(), + ClientClusterCertificateStore = configuration.BindOptions(), Cluster = configuration.BindOptions(), Database = configuration.BindOptions(), Grpc = configuration.BindOptions(), @@ -254,6 +258,53 @@ public record CertificateStoreOptions { public string TrustedRootCertificateThumbprint { get; init; } = string.Empty; } + [Description("Cluster Client Certificate Options (from file)")] + public record ClientClusterCertificateFileOptions { + [Description("The path to a PKCS #12 (.p12/.pfx) or an X.509 (.pem, .crt, .cer, .der) cluster client certificate file " + + "for outbound intra-cluster connections. If specified, this certificate is used when connecting to other nodes " + + "instead of the main node certificate.")] + public string? ClientClusterCertificateFile { get; init; } + + [Description("The path to the cluster client certificate private key file (.key) if an X.509 (.pem, .crt, .cer, .der) " + + "cluster client certificate file is provided.")] + public string? ClientClusterCertificatePrivateKeyFile { get; init; } + + [Description("The password to the cluster client certificate if a PKCS #12 (.p12/.pfx) certificate file is provided."), + Sensitive] + public string? ClientClusterCertificatePassword { get; init; } + + [Description("The password to the cluster client certificate private key file if an encrypted PKCS #8 private key file is provided."), + Sensitive] + public string? ClientClusterCertificatePrivateKeyPassword { get; init; } + } + + [Description("Cluster Client Certificate Options (from store)")] + public record ClientClusterCertificateStoreOptions { + [Description("The certificate store location name for the cluster client certificate.")] + public string ClientClusterCertificateStoreLocation { get; init; } = string.Empty; + + [Description("The certificate store name for the cluster client certificate.")] + public string ClientClusterCertificateStoreName { get; init; } = string.Empty; + + [Description("The subject name of the cluster client certificate.")] + public string ClientClusterCertificateSubjectName { get; init; } = string.Empty; + + [Description("The fingerprint/thumbprint of the cluster client certificate.")] + public string ClientClusterCertificateThumbprint { get; init; } = string.Empty; + + [Description("The name of the certificate store that contains the trusted root certificate for the cluster client certificate.")] + public string ClientClusterTrustedRootCertificateStoreName { get; init; } = string.Empty; + + [Description("The certificate store location that contains the trusted root certificate for the cluster client certificate.")] + public string ClientClusterTrustedRootCertificateStoreLocation { get; init; } = string.Empty; + + [Description("The trusted root certificate subject name for the cluster client certificate.")] + public string ClientClusterTrustedRootCertificateSubjectName { get; init; } = string.Empty; + + [Description("The trusted root certificate fingerprint/thumbprint for the cluster client certificate.")] + public string ClientClusterTrustedRootCertificateThumbprint { get; init; } = string.Empty; + } + [Description("Cluster Options")] public record ClusterOptions { [Description("The maximum number of entries to keep in the stream info cache.")] From 41cbb4f6eb9bbd13cf9561214dfb337564dc0016 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Sat, 18 Apr 2026 08:35:46 +0100 Subject: [PATCH 05/15] remove SAN requirement from node certificate authentication (!) The node certificate authentication provider required client certificates to have an IP or DNS Subject Alternative Name. This was introduced before EKU-based classification existed, as a heuristic for "this cert belongs to a machine rather than a user." With the inbound certificate classifier now distinguishing node certs from user certs by their EKU profile, the SAN requirement is redundant. Neither the .NET SslStream framework nor KurrentDB checks SANs against the actual connection origin for client certificates, so the SAN check was purely existence-based and provided no security benefit. (TimC: this isn't just an AI guess - we really checked) - Remove the SAN check from NodeCertificateAuthenticationProvider - Remove tests that were testing SAN-specific behaviour; rename and tighten the remaining tests to reflect what they actually cover (key usages, CN matching) - Move the test classes into a sub-namespace to avoid name collisions We've kept the HasIpOrDnsSan extension method because the outgoing node cert does still require it and it may be helpful later during startup validation --- ...ode_certificate_authentication_provider.cs | 166 +----------------- .../unix_socket_authentication_provider.cs | 2 +- .../NodeCertificateAuthenticationProvider.cs | 7 +- 3 files changed, 9 insertions(+), 166 deletions(-) diff --git a/src/KurrentDB.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs b/src/KurrentDB.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs index 367a2c186d7..9bef31dfa05 100644 --- a/src/KurrentDB.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs +++ b/src/KurrentDB.Core.Tests/Services/Transport/Http/Authentication/node_certificate_authentication_provider.cs @@ -13,7 +13,7 @@ using Microsoft.AspNetCore.Http; using NUnit.Framework; -namespace KurrentDB.Core.Tests.Services.Transport.Http.Authentication; +namespace KurrentDB.Core.Tests.Services.Transport.Http.Authentication.NodeCertificateAuthenticationProviderTests; public class TestFixtureWithNodeCertificateHttpAuthenticationProvider { protected NodeCertificateAuthenticationProvider _provider; @@ -47,7 +47,7 @@ public void returns_false() { [TestFixture] public class - when_handling_a_request_with_a_client_certificate_having_no_san : + when_handling_a_request_with_a_client_certificate_having_no_digital_signature_key_usage : TestFixtureWithNodeCertificateHttpAuthenticationProvider { private HttpAuthenticationRequest _authenticateRequest; private bool _authenticateResult; @@ -84,7 +84,7 @@ public void no_roles_are_assigned() { [TestFixture] public class - when_handling_a_request_with_a_client_certificate_having_an_ip_san_but_without_node_cn : + when_handling_a_request_with_a_client_certificate_with_incorrect_cn : TestFixtureWithNodeCertificateHttpAuthenticationProvider { private HttpAuthenticationRequest _authenticateRequest; private bool _authenticateResult; @@ -98,9 +98,8 @@ public void SetUp() { using (RSA rsa = RSA.Create()) { var certReq = new CertificateRequest("CN=hello", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddIpAddress(IPAddress.Loopback); - certReq.CertificateExtensions.Add(sanBuilder.Build()); + certReq.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, critical: false)); certificate = certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddMonths(-1), DateTimeOffset.UtcNow.AddMonths(1)); } @@ -126,7 +125,7 @@ public void no_roles_are_assigned() { [TestFixture] public class - when_handling_a_request_with_a_client_certificate_having_an_ip_san_and_node_cn : + when_handling_a_request_with_a_valid_client_certificate : TestFixtureWithNodeCertificateHttpAuthenticationProvider { private HttpAuthenticationRequest _authenticateRequest; private bool _authenticateResult; @@ -141,10 +140,6 @@ public void SetUp() { using (RSA rsa = RSA.Create()) { var certReq = new CertificateRequest("CN=eventstoredb-node", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddIpAddress(IPAddress.Loopback); - certReq.CertificateExtensions.Add(sanBuilder.Build()); - certReq.CertificateExtensions.Add(new X509KeyUsageExtension( X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, critical: false)); @@ -182,7 +177,7 @@ public async Task sets_user_to_system_user() { [TestFixture] public class - when_handling_a_request_with_a_client_certificate_having_an_ip_san_and_node_cn_with_additional_subject_details : + when_handling_a_request_with_a_valid_client_certificate_with_additional_subject_details : TestFixtureWithNodeCertificateHttpAuthenticationProvider { private HttpAuthenticationRequest _authenticateRequest; private bool _authenticateResult; @@ -197,10 +192,6 @@ public void SetUp() { using (RSA rsa = RSA.Create()) { var certReq = new CertificateRequest("C=UK, O=Event Store Ltd, CN=eventstoredb-node", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddIpAddress(IPAddress.Loopback); - certReq.CertificateExtensions.Add(sanBuilder.Build()); - certReq.CertificateExtensions.Add(new X509KeyUsageExtension( X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, critical: false)); @@ -235,146 +226,3 @@ public async Task sets_user_to_system_user() { Assert.AreEqual(SystemAccounts.System.Claims, user.Claims); } } - - -[TestFixture] -public class - when_handling_a_request_with_a_client_certificate_having_a_dns_san_but_without_node_cn : - TestFixtureWithNodeCertificateHttpAuthenticationProvider { - private HttpAuthenticationRequest _authenticateRequest; - private bool _authenticateResult; - private HttpContext _context; - - [SetUp] - public void SetUp() { - SetUpProvider(); - _context = new DefaultHttpContext(); - X509Certificate2 certificate; - - using (RSA rsa = RSA.Create()) { - var certReq = new CertificateRequest("CN=hello", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("localhost"); - certReq.CertificateExtensions.Add(sanBuilder.Build()); - certificate = certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddMonths(-1), DateTimeOffset.UtcNow.AddMonths(1)); - } - - _context.Connection.ClientCertificate = certificate; - _authenticateResult = _provider.Authenticate(_context, out _authenticateRequest); - } - - [Test] - public void returns_false() { - Assert.IsFalse(_authenticateResult); - } - - [Test] - public void authentication_request_is_null() { - Assert.IsNull(_authenticateRequest); - } - - [Test] - public void no_roles_are_assigned() { - Assert.AreEqual(0, _context.User.Claims.Count()); - } -} - -[TestFixture] -public class - when_handling_a_request_with_a_client_certificate_having_a_dns_san_and_node_cn : - TestFixtureWithNodeCertificateHttpAuthenticationProvider { - private HttpAuthenticationRequest _authenticateRequest; - private bool _authenticateResult; - private HttpContext _context; - - [SetUp] - public void SetUp() { - SetUpProvider(); - _context = new DefaultHttpContext(); - X509Certificate2 certificate; - - using (RSA rsa = RSA.Create()) { - var certReq = new CertificateRequest("CN=eventstoredb-node", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddDnsName("localhost"); - certReq.CertificateExtensions.Add(sanBuilder.Build()); - - certReq.CertificateExtensions.Add(new X509KeyUsageExtension( - X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, critical: false)); - - certReq.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( - [ - new("1.3.6.1.5.5.7.3.1"), // serverAuth - new("1.3.6.1.5.5.7.3.2"), // clientAuth - ], - critical: false)); - - certificate = certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddMonths(-1), DateTimeOffset.UtcNow.AddMonths(1)); - } - - _context.Connection.ClientCertificate = certificate; - _authenticateResult = _provider.Authenticate(_context, out _authenticateRequest); - } - - [Test] - public void returns_true() { - Assert.IsTrue(_authenticateResult); - } - - [Test] - public async Task passes_authentication() { - var (status, _) = await _authenticateRequest.AuthenticateAsync(); - Assert.AreEqual(HttpAuthenticationRequestStatus.Authenticated, status); - } - - [Test] - public async Task sets_user_to_system_user() { - var (_, user) = await _authenticateRequest.AuthenticateAsync(); - Assert.AreEqual(SystemAccounts.System.Claims, user.Claims); - } -} - -[TestFixture] -public class - when_handling_a_request_with_a_client_certificate_having_a_non_dns_or_ip_san_with_node_cn : - TestFixtureWithNodeCertificateHttpAuthenticationProvider { - private HttpAuthenticationRequest _authenticateRequest; - private bool _authenticateResult; - private HttpContext _context; - - [SetUp] - public void SetUp() { - SetUpProvider(); - _context = new DefaultHttpContext(); - X509Certificate2 certificate; - - using (RSA rsa = RSA.Create()) { - var certReq = new CertificateRequest("CN=eventstoredb-node", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var sanBuilder = new SubjectAlternativeNameBuilder(); - sanBuilder.AddEmailAddress("hello@hello.org"); - sanBuilder.AddUserPrincipalName("test@test.com"); - sanBuilder.AddUri(new Uri("http://localhost")); - certReq.CertificateExtensions.Add(sanBuilder.Build()); - certificate = certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddMonths(-1), DateTimeOffset.UtcNow.AddMonths(1)); - } - - _context.Connection.ClientCertificate = certificate; - _authenticateResult = _provider.Authenticate(_context, out _authenticateRequest); - } - - [Test] - public void returns_false() { - Assert.IsFalse(_authenticateResult); - } - - [Test] - public void authentication_request_is_null() { - Assert.IsNull(_authenticateRequest); - } - - [Test] - public void no_roles_are_assigned() { - Assert.AreEqual(0, _context.User.Claims.Count()); - } -} diff --git a/src/KurrentDB.Core.Tests/Services/Transport/Http/Authentication/unix_socket_authentication_provider.cs b/src/KurrentDB.Core.Tests/Services/Transport/Http/Authentication/unix_socket_authentication_provider.cs index 6eefe0f1a09..118aaa80221 100644 --- a/src/KurrentDB.Core.Tests/Services/Transport/Http/Authentication/unix_socket_authentication_provider.cs +++ b/src/KurrentDB.Core.Tests/Services/Transport/Http/Authentication/unix_socket_authentication_provider.cs @@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Http; using NUnit.Framework; -namespace KurrentDB.Core.Tests.Services.Transport.Http.Authentication; +namespace KurrentDB.Core.Tests.Services.Transport.Http.Authentication.UnixSocketAuthenticationProviderTests; public class TestFixtureWithUnixSocketAuthenticationProvider { protected UnixSocketAuthenticationProvider _provider; diff --git a/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs b/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs index 4090902062e..689b31a703e 100644 --- a/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs +++ b/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs @@ -98,12 +98,7 @@ private bool AuthenticateUncached(HttpContext context, X509Certificate2 clientCe ip, clientCertificateCN, reservedNodeCN); } - bool hasIpOrDnsSan = clientCertificate.HasIpOrDnsSan(); - if (!hasIpOrDnsSan) { - Log.Error("Connection from node: {ip} was denied because its certificate does not have any IP or DNS Subject Alternative Names (SAN).", ip); - } - - return hasReservedNodeCN && hasIpOrDnsSan; + return hasReservedNodeCN; } case CertificateClassification.User: { From 022a54206286d3b17e917a9bb75fbf5679720813 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Mon, 20 Apr 2026 11:20:05 +0100 Subject: [PATCH 06/15] refactor: add helper method TryLoadCertificate --- .../ClusterVNodeOptionsExtensions.cs | 76 ++++++++++++++----- 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs index a0e1cea8592..93b9a93765e 100644 --- a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs +++ b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs @@ -203,34 +203,72 @@ public static (X509Certificate2 certificate, X509Certificate2Collection intermed return (options.ServerCertificate!, null); } + if (!TryLoadCertificate( + logLabel: "node", + store: new StoreCertInfo( + StoreLocation: options.CertificateStore.CertificateStoreLocation, + StoreName: options.CertificateStore.CertificateStoreName, + SubjectName: options.CertificateStore.CertificateSubjectName, + Thumbprint: options.CertificateStore.CertificateThumbprint), + file: new FileCertInfo( + File: options.CertificateFile.CertificateFile, + PrivateKeyFile: options.CertificateFile.CertificatePrivateKeyFile, + Password: options.CertificateFile.CertificatePassword, + PrivateKeyPassword: options.CertificateFile.CertificatePrivateKeyPassword), + certificate: out var certificate, + intermediates: out var intermediates)) { - if (!string.IsNullOrWhiteSpace(options.CertificateStore.CertificateStoreLocation)) { - var location = - CertificateUtils.GetCertificateStoreLocation(options.CertificateStore.CertificateStoreLocation); - var name = CertificateUtils.GetCertificateStoreName(options.CertificateStore.CertificateStoreName); - return (CertificateUtils.LoadFromStore(location, name, options.CertificateStore.CertificateSubjectName, - options.CertificateStore.CertificateThumbprint), null); + throw new InvalidConfigurationException( + "A certificate is required unless insecure mode (--insecure) is set."); + } + + return (certificate, intermediates); + } + + private static bool TryLoadCertificate( + string logLabel, + StoreCertInfo store, + FileCertInfo file, + out X509Certificate2 certificate, + out X509Certificate2Collection intermediates) { + + if (!string.IsNullOrWhiteSpace(store.StoreLocation)) { + var location = CertificateUtils.GetCertificateStoreLocation(store.StoreLocation); + var name = CertificateUtils.GetCertificateStoreName(store.StoreName); + certificate = CertificateUtils.LoadFromStore( + location, + name, + store.SubjectName, + store.Thumbprint); + intermediates = null; + return true; } - if (!string.IsNullOrWhiteSpace(options.CertificateStore.CertificateStoreName)) { - var name = CertificateUtils.GetCertificateStoreName(options.CertificateStore.CertificateStoreName); - return ( - CertificateUtils.LoadFromStore(name, options.CertificateStore.CertificateSubjectName, - options.CertificateStore.CertificateThumbprint), null); + if (!string.IsNullOrWhiteSpace(store.StoreName)) { + var name = CertificateUtils.GetCertificateStoreName(store.StoreName); + certificate = CertificateUtils.LoadFromStore(name, store.SubjectName, store.Thumbprint); + intermediates = null; + return true; } - if (options.CertificateFile.CertificateFile.IsNotEmptyString()) { - Log.Information("Loading the node's certificate(s) from file: {path}", - options.CertificateFile.CertificateFile); - return CertificateUtils.LoadFromFile(options.CertificateFile.CertificateFile, - options.CertificateFile.CertificatePrivateKeyFile, options.CertificateFile.CertificatePassword, - options.CertificateFile.CertificatePrivateKeyPassword); + if (file.File.IsNotEmptyString()) { + Log.Information("Loading the {label} certificate(s) from file: {path}", logLabel, file.File); + (certificate, intermediates) = CertificateUtils.LoadFromFile( + file.File, + file.PrivateKeyFile, + file.Password, + file.PrivateKeyPassword); + return true; } - throw new InvalidConfigurationException( - "A certificate is required unless insecure mode (--insecure) is set."); + certificate = null; + intermediates = null; + return false; } + private record StoreCertInfo(string StoreLocation, string StoreName, string SubjectName, string Thumbprint); + private record FileCertInfo(string File, string PrivateKeyFile, string Password, string PrivateKeyPassword); + /// /// Loads an from the options set. /// If either TrustedRootCertificateStoreLocation or TrustedRootCertificateStoreName is set, From dcf0b961b4915a413148a5e1c41ece3a652c568b Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Mon, 20 Apr 2026 11:54:03 +0100 Subject: [PATCH 07/15] refactor: add helper method LoadTrustedRootCertificates --- .../ClusterVNodeOptionsExtensions.cs | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs index 93b9a93765e..e8083d9beb6 100644 --- a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs +++ b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs @@ -280,45 +280,48 @@ private record FileCertInfo(string File, string PrivateKeyFile, string Password, /// public static X509Certificate2Collection LoadTrustedRootCertificates(this ClusterVNodeOptions options) { if (options.TrustedRootCertificates != null) + //used by test code paths only return options.TrustedRootCertificates; + + return LoadTrustedRootsFromStoreOrPath( + store: new StoreCertInfo( + StoreLocation: options.CertificateStore.TrustedRootCertificateStoreLocation, + StoreName: options.CertificateStore.TrustedRootCertificateStoreName, + SubjectName: options.CertificateStore.TrustedRootCertificateSubjectName, + Thumbprint: options.CertificateStore.TrustedRootCertificateThumbprint), + path: options.Certificate.TrustedRootCertificatesPath); + } + + private static X509Certificate2Collection LoadTrustedRootsFromStoreOrPath(StoreCertInfo store, string path) { var trustedRootCerts = new X509Certificate2Collection(); - if (!string.IsNullOrWhiteSpace(options.CertificateStore.TrustedRootCertificateStoreLocation)) { - var location = - CertificateUtils.GetCertificateStoreLocation(options.CertificateStore - .TrustedRootCertificateStoreLocation); - var name = CertificateUtils.GetCertificateStoreName(options.CertificateStore - .TrustedRootCertificateStoreName); - trustedRootCerts.Add(CertificateUtils.LoadFromStore(location, name, - options.CertificateStore.TrustedRootCertificateSubjectName, - options.CertificateStore.TrustedRootCertificateThumbprint)); + if (!string.IsNullOrWhiteSpace(store.StoreLocation)) { + var location = CertificateUtils.GetCertificateStoreLocation(store.StoreLocation); + var name = CertificateUtils.GetCertificateStoreName(store.StoreName); + trustedRootCerts.Add(CertificateUtils.LoadFromStore(location, name, store.SubjectName, store.Thumbprint)); return trustedRootCerts; } - if (!string.IsNullOrWhiteSpace(options.CertificateStore.TrustedRootCertificateStoreName)) { - var name = CertificateUtils.GetCertificateStoreName(options.CertificateStore - .TrustedRootCertificateStoreName); - trustedRootCerts.Add(CertificateUtils.LoadFromStore(name, - options.CertificateStore.TrustedRootCertificateSubjectName, - options.CertificateStore.TrustedRootCertificateThumbprint)); + if (!string.IsNullOrWhiteSpace(store.StoreName)) { + var name = CertificateUtils.GetCertificateStoreName(store.StoreName); + trustedRootCerts.Add(CertificateUtils.LoadFromStore(name, store.SubjectName, store.Thumbprint)); return trustedRootCerts; } - if (string.IsNullOrEmpty(options.Certificate.TrustedRootCertificatesPath)) { + if (string.IsNullOrEmpty(path)) { throw new InvalidConfigurationException( - $"{nameof(options.Certificate.TrustedRootCertificatesPath)} must be specified unless insecure mode (--insecure) is set."); + $"{nameof(ClusterVNodeOptions.CertificateOptions.TrustedRootCertificatesPath)} must be specified unless insecure mode (--insecure) is set."); } - Log.Information("Loading trusted root certificates."); - foreach (var (fileName, cert) in CertificateUtils - .LoadAllCertificates(options.Certificate.TrustedRootCertificatesPath)) { + Log.Information("Loading trusted root certificates from path: {path}", path); + foreach (var (fileName, cert) in CertificateUtils.LoadAllCertificates(path)) { trustedRootCerts.Add(cert); Log.Information("Loading trusted root certificate file: {file}", fileName); } if (trustedRootCerts.Count == 0) throw new InvalidConfigurationException( - $"No trusted root certificate files were loaded from the specified path: {options.Certificate.TrustedRootCertificatesPath}"); + $"No trusted root certificate files were loaded from the specified path: {path}"); return trustedRootCerts; } } From 869f9167b4eefb4e015daacc6e7ebf03b4549519 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Tue, 21 Apr 2026 11:55:29 +0100 Subject: [PATCH 08/15] load and validate the cluster client certificate at startup --- .../OptionsCertificateProviderTests.cs | 191 ++++++++++++++++++ .../Certificates/CertificateProvider.cs | 2 + .../OptionsCertificateProvider.cs | 101 ++++++--- .../ClusterVNodeOptionsExtensions.cs | 39 ++++ 4 files changed, 309 insertions(+), 24 deletions(-) create mode 100644 src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs diff --git a/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs new file mode 100644 index 00000000000..12ca312520c --- /dev/null +++ b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs @@ -0,0 +1,191 @@ +// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. +// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). + +using System; +using System.IO; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using KurrentDB.Core.Certificates; +using Xunit; + +namespace KurrentDB.Core.XUnit.Tests.Certificates; + +public class OptionsCertificateProviderTests(DirectoryFixture fixture) : + IClassFixture> { + + private static X509Certificate2 CreateCert( + string subject, + bool ca = false, + X509Certificate2 parent = null, + bool clientAuthEKU = false, + bool serverAuthEKU = false) { + + var rsa = RSA.Create(); + var certReq = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + if (ca) + certReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + + if (clientAuthEKU || serverAuthEKU) { + certReq.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, critical: false)); + + var oids = new OidCollection(); + if (serverAuthEKU) oids.Add(new Oid("1.3.6.1.5.5.7.3.1")); + if (clientAuthEKU) oids.Add(new Oid("1.3.6.1.5.5.7.3.2")); + certReq.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(oids, critical: false)); + } + + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddIpAddress(IPAddress.Loopback); + certReq.CertificateExtensions.Add(sanBuilder.Build()); + + X509Certificate2 cert; + if (parent == null) { + cert = certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddMonths(-1), DateTimeOffset.UtcNow.AddMonths(1)); + } else { + var parentKey = parent.GetRSAPrivateKey()!; + var generator = X509SignatureGenerator.CreateForRSA(parentKey, RSASignaturePadding.Pkcs1); + cert = certReq.Create(parent.SubjectName, generator, + DateTimeOffset.UtcNow.AddMonths(-1), DateTimeOffset.UtcNow.AddMonths(1), + BitConverter.GetBytes(Random.Shared.Next())).CopyWithPrivateKey(rsa); + } + + // re-import via PFX so the cert owns its private key independently, and + // flag the key as exportable so the cert can be exported again later (e.g. to write it to disk) + return new X509Certificate2(cert.Export(X509ContentType.Pfx), (string)null, X509KeyStorageFlags.Exportable); + } + + private string WriteCertToFile(X509Certificate2 cert, string fileName) { + var path = fixture.GetFilePathFor(fileName); + File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx)); + return path; + } + + private static X509Certificate2 StripPrivateKey(X509Certificate2 cert) => + new(cert.Export(X509ContentType.Cert)); + + private ClusterVNodeOptions BuildOptions( + X509Certificate2 nodeCert, + X509Certificate2 rootCert, + X509Certificate2 clusterClientCert = null, + string reservedNodeCN = null) { + + var options = new ClusterVNodeOptions { + ServerCertificate = nodeCert, + TrustedRootCertificates = new X509Certificate2Collection(StripPrivateKey(rootCert)), + Certificate = new ClusterVNodeOptions.CertificateOptions { + CertificateReservedNodeCommonName = reservedNodeCN ?? string.Empty, + TrustedRootCertificatesPath = fixture.Directory, + }, + }; + + if (clusterClientCert != null) { + var certPath = WriteCertToFile(clusterClientCert, $"cluster-client-{Guid.NewGuid()}.pfx"); + options = options with { + ClientClusterCertificateFile = new ClusterVNodeOptions.ClientClusterCertificateFileOptions { + ClientClusterCertificateFile = certPath, + }, + }; + // also write the root to the temp dir so the cluster client trusted-root fallback can pick it up + File.WriteAllBytes(fixture.GetFilePathFor($"root-{Guid.NewGuid()}.crt"), rootCert.Export(X509ContentType.Cert)); + } + + return options; + } + + [Fact] + public void insecure_mode_skips_loading() { + var options = new ClusterVNodeOptions { + Application = new ClusterVNodeOptions.ApplicationOptions { Insecure = true }, + }; + var sut = new OptionsCertificateProvider(); + + var result = sut.LoadCertificates(options); + + Assert.Equal(LoadCertificateResult.Skipped, result); + Assert.Null(sut.Certificate); + } + + [Fact] + public void single_cert_mode_loads_successfully() { + var root = CreateCert("CN=test-ca", ca: true); + var node = CreateCert("CN=eventstoredb-node", parent: root, serverAuthEKU: true, clientAuthEKU: true); + var options = BuildOptions(node, root); + var sut = new OptionsCertificateProvider(); + + var result = sut.LoadCertificates(options); + + Assert.Equal(LoadCertificateResult.Success, result); + Assert.Equal(node.Thumbprint, sut.Certificate.Thumbprint); + // in single cert mode, cluster client cert is the same as the main cert + Assert.Equal(node.Thumbprint, sut.ClientClusterCertificate.Thumbprint); + Assert.Equal("eventstoredb-node", sut.GetReservedNodeCommonName()); + } + + [Fact] + public void dual_cert_mode_loads_successfully() { + var root = CreateCert("CN=test-ca", ca: true); + var node = CreateCert("CN=kurrentdb.example.com", parent: root, serverAuthEKU: true); + var clusterClient = CreateCert("CN=eventstoredb-node", parent: root, serverAuthEKU: true, clientAuthEKU: true); + var options = BuildOptions(node, root, clusterClient); + var sut = new OptionsCertificateProvider(); + + var result = sut.LoadCertificates(options); + + Assert.Equal(LoadCertificateResult.Success, result); + Assert.Equal(node.Thumbprint, sut.Certificate.Thumbprint); + Assert.Equal(clusterClient.Thumbprint, sut.ClientClusterCertificate.Thumbprint); + // reserved CN auto-derived from the cluster client cert (not the main cert) + Assert.Equal("eventstoredb-node", sut.GetReservedNodeCommonName()); + } + + [Fact] + public void dual_cert_mode_cluster_client_cert_with_clientauth_only_fails_classification() { + var root = CreateCert("CN=test-ca", ca: true); + var node = CreateCert("CN=eventstoredb-node", parent: root, serverAuthEKU: true, clientAuthEKU: true); + // clientAuth-only classifies as User, not Node + var clusterClient = CreateCert("CN=eventstoredb-node", parent: root, clientAuthEKU: true); + var options = BuildOptions(node, root, clusterClient); + var sut = new OptionsCertificateProvider(); + + var result = sut.LoadCertificates(options); + + Assert.Equal(LoadCertificateResult.VerificationFailed, result); + } + + [Fact] + public void reserved_node_cn_mismatch_fails() { + var root = CreateCert("CN=test-ca", ca: true); + var node = CreateCert("CN=something-else", parent: root, serverAuthEKU: true, clientAuthEKU: true); + var options = BuildOptions(node, root, reservedNodeCN: "eventstoredb-node"); + var sut = new OptionsCertificateProvider(); + + var result = sut.LoadCertificates(options); + + Assert.Equal(LoadCertificateResult.VerificationFailed, result); + } + + [Fact] + public void reserved_node_cn_matches_cluster_client_cert_in_dual_cert_mode() { + var root = CreateCert("CN=test-ca", ca: true); + // main cert has a different CN — expected in dual mode (public CA hostname) + var node = CreateCert("CN=kurrentdb.example.com", parent: root, serverAuthEKU: true); + var clusterClient = CreateCert("CN=eventstoredb-node", parent: root, serverAuthEKU: true, clientAuthEKU: true); + var options = BuildOptions(node, root, clusterClient, reservedNodeCN: "eventstoredb-node"); + var sut = new OptionsCertificateProvider(); + + var result = sut.LoadCertificates(options); + + // reserved CN matches the cluster client cert, not the main cert — should succeed + Assert.Equal(LoadCertificateResult.Success, result); + } + + [Fact] + public void get_reserved_node_common_name_throws_before_load() { + var sut = new OptionsCertificateProvider(); + + Assert.Throws(() => sut.GetReservedNodeCommonName()); + } +} diff --git a/src/KurrentDB.Core/Certificates/CertificateProvider.cs b/src/KurrentDB.Core/Certificates/CertificateProvider.cs index c00e5151d7c..9d62af6132c 100644 --- a/src/KurrentDB.Core/Certificates/CertificateProvider.cs +++ b/src/KurrentDB.Core/Certificates/CertificateProvider.cs @@ -8,6 +8,8 @@ namespace KurrentDB.Core.Certificates; public abstract class CertificateProvider { public X509Certificate2 Certificate; public X509Certificate2Collection IntermediateCerts; + public X509Certificate2 ClientClusterCertificate; + public X509Certificate2Collection ClientClusterIntermediateCerts; public X509Certificate2Collection TrustedRootCerts; public abstract LoadCertificateResult LoadCertificates(ClusterVNodeOptions options); public abstract string GetReservedNodeCommonName(); diff --git a/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs b/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs index 43dab522394..9743d49d33b 100644 --- a/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs +++ b/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs @@ -9,6 +9,7 @@ namespace KurrentDB.Core.Certificates; +// (Re)Loads the certificates specified in the ClusterVNodeOptions public class OptionsCertificateProvider : CertificateProvider { private static readonly ILogger Log = Serilog.Log.ForContext(); private string _cachedReservedNodeCN; @@ -19,55 +20,101 @@ public override LoadCertificateResult LoadCertificates(ClusterVNodeOptions optio return LoadCertificateResult.Skipped; } + // Load both Node and ClusterClient certificates up-front. + // Node cert is the server cert sent to incoming connections + // ClusterClient cert is the client cert sent on outgoing connections to other nodes. + // ClusterClient cert defaults to the same certificate as Node cert if not provided. var (certificate, intermediates) = options.LoadNodeCertificate(); + var hasClientClusterCert = options.TryLoadClientClusterCertificate(out var clientClusterCertificate, out var clientClusterIntermediates); + if (!hasClientClusterCert) { + clientClusterCertificate = certificate; + clientClusterIntermediates = intermediates; + } string reservedNodeCN; var reservedNodeCNOption = nameof(options.Certificate.CertificateReservedNodeCommonName); + // Determine the CN pattern expected from incomming node certificates. if (options.Certificate.CertificateReservedNodeCommonName.IsNotEmptyString()) { + // Reserved CN is configured. Check that the cluster client cert matches it. reservedNodeCN = options.Certificate.CertificateReservedNodeCommonName; - if (!certificate.ClientCertificateMatchesName(reservedNodeCN)) { - var certificateCN = certificate.GetCommonName(); + if (!clientClusterCertificate.ClientCertificateMatchesName(reservedNodeCN)) { + var clientClusterCertCN = clientClusterCertificate.GetCommonName(); Log.Error( "Certificate CN: {certificateCN} does not match with the {reservedNodeCNOption} configuration setting: {reservedNodeCN}", - certificateCN, reservedNodeCNOption, reservedNodeCN); + clientClusterCertCN, reservedNodeCNOption, reservedNodeCN); return LoadCertificateResult.VerificationFailed; } Log.Information("{reservedNodeCNOption} configured to: {reservedNodeCN}", reservedNodeCNOption, reservedNodeCN); } else { - reservedNodeCN = certificate.GetCommonName(); + reservedNodeCN = clientClusterCertificate.GetCommonName(); Log.Information("{reservedNodeCNOption} auto-configured to: {reservedNodeCN} based on certificate", reservedNodeCNOption, reservedNodeCN); } - var previousThumbprint = Certificate?.Thumbprint; - var newThumbprint = certificate.Thumbprint; - Log.Information("Loading the node's certificate. Subject: {subject}, Previous thumbprint: {previousThumbprint}, New thumbprint: {newThumbprint}", - certificate.SubjectName.Name, previousThumbprint, newThumbprint); + // Log information about the certificates and their intermediates + LogThumbprints("node", Certificate, certificate, intermediates); + LogThumbprints("cluster client", ClientClusterCertificate, clientClusterCertificate, clientClusterIntermediates); - if (intermediates != null) { - foreach (var intermediateCert in intermediates) { - Log.Information("Loading intermediate certificate. Subject: {subject}, Thumbprint: {thumbprint}", intermediateCert.SubjectName.Name, intermediateCert.Thumbprint); + static void LogThumbprints(string label, X509Certificate2 previousCertificate, X509Certificate2 certificate, X509Certificate2Collection intermediates) { + var previousThumbprint = previousCertificate?.Thumbprint; + var newThumbprint = certificate.Thumbprint; + Log.Information("Loading the {label} certificate. Subject: {subject}, Previous thumbprint: {previousThumbprint}, New thumbprint: {newThumbprint}", + label, certificate.SubjectName.Name, previousThumbprint, newThumbprint); + + if (intermediates != null) { + foreach (var intermediateCert in intermediates) { + Log.Information("Loading {label} intermediate certificate. Subject: {subject}, Thumbprint: {thumbprint}", + label, intermediateCert.SubjectName.Name, intermediateCert.Thumbprint); + } } } + // Validate the node certificate var trustedRootCerts = options.LoadTrustedRootCertificates(); foreach (var trustedRootCert in trustedRootCerts) { - Log.Information("Loading trusted root certificate. Subject: {subject}, Thumbprint: {thumbprint}", trustedRootCert.SubjectName.Name, trustedRootCert.Thumbprint); + Log.Information("Loading trusted root for node certificate. Subject: {subject}, Thumbprint: {thumbprint}", trustedRootCert.SubjectName.Name, trustedRootCert.Thumbprint); } - if (!VerifyCertificates(certificate, intermediates, trustedRootCerts)) { + if (!VerifyCertificates("node", certificate, intermediates, trustedRootCerts)) { return LoadCertificateResult.VerificationFailed; } + // Validate the cluster client certificate + if (hasClientClusterCert) { + var clientClusterTrustedRoots = options.LoadClientClusterTrustedRootCertificates(); + + foreach (var trustedRootCert in clientClusterTrustedRoots) { + Log.Information("Loading trusted root for cluster client certificate. Subject: {subject}, Thumbprint: {thumbprint}", trustedRootCert.SubjectName.Name, trustedRootCert.Thumbprint); + } + + if (!VerifyCertificates("cluster client", clientClusterCertificate, clientClusterIntermediates, clientClusterTrustedRoots)) { + return LoadCertificateResult.VerificationFailed; + } + } + + // Check the EKUs + if (clientClusterCertificate.ClassifyInboundCertificate( + disableClientAuthEkuValidation: options.Certificate.DisableClientAuthEkuValidation, + out var clientClusterCertDescription) is not CertificateClassification.Node) { + + Log.Error(hasClientClusterCert + ? "The cluster client certificate was not recognized as a node certificate: {description}" + : "The node certificate was not recognized as a node certificate: {description}", + clientClusterCertDescription); + return LoadCertificateResult.VerificationFailed; + } + // no need for a lock here since reference assignment is atomic. however, other threads may not immediately // see the changes and the order in which they see the changes is also not guaranteed as we don't have any // memory barriers here. this is not a problem as in the worst case, it will cause the certificate verifications // to fail when establishing/receiving a connection and the next connection retries will succeed. Certificate = certificate; IntermediateCerts = intermediates; + ClientClusterCertificate = clientClusterCertificate; + ClientClusterIntermediateCerts = clientClusterIntermediates; TrustedRootCerts = trustedRootCerts; _cachedReservedNodeCN = reservedNodeCN; @@ -79,18 +126,23 @@ public override string GetReservedNodeCommonName() { return _cachedReservedNodeCN ?? throw new InvalidOperationException("Certificates are not loaded."); } - private static bool VerifyCertificates(X509Certificate2 nodeCertificate, X509Certificate2Collection intermediates, X509Certificate2Collection trustedRoots) { + private static bool VerifyCertificates( + string label, + X509Certificate2 certificate, + X509Certificate2Collection intermediates, + X509Certificate2Collection trustedRoots) { + bool error = false; - if (!CertificateUtils.IsValidNodeCertificate(nodeCertificate, out var errorMsg)) { - Log.Error(errorMsg); + if (!CertificateUtils.IsValidNodeCertificate(certificate, out var errorMsg)) { + Log.Error("The {label} certificate: {error}", label, errorMsg); error = true; } if (intermediates != null) { foreach (var cert in intermediates) { if (!CertificateUtils.IsValidIntermediateCertificate(cert, out errorMsg)) { - Log.Error($"{errorMsg} Please bundle only intermediate certificates (if any) and not root certificates with the node's certificate."); + Log.Error("{error} Please bundle only intermediate certificates (if any) and not root certificates with the {label} certificate.", errorMsg, label); error = true; } } @@ -99,24 +151,25 @@ private static bool VerifyCertificates(X509Certificate2 nodeCertificate, X509Cer if (trustedRoots != null && trustedRoots.Count > 0) { foreach (var cert in trustedRoots) { if (!CertificateUtils.IsValidRootCertificate(cert, out errorMsg)) { - Log.Error($"{errorMsg} If you have intermediate certificates, please bundle them with the node's certificate (in PEM or PKCS #12 format)."); + Log.Error("{error} If you have intermediate certificates, please bundle them with the {label} certificate (in PEM or PKCS #12 format).", errorMsg, label); error = true; } } } else { - Log.Error("No trusted root certificates loaded"); + Log.Error("No trusted root certificates loaded for the {label} certificate", label); error = true; } if (error) return false; - var chainStatus = CertificateUtils.BuildChain(nodeCertificate, intermediates, trustedRoots, out var chainStatusInformation); + var chainStatus = CertificateUtils.BuildChain(certificate, intermediates, trustedRoots, out var chainStatusInformation); if (chainStatus != X509ChainStatusFlags.NoError) { Log.Error( - "Failed to build the certificate chain with the node's own certificate up to the root. " + - "If you have intermediate certificates, please bundle them with the node's certificate (in PEM or PKCS #12 format). Errors:-"); + "Failed to build the certificate chain with the {label} certificate up to the root. " + + "If you have intermediate certificates, please bundle them with the {label} certificate (in PEM or PKCS #12 format). Errors:-", + label, label); foreach (var status in chainStatusInformation) { Log.Error(status); } @@ -125,7 +178,7 @@ private static bool VerifyCertificates(X509Certificate2 nodeCertificate, X509Cer } if (!error && intermediates != null) { - chainStatus = CertificateUtils.BuildChain(nodeCertificate, null, trustedRoots, out chainStatusInformation); + chainStatus = CertificateUtils.BuildChain(certificate, null, trustedRoots, out chainStatusInformation); // Adding the intermediate certificates to the store is required so that // i) the full certificate chain (excluding the root) is sent from client to server (on both Windows/Linux) @@ -146,7 +199,7 @@ private static bool VerifyCertificates(X509Certificate2 nodeCertificate, X509Cer } if (!error) { - Log.Information("Certificate chain verification successful."); + Log.Information("The {label} certificate chain verification successful.", label); } return !error; diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs index e8083d9beb6..528765b9c76 100644 --- a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs +++ b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs @@ -225,6 +225,30 @@ public static (X509Certificate2 certificate, X509Certificate2Collection intermed return (certificate, intermediates); } + /// + /// Tries to load the cluster client certificate from the options set. + /// Returns false if no cluster client certificate is configured. + /// + public static bool TryLoadClientClusterCertificate( + this ClusterVNodeOptions options, + out X509Certificate2 certificate, + out X509Certificate2Collection intermediates) => + + TryLoadCertificate( + logLabel: "cluster client", + store: new StoreCertInfo( + StoreLocation: options.ClientClusterCertificateStore.ClientClusterCertificateStoreLocation, + StoreName: options.ClientClusterCertificateStore.ClientClusterCertificateStoreName, + SubjectName: options.ClientClusterCertificateStore.ClientClusterCertificateSubjectName, + Thumbprint: options.ClientClusterCertificateStore.ClientClusterCertificateThumbprint), + file: new FileCertInfo( + File: options.ClientClusterCertificateFile.ClientClusterCertificateFile, + PrivateKeyFile: options.ClientClusterCertificateFile.ClientClusterCertificatePrivateKeyFile, + Password: options.ClientClusterCertificateFile.ClientClusterCertificatePassword, + PrivateKeyPassword: options.ClientClusterCertificateFile.ClientClusterCertificatePrivateKeyPassword), + certificate: out certificate, + intermediates: out intermediates); + private static bool TryLoadCertificate( string logLabel, StoreCertInfo store, @@ -292,6 +316,21 @@ public static X509Certificate2Collection LoadTrustedRootCertificates(this Cluste path: options.Certificate.TrustedRootCertificatesPath); } + /// + /// Loads trusted root certificates for the cluster client certificate. + /// If cluster-client-specific trusted root store options are not set, falls back to the main + /// . + /// + public static X509Certificate2Collection LoadClientClusterTrustedRootCertificates(this ClusterVNodeOptions options) { + return LoadTrustedRootsFromStoreOrPath( + new StoreCertInfo( + StoreLocation: options.ClientClusterCertificateStore.ClientClusterTrustedRootCertificateStoreLocation, + StoreName: options.ClientClusterCertificateStore.ClientClusterTrustedRootCertificateStoreName, + SubjectName: options.ClientClusterCertificateStore.ClientClusterTrustedRootCertificateSubjectName, + Thumbprint: options.ClientClusterCertificateStore.ClientClusterTrustedRootCertificateThumbprint), + options.Certificate.TrustedRootCertificatesPath); + } + private static X509Certificate2Collection LoadTrustedRootsFromStoreOrPath(StoreCertInfo store, string path) { var trustedRootCerts = new X509Certificate2Collection(); From c2b77c03e5106f76bc82ec6dc29f6ba9e6347b80 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Tue, 21 Apr 2026 16:06:54 +0100 Subject: [PATCH 09/15] use the cluster client certificate for outgoing connections to other nodes use the outgoing cert as the required base for user certs (so all client certs come from the same CA) --- src/KurrentDB.Core/ClusterVNode.cs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/KurrentDB.Core/ClusterVNode.cs b/src/KurrentDB.Core/ClusterVNode.cs index fccee8cbdfb..e23103f9427 100644 --- a/src/KurrentDB.Core/ClusterVNode.cs +++ b/src/KurrentDB.Core/ClusterVNode.cs @@ -204,8 +204,10 @@ internal ThreadPoolMessageScheduler WorkersHandler { private readonly bool _disableHttps; private readonly bool _enableUnixSocket; private readonly Func _certificateSelector; + private readonly Func _clusterClientCertificateSelector; private readonly Func _trustedRootCertsSelector; private readonly Func _intermediateCertsSelector; + private readonly Func _clusterClientIntermediateCertsSelector; private readonly CertificateDelegates.ServerCertificateValidator _internalServerCertificateValidator; private readonly CertificateDelegates.ClientCertificateValidator _internalClientCertificateValidator; private readonly CertificateDelegates.ServerCertificateValidator _externalServerCertificateValidator; @@ -511,14 +513,19 @@ TFChunkDbConfig CreateDbConfig( _queueStatsManager = new QueueStatsManager(); _certificateSelector = () => _certificateProvider?.Certificate; + _clusterClientCertificateSelector = () => _certificateProvider?.ClientClusterCertificate; _trustedRootCertsSelector = () => _certificateProvider?.TrustedRootCerts; _intermediateCertsSelector = () => - _certificateProvider?.IntermediateCerts == null + _certificateProvider?.IntermediateCerts is not { } intermediates ? null - : new X509Certificate2Collection(_certificateProvider?.IntermediateCerts); + : new X509Certificate2Collection(intermediates); + _clusterClientIntermediateCertsSelector = () => + _certificateProvider?.ClientClusterIntermediateCerts is not { } intermediates + ? null + : new X509Certificate2Collection(intermediates); _internalServerCertificateValidator = (cert, chain, errors, otherNames) => ValidateServerCertificate(cert, chain, errors, _intermediateCertsSelector, _trustedRootCertsSelector, otherNames); - _internalClientCertificateValidator = (cert, chain, errors) => ValidateClientCertificate(cert, chain, errors, _intermediateCertsSelector, _trustedRootCertsSelector); + _internalClientCertificateValidator = (cert, chain, errors) => ValidateClientCertificate(cert, chain, errors, _clusterClientIntermediateCertsSelector, _trustedRootCertsSelector); _externalServerCertificateValidator = (cert, chain, errors, otherNames) => ValidateServerCertificate(cert, chain, errors, _intermediateCertsSelector, _trustedRootCertsSelector, otherNames); var forwardingProxy = new MessageForwardingProxy(); @@ -564,7 +571,7 @@ void StartSubsystems() { _nodeHttpClientFactory = new NodeHttpClientFactory( uriScheme, _internalServerCertificateValidator, - _certificateSelector); + _clusterClientCertificateSelector); _eventStoreClusterClientCache = new EventStoreClusterClientCache(_mainQueue, (endpoint, publisher) => @@ -1508,7 +1515,7 @@ GossipAdvertiseInfo GetGossipAdvertiseInfo() { GossipAdvertiseInfo.InternalTcp ?? GossipAdvertiseInfo.InternalSecureTcp, options.Cluster.ReadOnlyReplica, !disableInternalTcpTls, _internalServerCertificateValidator, - _certificateSelector, + _clusterClientCertificateSelector, TimeSpan.FromMilliseconds(options.Interface.ReplicationHeartbeatTimeout), TimeSpan.FromMilliseconds(options.Interface.ReplicationHeartbeatInterval), TimeSpan.FromMilliseconds(options.Database.WriteTimeoutMs)); @@ -1612,7 +1619,10 @@ IServiceCollection ConfigureNodeServices(IServiceCollection services) { .AddSingleton>(httpAuthenticationProviders) .AddSingleton> - (() => (_certificateSelector(), _intermediateCertsSelector(), _trustedRootCertsSelector())) + (() => ( + _clusterClientCertificateSelector(), + _clusterClientIntermediateCertsSelector(), + _trustedRootCertsSelector())) .AddSingleton(_nodeHttpClientFactory) .AddSingleton>(Db.Manager) .AddSingleton(Db.Manager.FileSystem.LocalNamingStrategy) From d4ad49e0977ee684506e237e1f8b7add319bed04 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Tue, 21 Apr 2026 16:12:54 +0100 Subject: [PATCH 10/15] monitor both certificates for expiry --- .../CertificateExpiryMonitorTests.cs | 10 +++---- .../Certificates/CertificateExpiryMonitor.cs | 30 ++++++++++++------- src/KurrentDB.Core/ClusterVNode.cs | 2 +- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/KurrentDB.Core.Tests/Certificates/CertificateExpiryMonitorTests.cs b/src/KurrentDB.Core.Tests/Certificates/CertificateExpiryMonitorTests.cs index 48a8c96b0b8..4cab7a9247f 100644 --- a/src/KurrentDB.Core.Tests/Certificates/CertificateExpiryMonitorTests.cs +++ b/src/KurrentDB.Core.Tests/Certificates/CertificateExpiryMonitorTests.cs @@ -37,7 +37,7 @@ public void SetUp() { public void on_start() { // given var certificate = GenCertificate(TimeSpan.FromDays(60)); - var sut = new CertificateExpiryMonitor(_publisher, () => certificate, _logger); + var sut = new CertificateExpiryMonitor(_publisher, _logger, () => certificate); // when sut.Handle(new SystemMessage.SystemStart()); @@ -51,7 +51,7 @@ public void on_start() { public void certificate_is_going_to_expire_within_30_days() { // given var certificate = GenCertificate(TimeSpan.FromDays(29)); - var sut = new CertificateExpiryMonitor(_publisher, () => certificate, _logger); + var sut = new CertificateExpiryMonitor(_publisher, _logger, () => certificate); // when sut.Handle(new MonitoringMessage.CheckCertificateExpiry()); @@ -62,8 +62,8 @@ public void certificate_is_going_to_expire_within_30_days() { Assert.IsInstanceOf(schedule.ReplyMessage); var logMessage = _logger.LogMessages.Single(); - Assert.AreEqual( - "Certificates are going to expire in 29.0 days", + StringAssert.Contains( + "is going to expire in 29.0 days", logMessage.RenderMessage()); } @@ -71,7 +71,7 @@ public void certificate_is_going_to_expire_within_30_days() { public void certificate_is_not_going_to_expire_within_30_days() { // given var certificate = GenCertificate(TimeSpan.FromDays(31)); - var sut = new CertificateExpiryMonitor(_publisher, () => certificate, _logger); + var sut = new CertificateExpiryMonitor(_publisher, _logger, () => certificate); // when sut.Handle(new MonitoringMessage.CheckCertificateExpiry()); diff --git a/src/KurrentDB.Core/Certificates/CertificateExpiryMonitor.cs b/src/KurrentDB.Core/Certificates/CertificateExpiryMonitor.cs index de226a7a0eb..efbb741904a 100644 --- a/src/KurrentDB.Core/Certificates/CertificateExpiryMonitor.cs +++ b/src/KurrentDB.Core/Certificates/CertificateExpiryMonitor.cs @@ -2,6 +2,7 @@ // Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). using System; +using System.Collections.Generic; using System.Security.Cryptography.X509Certificates; using KurrentDB.Common.Utils; using KurrentDB.Core.Bus; @@ -18,22 +19,22 @@ public class CertificateExpiryMonitor : private static readonly TimeSpan _warningThreshold = TimeSpan.FromDays(30); private static readonly TimeSpan _interval = TimeSpan.FromDays(1); - private readonly Func _getCertificate; + private readonly IReadOnlyList> _getCertificates; private readonly IPublisher _publisher; private readonly TimerMessage.Schedule _nodeCertificateExpirySchedule; private readonly ILogger _logger; public CertificateExpiryMonitor( IPublisher publisher, - Func getCertificate, - ILogger logger) { + ILogger logger, + params Func[] getCertificates) { Ensure.NotNull(publisher, nameof(publisher)); - Ensure.NotNull(getCertificate, nameof(getCertificate)); Ensure.NotNull(logger, nameof(logger)); + Ensure.NotNull(getCertificates, nameof(getCertificates)); _publisher = publisher; - _getCertificate = getCertificate; + _getCertificates = getCertificates; _logger = logger; _nodeCertificateExpirySchedule = TimerMessage.Schedule.Create( _interval, @@ -46,16 +47,23 @@ public void Handle(SystemMessage.SystemStart message) { } public void Handle(MonitoringMessage.CheckCertificateExpiry message) { - var certificate = _getCertificate(); + // dedup so single cert mode (where multiple selectors resolve to the same cert) isn't double-logged + HashSet checkedThumbprints = null; - if (certificate != null) { - var certExpiryDate = certificate.NotAfter; - var timeUntilExpiry = certExpiryDate - DateTime.Now; + foreach (var getCertificate in _getCertificates) { + var certificate = getCertificate(); + if (certificate is null) + continue; + checkedThumbprints ??= []; + if (!checkedThumbprints.Add(certificate.Thumbprint)) + continue; + + var timeUntilExpiry = certificate.NotAfter - DateTime.Now; if (timeUntilExpiry <= _warningThreshold) { _logger.Warning( - "Certificates are going to expire in {daysUntilExpiry:N1} days", - timeUntilExpiry.TotalDays); + "Certificate ({subject}, thumbprint {thumbprint}) is going to expire in {daysUntilExpiry:N1} days", + certificate.SubjectName.Name, certificate.Thumbprint, timeUntilExpiry.TotalDays); } } diff --git a/src/KurrentDB.Core/ClusterVNode.cs b/src/KurrentDB.Core/ClusterVNode.cs index e23103f9427..b74c5a92d76 100644 --- a/src/KurrentDB.Core/ClusterVNode.cs +++ b/src/KurrentDB.Core/ClusterVNode.cs @@ -1712,7 +1712,7 @@ await Db.Open(!options.Database.SkipDbVerify, threads: options.Database.Initiali _mainBus.Subscribe(_startup); _mainBus.Subscribe(_startup); - var certificateExpiryMonitor = new CertificateExpiryMonitor(_mainQueue, _certificateSelector, Log); + var certificateExpiryMonitor = new CertificateExpiryMonitor(_mainQueue, Log, _certificateSelector, _clusterClientCertificateSelector); _mainBus.Subscribe(certificateExpiryMonitor); _mainBus.Subscribe(certificateExpiryMonitor); From c09a7c86a8efa5b5953135f8b2220d630137191c Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Tue, 21 Apr 2026 19:55:25 +0100 Subject: [PATCH 11/15] rename from ClientClusterCertificate to NodeClientCertificate --- .../OptionsCertificateProviderTests.cs | 38 +++++------ .../Certificates/CertificateProvider.cs | 4 +- .../OptionsCertificateProvider.cs | 52 +++++++------- src/KurrentDB.Core/ClusterVNode.cs | 22 +++--- .../Configuration/ClusterVNodeOptions.cs | 68 +++++++++---------- .../ClusterVNodeOptionsExtensions.cs | 38 +++++------ 6 files changed, 111 insertions(+), 111 deletions(-) diff --git a/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs index 12ca312520c..d923c7e7518 100644 --- a/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs +++ b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs @@ -69,7 +69,7 @@ private static X509Certificate2 StripPrivateKey(X509Certificate2 cert) => private ClusterVNodeOptions BuildOptions( X509Certificate2 nodeCert, X509Certificate2 rootCert, - X509Certificate2 clusterClientCert = null, + X509Certificate2 nodeClientCert = null, string reservedNodeCN = null) { var options = new ClusterVNodeOptions { @@ -81,14 +81,14 @@ private ClusterVNodeOptions BuildOptions( }, }; - if (clusterClientCert != null) { - var certPath = WriteCertToFile(clusterClientCert, $"cluster-client-{Guid.NewGuid()}.pfx"); + if (nodeClientCert != null) { + var certPath = WriteCertToFile(nodeClientCert, $"node-client-{Guid.NewGuid()}.pfx"); options = options with { - ClientClusterCertificateFile = new ClusterVNodeOptions.ClientClusterCertificateFileOptions { - ClientClusterCertificateFile = certPath, + NodeClientCertificateFile = new ClusterVNodeOptions.NodeClientCertificateFileOptions { + NodeClientCertificateFile = certPath, }, }; - // also write the root to the temp dir so the cluster client trusted-root fallback can pick it up + // also write the root to the temp dir so the node client trusted-root fallback can pick it up File.WriteAllBytes(fixture.GetFilePathFor($"root-{Guid.NewGuid()}.crt"), rootCert.Export(X509ContentType.Cert)); } @@ -119,8 +119,8 @@ public void single_cert_mode_loads_successfully() { Assert.Equal(LoadCertificateResult.Success, result); Assert.Equal(node.Thumbprint, sut.Certificate.Thumbprint); - // in single cert mode, cluster client cert is the same as the main cert - Assert.Equal(node.Thumbprint, sut.ClientClusterCertificate.Thumbprint); + // in single cert mode, node client cert is the same as the main cert + Assert.Equal(node.Thumbprint, sut.NodeClientCertificate.Thumbprint); Assert.Equal("eventstoredb-node", sut.GetReservedNodeCommonName()); } @@ -128,26 +128,26 @@ public void single_cert_mode_loads_successfully() { public void dual_cert_mode_loads_successfully() { var root = CreateCert("CN=test-ca", ca: true); var node = CreateCert("CN=kurrentdb.example.com", parent: root, serverAuthEKU: true); - var clusterClient = CreateCert("CN=eventstoredb-node", parent: root, serverAuthEKU: true, clientAuthEKU: true); - var options = BuildOptions(node, root, clusterClient); + var nodeClient = CreateCert("CN=eventstoredb-node", parent: root, serverAuthEKU: true, clientAuthEKU: true); + var options = BuildOptions(node, root, nodeClient); var sut = new OptionsCertificateProvider(); var result = sut.LoadCertificates(options); Assert.Equal(LoadCertificateResult.Success, result); Assert.Equal(node.Thumbprint, sut.Certificate.Thumbprint); - Assert.Equal(clusterClient.Thumbprint, sut.ClientClusterCertificate.Thumbprint); - // reserved CN auto-derived from the cluster client cert (not the main cert) + Assert.Equal(nodeClient.Thumbprint, sut.NodeClientCertificate.Thumbprint); + // reserved CN auto-derived from the node client cert (not the main cert) Assert.Equal("eventstoredb-node", sut.GetReservedNodeCommonName()); } [Fact] - public void dual_cert_mode_cluster_client_cert_with_clientauth_only_fails_classification() { + public void dual_cert_mode_node_client_cert_with_clientauth_only_fails_classification() { var root = CreateCert("CN=test-ca", ca: true); var node = CreateCert("CN=eventstoredb-node", parent: root, serverAuthEKU: true, clientAuthEKU: true); // clientAuth-only classifies as User, not Node - var clusterClient = CreateCert("CN=eventstoredb-node", parent: root, clientAuthEKU: true); - var options = BuildOptions(node, root, clusterClient); + var nodeClient = CreateCert("CN=eventstoredb-node", parent: root, clientAuthEKU: true); + var options = BuildOptions(node, root, nodeClient); var sut = new OptionsCertificateProvider(); var result = sut.LoadCertificates(options); @@ -168,17 +168,17 @@ public void reserved_node_cn_mismatch_fails() { } [Fact] - public void reserved_node_cn_matches_cluster_client_cert_in_dual_cert_mode() { + public void reserved_node_cn_matches_node_client_cert_in_dual_cert_mode() { var root = CreateCert("CN=test-ca", ca: true); // main cert has a different CN — expected in dual mode (public CA hostname) var node = CreateCert("CN=kurrentdb.example.com", parent: root, serverAuthEKU: true); - var clusterClient = CreateCert("CN=eventstoredb-node", parent: root, serverAuthEKU: true, clientAuthEKU: true); - var options = BuildOptions(node, root, clusterClient, reservedNodeCN: "eventstoredb-node"); + var nodeClient = CreateCert("CN=eventstoredb-node", parent: root, serverAuthEKU: true, clientAuthEKU: true); + var options = BuildOptions(node, root, nodeClient, reservedNodeCN: "eventstoredb-node"); var sut = new OptionsCertificateProvider(); var result = sut.LoadCertificates(options); - // reserved CN matches the cluster client cert, not the main cert — should succeed + // reserved CN matches the node client cert, not the main cert — should succeed Assert.Equal(LoadCertificateResult.Success, result); } diff --git a/src/KurrentDB.Core/Certificates/CertificateProvider.cs b/src/KurrentDB.Core/Certificates/CertificateProvider.cs index 9d62af6132c..c2a4cfc3b16 100644 --- a/src/KurrentDB.Core/Certificates/CertificateProvider.cs +++ b/src/KurrentDB.Core/Certificates/CertificateProvider.cs @@ -8,8 +8,8 @@ namespace KurrentDB.Core.Certificates; public abstract class CertificateProvider { public X509Certificate2 Certificate; public X509Certificate2Collection IntermediateCerts; - public X509Certificate2 ClientClusterCertificate; - public X509Certificate2Collection ClientClusterIntermediateCerts; + public X509Certificate2 NodeClientCertificate; + public X509Certificate2Collection NodeClientIntermediateCerts; public X509Certificate2Collection TrustedRootCerts; public abstract LoadCertificateResult LoadCertificates(ClusterVNodeOptions options); public abstract string GetReservedNodeCommonName(); diff --git a/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs b/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs index 9743d49d33b..2d532633aac 100644 --- a/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs +++ b/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs @@ -20,15 +20,15 @@ public override LoadCertificateResult LoadCertificates(ClusterVNodeOptions optio return LoadCertificateResult.Skipped; } - // Load both Node and ClusterClient certificates up-front. + // Load both Node and NodeClient certificates up-front. // Node cert is the server cert sent to incoming connections - // ClusterClient cert is the client cert sent on outgoing connections to other nodes. - // ClusterClient cert defaults to the same certificate as Node cert if not provided. + // NodeClient cert is the client cert sent on outgoing connections to other nodes. + // NodeClient cert defaults to the same certificate as Node cert if not provided. var (certificate, intermediates) = options.LoadNodeCertificate(); - var hasClientClusterCert = options.TryLoadClientClusterCertificate(out var clientClusterCertificate, out var clientClusterIntermediates); - if (!hasClientClusterCert) { - clientClusterCertificate = certificate; - clientClusterIntermediates = intermediates; + var hasNodeClientCert = options.TryLoadNodeClientCertificate(out var nodeClientCertificate, out var nodeClientIntermediates); + if (!hasNodeClientCert) { + nodeClientCertificate = certificate; + nodeClientIntermediates = intermediates; } string reservedNodeCN; @@ -36,26 +36,26 @@ public override LoadCertificateResult LoadCertificates(ClusterVNodeOptions optio // Determine the CN pattern expected from incomming node certificates. if (options.Certificate.CertificateReservedNodeCommonName.IsNotEmptyString()) { - // Reserved CN is configured. Check that the cluster client cert matches it. + // Reserved CN is configured. Check that the node client cert matches it. reservedNodeCN = options.Certificate.CertificateReservedNodeCommonName; - if (!clientClusterCertificate.ClientCertificateMatchesName(reservedNodeCN)) { - var clientClusterCertCN = clientClusterCertificate.GetCommonName(); + if (!nodeClientCertificate.ClientCertificateMatchesName(reservedNodeCN)) { + var nodeClientCertCN = nodeClientCertificate.GetCommonName(); Log.Error( "Certificate CN: {certificateCN} does not match with the {reservedNodeCNOption} configuration setting: {reservedNodeCN}", - clientClusterCertCN, reservedNodeCNOption, reservedNodeCN); + nodeClientCertCN, reservedNodeCNOption, reservedNodeCN); return LoadCertificateResult.VerificationFailed; } Log.Information("{reservedNodeCNOption} configured to: {reservedNodeCN}", reservedNodeCNOption, reservedNodeCN); } else { - reservedNodeCN = clientClusterCertificate.GetCommonName(); + reservedNodeCN = nodeClientCertificate.GetCommonName(); Log.Information("{reservedNodeCNOption} auto-configured to: {reservedNodeCN} based on certificate", reservedNodeCNOption, reservedNodeCN); } // Log information about the certificates and their intermediates LogThumbprints("node", Certificate, certificate, intermediates); - LogThumbprints("cluster client", ClientClusterCertificate, clientClusterCertificate, clientClusterIntermediates); + LogThumbprints("node client", NodeClientCertificate, nodeClientCertificate, nodeClientIntermediates); static void LogThumbprints(string label, X509Certificate2 previousCertificate, X509Certificate2 certificate, X509Certificate2Collection intermediates) { var previousThumbprint = previousCertificate?.Thumbprint; @@ -82,28 +82,28 @@ static void LogThumbprints(string label, X509Certificate2 previousCertificate, X return LoadCertificateResult.VerificationFailed; } - // Validate the cluster client certificate - if (hasClientClusterCert) { - var clientClusterTrustedRoots = options.LoadClientClusterTrustedRootCertificates(); + // Validate the node client certificate + if (hasNodeClientCert) { + var nodeClientTrustedRootCerts = options.LoadNodeClientTrustedRootCertificates(); - foreach (var trustedRootCert in clientClusterTrustedRoots) { - Log.Information("Loading trusted root for cluster client certificate. Subject: {subject}, Thumbprint: {thumbprint}", trustedRootCert.SubjectName.Name, trustedRootCert.Thumbprint); + foreach (var trustedRootCert in nodeClientTrustedRootCerts) { + Log.Information("Loading trusted root for node client certificate. Subject: {subject}, Thumbprint: {thumbprint}", trustedRootCert.SubjectName.Name, trustedRootCert.Thumbprint); } - if (!VerifyCertificates("cluster client", clientClusterCertificate, clientClusterIntermediates, clientClusterTrustedRoots)) { + if (!VerifyCertificates("node client", nodeClientCertificate, nodeClientIntermediates, nodeClientTrustedRootCerts)) { return LoadCertificateResult.VerificationFailed; } } // Check the EKUs - if (clientClusterCertificate.ClassifyInboundCertificate( + if (nodeClientCertificate.ClassifyInboundCertificate( disableClientAuthEkuValidation: options.Certificate.DisableClientAuthEkuValidation, - out var clientClusterCertDescription) is not CertificateClassification.Node) { + out var nodeClientCertDescription) is not CertificateClassification.Node) { - Log.Error(hasClientClusterCert - ? "The cluster client certificate was not recognized as a node certificate: {description}" + Log.Error(hasNodeClientCert + ? "The node client certificate was not recognized as a node certificate: {description}" : "The node certificate was not recognized as a node certificate: {description}", - clientClusterCertDescription); + nodeClientCertDescription); return LoadCertificateResult.VerificationFailed; } @@ -113,8 +113,8 @@ static void LogThumbprints(string label, X509Certificate2 previousCertificate, X // to fail when establishing/receiving a connection and the next connection retries will succeed. Certificate = certificate; IntermediateCerts = intermediates; - ClientClusterCertificate = clientClusterCertificate; - ClientClusterIntermediateCerts = clientClusterIntermediates; + NodeClientCertificate = nodeClientCertificate; + NodeClientIntermediateCerts = nodeClientIntermediates; TrustedRootCerts = trustedRootCerts; _cachedReservedNodeCN = reservedNodeCN; diff --git a/src/KurrentDB.Core/ClusterVNode.cs b/src/KurrentDB.Core/ClusterVNode.cs index b74c5a92d76..3e20b229ae5 100644 --- a/src/KurrentDB.Core/ClusterVNode.cs +++ b/src/KurrentDB.Core/ClusterVNode.cs @@ -204,10 +204,10 @@ internal ThreadPoolMessageScheduler WorkersHandler { private readonly bool _disableHttps; private readonly bool _enableUnixSocket; private readonly Func _certificateSelector; - private readonly Func _clusterClientCertificateSelector; + private readonly Func _nodeClientCertificateSelector; private readonly Func _trustedRootCertsSelector; private readonly Func _intermediateCertsSelector; - private readonly Func _clusterClientIntermediateCertsSelector; + private readonly Func _nodeClientIntermediateCertsSelector; private readonly CertificateDelegates.ServerCertificateValidator _internalServerCertificateValidator; private readonly CertificateDelegates.ClientCertificateValidator _internalClientCertificateValidator; private readonly CertificateDelegates.ServerCertificateValidator _externalServerCertificateValidator; @@ -513,19 +513,19 @@ TFChunkDbConfig CreateDbConfig( _queueStatsManager = new QueueStatsManager(); _certificateSelector = () => _certificateProvider?.Certificate; - _clusterClientCertificateSelector = () => _certificateProvider?.ClientClusterCertificate; + _nodeClientCertificateSelector = () => _certificateProvider?.NodeClientCertificate; _trustedRootCertsSelector = () => _certificateProvider?.TrustedRootCerts; _intermediateCertsSelector = () => _certificateProvider?.IntermediateCerts is not { } intermediates ? null : new X509Certificate2Collection(intermediates); - _clusterClientIntermediateCertsSelector = () => - _certificateProvider?.ClientClusterIntermediateCerts is not { } intermediates + _nodeClientIntermediateCertsSelector = () => + _certificateProvider?.NodeClientIntermediateCerts is not { } intermediates ? null : new X509Certificate2Collection(intermediates); _internalServerCertificateValidator = (cert, chain, errors, otherNames) => ValidateServerCertificate(cert, chain, errors, _intermediateCertsSelector, _trustedRootCertsSelector, otherNames); - _internalClientCertificateValidator = (cert, chain, errors) => ValidateClientCertificate(cert, chain, errors, _clusterClientIntermediateCertsSelector, _trustedRootCertsSelector); + _internalClientCertificateValidator = (cert, chain, errors) => ValidateClientCertificate(cert, chain, errors, _nodeClientIntermediateCertsSelector, _trustedRootCertsSelector); _externalServerCertificateValidator = (cert, chain, errors, otherNames) => ValidateServerCertificate(cert, chain, errors, _intermediateCertsSelector, _trustedRootCertsSelector, otherNames); var forwardingProxy = new MessageForwardingProxy(); @@ -571,7 +571,7 @@ void StartSubsystems() { _nodeHttpClientFactory = new NodeHttpClientFactory( uriScheme, _internalServerCertificateValidator, - _clusterClientCertificateSelector); + _nodeClientCertificateSelector); _eventStoreClusterClientCache = new EventStoreClusterClientCache(_mainQueue, (endpoint, publisher) => @@ -1515,7 +1515,7 @@ GossipAdvertiseInfo GetGossipAdvertiseInfo() { GossipAdvertiseInfo.InternalTcp ?? GossipAdvertiseInfo.InternalSecureTcp, options.Cluster.ReadOnlyReplica, !disableInternalTcpTls, _internalServerCertificateValidator, - _clusterClientCertificateSelector, + _nodeClientCertificateSelector, TimeSpan.FromMilliseconds(options.Interface.ReplicationHeartbeatTimeout), TimeSpan.FromMilliseconds(options.Interface.ReplicationHeartbeatInterval), TimeSpan.FromMilliseconds(options.Database.WriteTimeoutMs)); @@ -1620,8 +1620,8 @@ IServiceCollection ConfigureNodeServices(IServiceCollection services) { .AddSingleton> (() => ( - _clusterClientCertificateSelector(), - _clusterClientIntermediateCertsSelector(), + _nodeClientCertificateSelector(), + _nodeClientIntermediateCertsSelector(), _trustedRootCertsSelector())) .AddSingleton(_nodeHttpClientFactory) .AddSingleton>(Db.Manager) @@ -1712,7 +1712,7 @@ await Db.Open(!options.Database.SkipDbVerify, threads: options.Database.Initiali _mainBus.Subscribe(_startup); _mainBus.Subscribe(_startup); - var certificateExpiryMonitor = new CertificateExpiryMonitor(_mainQueue, Log, _certificateSelector, _clusterClientCertificateSelector); + var certificateExpiryMonitor = new CertificateExpiryMonitor(_mainQueue, Log, _certificateSelector, _nodeClientCertificateSelector); _mainBus.Subscribe(certificateExpiryMonitor); _mainBus.Subscribe(certificateExpiryMonitor); diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs index 08a1e3d66cf..b095fc44b98 100644 --- a/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs +++ b/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs @@ -41,8 +41,8 @@ public partial record ClusterVNodeOptions { [OptionGroup] public CertificateOptions Certificate { get; init; } = new(); [OptionGroup] public CertificateFileOptions CertificateFile { get; init; } = new(); [OptionGroup] public CertificateStoreOptions CertificateStore { get; init; } = new(); - [OptionGroup] public ClientClusterCertificateFileOptions ClientClusterCertificateFile { get; init; } = new(); - [OptionGroup] public ClientClusterCertificateStoreOptions ClientClusterCertificateStore { get; init; } = new(); + [OptionGroup] public NodeClientCertificateFileOptions NodeClientCertificateFile { get; init; } = new(); + [OptionGroup] public NodeClientCertificateStoreOptions NodeClientCertificateStore { get; init; } = new(); [OptionGroup] public ClusterOptions Cluster { get; init; } = new(); [OptionGroup] public DatabaseOptions Database { get; init; } = new(); [OptionGroup] public GrpcOptions Grpc { get; init; } = new(); @@ -81,8 +81,8 @@ public static ClusterVNodeOptions FromConfiguration(IConfigurationRoot configura Certificate = configuration.BindOptions(), CertificateFile = configuration.BindOptions(), CertificateStore = configuration.BindOptions(), - ClientClusterCertificateFile = configuration.BindOptions(), - ClientClusterCertificateStore = configuration.BindOptions(), + NodeClientCertificateFile = configuration.BindOptions(), + NodeClientCertificateStore = configuration.BindOptions(), Cluster = configuration.BindOptions(), Database = configuration.BindOptions(), Grpc = configuration.BindOptions(), @@ -258,51 +258,51 @@ public record CertificateStoreOptions { public string TrustedRootCertificateThumbprint { get; init; } = string.Empty; } - [Description("Cluster Client Certificate Options (from file)")] - public record ClientClusterCertificateFileOptions { - [Description("The path to a PKCS #12 (.p12/.pfx) or an X.509 (.pem, .crt, .cer, .der) cluster client certificate file " + - "for outbound intra-cluster connections. If specified, this certificate is used when connecting to other nodes " + + [Description("Node Client Certificate Options (from file)")] + public record NodeClientCertificateFileOptions { + [Description("The path to a PKCS #12 (.p12/.pfx) or an X.509 (.pem, .crt, .cer, .der) certificate file " + + "for the node's client certificate used for outbound intra-cluster connections. If specified, this certificate is used when connecting to other nodes " + "instead of the main node certificate.")] - public string? ClientClusterCertificateFile { get; init; } + public string? NodeClientCertificateFile { get; init; } - [Description("The path to the cluster client certificate private key file (.key) if an X.509 (.pem, .crt, .cer, .der) " + - "cluster client certificate file is provided.")] - public string? ClientClusterCertificatePrivateKeyFile { get; init; } + [Description("The path to the node's client certificate private key file (.key) if an X.509 (.pem, .crt, .cer, .der) " + + "node client certificate file is provided.")] + public string? NodeClientCertificatePrivateKeyFile { get; init; } - [Description("The password to the cluster client certificate if a PKCS #12 (.p12/.pfx) certificate file is provided."), + [Description("The password to the node's client certificate if a PKCS #12 (.p12/.pfx) certificate file is provided."), Sensitive] - public string? ClientClusterCertificatePassword { get; init; } + public string? NodeClientCertificatePassword { get; init; } - [Description("The password to the cluster client certificate private key file if an encrypted PKCS #8 private key file is provided."), + [Description("The password to the node's client certificate private key file if an encrypted PKCS #8 private key file is provided."), Sensitive] - public string? ClientClusterCertificatePrivateKeyPassword { get; init; } + public string? NodeClientCertificatePrivateKeyPassword { get; init; } } - [Description("Cluster Client Certificate Options (from store)")] - public record ClientClusterCertificateStoreOptions { - [Description("The certificate store location name for the cluster client certificate.")] - public string ClientClusterCertificateStoreLocation { get; init; } = string.Empty; + [Description("Node Client Certificate Options (from store)")] + public record NodeClientCertificateStoreOptions { + [Description("The certificate store location name for the node's client certificate.")] + public string NodeClientCertificateStoreLocation { get; init; } = string.Empty; - [Description("The certificate store name for the cluster client certificate.")] - public string ClientClusterCertificateStoreName { get; init; } = string.Empty; + [Description("The certificate store name for the node's client certificate.")] + public string NodeClientCertificateStoreName { get; init; } = string.Empty; - [Description("The subject name of the cluster client certificate.")] - public string ClientClusterCertificateSubjectName { get; init; } = string.Empty; + [Description("The subject name of the node's client certificate.")] + public string NodeClientCertificateSubjectName { get; init; } = string.Empty; - [Description("The fingerprint/thumbprint of the cluster client certificate.")] - public string ClientClusterCertificateThumbprint { get; init; } = string.Empty; + [Description("The fingerprint/thumbprint of the node's client certificate.")] + public string NodeClientCertificateThumbprint { get; init; } = string.Empty; - [Description("The name of the certificate store that contains the trusted root certificate for the cluster client certificate.")] - public string ClientClusterTrustedRootCertificateStoreName { get; init; } = string.Empty; + [Description("The name of the certificate store that contains the trusted root certificate for the node's client certificate.")] + public string NodeClientTrustedRootCertificateStoreName { get; init; } = string.Empty; - [Description("The certificate store location that contains the trusted root certificate for the cluster client certificate.")] - public string ClientClusterTrustedRootCertificateStoreLocation { get; init; } = string.Empty; + [Description("The certificate store location that contains the trusted root certificate for the node's client certificate.")] + public string NodeClientTrustedRootCertificateStoreLocation { get; init; } = string.Empty; - [Description("The trusted root certificate subject name for the cluster client certificate.")] - public string ClientClusterTrustedRootCertificateSubjectName { get; init; } = string.Empty; + [Description("The trusted root certificate subject name for the node's client certificate.")] + public string NodeClientTrustedRootCertificateSubjectName { get; init; } = string.Empty; - [Description("The trusted root certificate fingerprint/thumbprint for the cluster client certificate.")] - public string ClientClusterTrustedRootCertificateThumbprint { get; init; } = string.Empty; + [Description("The trusted root certificate fingerprint/thumbprint for the node's client certificate.")] + public string NodeClientTrustedRootCertificateThumbprint { get; init; } = string.Empty; } [Description("Cluster Options")] diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs index 528765b9c76..50de4b12a3d 100644 --- a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs +++ b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs @@ -226,26 +226,26 @@ public static (X509Certificate2 certificate, X509Certificate2Collection intermed } /// - /// Tries to load the cluster client certificate from the options set. - /// Returns false if no cluster client certificate is configured. + /// Tries to load the node client certificate from the options set. + /// Returns false if no node client certificate is configured. /// - public static bool TryLoadClientClusterCertificate( + public static bool TryLoadNodeClientCertificate( this ClusterVNodeOptions options, out X509Certificate2 certificate, out X509Certificate2Collection intermediates) => TryLoadCertificate( - logLabel: "cluster client", + logLabel: "node client", store: new StoreCertInfo( - StoreLocation: options.ClientClusterCertificateStore.ClientClusterCertificateStoreLocation, - StoreName: options.ClientClusterCertificateStore.ClientClusterCertificateStoreName, - SubjectName: options.ClientClusterCertificateStore.ClientClusterCertificateSubjectName, - Thumbprint: options.ClientClusterCertificateStore.ClientClusterCertificateThumbprint), + StoreLocation: options.NodeClientCertificateStore.NodeClientCertificateStoreLocation, + StoreName: options.NodeClientCertificateStore.NodeClientCertificateStoreName, + SubjectName: options.NodeClientCertificateStore.NodeClientCertificateSubjectName, + Thumbprint: options.NodeClientCertificateStore.NodeClientCertificateThumbprint), file: new FileCertInfo( - File: options.ClientClusterCertificateFile.ClientClusterCertificateFile, - PrivateKeyFile: options.ClientClusterCertificateFile.ClientClusterCertificatePrivateKeyFile, - Password: options.ClientClusterCertificateFile.ClientClusterCertificatePassword, - PrivateKeyPassword: options.ClientClusterCertificateFile.ClientClusterCertificatePrivateKeyPassword), + File: options.NodeClientCertificateFile.NodeClientCertificateFile, + PrivateKeyFile: options.NodeClientCertificateFile.NodeClientCertificatePrivateKeyFile, + Password: options.NodeClientCertificateFile.NodeClientCertificatePassword, + PrivateKeyPassword: options.NodeClientCertificateFile.NodeClientCertificatePrivateKeyPassword), certificate: out certificate, intermediates: out intermediates); @@ -317,17 +317,17 @@ public static X509Certificate2Collection LoadTrustedRootCertificates(this Cluste } /// - /// Loads trusted root certificates for the cluster client certificate. - /// If cluster-client-specific trusted root store options are not set, falls back to the main + /// Loads trusted root certificates for the node client certificate. + /// If node-client-specific trusted root store options are not set, falls back to the main /// . /// - public static X509Certificate2Collection LoadClientClusterTrustedRootCertificates(this ClusterVNodeOptions options) { + public static X509Certificate2Collection LoadNodeClientTrustedRootCertificates(this ClusterVNodeOptions options) { return LoadTrustedRootsFromStoreOrPath( new StoreCertInfo( - StoreLocation: options.ClientClusterCertificateStore.ClientClusterTrustedRootCertificateStoreLocation, - StoreName: options.ClientClusterCertificateStore.ClientClusterTrustedRootCertificateStoreName, - SubjectName: options.ClientClusterCertificateStore.ClientClusterTrustedRootCertificateSubjectName, - Thumbprint: options.ClientClusterCertificateStore.ClientClusterTrustedRootCertificateThumbprint), + StoreLocation: options.NodeClientCertificateStore.NodeClientTrustedRootCertificateStoreLocation, + StoreName: options.NodeClientCertificateStore.NodeClientTrustedRootCertificateStoreName, + SubjectName: options.NodeClientCertificateStore.NodeClientTrustedRootCertificateSubjectName, + Thumbprint: options.NodeClientCertificateStore.NodeClientTrustedRootCertificateThumbprint), options.Certificate.TrustedRootCertificatesPath); } From 5923364bce8ad2598c48418a35fc94a093ab56f7 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Tue, 21 Apr 2026 20:38:23 +0100 Subject: [PATCH 12/15] directory per test --- .../Certificates/OptionsCertificateProviderTests.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs index d923c7e7518..8f9157d6255 100644 --- a/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs +++ b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs @@ -11,8 +11,7 @@ namespace KurrentDB.Core.XUnit.Tests.Certificates; -public class OptionsCertificateProviderTests(DirectoryFixture fixture) : - IClassFixture> { +public class OptionsCertificateProviderTests : DirectoryPerTest { private static X509Certificate2 CreateCert( string subject, @@ -58,7 +57,7 @@ private static X509Certificate2 CreateCert( } private string WriteCertToFile(X509Certificate2 cert, string fileName) { - var path = fixture.GetFilePathFor(fileName); + var path = Fixture.GetFilePathFor(fileName); File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx)); return path; } @@ -77,7 +76,7 @@ private ClusterVNodeOptions BuildOptions( TrustedRootCertificates = new X509Certificate2Collection(StripPrivateKey(rootCert)), Certificate = new ClusterVNodeOptions.CertificateOptions { CertificateReservedNodeCommonName = reservedNodeCN ?? string.Empty, - TrustedRootCertificatesPath = fixture.Directory, + TrustedRootCertificatesPath = Fixture.Directory, }, }; @@ -89,7 +88,7 @@ private ClusterVNodeOptions BuildOptions( }, }; // also write the root to the temp dir so the node client trusted-root fallback can pick it up - File.WriteAllBytes(fixture.GetFilePathFor($"root-{Guid.NewGuid()}.crt"), rootCert.Export(X509ContentType.Cert)); + File.WriteAllBytes(Fixture.GetFilePathFor($"root-{Guid.NewGuid()}.crt"), rootCert.Export(X509ContentType.Cert)); } return options; From cd6e62e274ca631a86e04410381a96b538438f3a Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Wed, 22 Apr 2026 07:26:53 +0100 Subject: [PATCH 13/15] only check classification of node client cert on startup when part of a cluster if we aren't part of a cluster we won't ever use it as a client cert --- .../Certificates/OptionsCertificateProviderTests.cs | 2 ++ .../Certificates/OptionsCertificateProvider.cs | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs index 8f9157d6255..bda9babd30a 100644 --- a/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs +++ b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs @@ -78,6 +78,8 @@ private ClusterVNodeOptions BuildOptions( CertificateReservedNodeCommonName = reservedNodeCN ?? string.Empty, TrustedRootCertificatesPath = Fixture.Directory, }, + // multi-node so the node-client-cert classifier check fires + Cluster = new ClusterVNodeOptions.ClusterOptions { ClusterSize = 3 }, }; if (nodeClientCert != null) { diff --git a/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs b/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs index 2d532633aac..19141c6809e 100644 --- a/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs +++ b/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs @@ -95,11 +95,11 @@ static void LogThumbprints(string label, X509Certificate2 previousCertificate, X } } - // Check the EKUs - if (nodeClientCertificate.ClassifyInboundCertificate( + // Check our client certificate will be classified correctly by other nodes + if (options.Cluster.ClusterSize > 1 && nodeClientCertificate.ClassifyInboundCertificate( disableClientAuthEkuValidation: options.Certificate.DisableClientAuthEkuValidation, out var nodeClientCertDescription) is not CertificateClassification.Node) { - + Log.Error(hasNodeClientCert ? "The node client certificate was not recognized as a node certificate: {description}" : "The node certificate was not recognized as a node certificate: {description}", From cbbc6cfc24f821f9f7863516bee2f0cefdd5eb50 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Wed, 22 Apr 2026 07:29:35 +0100 Subject: [PATCH 14/15] add docs --- docs/server/quick-start/whatsnew.md | 14 +++ docs/server/release-schedule/release-notes.md | 10 +- docs/server/security/protocol-security.md | 92 +++++++++++++++++++ docs/server/security/user-authentication.md | 4 +- 4 files changed, 111 insertions(+), 9 deletions(-) diff --git a/docs/server/quick-start/whatsnew.md b/docs/server/quick-start/whatsnew.md index e08a8240d89..3295fb55637 100644 --- a/docs/server/quick-start/whatsnew.md +++ b/docs/server/quick-start/whatsnew.md @@ -4,6 +4,20 @@ order: 2 # What's New +## New in 26.1 + +### Dual certificate support for cluster communication + + + +KurrentDB now supports configuring a separate certificate for outbound connections to other cluster nodes. This allows using a publicly trusted certificate to authenticate the node as a server (so that client applications need not install a private CA root) while using a privately issued certificate to authenticate the node as a client to other nodes. + +This addresses the industry-wide removal of the `clientAuth` Extended Key Usage from public CA certificates, driven by changes to the Chrome Root Program policy. With dual certificates, clusters can use public CA server certificates without the `DisableClientAuthEkuValidation` workaround, while remaining compliant with RFC 5280. + +See [Protocol security](../security/protocol-security.md#node-client-certificate-file) for configuration details. + +This feature is experimental in the sense that the configuration options and behavior are subject to change according to feedback from the community. + ## New in 26.0 Features diff --git a/docs/server/release-schedule/release-notes.md b/docs/server/release-schedule/release-notes.md index 0db96d22f96..4689b06f134 100644 --- a/docs/server/release-schedule/release-notes.md +++ b/docs/server/release-schedule/release-notes.md @@ -4,16 +4,12 @@ order: 1 # Release notes -This page contains the release notes for KurrentDB v26.0. +This page contains the release notes for KurrentDB v26.1. -## [26.0.0](https://github.com/kurrent-io/KurrentDB/releases/tag/v26.0.0) +## [26.0.1](https://github.com/kurrent-io/KurrentDB/releases/tag/v26.0.1) -16 January 2026 +30 April 2026 ### What's new Find out [what's new](../quick-start/whatsnew.md) in this release. - -### Projections: Fixed wake-up race condition (PR [#5428](https://github.com/kurrent-io/KurrentDB/pull/5428)) - -When writing empty transactions (write requests with 0 events in) a race condition existed where a projection that had reached the end of its input stream and stopped might not detect the addition of a new event. The new event could remain unprocessed until another event is written to any stream. Subsequent new events written to any stream would allow the projection to continue and process any outstanding events correctly. Writing empty transactions is uncommon but supported by the database. diff --git a/docs/server/security/protocol-security.md b/docs/server/security/protocol-security.md index 00df3d595cd..22567bad5aa 100644 --- a/docs/server/security/protocol-security.md +++ b/docs/server/security/protocol-security.md @@ -194,6 +194,98 @@ If multiple matching root certificates are found, then the root certificate with | YAML | `TrustedRootCertificateSubjectName` | | Environment variable | `KURRENTDB_TRUSTED_ROOT_CERTIFICATE_SUBJECT_NAME` | +### Node client certificate file + + + +By default, a KurrentDB node uses the same certificate for both inbound connections (server authentication) and outbound connections to other nodes (client authentication). The node client certificate settings allow you to configure a separate certificate for outbound connections to other nodes. + +This is useful when you want to use a certificate from a public CA for inbound client connections (which may only have the `serverAuth` EKU), while using a certificate from a private CA with both the `serverAuth` and `clientAuth` EKUs for inter-node communication. Using dual certificates this way is an alternative to enabling [`DisableClientAuthEkuValidation`](#disable-client-authentication-eku-validation) and keeps the node in compliance with RFC 5280. + +If no node client certificate is configured, the node will use its main certificate as it's client certificate. + +The node client certificate must have both the `serverAuth` and `clientAuth` Extended Key Usages (EKUs), or no EKU extension at all, so that receiving nodes can identify it as a node certificate. + +User certificates (for X.509 client certificate authentication) must share a root CA with the node's client certificate. + +| Format | Syntax | +|:---------------------|:--------------------------------------------| +| Command line | `--node-client-certificate-file` | +| YAML | `NodeClientCertificateFile` | +| Environment variable | `KURRENTDB_NODE_CLIENT_CERTIFICATE_FILE` | + +| Format | Syntax | +|:---------------------|:------------------------------------------------| +| Command line | `--node-client-certificate-password` | +| YAML | `NodeClientCertificatePassword` | +| Environment variable | `KURRENTDB_NODE_CLIENT_CERTIFICATE_PASSWORD` | + +| Format | Syntax | +|:---------------------|:-------------------------------------------------------| +| Command line | `--node-client-certificate-private-key-file` | +| YAML | `NodeClientCertificatePrivateKeyFile` | +| Environment variable | `KURRENTDB_NODE_CLIENT_CERTIFICATE_PRIVATE_KEY_FILE` | + +| Format | Syntax | +|:---------------------|:-----------------------------------------------------------| +| Command line | `--node-client-certificate-private-key-password` | +| YAML | `NodeClientCertificatePrivateKeyPassword` | +| Environment variable | `KURRENTDB_NODE_CLIENT_CERTIFICATE_PRIVATE_KEY_PASSWORD` | + +### Node client certificate store (Windows) + + + +You can also load the node client certificate from the Windows certificate store. + +| Format | Syntax | +|:---------------------|:------------------------------------------------------| +| Command line | `--node-client-certificate-store-location` | +| YAML | `NodeClientCertificateStoreLocation` | +| Environment variable | `KURRENTDB_NODE_CLIENT_CERTIFICATE_STORE_LOCATION` | + +| Format | Syntax | +|:---------------------|:--------------------------------------------------| +| Command line | `--node-client-certificate-store-name` | +| YAML | `NodeClientCertificateStoreName` | +| Environment variable | `KURRENTDB_NODE_CLIENT_CERTIFICATE_STORE_NAME` | + +| Format | Syntax | +|:---------------------|:--------------------------------------------------| +| Command line | `--node-client-certificate-thumbprint` | +| YAML | `NodeClientCertificateThumbprint` | +| Environment variable | `KURRENTDB_NODE_CLIENT_CERTIFICATE_THUMBPRINT` | + +| Format | Syntax | +|:---------------------|:----------------------------------------------------| +| Command line | `--node-client-certificate-subject-name` | +| YAML | `NodeClientCertificateSubjectName` | +| Environment variable | `KURRENTDB_NODE_CLIENT_CERTIFICATE_SUBJECT_NAME` | + +| Format | Syntax | +|:---------------------|:-----------------------------------------------------------------| +| Command line | `--node-client-trusted-root-certificate-store-location` | +| YAML | `NodeClientTrustedRootCertificateStoreLocation` | +| Environment variable | `KURRENTDB_NODE_CLIENT_TRUSTED_ROOT_CERTIFICATE_STORE_LOCATION` | + +| Format | Syntax | +|:---------------------|:-------------------------------------------------------------| +| Command line | `--node-client-trusted-root-certificate-store-name` | +| YAML | `NodeClientTrustedRootCertificateStoreName` | +| Environment variable | `KURRENTDB_NODE_CLIENT_TRUSTED_ROOT_CERTIFICATE_STORE_NAME` | + +| Format | Syntax | +|:---------------------|:-------------------------------------------------------------| +| Command line | `--node-client-trusted-root-certificate-thumbprint` | +| YAML | `NodeClientTrustedRootCertificateThumbprint` | +| Environment variable | `KURRENTDB_NODE_CLIENT_TRUSTED_ROOT_CERTIFICATE_THUMBPRINT` | + +| Format | Syntax | +|:---------------------|:---------------------------------------------------------------| +| Command line | `--node-client-trusted-root-certificate-subject-name` | +| YAML | `NodeClientTrustedRootCertificateSubjectName` | +| Environment variable | `KURRENTDB_NODE_CLIENT_TRUSTED_ROOT_CERTIFICATE_SUBJECT_NAME` | + ## Certificate generation tool Kurrent provides the interactive Certificate Generation CLI, which creates certificates signed by a private, auto-generated CA for KurrentDB. You can use the [configuration wizard](https://configurator.eventstore.com), that will provide you exact CLI commands that you need to run to generate certificates matching your configuration. diff --git a/docs/server/security/user-authentication.md b/docs/server/security/user-authentication.md index 45fe3d84d33..ff5f3e85592 100644 --- a/docs/server/security/user-authentication.md +++ b/docs/server/security/user-authentication.md @@ -114,7 +114,7 @@ For using X.509 user certificate with KurrentDB client from an application, refe The user certificate must adhere to the following requirements: -- The certificate has a root CA in common with the node certificate. +- The certificate has a root CA in common with the node's client certificate. - The root CA that they have in common is trusted by the node. - The certificate has the ClientAuth EKU, and not the ServerAuth EKU. - The certificate must be in date. @@ -252,7 +252,7 @@ Signature Hash: 6d922badaba2372070f13c69b620286262eab1d8d2d2156a271a1d73aaaf64e4 | Error | Solution | |:------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Feature not enabled | The feature has to be enabled in order to authenticate user requests.

The following log indicates that the feature was not enabled: `UserCertificatesPlugin is not enabled`. | -| Feature enabled and user not authenticated | If the feature has been enabled but there are still access denied errors, check the following:
  • The user exists and is enabled in the KurrentDB database. Can you log in with the username and password?
  • The user certificate is valid, and has a valid chain up to a trusted root CA.
  • The user certificate and node certificate share a common root CA.
  • Use 'requires leader' (which is the default) in your client configuration to rule out issues with forwarding requests.
| +| Feature enabled and user not authenticated | If the feature has been enabled but there are still access denied errors, check the following:
  • The user exists and is enabled in the KurrentDB database. Can you log in with the username and password?
  • The user certificate is valid, and has a valid chain up to a trusted root CA.
  • The user certificate and the node's client certificate share a common root CA.
  • Use 'requires leader' (which is the default) in your client configuration to rule out issues with forwarding requests.
| ## LDAP authentication From 7118b5a2c8b28defef1246966147748465ec0bf7 Mon Sep 17 00:00:00 2001 From: Timothy Coleman Date: Wed, 22 Apr 2026 10:41:07 +0100 Subject: [PATCH 15/15] fix: track the trusted roots of the two certs separately. with a cert store configuration they can be different --- docs/server/release-schedule/release-notes.md | 2 +- docs/server/security/protocol-security.md | 2 +- .../OptionsCertificateProviderTests.cs | 25 +++++++++++++++++++ .../Certificates/CertificateProvider.cs | 3 ++- .../Certificates/DevCertificateProvider.cs | 2 ++ .../OptionsCertificateProvider.cs | 9 ++++--- src/KurrentDB.Core/ClusterVNode.cs | 6 +++-- .../Configuration/ClusterVNodeOptions.cs | 1 + .../ClusterVNodeOptionsExtensions.cs | 10 ++++++-- 9 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/server/release-schedule/release-notes.md b/docs/server/release-schedule/release-notes.md index 4689b06f134..282644fa7ca 100644 --- a/docs/server/release-schedule/release-notes.md +++ b/docs/server/release-schedule/release-notes.md @@ -6,7 +6,7 @@ order: 1 This page contains the release notes for KurrentDB v26.1. -## [26.0.1](https://github.com/kurrent-io/KurrentDB/releases/tag/v26.0.1) +## [26.1.0](https://github.com/kurrent-io/KurrentDB/releases/tag/v26.1.0) 30 April 2026 diff --git a/docs/server/security/protocol-security.md b/docs/server/security/protocol-security.md index 22567bad5aa..2f7677ff511 100644 --- a/docs/server/security/protocol-security.md +++ b/docs/server/security/protocol-security.md @@ -202,7 +202,7 @@ By default, a KurrentDB node uses the same certificate for both inbound connecti This is useful when you want to use a certificate from a public CA for inbound client connections (which may only have the `serverAuth` EKU), while using a certificate from a private CA with both the `serverAuth` and `clientAuth` EKUs for inter-node communication. Using dual certificates this way is an alternative to enabling [`DisableClientAuthEkuValidation`](#disable-client-authentication-eku-validation) and keeps the node in compliance with RFC 5280. -If no node client certificate is configured, the node will use its main certificate as it's client certificate. +If no node client certificate is configured, the node will use its main certificate as its client certificate. The node client certificate must have both the `serverAuth` and `clientAuth` Extended Key Usages (EKUs), or no EKU extension at all, so that receiving nodes can identify it as a node certificate. diff --git a/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs index bda9babd30a..d89b2e2ce5f 100644 --- a/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs +++ b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Linq; using System.Net; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -189,4 +190,28 @@ public void get_reserved_node_common_name_throws_before_load() { Assert.Throws(() => sut.GetReservedNodeCommonName()); } + + [Fact] + public void node_client_trust_pool_is_separate_from_main_trust_pool() { + // In dual-cert deployments the main cert and the node client cert can be issued by + // different CAs. The provider must expose each trust pool separately so that runtime + // validators (user cert auth, internal client cert validator) can consult the correct + // pool without widening trust. + var mainCa = CreateCert("CN=main-ca", ca: true); + var nodeClientCa = CreateCert("CN=node-client-ca", ca: true); + var node = CreateCert("CN=kurrentdb.example.com", parent: mainCa, serverAuthEKU: true); + var nodeClient = CreateCert("CN=eventstoredb-node", parent: nodeClientCa, serverAuthEKU: true, clientAuthEKU: true); + var options = BuildOptions(node, mainCa, nodeClient) with { + NodeClientTrustedRootCertificates = new X509Certificate2Collection(StripPrivateKey(nodeClientCa)), + }; + var sut = new OptionsCertificateProvider(); + + var result = sut.LoadCertificates(options); + + Assert.Equal(LoadCertificateResult.Success, result); + Assert.Contains(sut.TrustedRootCerts.Cast(), r => r.Thumbprint == mainCa.Thumbprint); + Assert.DoesNotContain(sut.TrustedRootCerts.Cast(), r => r.Thumbprint == nodeClientCa.Thumbprint); + Assert.Contains(sut.NodeClientTrustedRootCerts.Cast(), r => r.Thumbprint == nodeClientCa.Thumbprint); + Assert.DoesNotContain(sut.NodeClientTrustedRootCerts.Cast(), r => r.Thumbprint == mainCa.Thumbprint); + } } diff --git a/src/KurrentDB.Core/Certificates/CertificateProvider.cs b/src/KurrentDB.Core/Certificates/CertificateProvider.cs index c2a4cfc3b16..c562481a93e 100644 --- a/src/KurrentDB.Core/Certificates/CertificateProvider.cs +++ b/src/KurrentDB.Core/Certificates/CertificateProvider.cs @@ -8,9 +8,10 @@ namespace KurrentDB.Core.Certificates; public abstract class CertificateProvider { public X509Certificate2 Certificate; public X509Certificate2Collection IntermediateCerts; + public X509Certificate2Collection TrustedRootCerts; public X509Certificate2 NodeClientCertificate; public X509Certificate2Collection NodeClientIntermediateCerts; - public X509Certificate2Collection TrustedRootCerts; + public X509Certificate2Collection NodeClientTrustedRootCerts; public abstract LoadCertificateResult LoadCertificates(ClusterVNodeOptions options); public abstract string GetReservedNodeCommonName(); } diff --git a/src/KurrentDB.Core/Certificates/DevCertificateProvider.cs b/src/KurrentDB.Core/Certificates/DevCertificateProvider.cs index 335a6e63200..b3831d6780e 100644 --- a/src/KurrentDB.Core/Certificates/DevCertificateProvider.cs +++ b/src/KurrentDB.Core/Certificates/DevCertificateProvider.cs @@ -10,6 +10,8 @@ public class DevCertificateProvider : CertificateProvider { public DevCertificateProvider(X509Certificate2 certificate) { Certificate = certificate; TrustedRootCerts = new X509Certificate2Collection(certificate); + NodeClientCertificate = certificate; + NodeClientTrustedRootCerts = TrustedRootCerts; } public override LoadCertificateResult LoadCertificates(ClusterVNodeOptions options) { return LoadCertificateResult.Skipped; diff --git a/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs b/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs index 19141c6809e..3b206f68724 100644 --- a/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs +++ b/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs @@ -34,7 +34,7 @@ public override LoadCertificateResult LoadCertificates(ClusterVNodeOptions optio string reservedNodeCN; var reservedNodeCNOption = nameof(options.Certificate.CertificateReservedNodeCommonName); - // Determine the CN pattern expected from incomming node certificates. + // Determine the CN pattern expected from incoming node certificates. if (options.Certificate.CertificateReservedNodeCommonName.IsNotEmptyString()) { // Reserved CN is configured. Check that the node client cert matches it. reservedNodeCN = options.Certificate.CertificateReservedNodeCommonName; @@ -83,9 +83,11 @@ static void LogThumbprints(string label, X509Certificate2 previousCertificate, X } // Validate the node client certificate - if (hasNodeClientCert) { - var nodeClientTrustedRootCerts = options.LoadNodeClientTrustedRootCertificates(); + var nodeClientTrustedRootCerts = hasNodeClientCert + ? options.LoadNodeClientTrustedRootCertificates() + : trustedRootCerts; + if (hasNodeClientCert) { foreach (var trustedRootCert in nodeClientTrustedRootCerts) { Log.Information("Loading trusted root for node client certificate. Subject: {subject}, Thumbprint: {thumbprint}", trustedRootCert.SubjectName.Name, trustedRootCert.Thumbprint); } @@ -116,6 +118,7 @@ static void LogThumbprints(string label, X509Certificate2 previousCertificate, X NodeClientCertificate = nodeClientCertificate; NodeClientIntermediateCerts = nodeClientIntermediates; TrustedRootCerts = trustedRootCerts; + NodeClientTrustedRootCerts = nodeClientTrustedRootCerts; _cachedReservedNodeCN = reservedNodeCN; Log.Information("All certificates successfully loaded."); diff --git a/src/KurrentDB.Core/ClusterVNode.cs b/src/KurrentDB.Core/ClusterVNode.cs index 3e20b229ae5..5457e858831 100644 --- a/src/KurrentDB.Core/ClusterVNode.cs +++ b/src/KurrentDB.Core/ClusterVNode.cs @@ -206,6 +206,7 @@ internal ThreadPoolMessageScheduler WorkersHandler { private readonly Func _certificateSelector; private readonly Func _nodeClientCertificateSelector; private readonly Func _trustedRootCertsSelector; + private readonly Func _nodeClientTrustedRootCertsSelector; private readonly Func _intermediateCertsSelector; private readonly Func _nodeClientIntermediateCertsSelector; private readonly CertificateDelegates.ServerCertificateValidator _internalServerCertificateValidator; @@ -515,6 +516,7 @@ TFChunkDbConfig CreateDbConfig( _certificateSelector = () => _certificateProvider?.Certificate; _nodeClientCertificateSelector = () => _certificateProvider?.NodeClientCertificate; _trustedRootCertsSelector = () => _certificateProvider?.TrustedRootCerts; + _nodeClientTrustedRootCertsSelector = () => _certificateProvider?.NodeClientTrustedRootCerts; _intermediateCertsSelector = () => _certificateProvider?.IntermediateCerts is not { } intermediates ? null @@ -525,7 +527,7 @@ TFChunkDbConfig CreateDbConfig( : new X509Certificate2Collection(intermediates); _internalServerCertificateValidator = (cert, chain, errors, otherNames) => ValidateServerCertificate(cert, chain, errors, _intermediateCertsSelector, _trustedRootCertsSelector, otherNames); - _internalClientCertificateValidator = (cert, chain, errors) => ValidateClientCertificate(cert, chain, errors, _nodeClientIntermediateCertsSelector, _trustedRootCertsSelector); + _internalClientCertificateValidator = (cert, chain, errors) => ValidateClientCertificate(cert, chain, errors, _nodeClientIntermediateCertsSelector, _nodeClientTrustedRootCertsSelector); _externalServerCertificateValidator = (cert, chain, errors, otherNames) => ValidateServerCertificate(cert, chain, errors, _intermediateCertsSelector, _trustedRootCertsSelector, otherNames); var forwardingProxy = new MessageForwardingProxy(); @@ -1622,7 +1624,7 @@ IServiceCollection ConfigureNodeServices(IServiceCollection services) { (() => ( _nodeClientCertificateSelector(), _nodeClientIntermediateCertsSelector(), - _trustedRootCertsSelector())) + _nodeClientTrustedRootCertsSelector())) .AddSingleton(_nodeHttpClientFactory) .AddSingleton>(Db.Manager) .AddSingleton(Db.Manager.FileSystem.LocalNamingStrategy) diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs index b095fc44b98..9164cfeb079 100644 --- a/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs +++ b/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs @@ -54,6 +54,7 @@ public partial record ClusterVNodeOptions { public X509Certificate2? ServerCertificate { get; init; } public X509Certificate2Collection? TrustedRootCertificates { get; init; } + public X509Certificate2Collection? NodeClientTrustedRootCertificates { get; init; } public IReadOnlyList PlugableComponents { get; init; } = []; public IReadOnlyList Subsystems => PlugableComponents.OfType().ToArray(); diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs index 50de4b12a3d..dc145b0826b 100644 --- a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs +++ b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs @@ -71,7 +71,8 @@ public static ClusterVNodeOptions Insecure(this ClusterVNodeOptions options) => Insecure = true }, ServerCertificate = null, - TrustedRootCertificates = null + TrustedRootCertificates = null, + NodeClientTrustedRootCertificates = null, }; /// @@ -87,7 +88,8 @@ public static ClusterVNodeOptions Secure(this ClusterVNodeOptions options, Insecure = false, }, ServerCertificate = serverCertificate, - TrustedRootCertificates = trustedRootCertificates + TrustedRootCertificates = trustedRootCertificates, + NodeClientTrustedRootCertificates = trustedRootCertificates, }; /// @@ -322,6 +324,10 @@ public static X509Certificate2Collection LoadTrustedRootCertificates(this Cluste /// . /// public static X509Certificate2Collection LoadNodeClientTrustedRootCertificates(this ClusterVNodeOptions options) { + if (options.NodeClientTrustedRootCertificates != null) + //used by test code paths only + return options.NodeClientTrustedRootCertificates; + return LoadTrustedRootsFromStoreOrPath( new StoreCertInfo( StoreLocation: options.NodeClientCertificateStore.NodeClientTrustedRootCertificateStoreLocation,