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