Skip to content

Commit 8c0506b

Browse files
authored
Merge pull request #1061 from bgrainger/certificate-callbacks
Provide mechanism to supply certificates dynamically.
2 parents 54988d4 + 6d0c83f commit 8c0506b

File tree

7 files changed

+173
-12
lines changed

7 files changed

+173
-12
lines changed

docs/content/api/MySqlConnector/MySqlConnection/ProvideClientCertificatesCallback.md

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/content/api/MySqlConnector/MySqlConnection/RemoteCertificateValidationCallback.md

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/content/api/MySqlConnector/MySqlConnectionType.md

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/content/connection-options.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ These are the options that need to be used in order to configure a connection to
107107
<tr id="CertificateFile">
108108
<td>Certificate File, CertificateFile</td>
109109
<td></td>
110-
<td>The path to a certificate file in PKCS #12 (.pfx) format containing a bundled Certificate and Private Key used for mutual authentication. To create a PKCS #12 bundle from a PEM encoded Certificate and Key, use <code>openssl pkcs12 -in cert.pem -inkey key.pem -export -out bundle.pfx</code>. This option should not be specified if <code>SslCert</code> and <code>SslKey</code> are used.</td>
110+
<td>
111+
<p>The path to a certificate file in PKCS #12 (.pfx) format containing a bundled Certificate and Private Key used for mutual authentication. To create a PKCS #12 bundle from a PEM encoded Certificate and Key, use <code>openssl pkcs12 -in cert.pem -inkey key.pem -export -out bundle.pfx</code>. This option should not be specified if <code>SslCert</code> and <code>SslKey</code> are used.</p>
112+
<p>If the certificate can't be loaded from a file path, leave this value empty and set <a href="/api/mysqlconnector/mysqlconnection/provideclientcertificatescallback/"><code>MySqlConnection.ProvideClientCertificatesCallback</code></a> before calling <a href="/api/mysqlconnector/mysqlconnection/open/"><code>MySqlConnection.Open</code></a>. The property should be set to an async delegate that will populate a <code>X509CertificateCollection</code> with the client certificate(s) needed to connect.</p>
113+
</td>
111114
</tr>
112115
<tr id="CertificatePassword">
113116
<td>Certificate Password, CertificatePassword</td>
@@ -127,7 +130,10 @@ These are the options that need to be used in order to configure a connection to
127130
<tr id="SslCa">
128131
<td>SSL CA, CA Certificate File, CACertificateFile, SslCa, Ssl-Ca</td>
129132
<td></td>
130-
<td>The path to a CA certificate file in a PEM Encoded (.pem) format. This should be used with <code>SslMode=VerifyCA</code> or <code>SslMode=VerifyFull</code> to enable verification of a CA certificate that is not trusted by the operating system’s certificate store.</td>
133+
<td>
134+
<p>The path to a CA certificate file in a PEM Encoded (.pem) format. This should be used with <code>SslMode=VerifyCA</code> or <code>SslMode=VerifyFull</code> to enable verification of a CA certificate that is not trusted by the operating system’s certificate store.</p>
135+
<p>To provide a custom callback to validate the remote certificate, leave this option empty and set <code>SslMode</code> to <code>Required</code> (or <code>Preferred</code>), then set <a href="/api/mysqlconnector/mysqlconnection/remotecertificatevalidationcallback/"><code>MySqlConnection.RemoteCertificateValidationCallback</code></a> before calling <a href="/api/mysqlconnector/mysqlconnection/open/"><code>MySqlConnection.Open</code></a>. The property should be set to a delegate that will validate the remote certificate, as per <a href="https://docs.microsoft.com/en-us/dotnet/api/system.net.security.remotecertificatevalidationcallback" title="RemoteCertificateValidationCallback Delegate (MSDN)">the documentation</a>.</p>
136+
</td>
131137
</tr>
132138
<tr id="CertificateStoreLocation">
133139
<td>Certificate Store Location, CertificateStoreLocation</td>

