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) => {