Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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]);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
2 changes: 2 additions & 0 deletions src/KurrentDB.Core/Certificates/CertificateProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading
Loading