src/MySqlConnector/Core/ServerSession.cs

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,9 @@ public async Task PrepareAsync(IMySqlCommand command, IOBehavior ioBehavior, Can
218218
{
219219
payload = await ReceiveReplyAsync(ioBehavior, cancellationToken).ConfigureAwait(false);
220220
}
221-
catch (MySqlException exception)
221+
catch (MySqlException ex)
222222
{
223-
ThrowIfStatementContainsDelimiter(exception, command);
223+
ThrowIfStatementContainsDelimiter(ex, command);
224224
throw;
225225
}
226226

@@ -482,7 +482,7 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella
482482

483483
try
484484
{
485-
await InitSslAsync(initialHandshake.ProtocolCapabilities, cs, sslProtocols, ioBehavior, cancellationToken).ConfigureAwait(false);
485+
await InitSslAsync(initialHandshake.ProtocolCapabilities, cs, connection, sslProtocols, ioBehavior, cancellationToken).ConfigureAwait(false);
486486
shouldRetrySsl = false;
487487
}
488488
catch (ArgumentException ex) when (ex.ParamName == "sslProtocolType" && sslProtocols == SslProtocols.None)
@@ -1185,7 +1185,7 @@ private async Task<bool> OpenNamedPipeAsync(ConnectionSettings cs, int startTick
11851185
return false;
11861186
}
11871187

1188-
private async Task InitSslAsync(ProtocolCapabilities serverCapabilities, ConnectionSettings cs, SslProtocols sslProtocols, IOBehavior ioBehavior, CancellationToken cancellationToken)
1188+
private async Task InitSslAsync(ProtocolCapabilities serverCapabilities, ConnectionSettings cs, MySqlConnection connection, SslProtocols sslProtocols, IOBehavior ioBehavior, CancellationToken cancellationToken)
11891189
{
11901190
Log.Trace("Session{0} initializing TLS connection", m_logArguments);
11911191
X509CertificateCollection? clientCertificates = null;
@@ -1264,6 +1264,21 @@ private async Task InitSslAsync(ProtocolCapabilities serverCapabilities, Connect
12641264
}
12651265
}
12661266

1267+
if (clientCertificates is null && connection.ProvideClientCertificatesCallback is { } clientCertificatesProvider)
1268+
{
1269+
clientCertificates = new();
1270+
try
1271+
{
1272+
await clientCertificatesProvider(clientCertificates).ConfigureAwait(false);
1273+
}
1274+
catch (Exception ex)
1275+
{
1276+
m_logArguments[1] = ex.Message;
1277+
Log.Error(ex, "Session{0} failed to obtain client certificates via ProvideClientCertificatesCallback: {1}", m_logArguments);
1278+
throw new MySqlException("Failed to obtain client certificates via ProvideClientCertificatesCallback", ex);
1279+
}
1280+
}
1281+
12671282
X509Chain? caCertificateChain = null;
12681283
if (cs.CACertificateFile.Length != 0)
12691284
{
@@ -1354,8 +1369,28 @@ caCertificateChain is not null &&
13541369
return rcbPolicyErrors == SslPolicyErrors.None;
13551370
}
13561371

1357-
var sslStream = clientCertificates is null ? new SslStream(m_stream!, false, ValidateRemoteCertificate) :
1358-
new SslStream(m_stream!, false, ValidateRemoteCertificate, ValidateLocalCertificate);
1372+
// use the client's callback (if any) for Preferred or Required mode
1373+
RemoteCertificateValidationCallback validateRemoteCertificate = ValidateRemoteCertificate;
1374+
if (connection.RemoteCertificateValidationCallback is not null)
1375+
{
1376+
if (caCertificateChain is not null)
1377+
{
1378+
Log.Warn("Session{0} not using client-provided RemoteCertificateValidationCallback because SslCA is specified", m_logArguments);
1379+
}
1380+
else if (cs.SslMode is not MySqlSslMode.Preferred and not MySqlSslMode.Required)
1381+
{
1382+
m_logArguments[1] = cs.SslMode;
1383+
Log.Warn("Session{0} not using client-provided RemoteCertificateValidationCallback because SslMode is {1}", m_logArguments);
1384+
}
1385+
else
1386+
{
1387+
Log.Debug("Session{0} using client-provided RemoteCertificateValidationCallback", m_logArguments);
1388+
validateRemoteCertificate = connection.RemoteCertificateValidationCallback;
1389+
}
1390+
}
1391+
1392+
var sslStream = clientCertificates is null ? new SslStream(m_stream!, false, validateRemoteCertificate) :
1393+
new SslStream(m_stream!, false, validateRemoteCertificate, ValidateLocalCertificate);
13591394

13601395
var checkCertificateRevocation = cs.SslMode == MySqlSslMode.VerifyFull;
13611396

@@ -1767,11 +1802,11 @@ private string GetPassword(ConnectionSettings cs, MySqlConnection connection)
17671802
Log.Trace("Session{0} obtaining password via ProvidePasswordCallback", m_logArguments);
17681803
return passwordProvider(new(HostName, cs.Port, cs.UserID, cs.Database));
17691804
}
1770-
catch (Exception e)
1805+
catch (Exception ex)
17711806
{
1772-
m_logArguments[1] = e.Message;
1773-
Log.Error("Session{0} failed to obtain password via ProvidePasswordCallback: {1}", m_logArguments);
1774-
throw new MySqlException(MySqlErrorCode.ProvidePasswordCallbackFailed, "Failed to obtain password via ProvidePasswordCallback", e);
1807+
m_logArguments[1] = ex.Message;
1808+
Log.Error(ex, "Session{0} failed to obtain password via ProvidePasswordCallback: {1}", m_logArguments);
1809+
throw new MySqlException(MySqlErrorCode.ProvidePasswordCallbackFailed, "Failed to obtain password via ProvidePasswordCallback", ex);
17751810
}
17761811
}
17771812

