Skip to content

Commit c3cf9f9

Browse files
authored
Allow specifying multiple root certificates in NpgsqlDataSourceBuilder (#6057)
Closes #6056
1 parent a68839b commit c3cf9f9

6 files changed

Lines changed: 117 additions & 14 deletions

File tree

src/Npgsql/Internal/NpgsqlConnector.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,19 +1127,19 @@ internal async Task NegotiateEncryption(SslMode sslMode, NpgsqlTimeout timeout,
11271127
var checkCertificateRevocation = Settings.CheckCertificateRevocation;
11281128

11291129
RemoteCertificateValidationCallback? certificateValidationCallback;
1130-
X509Certificate2? caCert;
1130+
X509Certificate2Collection? caCerts;
11311131
string? certRootPath = null;
11321132

11331133
if (sslMode is SslMode.Prefer or SslMode.Require)
11341134
{
11351135
certificateValidationCallback = SslTrustServerValidation;
11361136
checkCertificateRevocation = false;
11371137
}
1138-
else if ((caCert = DataSource.TransportSecurityHandler.RootCertificateCallback?.Invoke()) is not null ||
1138+
else if (((caCerts = DataSource.TransportSecurityHandler.RootCertificatesCallback?.Invoke()) is not null && caCerts.Count > 0) ||
11391139
(certRootPath = Settings.RootCertificate ??
11401140
PostgresEnvironment.SslCertRoot ?? PostgresEnvironment.SslCertRootDefault) is not null)
11411141
{
1142-
certificateValidationCallback = SslRootValidation(sslMode == SslMode.VerifyFull, certRootPath, caCert);
1142+
certificateValidationCallback = SslRootValidation(sslMode == SslMode.VerifyFull, certRootPath, caCerts);
11431143
}
11441144
else if (sslMode == SslMode.VerifyCA)
11451145
{
@@ -1195,7 +1195,7 @@ internal async Task NegotiateEncryption(SslMode sslMode, NpgsqlTimeout timeout,
11951195
if (Settings.RootCertificate is not null)
11961196
throw new ArgumentException(NpgsqlStrings.CannotUseSslRootCertificateWithCustomValidationCallback);
11971197

1198-
if (DataSource.TransportSecurityHandler.RootCertificateCallback is not null)
1198+
if (DataSource.TransportSecurityHandler.RootCertificatesCallback is not null)
11991199
throw new ArgumentException(NpgsqlStrings.CannotUseValidationRootCertificateCallbackWithCustomValidationCallback);
12001200
}
12011201
}
@@ -1984,7 +1984,7 @@ internal void ClearTransaction(Exception? disposeReason = null)
19841984
(sender, certificate, chain, sslPolicyErrors)
19851985
=> true;
19861986

1987-
static RemoteCertificateValidationCallback SslRootValidation(bool verifyFull, string? certRootPath, X509Certificate2? caCertificate)
1987+
static RemoteCertificateValidationCallback SslRootValidation(bool verifyFull, string? certRootPath, X509Certificate2Collection? caCertificates)
19881988
=> (_, certificate, chain, sslPolicyErrors) =>
19891989
{
19901990
if (certificate is null || chain is null)
@@ -2001,12 +2001,12 @@ static RemoteCertificateValidationCallback SslRootValidation(bool verifyFull, st
20012001

20022002
if (certRootPath is null)
20032003
{
2004-
Debug.Assert(caCertificate is not null);
2005-
certs.Add(caCertificate);
2004+
Debug.Assert(caCertificates is { Count: > 0 });
2005+
certs.AddRange(caCertificates);
20062006
}
20072007
else
20082008
{
2009-
Debug.Assert(caCertificate is null);
2009+
Debug.Assert(caCertificates is null or { Count: > 0 });
20102010
if (Path.GetExtension(certRootPath).ToUpperInvariant() != ".PFX")
20112011
certs.ImportFromPemFile(certRootPath);
20122012

src/Npgsql/Internal/TransportSecurityHandler.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class TransportSecurityHandler
1111
{
1212
public virtual bool SupportEncryption => false;
1313

14-
public virtual Func<X509Certificate2?>? RootCertificateCallback
14+
public virtual Func<X509Certificate2Collection?>? RootCertificatesCallback
1515
{
1616
get => throw new NotSupportedException(string.Format(NpgsqlStrings.TransportSecurityDisabled, nameof(NpgsqlSlimDataSourceBuilder.EnableTransportSecurity)));
1717
set => throw new NotSupportedException(string.Format(NpgsqlStrings.TransportSecurityDisabled, nameof(NpgsqlSlimDataSourceBuilder.EnableTransportSecurity)));
@@ -29,7 +29,7 @@ sealed class RealTransportSecurityHandler : TransportSecurityHandler
2929
{
3030
public override bool SupportEncryption => true;
3131

32-
public override Func<X509Certificate2?>? RootCertificateCallback { get; set; }
32+
public override Func<X509Certificate2Collection?>? RootCertificatesCallback { get; set; }
3333

3434
public override Task NegotiateEncryption(bool async, NpgsqlConnector connector, SslMode sslMode, NpgsqlTimeout timeout, CancellationToken cancellationToken)
3535
=> connector.NegotiateEncryption(sslMode, timeout, async, cancellationToken);

src/Npgsql/NpgsqlDataSourceBuilder.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public INpgsqlNameTranslator DefaultNameTranslator
4141
}
4242

4343
/// <summary>
44-
/// A connection string builder that can be used to configured the connection string on the builder.
44+
/// A connection string builder that can be used to configure the connection string on the builder.
4545
/// </summary>
4646
public NpgsqlConnectionStringBuilder ConnectionStringBuilder => _internalBuilder.ConnectionStringBuilder;
4747

@@ -297,6 +297,17 @@ public NpgsqlDataSourceBuilder UseRootCertificate(X509Certificate2? rootCertific
297297
return this;
298298
}
299299

300+
/// <summary>
301+
/// Sets the <see cref="X509Certificate2Collection" /> that will be used validate SSL certificate, received from the server.
302+
/// </summary>
303+
/// <param name="rootCertificates">The CA certificates.</param>
304+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
305+
public NpgsqlDataSourceBuilder UseRootCertificates(X509Certificate2Collection? rootCertificates)
306+
{
307+
_internalBuilder.UseRootCertificates(rootCertificates);
308+
return this;
309+
}
310+
300311
/// <summary>
301312
/// Specifies a callback that will be used to validate SSL certificate, received from the server.
302313
/// </summary>
@@ -313,6 +324,23 @@ public NpgsqlDataSourceBuilder UseRootCertificateCallback(Func<X509Certificate2>
313324
return this;
314325
}
315326

327+
/// <summary>
328+
/// Specifies a callback that will be used to validate SSL certificate, received from the server.
329+
/// </summary>
330+
/// <param name="rootCertificateCallback">The callback to get CA certificates.</param>
331+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
332+
/// <remarks>
333+
/// This overload, which accepts a callback, is suitable for scenarios where the certificate rotates
334+
/// and might change during the lifetime of the application.
335+
/// When that's not the case, use the overload which directly accepts the certificate.
336+
/// </remarks>
337+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
338+
public NpgsqlDataSourceBuilder UseRootCertificatesCallback(Func<X509Certificate2Collection>? rootCertificateCallback)
339+
{
340+
_internalBuilder.UseRootCertificatesCallback(rootCertificateCallback);
341+
return this;
342+
}
343+
316344
/// <summary>
317345
/// Configures a periodic password provider, which is automatically called by the data source at some regular interval. This is the
318346
/// recommended way to fetch a rotating access token.

src/Npgsql/NpgsqlSlimDataSourceBuilder.cs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public sealed class NpgsqlSlimDataSourceBuilder : INpgsqlTypeMapper
6060
internal Action<NpgsqlSlimDataSourceBuilder> ConfigureDefaultFactories { get; set; }
6161

6262
/// <summary>
63-
/// A connection string builder that can be used to configured the connection string on the builder.
63+
/// A connection string builder that can be used to configure the connection string on the builder.
6464
/// </summary>
6565
public NpgsqlConnectionStringBuilder ConnectionStringBuilder { get; }
6666

@@ -252,9 +252,19 @@ public NpgsqlSlimDataSourceBuilder UseClientCertificatesCallback(Action<X509Cert
252252
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
253253
public NpgsqlSlimDataSourceBuilder UseRootCertificate(X509Certificate2? rootCertificate)
254254
=> rootCertificate is null
255-
? UseRootCertificateCallback(null)
255+
? UseRootCertificatesCallback((Func<X509Certificate2Collection>?)null)
256256
: UseRootCertificateCallback(() => rootCertificate);
257257

258+
/// <summary>
259+
/// Sets the <see cref="X509Certificate2Collection" /> that will be used validate SSL certificate, received from the server.
260+
/// </summary>
261+
/// <param name="rootCertificates">The CA certificates.</param>
262+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
263+
public NpgsqlSlimDataSourceBuilder UseRootCertificates(X509Certificate2Collection? rootCertificates)
264+
=> rootCertificates is null
265+
? UseRootCertificatesCallback((Func<X509Certificate2Collection>?)null)
266+
: UseRootCertificatesCallback(() => rootCertificates);
267+
258268
/// <summary>
259269
/// Specifies a callback that will be used to validate SSL certificate, received from the server.
260270
/// </summary>
@@ -268,7 +278,27 @@ public NpgsqlSlimDataSourceBuilder UseRootCertificate(X509Certificate2? rootCert
268278
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
269279
public NpgsqlSlimDataSourceBuilder UseRootCertificateCallback(Func<X509Certificate2>? rootCertificateCallback)
270280
{
271-
_transportSecurityHandler.RootCertificateCallback = rootCertificateCallback;
281+
_transportSecurityHandler.RootCertificatesCallback = () => rootCertificateCallback is not null
282+
? new X509Certificate2Collection(rootCertificateCallback())
283+
: null;
284+
285+
return this;
286+
}
287+
288+
/// <summary>
289+
/// Specifies a callback that will be used to validate SSL certificate, received from the server.
290+
/// </summary>
291+
/// <param name="rootCertificateCallback">The callback to get CA certificates.</param>
292+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
293+
/// <remarks>
294+
/// This overload, which accepts a callback, is suitable for scenarios where the certificate rotates
295+
/// and might change during the lifetime of the application.
296+
/// When that's not the case, use the overload which directly accepts the certificate.
297+
/// </remarks>
298+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
299+
public NpgsqlSlimDataSourceBuilder UseRootCertificatesCallback(Func<X509Certificate2Collection>? rootCertificateCallback)
300+
{
301+
_transportSecurityHandler.RootCertificatesCallback = rootCertificateCallback;
272302

273303
return this;
274304
}

src/Npgsql/PublicAPI.Unshipped.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Npgsql.NpgsqlDataSourceBuilder.MapEnum(System.Type! clrType, string? pgName = nu
2222
Npgsql.NpgsqlDataSourceBuilder.MapEnum<TEnum>(string? pgName = null, Npgsql.INpgsqlNameTranslator? nameTranslator = null) -> Npgsql.NpgsqlDataSourceBuilder!
2323
Npgsql.NpgsqlDataSourceBuilder.ConfigureTracing(System.Action<Npgsql.NpgsqlTracingOptionsBuilder!>! configureAction) -> Npgsql.NpgsqlDataSourceBuilder!
2424
Npgsql.NpgsqlDataSourceBuilder.UseNegotiateOptionsCallback(System.Action<System.Net.Security.NegotiateAuthenticationClientOptions!>? negotiateOptionsCallback) -> Npgsql.NpgsqlDataSourceBuilder!
25+
Npgsql.NpgsqlDataSourceBuilder.UseRootCertificates(System.Security.Cryptography.X509Certificates.X509Certificate2Collection? rootCertificates) -> Npgsql.NpgsqlDataSourceBuilder!
26+
Npgsql.NpgsqlDataSourceBuilder.UseRootCertificatesCallback(System.Func<System.Security.Cryptography.X509Certificates.X509Certificate2Collection!>? rootCertificateCallback) -> Npgsql.NpgsqlDataSourceBuilder!
2527
Npgsql.NpgsqlDataSourceBuilder.UseSslClientAuthenticationOptionsCallback(System.Action<System.Net.Security.SslClientAuthenticationOptions!>? sslClientAuthenticationOptionsCallback) -> Npgsql.NpgsqlDataSourceBuilder!
2628
Npgsql.NpgsqlMetricsOptions
2729
Npgsql.NpgsqlMetricsOptions.NpgsqlMetricsOptions() -> void
@@ -36,6 +38,8 @@ Npgsql.NpgsqlSlimDataSourceBuilder.MapComposite<T>(string? pgName = null, Npgsql
3638
Npgsql.NpgsqlSlimDataSourceBuilder.MapEnum(System.Type! clrType, string? pgName = null, Npgsql.INpgsqlNameTranslator? nameTranslator = null) -> Npgsql.NpgsqlSlimDataSourceBuilder!
3739
Npgsql.NpgsqlSlimDataSourceBuilder.MapEnum<TEnum>(string? pgName = null, Npgsql.INpgsqlNameTranslator? nameTranslator = null) -> Npgsql.NpgsqlSlimDataSourceBuilder!
3840
Npgsql.NpgsqlSlimDataSourceBuilder.UseNegotiateOptionsCallback(System.Action<System.Net.Security.NegotiateAuthenticationClientOptions!>? negotiateOptionsCallback) -> Npgsql.NpgsqlSlimDataSourceBuilder!
41+
Npgsql.NpgsqlSlimDataSourceBuilder.UseRootCertificates(System.Security.Cryptography.X509Certificates.X509Certificate2Collection? rootCertificates) -> Npgsql.NpgsqlSlimDataSourceBuilder!
42+
Npgsql.NpgsqlSlimDataSourceBuilder.UseRootCertificatesCallback(System.Func<System.Security.Cryptography.X509Certificates.X509Certificate2Collection!>? rootCertificateCallback) -> Npgsql.NpgsqlSlimDataSourceBuilder!
3943
Npgsql.NpgsqlSlimDataSourceBuilder.UseSslClientAuthenticationOptionsCallback(System.Action<System.Net.Security.SslClientAuthenticationOptions!>? sslClientAuthenticationOptionsCallback) -> Npgsql.NpgsqlSlimDataSourceBuilder!
4044
*REMOVED*Npgsql.NpgsqlTracingOptions
4145
*REMOVED*Npgsql.NpgsqlTracingOptions.NpgsqlTracingOptions() -> void

test/Npgsql.Tests/SecurityTests.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
using System.IO;
33
using System.Runtime.InteropServices;
44
using System.Security.Authentication;
5+
using System.Security.Cryptography;
6+
using System.Security.Cryptography.X509Certificates;
57
using System.Threading;
68
using System.Threading.Tasks;
79
using Npgsql.Properties;
@@ -563,6 +565,45 @@ public async Task Connect_with_verify_check_host([Values(SslMode.VerifyCA, SslMo
563565
}
564566
}
565567

568+
[Test]
569+
[Platform(Exclude = "MacOsX", Reason = "Mac requires explicit opt-in to receive CA certificate in TLS handshake")]
570+
public async Task Connect_with_verify_and_multiple_ca_cert([Values(SslMode.VerifyCA, SslMode.VerifyFull)] SslMode sslMode, [Values] bool realCaFirst)
571+
{
572+
if (!IsOnBuildServer)
573+
Assert.Ignore("Only executed in CI");
574+
575+
var certificates = new X509Certificate2Collection();
576+
577+
#if NET9_0_OR_GREATER
578+
using var realCaCert = X509CertificateLoader.LoadCertificateFromFile("ca.crt");
579+
#else
580+
using var realCaCert = new X509Certificate2("ca.crt");
581+
#endif
582+
583+
using var ecdsa = ECDsa.Create();
584+
var req = new CertificateRequest("cn=localhost", ecdsa, HashAlgorithmName.SHA256);
585+
using var unrelatedCaCert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
586+
587+
if (realCaFirst)
588+
{
589+
certificates.Add(realCaCert);
590+
certificates.Add(unrelatedCaCert);
591+
}
592+
else
593+
{
594+
certificates.Add(unrelatedCaCert);
595+
certificates.Add(realCaCert);
596+
}
597+
598+
var dataSourceBuilder = CreateDataSourceBuilder();
599+
dataSourceBuilder.ConnectionStringBuilder.SslMode = sslMode;
600+
dataSourceBuilder.UseRootCertificates(certificates);
601+
602+
await using var dataSource = dataSourceBuilder.Build();
603+
604+
await using var _ = await dataSource.OpenConnectionAsync();
605+
}
606+
566607
[Test]
567608
[NonParallelizable] // Sets environment variable
568609
public async Task Direct_ssl_via_env_requires_correct_sslmode()

0 commit comments

Comments
 (0)