diff --git a/CHANGELOG.md b/CHANGELOG.md index e11d8c004..6304e9f49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # ChangeLog - DataStax C# Driver +## 3.22.1 + +2026-08-26 + +### Improvements + +* [[#634](https://github.com/datastax/csharp-driver/pull/634)] Improvements to Astra cert validator + ## 3.22.0 2024-09-30 diff --git a/src/Cassandra.Tests/DataStax/Cloud/CustomCaCertificateValidatorTests.cs b/src/Cassandra.Tests/DataStax/Cloud/CustomCaCertificateValidatorTests.cs index 238c96cfc..becb42fd5 100644 --- a/src/Cassandra.Tests/DataStax/Cloud/CustomCaCertificateValidatorTests.cs +++ b/src/Cassandra.Tests/DataStax/Cloud/CustomCaCertificateValidatorTests.cs @@ -1,4 +1,4 @@ -// +// // Copyright (C) DataStax Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,10 +12,11 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -// +// using System; using System.Net.Security; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Cassandra.DataStax.Cloud; @@ -27,6 +28,14 @@ namespace Cassandra.Tests.DataStax.Cloud [TestFixture] public class CustomCaCertificateValidatorTests { + // rootcacsharp.crt — self-signed CA used across multiple tests + private const string RootCaB64 = + "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMvakNDQWVZQ0NRQ01JNHlFM0J5WnpUQU5CZ2txaGtpRzl3MEJBUXNGQURCQU1Rc3dDUVlEVlFRR0V3SlYKVXpFUk1BOEdBMVVFQ2d3SVJHRjBZVk4wWVhneERqQU1CZ05WQkFzTUJVTnNiM1ZrTVE0d0RBWURWUVFEREFWRApiRzkxWkRBZ0Z3MHlNakExTXpBeE1qTTJNRGhhR0E4ek1ESXhNRGt6TURFeU16WXdPRm93UURFTE1Ba0dBMVVFCkJoTUNWVk14RVRBUEJnTlZCQW9NQ0VSaGRHRlRkR0Y0TVE0d0RBWURWUVFMREFWRGJHOTFaREVPTUF3R0ExVUUKQXd3RlEyeHZkV1F3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRRGFZTjVBcm5VOQo5b2Q5cXdPMzVaRlBwdFpCc0psc29CQWl5b0V1WEpCbFFwdnlveHY2ckFXYkJaMkxpWEFvZkZWdzVjWThxTlNxCmUzRytjWmJzUm0xbk9Yc0lTRnVYekhGSWFJWWEzZi9OblIzc25SRG1uZUwwS3lhUVI5VnFvMCt5V0RUMmFlZWsKSDNrNFdrUlJrMEZtOUhIUWlKTWFTdU05WC9nQnhyUTdiMkxBOFRjM2FjTForbmYxdGpSa01ZN0hDNnJ6TW9rRwp0QmhCN3lxL1dtMzFOVk1ucVk2UnZhOWpBV1lIcW1YWXZkOG9uMmRsTDlzVzEybFRUNHd4Qkp1VTV6Mzd5bDhXClZOY0RkY3lhdWJzQUtDU3NwZmord2pVL0ZEL00zZUdYNEdZQVlDdjdQbStNL2NMNG1wMDRtR3dEakhVOE1RS28KUi91QmRRbllRN2lYQWdNQkFBRXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBTFhvL01UUUVYY29vQ3dTSVczaAovemhaSFNrUko1SmYyd2pjTTlPTmxEaVI1K01NU2pYVFVTMHF6amlhZFNvSzlZeGdWN1ByVHpjMWR0cVNzTjdXCllQNHM0VG9zbWRzRVRTdmVwRXFRSklLRHZyVFJaTDlPV2hLb1BVRTd3NzJVVnVYcERaWHIraTNNQ3p3U05zZGwKNzFXbHpVcXJqcGJCSkZnU21xZmZkNmJuVXExWVhKU3orcy9KSnV4SmlrNGVqTGlIbTh3MWhIckUvYWRvYUtMbQo1elYxaHpPT3Y0d0VnSTQ3VnNvMUxYenQzQmROeXVVMnZPR1R4Wmwwa0E1bUVSRUwxR3J0TEova251SkF6aWZ5ClFKMnVlU2JMTVZadXBsNy84MW5Ib3k3eVNaKzNYM1VUUkdQNzNNYWM5cTdvK0ZJeTV2M0laL3NJM0FEaWM4NXUKWGVvPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg=="; + + // localhostcsharp.crt — leaf cert signed by RootCaB64 + private const string LocalhostCertB64 = + "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURBakNDQWVvQ0NRQ2ZCNGxlaUM0ZGl6QU5CZ2txaGtpRzl3MEJBUXNGQURCQU1Rc3dDUVlEVlFRR0V3SlYKVXpFUk1BOEdBMVVFQ2d3SVJHRjBZVk4wWVhneERqQU1CZ05WQkFzTUJVTnNiM1ZrTVE0d0RBWURWUVFEREFWRApiRzkxWkRBZ0Z3MHlNakExTXpBeE1qUXpOVE5hR0E4ek1ESXhNRGt6TURFeU5ETTFNMW93UkRFTE1Ba0dBMVVFCkJoTUNWVk14RVRBUEJnTlZCQW9UQ0VSaGRHRlRkR0Y0TVE0d0RBWURWUVFMRXdWRGJHOTFaREVTTUJBR0ExVUUKQXhNSmJHOWpZV3hvYjNOME1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBb2Z3TwpSNW5CQW9PaWhsengrTSt3enhCdzg2OHU5L3FxZlVyRlIycXlySFBYZkNjSm14ZUFXVE5UMXllbWcyd2pZSUZmClZoU2VmOXZNSWlQbWh1eXd2R1RkVFlCcFdsZTNKRlY3ejdvb1JYb1d4SGwwNFJTNVY3Q0p6Sk1vNEdNRWZYamgKL2VyNVk2NU9ibnY3Z2hiN0IzNEh5NDVzZFMyWWpPRklZVFFzZXgwMDhIMnpDSmVoT3J0OWFZWlFOVEp1Zk9BMwovNnJRaG9hZVhjKy9DdWdKUWlkOEQyRlJvUENmM1NsQlkzeHFZbmhJNGJmQWJvUTJqYmJINitzYjlYRDFsdTVCCiswTWl5NGcwL00yQTA0NWZpYnJjQVAvRG9YWnpSdkd5SWlmYU55RHltQjZERWhtUFNNUkNZRWdpWlFlMmpkRVQKSUVEMkk5NnJBZjhpb3B2ZG13SURBUUFCTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBM2V3MVhlaG9WMHpBZAo2SkJuS1FzMkdkVjl5WFVFRVhEQTQ3M0xJZ05JWWFrVEpWbVZTVGdQUlNEbW1LRzkyNlUwUUgzREtwbitsTDNxCnQ4V1IyM3gwRzJSeXFtaGJ5MTFlUXFkWUZrRzNIOGxVMVVIZTM3LzI3Q29EeE80cnpNUFNJd0JWRVN3aTVsbHoKc1FHWXY2K1dnZUFXUHpRYzlCOVV5UFYyenFSbitCQTNOdDBPSnJOdG1UcFJsRE5XU2NoZ1VhV1BCdXhRVk1kNgo2OEM5dkczNk9hYnJFZDBmakdJUWVaYlJjcHFPQVVEbEpMaUY3SElEU0NkNFo0MUxOM3pNdmMxTnhMN1orUDdhCm5jU2RTcjlkR0tIOXRvRm4ydk5Nd0JOZWgvbm5Xc3BRd3djSm85Yk9pZjNmeXlGOWZYWGF0TjVxM2dJcUhVd08KVXlFK1l1UjEKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo="; + /// /// Simulate classic Astra /// @@ -50,6 +59,79 @@ public void TestCertificateWithoutWildcardAndWithRootCaInChain() Assert.True(new CustomCaCertificateValidator(ca, "localhost").Validate(cert, chain, SslPolicyErrors.RemoteCertificateChainErrors | SslPolicyErrors.RemoteCertificateNameMismatch)); } + /// + /// When the OS reports SslPolicyErrors.None (server cert is trusted by the OS + /// store) and the chain root thumbprint matches the bundle CA, the connection must be accepted. + /// + [Test] + public void SslPolicyErrors_None_WithMatchingBundleCa_ShouldAccept() + { + var ca = new X509Certificate2(Convert.FromBase64String(RootCaB64)); + var cert = new X509Certificate2(Convert.FromBase64String(LocalhostCertB64)); + var chain = new X509Chain(); + chain.ChainPolicy.ExtraStore.Add(ca); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.Build(cert); + + Assert.True( + new CustomCaCertificateValidator(ca, "localhost") + .Validate(cert, chain, SslPolicyErrors.None), + "Should accept: OS says None and root thumbprint matches the bundle CA."); + } + +#if !NET462 + /// + /// When the OS reports SslPolicyErrors.None but the chain root does NOT match + /// the bundle CA (rogue OS-trusted CA), the connection must be rejected. + /// + [Test] + public void SslPolicyErrors_None_WithMismatchedBundleCa_ShouldReject() + { + var cert = new X509Certificate2(Convert.FromBase64String(LocalhostCertB64)); + + // A second self-signed CA — represents a rogue CA trusted by the OS but not in the bundle. + var rogueCa = CreateSelfSignedCa("CN=RogueCA"); + + // Chain was built against the real bundle CA (OS accepted it), but the validator + // is seeded with rogueCa — thumbprint will not match. + var bundleCa = new X509Certificate2(Convert.FromBase64String(RootCaB64)); + var chain = new X509Chain(); + chain.ChainPolicy.ExtraStore.Add(bundleCa); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.Build(cert); + + Assert.False( + new CustomCaCertificateValidator(rogueCa, "localhost") + .Validate(cert, chain, SslPolicyErrors.None), + "Should reject: OS says None but root thumbprint does NOT match the bundle CA."); + } + + /// + /// When chain.Build() returns true (no chain errors) but the root thumbprint + /// does not match the bundle CA, the connection must be rejected. + /// + [Test] + public void ChainBuildSucceeds_WithMismatchedBundleCa_ShouldReject() + { + var cert = new X509Certificate2(Convert.FromBase64String(LocalhostCertB64)); + var rogueCa = CreateSelfSignedCa("CN=RogueCA"); + + var bundleCa = new X509Certificate2(Convert.FromBase64String(RootCaB64)); + var chain = new X509Chain(); + chain.ChainPolicy.ExtraStore.Add(bundleCa); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.Build(cert); + + // Pass RemoteCertificateChainErrors so the chain block is entered; + // rogueCa thumbprint won't match the chain root regardless of which + // branch chain.Build() takes internally. + Assert.False( + new CustomCaCertificateValidator(rogueCa, "localhost") + .Validate(cert, chain, SslPolicyErrors.RemoteCertificateChainErrors), + "Should reject: chain root thumbprint does not match the bundle CA even when Build() returns true."); + } +#endif // !NET462 + /// /// Simulate serverless Astra /// @@ -165,5 +247,29 @@ public void TestCertificateWithoutCustomDomainInSan_WithCustomDomain() Assert.AreEqual("*.example.com", cert.GetNameInfo(X509NameType.SimpleName, false)); Assert.False(new CustomCaCertificateValidator(cert, "test123.customdomain.com").Validate(cert, chain, SslPolicyErrors.RemoteCertificateChainErrors | SslPolicyErrors.RemoteCertificateNameMismatch)); } + // ── Helper ──────────────────────────────────────────────────────────────── + +#if !NET462 + /// + /// Creates an in-memory self-signed CA certificate. Used to synthesise a rogue CA + /// whose thumbprint differs from the bundle CA. + /// + private static X509Certificate2 CreateSelfSignedCa(string subjectDn) + { + using (var rsa = RSA.Create(2048)) + { + var req = new CertificateRequest( + subjectDn, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add( + new X509BasicConstraintsExtension( + certificateAuthority: true, hasPathLengthConstraint: false, + pathLengthConstraint: 0, critical: true)); + var cert = req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10)); + // Export/re-import to detach the private key + return new X509Certificate2(cert.Export(X509ContentType.Cert)); + } + } +#endif // !NET462 } } \ No newline at end of file diff --git a/src/Cassandra/Cassandra.csproj b/src/Cassandra/Cassandra.csproj index d77828945..cda7f52bf 100644 --- a/src/Cassandra/Cassandra.csproj +++ b/src/Cassandra/Cassandra.csproj @@ -4,8 +4,8 @@ Copyright © by DataStax DataStax C# Driver for Apache Cassandra 3.99.0.0 - 3.22.0.0 - 3.22.0 + 3.22.1.0 + 3.22.1 false DataStax net452;netstandard2.0 diff --git a/src/Cassandra/DataStax/Cloud/CustomCACertificateValidator.cs b/src/Cassandra/DataStax/Cloud/CustomCACertificateValidator.cs index 30d816720..baf1ac6c5 100644 --- a/src/Cassandra/DataStax/Cloud/CustomCACertificateValidator.cs +++ b/src/Cassandra/DataStax/Cloud/CustomCACertificateValidator.cs @@ -86,10 +86,9 @@ public CustomCaCertificateValidator(X509Certificate2 trustedRootCertificateAutho public bool Validate(X509Certificate cert, X509Chain chain, SslPolicyErrors errors) { - if (errors == SslPolicyErrors.None) - { - return true; - } + // Do NOT short-circuit on SslPolicyErrors.None. + // Always rebuild the chain using the bundle CA and verify the root + // thumbprint regardless of what the OS-level validation decided. X509Certificate2 cert2 = null; var valid = true; @@ -128,7 +127,11 @@ public bool Validate(X509Certificate cert, X509Chain chain, SslPolicyErrors erro valid = validName; } - if (valid && (errors & SslPolicyErrors.RemoteCertificateChainErrors) != 0) + + // Always rebuild the chain against the bundle CA and verify the root thumbprint, + // regardless of whether the OS reported chain errors or None. This ensures the + // OS trust store is never the sole authority for accepting a connection. + if (valid) { var oldChain = chain; chain = new X509Chain(); @@ -149,29 +152,56 @@ public bool Validate(X509Certificate cert, X509Chain chain, SslPolicyErrors erro // clone CA object because on Mono it gets reset for some reason after using it to build a new chain var clonedCa = new X509Certificate2(_trustedRootCertificateAuthority); chain.ChainPolicy.ExtraStore.Add(clonedCa); - + GetOrCreateCert2(ref cert2, cert); if (!chain.Build(cert2)) { - // verify if the chain is correct - foreach (var status in chain.ChainStatus) + // chain.Build() returned false — inspect each status flag. + // UntrustedRoot is acceptable: the bundle CA is self-signed and will not be + // in the OS trust store on most machines. + if (chain.ChainStatus.Length == 0) { - if (status.Status == X509ChainStatusFlags.NoError || status.Status == X509ChainStatusFlags.UntrustedRoot) - { - //Acceptable Status - } - else + // No status information means we cannot determine why the build failed. + // Treat as a hard failure rather than silently falling through to the + // thumbprint check with an unknown chain state. + CustomCaCertificateValidator.Logger.Error( + "SSL validation failed: chain.Build() returned false with no status information."); + valid = false; + } + else + { + foreach (var status in chain.ChainStatus) { - CustomCaCertificateValidator.Logger.Error( - "Certificate chain validation failed. Found chain status {0} ({1}).", status.Status, status.StatusInformation); - valid = false; - break; + if (status.Status == X509ChainStatusFlags.NoError || status.Status == X509ChainStatusFlags.UntrustedRoot) + { + //Acceptable Status + } + else + { + CustomCaCertificateValidator.Logger.Error( + "Certificate chain validation failed. Found chain status {0} ({1}).", status.Status, status.StatusInformation); + valid = false; + break; + } } } + } - if (valid) + // Always verify the root thumbprint regardless of whether chain.Build() succeeded + // or failed with only UntrustedRoot. + if (valid) + { + if (chain.ChainElements.Count == 0) + { + // Should not happen after a successful or UntrustedRoot-only build, but + // guard against it explicitly to avoid an unhandled exception on the + // indexer that would produce an opaque SSL error with no log message. + CustomCaCertificateValidator.Logger.Error( + "SSL validation failed: certificate chain contains no elements."); + valid = false; + } + else { - //Now that we have tested to see if the cert builds properly, we now will check if the thumbprint of the root ca matches our trusted one var rootCertThumbprint = chain.ChainElements[chain.ChainElements.Count - 1].Certificate.Thumbprint; if (rootCertThumbprint != _trustedRootCertificateAuthority.Thumbprint) { @@ -180,8 +210,8 @@ public bool Validate(X509Certificate cert, X509Chain chain, SslPolicyErrors erro valid = false; } } - } + DisposeCert2(clonedCa); } diff --git a/src/Extensions/Cassandra.AppMetrics/Cassandra.AppMetrics.csproj b/src/Extensions/Cassandra.AppMetrics/Cassandra.AppMetrics.csproj index 6b67bc98d..8e05faaac 100644 --- a/src/Extensions/Cassandra.AppMetrics/Cassandra.AppMetrics.csproj +++ b/src/Extensions/Cassandra.AppMetrics/Cassandra.AppMetrics.csproj @@ -4,8 +4,8 @@ This package builds on the DataStax Enterprise C# driver and DataStax C# Driver for Apache Cassandra, adding a metrics provider implementation using App Metrics. Copyright © by DataStax 3.99.0.0 - 3.22.0.0 - 3.22.0 + 3.22.1.0 + 3.22.1 DataStax netstandard2.0;net461 netstandard2.0 diff --git a/src/Extensions/Cassandra.OpenTelemetry/Cassandra.OpenTelemetry.csproj b/src/Extensions/Cassandra.OpenTelemetry/Cassandra.OpenTelemetry.csproj index db4603ec6..e7965544d 100644 --- a/src/Extensions/Cassandra.OpenTelemetry/Cassandra.OpenTelemetry.csproj +++ b/src/Extensions/Cassandra.OpenTelemetry/Cassandra.OpenTelemetry.csproj @@ -1,14 +1,15 @@ - + This package builds on the DataStax Enterprise C# driver and DataStax C# Driver for Apache Cassandra, adding OpenTelemetry instrumentation. Copyright © by DataStax 3.99.0.0 - 3.22.0.0 - 3.22.0 + 3.22.1.0 + 3.22.1 DataStax netstandard2.0 true + NU1901;NU1902;NU1903;NU1904 true Cassandra.OpenTelemetry DataStax C# Drivers OpenTelemetry Extension