From 34e9f6caa3ef37f159e9423cafabfbbf5b120206 Mon Sep 17 00:00:00 2001 From: shaan1337 Date: Wed, 22 Apr 2026 12:57:06 +0400 Subject: [PATCH] Serve a publicly-trusted certificate for client-facing TLS without widening the internal mTLS trust anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background: DisableClientAuthEkuValidation was recently added to KurrentDB because publicly-trusted CAs are increasingly no longer issuing certificates with the clientAuth EKU. A consequence is that mTLS within a cluster using only public-CA certificates is no longer possible without that flag — and the flag is a short-term mitigation, not a long-term solution (it weakens RFC 5280 EKU enforcement). A private PKI is therefore required to run mTLS in the cluster at all. Given that, widening TrustedRootCertificatesPath to include the system root store (e.g. /etc/ssl/certs) for the sake of mTLS is strictly worse than doing nothing — it collapses the cluster's trust boundary onto every public CA the OS happens to trust, with only the CertificateReservedNodeCommonName string check standing between the cluster and any certificate issued by any public CA. Operators historically reached for a public CA in mTLS for two reasons: 1. To avoid running a private PKI and rotating its certs themselves. 2. To avoid distributing a custom CA to every gRPC client / browser that talks to the node. Reason #1 is moot today — a private PKI is required either way once public CAs stop issuing clientAuth EKU certs. Reason #2 is still real; terminating TLS at a load balancer / reverse proxy could solve it, but is not straightforward with gRPC. The primary recommendation is therefore to use a private PKI for cluster mTLS. For deployments that also need to solve reason #2, this change lets them configure a separate, publicly-trusted certificate served only on matching SNI — without widening the cluster's mTLS trust anchor. The node serves this second, publicly-trusted certificate on TLS connections whose ClientHello SNI matches one of its Subject Alternative Names, while the node's own internal-CA certificate continues to be served on every other connection (including all internal node-to-node HTTPS gossip). The publicly-trusted certificate's issuing CA never enters TrustedRootCerts, never participates in InternalClientCertificateValidator, and cannot authenticate anything as a node. Client certificate handling is identical on both paths — requested but not required, validated only against the internal CA — so user-certificate authentication continues to work for external gRPC clients connecting via the publicly-trusted hostname. Result: internal PKI stays strict (operator's private CA only), and external gRPC clients (and browsers) connecting to the public hostname see a publicly-trusted certificate — no need to distribute the internal CA to every client application or browser. Adds four new CertificateFile options (PubliclyTrustedCertificateFile, PubliclyTrustedCertificatePrivateKeyFile, PubliclyTrustedCertificatePassword, PubliclyTrustedCertificatePrivateKeyPassword) and four new CertificateStore options (PubliclyTrustedCertificateStoreLocation, PubliclyTrustedCertificateStoreName, PubliclyTrustedCertificateSubjectName, PubliclyTrustedCertificateThumbprint). Nothing fires unless one of these is set — the feature is purely additive. At startup the publicly-trusted certificate's DNS names are logged, and a warning is emitted if any of its SANs overlap with the node certificate's DNS SANs — that overlap would cause internal node-to-node HTTPS traffic using such a name for SNI to receive the publicly-trusted certificate and fail internal mTLS. A separate warning is emitted if TrustedRootCertificatesPath points at a well-known OS system trust store directory (e.g. /etc/ssl/certs, /etc/pki/ca-trust/extracted/pem) or if TrustedRootCertificateStoreName is 'Root' / 'AuthRoot' — both are indicators of the exact misconfiguration this feature is meant to make unnecessary. The warning recommends using the PubliclyTrustedCertificate* options instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../publicly_trusted_certificate_loading.cs | 78 +++++++++++ .../publicly_trusted_certificate_overlap.cs | 128 ++++++++++++++++++ ...licly_trusted_certificate_sni_selection.cs | 82 +++++++++++ .../system_trust_store_detection.cs | 50 +++++++ .../Certificates/CertificateProvider.cs | 2 + .../OptionsCertificateProvider.cs | 101 ++++++++++++++ .../PubliclyTrustedCertificateSelector.cs | 14 ++ src/KurrentDB.Core/ClusterVNode.cs | 11 ++ .../Configuration/ClusterVNodeOptions.cs | 26 ++++ .../ClusterVNodeOptionsExtensions.cs | 30 ++++ src/KurrentDB/KestrelHelpers.cs | 10 +- 11 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_loading.cs create mode 100644 src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_overlap.cs create mode 100644 src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_sni_selection.cs create mode 100644 src/KurrentDB.Core.Tests/Certificates/system_trust_store_detection.cs create mode 100644 src/KurrentDB.Core/Certificates/PubliclyTrustedCertificateSelector.cs diff --git a/src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_loading.cs b/src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_loading.cs new file mode 100644 index 00000000000..b2c7eef9ebf --- /dev/null +++ b/src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_loading.cs @@ -0,0 +1,78 @@ +// 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.IO; +using System.Security.Cryptography.X509Certificates; +using KurrentDB.Common.Utils; +using NUnit.Framework; + +namespace KurrentDB.Core.Tests.Certificates; + +public class with_no_publicly_trusted_certificate_configured { + [Test] + public void load_publicly_trusted_certificate_returns_null() { + var options = new ClusterVNodeOptions(); + var result = options.LoadPubliclyTrustedCertificate(); + Assert.IsNull(result); + } +} + +public class with_publicly_trusted_certificate_file : with_certificate_chain_of_length_1 { + private string _certPath; + private const string Password = "test$1234"; + + [SetUp] + public void Setup() { + _certPath = $"{PathName}/public.p12"; + File.WriteAllBytes(_certPath, _leaf.ExportToPkcs12(Password)); + } + + [Test] + public void load_publicly_trusted_certificate_returns_certificate() { + var options = new ClusterVNodeOptions { + CertificateFile = new() { + PubliclyTrustedCertificateFile = _certPath, + PubliclyTrustedCertificatePassword = Password + } + }; + var result = options.LoadPubliclyTrustedCertificate(); + Assert.IsNotNull(result); + Assert.AreEqual(_leaf, result.Value.certificate); + Assert.IsNull(result.Value.intermediates); + Assert.True(result.Value.certificate.HasPrivateKey); + } +} + +public class with_publicly_trusted_certificate_bundle : with_certificate_chain_of_length_3 { + private string _certPath; + private string _keyPath; + + [SetUp] + public void Setup() { + _certPath = $"{PathName}/public_fullchain.pem"; + _keyPath = $"{PathName}/public.key"; + + // write leaf followed by intermediate — a PEM bundle as produced by publicly-trusted CAs like Let's Encrypt + File.WriteAllText( + _certPath, + _leaf.Export(X509ContentType.Cert).PEM("CERTIFICATE") + + _intermediate.Export(X509ContentType.Cert).PEM("CERTIFICATE")); + File.WriteAllText(_keyPath, _leaf.PemPrivateKey()); + } + + [Test] + public void load_publicly_trusted_certificate_returns_certificate_and_intermediates() { + var options = new ClusterVNodeOptions { + CertificateFile = new() { + PubliclyTrustedCertificateFile = _certPath, + PubliclyTrustedCertificatePrivateKeyFile = _keyPath + } + }; + var result = options.LoadPubliclyTrustedCertificate(); + Assert.IsNotNull(result); + Assert.AreEqual(_leaf, result.Value.certificate); + Assert.IsNotNull(result.Value.intermediates); + Assert.AreEqual(1, result.Value.intermediates.Count); + Assert.AreEqual(_intermediate, result.Value.intermediates[0]); + } +} diff --git a/src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_overlap.cs b/src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_overlap.cs new file mode 100644 index 00000000000..9b9297715e5 --- /dev/null +++ b/src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_overlap.cs @@ -0,0 +1,128 @@ +// 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.Linq; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using KurrentDB.Core.Certificates; +using NUnit.Framework; + +namespace KurrentDB.Core.Tests.Certificates; + +public class publicly_trusted_certificate_overlap { + private static X509Certificate2 CertWithSans(string commonName, params (string name, string type)[] sans) { + using var rsa = RSA.Create(); + var certReq = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + if (sans.Length > 0) { + var sanBuilder = new SubjectAlternativeNameBuilder(); + foreach (var (name, type) in sans) { + if (type == "DNS") + sanBuilder.AddDnsName(name); + else if (type == "IP") + sanBuilder.AddIpAddress(IPAddress.Parse(name)); + } + certReq.CertificateExtensions.Add(sanBuilder.Build()); + } + return certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddMonths(-1), DateTimeOffset.UtcNow.AddMonths(1)); + } + + [Test] + public void no_overlap_when_node_cert_san_does_not_match_publicly_trusted_cert_san() { + var publiclyTrustedCert = CertWithSans("public", ("db.public.local", "DNS")); + var nodeCert = CertWithSans("node", ("node1.cluster.internal", "DNS")); + + var overlaps = OptionsCertificateProvider.FindNodeCertificateSanOverlaps(publiclyTrustedCert, nodeCert).ToArray(); + + Assert.IsEmpty(overlaps); + } + + [Test] + public void overlap_detected_when_node_cert_dns_san_matches_publicly_trusted_cert_san() { + var publiclyTrustedCert = CertWithSans("public", ("db.example.com", "DNS")); + var nodeCert = CertWithSans("node", ("db.example.com", "DNS")); + + var overlaps = OptionsCertificateProvider.FindNodeCertificateSanOverlaps(publiclyTrustedCert, nodeCert).ToArray(); + + Assert.AreEqual(1, overlaps.Length); + Assert.AreEqual("db.example.com", overlaps[0]); + } + + [Test] + public void overlap_detected_when_publicly_trusted_cert_wildcard_san_covers_node_cert_dns_san() { + var publiclyTrustedCert = CertWithSans("public", ("*.example.com", "DNS")); + var nodeCert = CertWithSans("node", ("node1.example.com", "DNS")); + + var overlaps = OptionsCertificateProvider.FindNodeCertificateSanOverlaps(publiclyTrustedCert, nodeCert).ToArray(); + + Assert.AreEqual(1, overlaps.Length); + Assert.AreEqual("node1.example.com", overlaps[0]); + } + + [Test] + public void ip_sans_on_node_cert_are_ignored_since_ips_are_not_valid_sni() { + var publiclyTrustedCert = CertWithSans("public", ("127.0.0.1", "IP")); + var nodeCert = CertWithSans("node", ("127.0.0.1", "IP")); + + var overlaps = OptionsCertificateProvider.FindNodeCertificateSanOverlaps(publiclyTrustedCert, nodeCert).ToArray(); + + Assert.IsEmpty(overlaps); + } + + [Test] + public void dns_san_overlap_reported_even_when_both_certs_also_have_ip_sans() { + var publiclyTrustedCert = CertWithSans("public", ("db.example.com", "DNS"), ("1.2.3.4", "IP")); + var nodeCert = CertWithSans("node", ("db.example.com", "DNS"), ("127.0.0.1", "IP")); + + var overlaps = OptionsCertificateProvider.FindNodeCertificateSanOverlaps(publiclyTrustedCert, nodeCert).ToArray(); + + Assert.AreEqual(1, overlaps.Length); + Assert.AreEqual("db.example.com", overlaps[0]); + } + + [Test] + public void overlap_match_is_case_insensitive() { + var publiclyTrustedCert = CertWithSans("public", ("DB.Example.COM", "DNS")); + var nodeCert = CertWithSans("node", ("db.example.com", "DNS")); + + var overlaps = OptionsCertificateProvider.FindNodeCertificateSanOverlaps(publiclyTrustedCert, nodeCert).ToArray(); + + Assert.AreEqual(1, overlaps.Length); + } + + [Test] + public void multiple_overlaps_are_all_reported() { + var publiclyTrustedCert = CertWithSans("public", ("node1.example.com", "DNS"), ("node2.example.com", "DNS")); + var nodeCert = CertWithSans("node", ("node1.example.com", "DNS"), ("node2.example.com", "DNS")); + + var overlaps = OptionsCertificateProvider.FindNodeCertificateSanOverlaps(publiclyTrustedCert, nodeCert).ToArray(); + + Assert.AreEqual(2, overlaps.Length); + CollectionAssert.AreEquivalent(new[] { "node1.example.com", "node2.example.com" }, overlaps); + } + + [Test] + public void falls_back_to_cn_when_node_cert_has_no_san_extension() { + // RFC 6125: CN is consulted only when the SAN extension is absent entirely + var publiclyTrustedCert = CertWithSans("public", ("db.example.com", "DNS")); + var nodeCert = CertWithSans("db.example.com"); // no SANs, CN matches + + var overlaps = OptionsCertificateProvider.FindNodeCertificateSanOverlaps(publiclyTrustedCert, nodeCert).ToArray(); + + Assert.AreEqual(1, overlaps.Length); + Assert.AreEqual("db.example.com", overlaps[0]); + } + + [Test] + public void does_not_fall_back_to_cn_when_node_cert_has_non_dns_sans() { + // SAN extension is present (IP only), so per RFC 6125 the CN is NOT consulted + // even though there's no DNS SAN. + var publiclyTrustedCert = CertWithSans("public", ("db.example.com", "DNS")); + var nodeCert = CertWithSans("db.example.com", ("127.0.0.1", "IP")); // CN matches but SAN is IP-only + + var overlaps = OptionsCertificateProvider.FindNodeCertificateSanOverlaps(publiclyTrustedCert, nodeCert).ToArray(); + + Assert.IsEmpty(overlaps); + } +} diff --git a/src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_sni_selection.cs b/src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_sni_selection.cs new file mode 100644 index 00000000000..d9b6aaadbf0 --- /dev/null +++ b/src/KurrentDB.Core.Tests/Certificates/publicly_trusted_certificate_sni_selection.cs @@ -0,0 +1,82 @@ +// 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.Core.Certificates; +using NUnit.Framework; + +namespace KurrentDB.Core.Tests.Certificates; + +public class publicly_trusted_certificate_sni_selection { + private static X509Certificate2 CertWithDnsSan(params string[] dnsNames) { + using var rsa = RSA.Create(); + var certReq = new CertificateRequest("CN=public", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var sanBuilder = new SubjectAlternativeNameBuilder(); + foreach (var dnsName in dnsNames) + sanBuilder.AddDnsName(dnsName); + certReq.CertificateExtensions.Add(sanBuilder.Build()); + return certReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddMonths(-1), DateTimeOffset.UtcNow.AddMonths(1)); + } + + [Test] + public void does_not_serve_when_publicly_trusted_certificate_is_null() { + Assert.False(PubliclyTrustedCertificateSelector.ShouldServe(null, "db.example.com")); + } + + [Test] + public void does_not_serve_when_sni_is_null() { + var cert = CertWithDnsSan("db.example.com"); + Assert.False(PubliclyTrustedCertificateSelector.ShouldServe(cert, null)); + } + + [Test] + public void does_not_serve_when_sni_is_empty() { + var cert = CertWithDnsSan("db.example.com"); + Assert.False(PubliclyTrustedCertificateSelector.ShouldServe(cert, string.Empty)); + } + + [Test] + public void serves_when_sni_matches_san_exactly() { + var cert = CertWithDnsSan("db.example.com"); + Assert.True(PubliclyTrustedCertificateSelector.ShouldServe(cert, "db.example.com")); + } + + [Test] + public void does_not_serve_when_sni_does_not_match_san() { + var cert = CertWithDnsSan("db.example.com"); + Assert.False(PubliclyTrustedCertificateSelector.ShouldServe(cert, "other.example.com")); + } + + [Test] + public void serves_when_sni_matches_any_of_multiple_sans() { + var cert = CertWithDnsSan("db.example.com", "api.example.com"); + Assert.True(PubliclyTrustedCertificateSelector.ShouldServe(cert, "api.example.com")); + } + + [Test] + public void serves_when_sni_matches_wildcard_san() { + var cert = CertWithDnsSan("*.example.com"); + Assert.True(PubliclyTrustedCertificateSelector.ShouldServe(cert, "foo.example.com")); + } + + [Test] + public void does_not_serve_when_sni_has_extra_labels_under_wildcard_san() { + var cert = CertWithDnsSan("*.example.com"); + Assert.False(PubliclyTrustedCertificateSelector.ShouldServe(cert, "sub.foo.example.com")); + } + + [Test] + public void does_not_serve_when_sni_is_bare_domain_of_wildcard_san() { + // "*.example.com" matches one label to the left of example.com, not the bare domain + var cert = CertWithDnsSan("*.example.com"); + Assert.False(PubliclyTrustedCertificateSelector.ShouldServe(cert, "example.com")); + } + + [Test] + public void match_is_case_insensitive() { + var cert = CertWithDnsSan("DB.Example.COM"); + Assert.True(PubliclyTrustedCertificateSelector.ShouldServe(cert, "db.example.com")); + } +} diff --git a/src/KurrentDB.Core.Tests/Certificates/system_trust_store_detection.cs b/src/KurrentDB.Core.Tests/Certificates/system_trust_store_detection.cs new file mode 100644 index 00000000000..8a74894412c --- /dev/null +++ b/src/KurrentDB.Core.Tests/Certificates/system_trust_store_detection.cs @@ -0,0 +1,50 @@ +// 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 KurrentDB.Core.Certificates; +using NUnit.Framework; + +namespace KurrentDB.Core.Tests.Certificates; + +public class system_trust_store_detection { + [TestCase("/etc/ssl/certs")] + [TestCase("/etc/ssl/certs/")] + [TestCase("/etc/pki/ca-trust/extracted/pem")] + [TestCase("/etc/pki/tls/certs")] + [TestCase("/usr/local/share/ca-certificates")] + public void identifies_well_known_system_trust_store_paths(string path) { + Assert.True(OptionsCertificateProvider.IsSystemTrustStorePath(path)); + } + + [TestCase("/etc/kurrent/trusted_roots")] + [TestCase("/etc/ssl/my-internal-ca")] + [TestCase("/opt/kurrent/ca")] + [TestCase("./trusted-roots")] + [TestCase("")] + [TestCase(null)] + public void does_not_flag_user_configured_paths(string path) { + Assert.False(OptionsCertificateProvider.IsSystemTrustStorePath(path)); + } + + [Test] + public void path_match_is_case_insensitive() { + Assert.True(OptionsCertificateProvider.IsSystemTrustStorePath("/ETC/SSL/certs")); + } + + [TestCase("Root")] + [TestCase("root")] + [TestCase("AuthRoot")] + public void identifies_system_trust_store_names(string storeName) { + Assert.True(OptionsCertificateProvider.IsSystemTrustStoreName(storeName)); + } + + [TestCase("My")] + [TestCase("CertificateAuthority")] + [TestCase("Disallowed")] + [TestCase("TrustedPeople")] + [TestCase("")] + [TestCase(null)] + public void does_not_flag_non_trust_anchor_store_names(string storeName) { + Assert.False(OptionsCertificateProvider.IsSystemTrustStoreName(storeName)); + } +} diff --git a/src/KurrentDB.Core/Certificates/CertificateProvider.cs b/src/KurrentDB.Core/Certificates/CertificateProvider.cs index c00e5151d7c..2bd290b5304 100644 --- a/src/KurrentDB.Core/Certificates/CertificateProvider.cs +++ b/src/KurrentDB.Core/Certificates/CertificateProvider.cs @@ -9,6 +9,8 @@ public abstract class CertificateProvider { public X509Certificate2 Certificate; public X509Certificate2Collection IntermediateCerts; public X509Certificate2Collection TrustedRootCerts; + public X509Certificate2 PubliclyTrustedCertificate; + public X509Certificate2Collection PubliclyTrustedIntermediateCerts; public abstract LoadCertificateResult LoadCertificates(ClusterVNodeOptions options); public abstract string GetReservedNodeCommonName(); } diff --git a/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs b/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs index 43dab522394..43a8924ea98 100644 --- a/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs +++ b/src/KurrentDB.Core/Certificates/OptionsCertificateProvider.cs @@ -2,6 +2,9 @@ // Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Runtime; using System.Security.Cryptography.X509Certificates; using KurrentDB.Common.Utils; @@ -19,6 +22,8 @@ public override LoadCertificateResult LoadCertificates(ClusterVNodeOptions optio return LoadCertificateResult.Skipped; } + WarnOnSystemTrustStoreUsage(options); + var (certificate, intermediates) = options.LoadNodeCertificate(); string reservedNodeCN; @@ -62,6 +67,26 @@ public override LoadCertificateResult LoadCertificates(ClusterVNodeOptions optio return LoadCertificateResult.VerificationFailed; } + var publiclyTrustedCert = options.LoadPubliclyTrustedCertificate(); + if (publiclyTrustedCert.HasValue) { + var dnsNames = GetDnsNames(publiclyTrustedCert.Value.certificate); + Log.Information( + "Loading the publicly-trusted certificate. Subject: {subject}, Thumbprint: {thumbprint}. " + + "It will be served on TLS connections whose SNI hostname matches: {dnsNames}", + publiclyTrustedCert.Value.certificate.SubjectName.Name, + publiclyTrustedCert.Value.certificate.Thumbprint, + dnsNames); + + if (publiclyTrustedCert.Value.intermediates != null) { + foreach (var intermediateCert in publiclyTrustedCert.Value.intermediates) { + Log.Information("Loading publicly-trusted intermediate certificate. Subject: {subject}, Thumbprint: {thumbprint}", + intermediateCert.SubjectName.Name, intermediateCert.Thumbprint); + } + } + + WarnOnNodeCertificateSanOverlap(publiclyTrustedCert.Value.certificate, certificate); + } + // 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 @@ -69,12 +94,88 @@ public override LoadCertificateResult LoadCertificates(ClusterVNodeOptions optio Certificate = certificate; IntermediateCerts = intermediates; TrustedRootCerts = trustedRootCerts; + PubliclyTrustedCertificate = publiclyTrustedCert?.certificate; + PubliclyTrustedIntermediateCerts = publiclyTrustedCert?.intermediates; _cachedReservedNodeCN = reservedNodeCN; Log.Information("All certificates successfully loaded."); return LoadCertificateResult.Success; } + private static string[] GetDnsNames(X509Certificate2 certificate) { + // Per RFC 6125, CN is only consulted when the SAN extension is absent entirely. + // Matches the behavior of X509Certificate2.MatchesName. + var sans = (certificate.GetSubjectAlternativeNames() ?? []).ToArray(); + if (sans.Length > 0) + return sans.Where(san => san.type == CertificateNameType.DnsName).Select(san => san.name).ToArray(); + var cn = certificate.GetCommonName(); + return string.IsNullOrEmpty(cn) ? [] : [cn]; + } + + // Well-known filesystem paths where the OS (or OS-like distributions) keep the + // system trust-anchor store. If TrustedRootCertificatesPath points here, the + // cluster's mTLS trust anchor has been widened to every publicly-trusted CA. + internal static readonly string[] SystemTrustStorePaths = [ + "/etc/ssl/certs", // Debian / Ubuntu / Alpine + "/etc/pki/ca-trust/extracted/pem", // RHEL / Fedora / CentOS + "/etc/pki/tls/certs", // older RHEL + "/usr/local/share/ca-certificates", // admin-installed, Debian/Ubuntu + ]; + + internal static bool IsSystemTrustStorePath(string path) { + if (string.IsNullOrWhiteSpace(path)) + return false; + string full; + try { + full = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } catch { + return false; + } + return SystemTrustStorePaths.Any(p => full.Equals(p, StringComparison.OrdinalIgnoreCase)); + } + + internal static bool IsSystemTrustStoreName(string storeName) => + !string.IsNullOrWhiteSpace(storeName) + && (storeName.Equals(nameof(StoreName.Root), StringComparison.OrdinalIgnoreCase) + || storeName.Equals(nameof(StoreName.AuthRoot), StringComparison.OrdinalIgnoreCase)); + + private static void WarnOnSystemTrustStoreUsage(ClusterVNodeOptions options) { + if (IsSystemTrustStorePath(options.Certificate.TrustedRootCertificatesPath)) { + Log.Warning( + "{option} '{path}' points to the OS system trust store. This widens the cluster's mTLS trust anchor to every publicly-trusted CA — any certificate issued by any public CA that also has the reserved node CN would be accepted as a cluster node. " + + "If the goal is to serve a certificate that external gRPC clients and web browsers already trust (so they don't need the internal CA installed), use the PubliclyTrustedCertificate* options instead and point {option} at your internal CA only.", + nameof(options.Certificate.TrustedRootCertificatesPath), + options.Certificate.TrustedRootCertificatesPath, + nameof(options.Certificate.TrustedRootCertificatesPath)); + } + + if (IsSystemTrustStoreName(options.CertificateStore.TrustedRootCertificateStoreName)) { + Log.Warning( + "{option} '{name}' refers to the OS system trust store. This widens the cluster's mTLS trust anchor to every publicly-trusted CA. " + + "If the goal is to serve a certificate that external gRPC clients and web browsers already trust (so they don't need the internal CA installed), use the PubliclyTrustedCertificate* options instead and point the trusted root store at your internal CA only.", + nameof(options.CertificateStore.TrustedRootCertificateStoreName), + options.CertificateStore.TrustedRootCertificateStoreName, + nameof(options.CertificateStore.TrustedRootCertificateStoreName)); + } + } + + private static void WarnOnNodeCertificateSanOverlap(X509Certificate2 publiclyTrustedCertificate, X509Certificate2 nodeCertificate) { + foreach (var name in FindNodeCertificateSanOverlaps(publiclyTrustedCertificate, nodeCertificate)) { + Log.Warning( + "The publicly-trusted certificate's SANs match the node certificate's DNS name '{name}'. " + + "Internal node-to-node HTTPS traffic that uses this name for SNI will receive the publicly-trusted certificate and fail to validate against the internal CA.", + name); + } + } + + internal static IEnumerable FindNodeCertificateSanOverlaps( + X509Certificate2 publiclyTrustedCertificate, X509Certificate2 nodeCertificate) { + foreach (var name in GetDnsNames(nodeCertificate)) { + if (publiclyTrustedCertificate.MatchesName(name)) + yield return name; + } + } + public override string GetReservedNodeCommonName() { return _cachedReservedNodeCN ?? throw new InvalidOperationException("Certificates are not loaded."); } diff --git a/src/KurrentDB.Core/Certificates/PubliclyTrustedCertificateSelector.cs b/src/KurrentDB.Core/Certificates/PubliclyTrustedCertificateSelector.cs new file mode 100644 index 00000000000..9ba4054151f --- /dev/null +++ b/src/KurrentDB.Core/Certificates/PubliclyTrustedCertificateSelector.cs @@ -0,0 +1,14 @@ +// 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.X509Certificates; +using KurrentDB.Common.Utils; + +namespace KurrentDB.Core.Certificates; + +public static class PubliclyTrustedCertificateSelector { + public static bool ShouldServe(X509Certificate2 publiclyTrustedCertificate, string sniHostname) => + publiclyTrustedCertificate != null + && !string.IsNullOrEmpty(sniHostname) + && publiclyTrustedCertificate.MatchesName(sniHostname); +} diff --git a/src/KurrentDB.Core/ClusterVNode.cs b/src/KurrentDB.Core/ClusterVNode.cs index fccee8cbdfb..ec7352df8ec 100644 --- a/src/KurrentDB.Core/ClusterVNode.cs +++ b/src/KurrentDB.Core/ClusterVNode.cs @@ -138,6 +138,8 @@ public static ClusterVNode Create( abstract public CertificateDelegates.ClientCertificateValidator InternalClientCertificateValidator { get; } abstract public Func CertificateSelector { get; } abstract public Func IntermediateCertificatesSelector { get; } + abstract public Func PubliclyTrustedCertificateSelector { get; } + abstract public Func PubliclyTrustedIntermediateCertificatesSelector { get; } abstract public bool DisableHttps { get; } abstract public bool EnableUnixSocket { get; } abstract public bool IsShutdown { get; } @@ -206,6 +208,8 @@ internal ThreadPoolMessageScheduler WorkersHandler { private readonly Func _certificateSelector; private readonly Func _trustedRootCertsSelector; private readonly Func _intermediateCertsSelector; + private readonly Func _publiclyTrustedCertificateSelector; + private readonly Func _publiclyTrustedIntermediateCertsSelector; private readonly CertificateDelegates.ServerCertificateValidator _internalServerCertificateValidator; private readonly CertificateDelegates.ClientCertificateValidator _internalClientCertificateValidator; private readonly CertificateDelegates.ServerCertificateValidator _externalServerCertificateValidator; @@ -227,6 +231,8 @@ public IEnumerable Tasks { public override CertificateDelegates.ClientCertificateValidator InternalClientCertificateValidator => _internalClientCertificateValidator; public override Func CertificateSelector => _certificateSelector; public override Func IntermediateCertificatesSelector => _intermediateCertsSelector; + public override Func PubliclyTrustedCertificateSelector => _publiclyTrustedCertificateSelector; + public override Func PubliclyTrustedIntermediateCertificatesSelector => _publiclyTrustedIntermediateCertsSelector; public override bool DisableHttps => _disableHttps; public sealed override bool EnableUnixSocket => _enableUnixSocket; public override bool IsShutdown => _shutdownSource.Task.IsCompleted; @@ -516,6 +522,11 @@ TFChunkDbConfig CreateDbConfig( _certificateProvider?.IntermediateCerts == null ? null : new X509Certificate2Collection(_certificateProvider?.IntermediateCerts); + _publiclyTrustedCertificateSelector = () => _certificateProvider?.PubliclyTrustedCertificate; + _publiclyTrustedIntermediateCertsSelector = () => + _certificateProvider?.PubliclyTrustedIntermediateCerts == null + ? null + : new X509Certificate2Collection(_certificateProvider?.PubliclyTrustedIntermediateCerts); _internalServerCertificateValidator = (cert, chain, errors, otherNames) => ValidateServerCertificate(cert, chain, errors, _intermediateCertsSelector, _trustedRootCertsSelector, otherNames); _internalClientCertificateValidator = (cert, chain, errors) => ValidateClientCertificate(cert, chain, errors, _intermediateCertsSelector, _trustedRootCertsSelector); diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs index e52df7fb2f2..29c35a40823 100644 --- a/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs +++ b/src/KurrentDB.Core/Configuration/ClusterVNodeOptions.cs @@ -207,6 +207,20 @@ public record CertificateFileOptions { [Description("The password to the certificate private key file if an encrypted PKCS #8 private key file is provided."), Sensitive] public string? CertificatePrivateKeyPassword { get; init; } + + [Description("The path to a PKCS #12 (.p12/.pfx) or an X.509 (.pem, .crt, .cer, .der) publicly-trusted certificate file served on TLS connections whose SNI hostname matches one of its Subject Alternative Names. Typically a certificate issued by a publicly-trusted CA (e.g. Let's Encrypt) for external gRPC clients & web browsers, while the node still uses its internal-CA certificate for cluster mTLS.")] + public string? PubliclyTrustedCertificateFile { get; init; } + + [Description("The path to the publicly-trusted certificate's private key file (.key) if an X.509 certificate file is provided.")] + public string? PubliclyTrustedCertificatePrivateKeyFile { get; init; } + + [Description("The password to the publicly-trusted certificate if a PKCS #12 (.p12/.pfx) certificate file is provided."), + Sensitive] + public string? PubliclyTrustedCertificatePassword { get; init; } + + [Description("The password to the publicly-trusted certificate's private key file if an encrypted PKCS #8 private key file is provided."), + Sensitive] + public string? PubliclyTrustedCertificatePrivateKeyPassword { get; init; } } [Description("Certificate Options")] @@ -252,6 +266,18 @@ public record CertificateStoreOptions { [Description("The trusted root certificate fingerprint/thumbprint.")] public string TrustedRootCertificateThumbprint { get; init; } = string.Empty; + + [Description("The publicly-trusted certificate store location name. See PubliclyTrustedCertificateFile for the intended use.")] + public string PubliclyTrustedCertificateStoreLocation { get; init; } = string.Empty; + + [Description("The publicly-trusted certificate store name.")] + public string PubliclyTrustedCertificateStoreName { get; init; } = string.Empty; + + [Description("The publicly-trusted certificate store subject name.")] + public string PubliclyTrustedCertificateSubjectName { get; init; } = string.Empty; + + [Description("The publicly-trusted certificate fingerprint/thumbprint.")] + public string PubliclyTrustedCertificateThumbprint { get; init; } = string.Empty; } [Description("Cluster Options")] diff --git a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs index a0e1cea8592..989d80a9ab1 100644 --- a/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs +++ b/src/KurrentDB.Core/Configuration/ClusterVNodeOptionsExtensions.cs @@ -231,6 +231,36 @@ public static (X509Certificate2 certificate, X509Certificate2Collection intermed "A certificate is required unless insecure mode (--insecure) is set."); } + /// + /// Loads the optional publicly-trusted certificate served when the TLS ClientHello SNI matches one of its SANs. + /// Returns null if no publicly-trusted certificate is configured. + /// + public static (X509Certificate2 certificate, X509Certificate2Collection intermediates)? LoadPubliclyTrustedCertificate( + this ClusterVNodeOptions options) { + if (!string.IsNullOrWhiteSpace(options.CertificateStore.PubliclyTrustedCertificateStoreLocation)) { + var location = CertificateUtils.GetCertificateStoreLocation(options.CertificateStore.PubliclyTrustedCertificateStoreLocation); + var name = CertificateUtils.GetCertificateStoreName(options.CertificateStore.PubliclyTrustedCertificateStoreName); + return (CertificateUtils.LoadFromStore(location, name, options.CertificateStore.PubliclyTrustedCertificateSubjectName, + options.CertificateStore.PubliclyTrustedCertificateThumbprint), null); + } + + if (!string.IsNullOrWhiteSpace(options.CertificateStore.PubliclyTrustedCertificateStoreName)) { + var name = CertificateUtils.GetCertificateStoreName(options.CertificateStore.PubliclyTrustedCertificateStoreName); + return (CertificateUtils.LoadFromStore(name, options.CertificateStore.PubliclyTrustedCertificateSubjectName, + options.CertificateStore.PubliclyTrustedCertificateThumbprint), null); + } + + if (options.CertificateFile.PubliclyTrustedCertificateFile.IsNotEmptyString()) { + Log.Information("Loading the publicly-trusted certificate(s) from file: {path}", + options.CertificateFile.PubliclyTrustedCertificateFile); + return CertificateUtils.LoadFromFile(options.CertificateFile.PubliclyTrustedCertificateFile, + options.CertificateFile.PubliclyTrustedCertificatePrivateKeyFile, options.CertificateFile.PubliclyTrustedCertificatePassword, + options.CertificateFile.PubliclyTrustedCertificatePrivateKeyPassword); + } + + return null; + } + /// /// Loads an from the options set. /// If either TrustedRootCertificateStoreLocation or TrustedRootCertificateStoreName is set, diff --git a/src/KurrentDB/KestrelHelpers.cs b/src/KurrentDB/KestrelHelpers.cs index 888ee38a7a4..a6776a8812d 100644 --- a/src/KurrentDB/KestrelHelpers.cs +++ b/src/KurrentDB/KestrelHelpers.cs @@ -7,6 +7,7 @@ using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; +using KurrentDB.Core.Certificates; using KurrentDB.Core.DuckDB; using KurrentDB.Core.Services.Transport.Http; using Microsoft.AspNetCore.Hosting; @@ -70,11 +71,14 @@ static void CleanupStaleSocket(string socketPath) { } public static ServerOptionsSelectionCallback CreateServerOptionsSelectionCallback(ClusterVNodeHostedService hostedService) { - return (_, _, _, _) => { + return (_, clientHelloInfo, _, _) => { + var publiclyTrustedCert = hostedService.Node.PubliclyTrustedCertificateSelector(); + var usePubliclyTrustedCert = PubliclyTrustedCertificateSelector.ShouldServe(publiclyTrustedCert, clientHelloInfo.ServerName); + var serverOptions = new SslServerAuthenticationOptions { ServerCertificateContext = SslStreamCertificateContext.Create( - hostedService.Node.CertificateSelector(), - hostedService.Node.IntermediateCertificatesSelector(), + usePubliclyTrustedCert ? publiclyTrustedCert : hostedService.Node.CertificateSelector(), + usePubliclyTrustedCert ? hostedService.Node.PubliclyTrustedIntermediateCertificatesSelector() : hostedService.Node.IntermediateCertificatesSelector(), offline: true), ClientCertificateRequired = true, // request a client certificate but it's not necessary for the client to supply one RemoteCertificateValidationCallback = (_, certificate, chain, sslPolicyErrors) => {