src/MySqlConnector/MySqlConnection.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using System.Collections.Generic;
22
using System.Diagnostics;
33
using System.Diagnostics.CodeAnalysis;
4+
using System.Net.Security;
45
using System.Net.Sockets;
56
using System.Security.Authentication;
7+
using System.Security.Cryptography.X509Certificates;
68
using MySqlConnector.Core;
79
using MySqlConnector.Logging;
810
using MySqlConnector.Protocol.Payloads;
@@ -496,6 +498,16 @@ public override string ConnectionString
496498
/// </summary>
497499
public int ServerThread => Session.ConnectionId;
498500

501+
/// <summary>
502+
/// Gets or sets the delegate used to provide client certificates for connecting to a server.
503+
/// </summary>
504+
/// <remarks>The provided <see cref="X509CertificateCollection"/> should be filled with the client certificate(s) needed to connect to the server.</remarks>
505+
#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER
506+
public Func<X509CertificateCollection, ValueTask>? ProvideClientCertificatesCallback { get; set; }
507+
#else
508+
public Func<X509CertificateCollection, Task>? ProvideClientCertificatesCallback { get; set; }
509+
#endif
510+
499511
/// <summary>
500512
/// Gets or sets the delegate used to generate a password for new database connections.
501513
/// </summary>
@@ -511,6 +523,14 @@ public override string ConnectionString
511523
/// </remarks>
512524
public Func<MySqlProvidePasswordContext, string>? ProvidePasswordCallback { get; set;}
513525

526+
/// <summary>
527+
/// Gets or sets the delegate used to verify that the server's certificate is valid.
528+
/// </summary>
529+
/// <remarks><see cref="MySqlConnectionStringBuilder.SslMode"/> must be set to <see cref="MySqlSslMode.Preferred"/>
530+
/// or <see cref="MySqlSslMode.Required"/> in order for this delegate to be invoked. See the documentation for
531+
/// <see cref="RemoteCertificateValidationCallback"/> for more information on the values passed to this delegate.</remarks>
532+
public RemoteCertificateValidationCallback? RemoteCertificateValidationCallback { get; set; }
533+
514534
/// <summary>
515535
/// Clears the connection pool that <paramref name="connection"/> belongs to.
516536
/// </summary>
@@ -674,6 +694,7 @@ public async Task DisposeAsync()
674694

675695
public MySqlConnection Clone() => new(m_connectionString, m_hasBeenOpened)
676696
{
697+
ProvideClientCertificatesCallback = ProvideClientCertificatesCallback,
677698
ProvidePasswordCallback = ProvidePasswordCallback,
678699
};
679700

