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..282644fa7ca 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.1.0](https://github.com/kurrent-io/KurrentDB/releases/tag/v26.1.0)
-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..2f7677ff511 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 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.
+
+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
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..9420173b702 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;
@@ -106,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(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 bf314fbdea2..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)>();
@@ -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
@@ -272,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/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.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.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.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.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs
new file mode 100644
index 00000000000..d89b2e2ce5f
--- /dev/null
+++ b/src/KurrentDB.Core.XUnit.Tests/Certificates/OptionsCertificateProviderTests.cs
@@ -0,0 +1,217 @@
+// 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.Linq;
+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 : DirectoryPerTest {
+
+ 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 nodeClientCert = 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,
+ },
+ // multi-node so the node-client-cert classifier check fires
+ Cluster = new ClusterVNodeOptions.ClusterOptions { ClusterSize = 3 },
+ };
+
+ if (nodeClientCert != null) {
+ var certPath = WriteCertToFile(nodeClientCert, $"node-client-{Guid.NewGuid()}.pfx");
+ options = options with {
+ NodeClientCertificateFile = new ClusterVNodeOptions.NodeClientCertificateFileOptions {
+ NodeClientCertificateFile = certPath,
+ },
+ };
+ // 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));
+ }
+
+ 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, node client cert is the same as the main cert
+ Assert.Equal(node.Thumbprint, sut.NodeClientCertificate.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 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(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_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 nodeClient = CreateCert("CN=eventstoredb-node", parent: root, clientAuthEKU: true);
+ var options = BuildOptions(node, root, nodeClient);
+ 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_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 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 node 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());
+ }
+
+ [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.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/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/Certificates/CertificateProvider.cs b/src/KurrentDB.Core/Certificates/CertificateProvider.cs
index c00e5151d7c..c562481a93e 100644
--- a/src/KurrentDB.Core/Certificates/CertificateProvider.cs
+++ b/src/KurrentDB.Core/Certificates/CertificateProvider.cs
@@ -9,6 +9,9 @@ public abstract class CertificateProvider {
public X509Certificate2 Certificate;
public X509Certificate2Collection IntermediateCerts;
public X509Certificate2Collection TrustedRootCerts;
+ public X509Certificate2 NodeClientCertificate;
+ public X509Certificate2Collection NodeClientIntermediateCerts;
+ 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 43dab522394..3b206f68724 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,56 +20,105 @@ public override LoadCertificateResult LoadCertificates(ClusterVNodeOptions optio
return LoadCertificateResult.Skipped;
}
+ // Load both Node and NodeClient certificates up-front.
+ // Node cert is the server cert sent to incoming connections
+ // 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 hasNodeClientCert = options.TryLoadNodeClientCertificate(out var nodeClientCertificate, out var nodeClientIntermediates);
+ if (!hasNodeClientCert) {
+ nodeClientCertificate = certificate;
+ nodeClientIntermediates = intermediates;
+ }
string reservedNodeCN;
var reservedNodeCNOption = nameof(options.Certificate.CertificateReservedNodeCommonName);
+ // 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;
- if (!certificate.ClientCertificateMatchesName(reservedNodeCN)) {
- var certificateCN = certificate.GetCommonName();
+ if (!nodeClientCertificate.ClientCertificateMatchesName(reservedNodeCN)) {
+ var nodeClientCertCN = nodeClientCertificate.GetCommonName();
Log.Error(
"Certificate CN: {certificateCN} does not match with the {reservedNodeCNOption} configuration setting: {reservedNodeCN}",
- certificateCN, reservedNodeCNOption, reservedNodeCN);
+ nodeClientCertCN, reservedNodeCNOption, reservedNodeCN);
return LoadCertificateResult.VerificationFailed;
}
Log.Information("{reservedNodeCNOption} configured to: {reservedNodeCN}",
reservedNodeCNOption, reservedNodeCN);
} else {
- reservedNodeCN = certificate.GetCommonName();
+ reservedNodeCN = nodeClientCertificate.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("node client", NodeClientCertificate, nodeClientCertificate, nodeClientIntermediates);
- 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 node client certificate
+ 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);
+ }
+
+ if (!VerifyCertificates("node client", nodeClientCertificate, nodeClientIntermediates, nodeClientTrustedRootCerts)) {
+ return LoadCertificateResult.VerificationFailed;
+ }
+ }
+
+ // 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}",
+ nodeClientCertDescription);
+ 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;
+ NodeClientCertificate = nodeClientCertificate;
+ NodeClientIntermediateCerts = nodeClientIntermediates;
TrustedRootCerts = trustedRootCerts;
+ NodeClientTrustedRootCerts = nodeClientTrustedRootCerts;
_cachedReservedNodeCN = reservedNodeCN;
Log.Information("All certificates successfully loaded.");
@@ -79,18 +129,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 +154,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 +181,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 +202,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/ClusterVNode.cs b/src/KurrentDB.Core/ClusterVNode.cs
index fccee8cbdfb..5457e858831 100644
--- a/src/KurrentDB.Core/ClusterVNode.cs
+++ b/src/KurrentDB.Core/ClusterVNode.cs
@@ -204,8 +204,11 @@ internal ThreadPoolMessageScheduler WorkersHandler {
private readonly bool _disableHttps;
private readonly bool _enableUnixSocket;
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;
private readonly CertificateDelegates.ClientCertificateValidator _internalClientCertificateValidator;
private readonly CertificateDelegates.ServerCertificateValidator _externalServerCertificateValidator;
@@ -511,14 +514,20 @@ TFChunkDbConfig CreateDbConfig(
_queueStatsManager = new QueueStatsManager();
_certificateSelector = () => _certificateProvider?.Certificate;
+ _nodeClientCertificateSelector = () => _certificateProvider?.NodeClientCertificate;
_trustedRootCertsSelector = () => _certificateProvider?.TrustedRootCerts;
+ _nodeClientTrustedRootCertsSelector = () => _certificateProvider?.NodeClientTrustedRootCerts;
_intermediateCertsSelector = () =>
- _certificateProvider?.IntermediateCerts == null
+ _certificateProvider?.IntermediateCerts is not { } intermediates
? null
- : new X509Certificate2Collection(_certificateProvider?.IntermediateCerts);
+ : new X509Certificate2Collection(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, _intermediateCertsSelector, _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();
@@ -564,7 +573,7 @@ void StartSubsystems() {
_nodeHttpClientFactory = new NodeHttpClientFactory(
uriScheme,
_internalServerCertificateValidator,
- _certificateSelector);
+ _nodeClientCertificateSelector);
_eventStoreClusterClientCache = new EventStoreClusterClientCache(_mainQueue,
(endpoint, publisher) =>
@@ -1508,7 +1517,7 @@ GossipAdvertiseInfo GetGossipAdvertiseInfo() {
GossipAdvertiseInfo.InternalTcp ?? GossipAdvertiseInfo.InternalSecureTcp,
options.Cluster.ReadOnlyReplica,
!disableInternalTcpTls, _internalServerCertificateValidator,
- _certificateSelector,
+ _nodeClientCertificateSelector,
TimeSpan.FromMilliseconds(options.Interface.ReplicationHeartbeatTimeout),
TimeSpan.FromMilliseconds(options.Interface.ReplicationHeartbeatInterval),
TimeSpan.FromMilliseconds(options.Database.WriteTimeoutMs));
@@ -1612,7 +1621,10 @@ IServiceCollection ConfigureNodeServices(IServiceCollection services) {
.AddSingleton>(httpAuthenticationProviders)
.AddSingleton>
- (() => (_certificateSelector(), _intermediateCertsSelector(), _trustedRootCertsSelector()))
+ (() => (
+ _nodeClientCertificateSelector(),
+ _nodeClientIntermediateCertsSelector(),
+ _nodeClientTrustedRootCertsSelector()))
.AddSingleton(_nodeHttpClientFactory)
.AddSingleton>(Db.Manager)
.AddSingleton(Db.Manager.FileSystem.LocalNamingStrategy)
@@ -1702,7 +1714,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, _nodeClientCertificateSelector);
_mainBus.Subscribe(certificateExpiryMonitor);
_mainBus.Subscribe(certificateExpiryMonitor);
diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs
index e52df7fb2f2..9164cfeb079 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 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();
@@ -52,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();
@@ -79,6 +82,8 @@ public static ClusterVNodeOptions FromConfiguration(IConfigurationRoot configura
Certificate = configuration.BindOptions(),
CertificateFile = configuration.BindOptions(),
CertificateStore = configuration.BindOptions(),
+ NodeClientCertificateFile = configuration.BindOptions(),
+ NodeClientCertificateStore = configuration.BindOptions(),
Cluster = configuration.BindOptions(),
Database = configuration.BindOptions(),
Grpc = configuration.BindOptions(),
@@ -254,6 +259,53 @@ public record CertificateStoreOptions {
public string TrustedRootCertificateThumbprint { get; init; } = string.Empty;
}
+ [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? NodeClientCertificateFile { 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 node's client certificate if a PKCS #12 (.p12/.pfx) certificate file is provided."),
+ Sensitive]
+ public string? NodeClientCertificatePassword { get; init; }
+
+ [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? NodeClientCertificatePrivateKeyPassword { get; init; }
+ }
+
+ [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 node's client certificate.")]
+ public string NodeClientCertificateStoreName { 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 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 node's client certificate.")]
+ public string NodeClientTrustedRootCertificateStoreName { 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 node's client certificate.")]
+ public string NodeClientTrustedRootCertificateSubjectName { 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")]
public record ClusterOptions {
[Description("The maximum number of entries to keep in the stream info cache.")]
diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs
index a0e1cea8592..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,
};
///
@@ -203,34 +205,96 @@ 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.");
}
- if (!string.IsNullOrWhiteSpace(options.CertificateStore.CertificateStoreName)) {
- var name = CertificateUtils.GetCertificateStoreName(options.CertificateStore.CertificateStoreName);
- return (
- CertificateUtils.LoadFromStore(name, options.CertificateStore.CertificateSubjectName,
- options.CertificateStore.CertificateThumbprint), null);
+ return (certificate, intermediates);
+ }
+
+ ///
+ /// Tries to load the node client certificate from the options set.
+ /// Returns false if no node client certificate is configured.
+ ///
+ public static bool TryLoadNodeClientCertificate(
+ this ClusterVNodeOptions options,
+ out X509Certificate2 certificate,
+ out X509Certificate2Collection intermediates) =>
+
+ TryLoadCertificate(
+ logLabel: "node client",
+ store: new StoreCertInfo(
+ StoreLocation: options.NodeClientCertificateStore.NodeClientCertificateStoreLocation,
+ StoreName: options.NodeClientCertificateStore.NodeClientCertificateStoreName,
+ SubjectName: options.NodeClientCertificateStore.NodeClientCertificateSubjectName,
+ Thumbprint: options.NodeClientCertificateStore.NodeClientCertificateThumbprint),
+ file: new FileCertInfo(
+ File: options.NodeClientCertificateFile.NodeClientCertificateFile,
+ PrivateKeyFile: options.NodeClientCertificateFile.NodeClientCertificatePrivateKeyFile,
+ Password: options.NodeClientCertificateFile.NodeClientCertificatePassword,
+ PrivateKeyPassword: options.NodeClientCertificateFile.NodeClientCertificatePrivateKeyPassword),
+ certificate: out certificate,
+ intermediates: out 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 (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 (!string.IsNullOrWhiteSpace(store.StoreName)) {
+ var name = CertificateUtils.GetCertificateStoreName(store.StoreName);
+ certificate = CertificateUtils.LoadFromStore(name, store.SubjectName, store.Thumbprint);
+ intermediates = null;
+ return true;
}
- throw new InvalidConfigurationException(
- "A certificate is required unless insecure mode (--insecure) is set.");
+ 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;
+ }
+
+ 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,
@@ -242,45 +306,67 @@ public static (X509Certificate2 certificate, X509Certificate2Collection intermed
///
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);
+ }
+
+ ///
+ /// 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 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,
+ StoreName: options.NodeClientCertificateStore.NodeClientTrustedRootCertificateStoreName,
+ SubjectName: options.NodeClientCertificateStore.NodeClientTrustedRootCertificateSubjectName,
+ Thumbprint: options.NodeClientCertificateStore.NodeClientTrustedRootCertificateThumbprint),
+ 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;
}
}
diff --git a/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs b/src/KurrentDB.Core/Services/Transport/Http/Authentication/NodeCertificateAuthenticationProvider.cs
index 69f82f50c56..689b31a703e 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,39 +79,36 @@ 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);
+ }
+
+ return hasReservedNodeCN;
+ }
+
+ 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.GetSubjectAlternativeNames()
- .Where(x => x.type is CertificateNameType.DnsName or CertificateNameType.IpAddress)
- .IsNotEmpty();
-
- 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;
}
}