@@ -697,6 +718,7 @@ public MySqlConnection CloneWith(string connectionString)
697718
newBuilder.Password = currentBuilder.Password;
698719
return new MySqlConnection(newBuilder.ConnectionString, m_hasBeenOpened && shouldCopyPassword && !currentBuilder.PersistSecurityInfo)
699720
{
721+
ProvideClientCertificatesCallback = ProvideClientCertificatesCallback,
700722
ProvidePasswordCallback = ProvidePasswordCallback,
701723
};
702724
}

tests/SideBySide/SslTests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,36 @@ public async Task ConnectSslClientCertificate(string certFile, string certFilePa
5252
await DoTestSsl(csb.ConnectionString);
5353
}
5454

55+
#if !BASELINE
5556
[SkippableTheory(ConfigSettings.RequiresSsl | ConfigSettings.KnownClientCertificate)]
57+
[InlineData("ssl-client.pfx", null)]
58+
[InlineData("ssl-client-pw-test.pfx", "test")]
59+
public async Task ConnectSslClientCertificateCallback(string certificateFile, string certificateFilePassword)
60+
{
61+
var csb = AppConfig.CreateConnectionStringBuilder();
62+
var certificateFilePath = Path.Combine(AppConfig.CertsPath, certificateFile);
63+
64+
using var connection = new MySqlConnection(csb.ConnectionString);
65+
#if NETFRAMEWORK
66+
connection.ProvideClientCertificatesCallback = x =>
67+
{
68+
x.Add(new X509Certificate2(certificateFilePath, certificateFilePassword));
69+
return MySqlConnector.Utilities.Utility.CompletedTask;
70+
};
71+
#else
72+
connection.ProvideClientCertificatesCallback = async x =>
73+
{
74+
var certificateBytes = await File.ReadAllBytesAsync(certificateFilePath);
75+
x.Add(new X509Certificate2(certificateBytes, certificateFilePassword));
76+
};
77+
#endif
78+
79+
await connection.OpenAsync();
80+
Assert.True(connection.SslIsEncrypted);
81+
}
82+
#endif
83+
84+
[SkippableTheory(ConfigSettings.RequiresSsl | ConfigSettings.KnownClientCertificate)]
5685
[InlineData("ssl-client-cert.pem", "ssl-client-key.pem", null)]
5786
[InlineData("ssl-client-cert.pem", "ssl-client-key-null.pem", null)]
5887
#if !BASELINE
@@ -163,6 +192,27 @@ public async Task ConnectSslBadCaCertificate()
163192
await Assert.ThrowsAsync<MySqlException>(async () => await connection.OpenAsync());
164193
}
165194

195+
#if !BASELINE
196+
[SkippableTheory(ServerFeatures.KnownCertificateAuthority, ConfigSettings.RequiresSsl)]
197+
[InlineData(MySqlSslMode.VerifyCA, false, false)]
198+
[InlineData(MySqlSslMode.VerifyCA, true, false)]
199+
[InlineData(MySqlSslMode.Required, true, true)]
200+
public async Task ConnectSslRemoteCertificateValidationCallback(MySqlSslMode sslMode, bool clearCA, bool expectedSuccess)
201+
{
202+
var csb = AppConfig.CreateConnectionStringBuilder();
203+
csb.CertificateFile = Path.Combine(AppConfig.CertsPath, "ssl-client.pfx");
204+
csb.SslMode = sslMode;
205+
csb.SslCa = clearCA ? "" : Path.Combine(AppConfig.CertsPath, "non-ca-client-cert.pem");
206+
using var connection = new MySqlConnection(csb.ConnectionString);
207+
connection.RemoteCertificateValidationCallback = (s, c, h, e) => true;
208+
209+
if (expectedSuccess)
210+
await connection.OpenAsync();
211+
else
212+
await Assert.ThrowsAsync<MySqlException>(async () => await connection.OpenAsync());
213+
}
214+
#endif
215+
166216
[SkippableFact(ConfigSettings.RequiresSsl)]
167217
public async Task ConnectSslTlsVersion()
168218
{

0 commit comments

Comments
 (0)