From 6abcff182fbe201f98cbabc0f02f69d1ca8021b2 Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sun, 17 Oct 2021 18:38:17 -0700 Subject: [PATCH 1/2] Add ProvideClientCertificatesCallback. Signed-off-by: Bradley Grainger --- .../ProvideClientCertificatesCallback.md | 23 ++++++++++++++ .../api/MySqlConnector/MySqlConnectionType.md | 1 + docs/content/connection-options.md | 5 ++- src/MySqlConnector/Core/ServerSession.cs | 31 ++++++++++++++----- src/MySqlConnector/MySqlConnection.cs | 13 ++++++++ tests/SideBySide/SslTests.cs | 29 +++++++++++++++++ 6 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 docs/content/api/MySqlConnector/MySqlConnection/ProvideClientCertificatesCallback.md diff --git a/docs/content/api/MySqlConnector/MySqlConnection/ProvideClientCertificatesCallback.md b/docs/content/api/MySqlConnector/MySqlConnection/ProvideClientCertificatesCallback.md new file mode 100644 index 000000000..ead598e30 --- /dev/null +++ b/docs/content/api/MySqlConnector/MySqlConnection/ProvideClientCertificatesCallback.md @@ -0,0 +1,23 @@ +--- +title: MySqlConnection.ProvideClientCertificatesCallback property +--- + +# MySqlConnection.ProvideClientCertificatesCallback property + +Gets or sets the delegate used to provide client certificates for connecting to a server. + +```csharp +public Func ProvideClientCertificatesCallback { get; set; } +``` + +## Remarks + +The provided X509CertificateCollection should be filled with the client certificate(s) needed to connect to the server. + +## See Also + +* class [MySqlConnection](../../MySqlConnectionType/) +* namespace [MySqlConnector](../../MySqlConnectionType/) +* assembly [MySqlConnector](../../../MySqlConnectorAssembly/) + + diff --git a/docs/content/api/MySqlConnector/MySqlConnectionType.md b/docs/content/api/MySqlConnector/MySqlConnectionType.md index 87247bf05..f72943c74 100644 --- a/docs/content/api/MySqlConnector/MySqlConnectionType.md +++ b/docs/content/api/MySqlConnector/MySqlConnectionType.md @@ -21,6 +21,7 @@ public sealed class MySqlConnection : DbConnection, ICloneable | override [ConnectionTimeout](../MySqlConnection/ConnectionTimeout/) { get; } | Gets the time (in seconds) to wait while trying to establish a connection before terminating the attempt and generating an error. This value is controlled by [`ConnectionTimeout`](../MySqlConnectionStringBuilder/ConnectionTimeout/), which defaults to 15 seconds. | | override [Database](../MySqlConnection/Database/) { get; } | | | override [DataSource](../MySqlConnection/DataSource/) { get; } | | +| [ProvideClientCertificatesCallback](../MySqlConnection/ProvideClientCertificatesCallback/) { get; set; } | Gets or sets the delegate used to provide client certificates for connecting to a server. | | [ProvidePasswordCallback](../MySqlConnection/ProvidePasswordCallback/) { get; set; } | Gets or sets the delegate used to generate a password for new database connections. | | [ServerThread](../MySqlConnection/ServerThread/) { get; } | The connection ID from MySQL Server. | | override [ServerVersion](../MySqlConnection/ServerVersion/) { get; } | | diff --git a/docs/content/connection-options.md b/docs/content/connection-options.md index 4a57267bc..ea8aaf3d4 100644 --- a/docs/content/connection-options.md +++ b/docs/content/connection-options.md @@ -107,7 +107,10 @@ These are the options that need to be used in order to configure a connection to Certificate File, CertificateFile - 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 openssl pkcs12 -in cert.pem -inkey key.pem -export -out bundle.pfx. This option should not be specified if SslCert and SslKey are used. + +

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 openssl pkcs12 -in cert.pem -inkey key.pem -export -out bundle.pfx. This option should not be specified if SslCert and SslKey are used.

+

If the certificate can't be loaded from a file path, leave this value empty and set MySqlConnection.ProvideClientCertificatesCallback before calling MySqlConnection.Open. The property should be set to an async delegate that will populate a X509CertificateCollection with the client certificate(s) needed to connect.

+ Certificate Password, CertificatePassword diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index f8bcec11e..971fcb02f 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -218,9 +218,9 @@ public async Task PrepareAsync(IMySqlCommand command, IOBehavior ioBehavior, Can { payload = await ReceiveReplyAsync(ioBehavior, cancellationToken).ConfigureAwait(false); } - catch (MySqlException exception) + catch (MySqlException ex) { - ThrowIfStatementContainsDelimiter(exception, command); + ThrowIfStatementContainsDelimiter(ex, command); throw; } @@ -482,7 +482,7 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella try { - await InitSslAsync(initialHandshake.ProtocolCapabilities, cs, sslProtocols, ioBehavior, cancellationToken).ConfigureAwait(false); + await InitSslAsync(initialHandshake.ProtocolCapabilities, cs, connection, sslProtocols, ioBehavior, cancellationToken).ConfigureAwait(false); shouldRetrySsl = false; } catch (ArgumentException ex) when (ex.ParamName == "sslProtocolType" && sslProtocols == SslProtocols.None) @@ -1185,7 +1185,7 @@ private async Task OpenNamedPipeAsync(ConnectionSettings cs, int startTick return false; } - private async Task InitSslAsync(ProtocolCapabilities serverCapabilities, ConnectionSettings cs, SslProtocols sslProtocols, IOBehavior ioBehavior, CancellationToken cancellationToken) + private async Task InitSslAsync(ProtocolCapabilities serverCapabilities, ConnectionSettings cs, MySqlConnection connection, SslProtocols sslProtocols, IOBehavior ioBehavior, CancellationToken cancellationToken) { Log.Trace("Session{0} initializing TLS connection", m_logArguments); X509CertificateCollection? clientCertificates = null; @@ -1264,6 +1264,21 @@ private async Task InitSslAsync(ProtocolCapabilities serverCapabilities, Connect } } + if (clientCertificates is null && connection.ProvideClientCertificatesCallback is { } clientCertificatesProvider) + { + clientCertificates = new(); + try + { + await clientCertificatesProvider(clientCertificates).ConfigureAwait(false); + } + catch (Exception ex) + { + m_logArguments[1] = ex.Message; + Log.Error(ex, "Session{0} failed to obtain client certificates via ProvideClientCertificatesCallback: {1}", m_logArguments); + throw new MySqlException("Failed to obtain client certificates via ProvideClientCertificatesCallback", ex); + } + } + X509Chain? caCertificateChain = null; if (cs.CACertificateFile.Length != 0) { @@ -1767,11 +1782,11 @@ private string GetPassword(ConnectionSettings cs, MySqlConnection connection) Log.Trace("Session{0} obtaining password via ProvidePasswordCallback", m_logArguments); return passwordProvider(new(HostName, cs.Port, cs.UserID, cs.Database)); } - catch (Exception e) + catch (Exception ex) { - m_logArguments[1] = e.Message; - Log.Error("Session{0} failed to obtain password via ProvidePasswordCallback: {1}", m_logArguments); - throw new MySqlException(MySqlErrorCode.ProvidePasswordCallbackFailed, "Failed to obtain password via ProvidePasswordCallback", e); + m_logArguments[1] = ex.Message; + Log.Error(ex, "Session{0} failed to obtain password via ProvidePasswordCallback: {1}", m_logArguments); + throw new MySqlException(MySqlErrorCode.ProvidePasswordCallbackFailed, "Failed to obtain password via ProvidePasswordCallback", ex); } } diff --git a/src/MySqlConnector/MySqlConnection.cs b/src/MySqlConnector/MySqlConnection.cs index f3346d00f..288b8f2ba 100644 --- a/src/MySqlConnector/MySqlConnection.cs +++ b/src/MySqlConnector/MySqlConnection.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Net.Sockets; using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; using MySqlConnector.Core; using MySqlConnector.Logging; using MySqlConnector.Protocol.Payloads; @@ -496,6 +497,16 @@ public override string ConnectionString /// public int ServerThread => Session.ConnectionId; + /// + /// Gets or sets the delegate used to provide client certificates for connecting to a server. + /// + /// The provided should be filled with the client certificate(s) needed to connect to the server. +#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER + public Func? ProvideClientCertificatesCallback { get; set; } +#else + public Func? ProvideClientCertificatesCallback { get; set; } +#endif + /// /// Gets or sets the delegate used to generate a password for new database connections. /// @@ -674,6 +685,7 @@ public async Task DisposeAsync() public MySqlConnection Clone() => new(m_connectionString, m_hasBeenOpened) { + ProvideClientCertificatesCallback = ProvideClientCertificatesCallback, ProvidePasswordCallback = ProvidePasswordCallback, }; @@ -697,6 +709,7 @@ public MySqlConnection CloneWith(string connectionString) newBuilder.Password = currentBuilder.Password; return new MySqlConnection(newBuilder.ConnectionString, m_hasBeenOpened && shouldCopyPassword && !currentBuilder.PersistSecurityInfo) { + ProvideClientCertificatesCallback = ProvideClientCertificatesCallback, ProvidePasswordCallback = ProvidePasswordCallback, }; } diff --git a/tests/SideBySide/SslTests.cs b/tests/SideBySide/SslTests.cs index 7e3d710bf..46b74b7e1 100644 --- a/tests/SideBySide/SslTests.cs +++ b/tests/SideBySide/SslTests.cs @@ -52,7 +52,36 @@ public async Task ConnectSslClientCertificate(string certFile, string certFilePa await DoTestSsl(csb.ConnectionString); } +#if !BASELINE [SkippableTheory(ConfigSettings.RequiresSsl | ConfigSettings.KnownClientCertificate)] + [InlineData("ssl-client.pfx", null)] + [InlineData("ssl-client-pw-test.pfx", "test")] + public async Task ConnectSslClientCertificateCallback(string certificateFile, string certificateFilePassword) + { + var csb = AppConfig.CreateConnectionStringBuilder(); + var certificateFilePath = Path.Combine(AppConfig.CertsPath, certificateFile); + + using var connection = new MySqlConnection(csb.ConnectionString); +#if NETFRAMEWORK + connection.ProvideClientCertificatesCallback = x => + { + x.Add(new X509Certificate2(certificateFilePath, certificateFilePassword)); + return MySqlConnector.Utilities.Utility.CompletedTask; + }; +#else + connection.ProvideClientCertificatesCallback = async x => + { + var certificateBytes = await File.ReadAllBytesAsync(certificateFilePath); + x.Add(new X509Certificate2(certificateBytes, certificateFilePassword)); + }; +#endif + + await connection.OpenAsync(); + Assert.True(connection.SslIsEncrypted); + } +#endif + + [SkippableTheory(ConfigSettings.RequiresSsl | ConfigSettings.KnownClientCertificate)] [InlineData("ssl-client-cert.pem", "ssl-client-key.pem", null)] [InlineData("ssl-client-cert.pem", "ssl-client-key-null.pem", null)] #if !BASELINE From 6d0c83fdde784078210282d0f59390e09eb0ccb0 Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 23 Oct 2021 10:13:59 -0700 Subject: [PATCH 2/2] Add RemoteCertificateValidationCallback. Signed-off-by: Bradley Grainger --- .../RemoteCertificateValidationCallback.md | 23 ++++++++++++++++++ .../api/MySqlConnector/MySqlConnectionType.md | 1 + docs/content/connection-options.md | 5 +++- src/MySqlConnector/Core/ServerSession.cs | 24 +++++++++++++++++-- src/MySqlConnector/MySqlConnection.cs | 9 +++++++ tests/SideBySide/SslTests.cs | 21 ++++++++++++++++ 6 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 docs/content/api/MySqlConnector/MySqlConnection/RemoteCertificateValidationCallback.md diff --git a/docs/content/api/MySqlConnector/MySqlConnection/RemoteCertificateValidationCallback.md b/docs/content/api/MySqlConnector/MySqlConnection/RemoteCertificateValidationCallback.md new file mode 100644 index 000000000..880ba2f04 --- /dev/null +++ b/docs/content/api/MySqlConnector/MySqlConnection/RemoteCertificateValidationCallback.md @@ -0,0 +1,23 @@ +--- +title: MySqlConnection.RemoteCertificateValidationCallback property +--- + +# MySqlConnection.RemoteCertificateValidationCallback property + +Gets or sets the delegate used to verify that the server's certificate is valid. + +```csharp +public RemoteCertificateValidationCallback RemoteCertificateValidationCallback { get; set; } +``` + +## Remarks + +[`SslMode`](../../MySqlConnectionStringBuilder/SslMode/) must be set to Preferred or Required in order for this delegate to be invoked. See the documentation for `RemoteCertificateValidationCallback` for more information on the values passed to this delegate. + +## See Also + +* class [MySqlConnection](../../MySqlConnectionType/) +* namespace [MySqlConnector](../../MySqlConnectionType/) +* assembly [MySqlConnector](../../../MySqlConnectorAssembly/) + + diff --git a/docs/content/api/MySqlConnector/MySqlConnectionType.md b/docs/content/api/MySqlConnector/MySqlConnectionType.md index f72943c74..8b724102c 100644 --- a/docs/content/api/MySqlConnector/MySqlConnectionType.md +++ b/docs/content/api/MySqlConnector/MySqlConnectionType.md @@ -23,6 +23,7 @@ public sealed class MySqlConnection : DbConnection, ICloneable | override [DataSource](../MySqlConnection/DataSource/) { get; } | | | [ProvideClientCertificatesCallback](../MySqlConnection/ProvideClientCertificatesCallback/) { get; set; } | Gets or sets the delegate used to provide client certificates for connecting to a server. | | [ProvidePasswordCallback](../MySqlConnection/ProvidePasswordCallback/) { get; set; } | Gets or sets the delegate used to generate a password for new database connections. | +| [RemoteCertificateValidationCallback](../MySqlConnection/RemoteCertificateValidationCallback/) { get; set; } | Gets or sets the delegate used to verify that the server's certificate is valid. | | [ServerThread](../MySqlConnection/ServerThread/) { get; } | The connection ID from MySQL Server. | | override [ServerVersion](../MySqlConnection/ServerVersion/) { get; } | | | override [State](../MySqlConnection/State/) { get; } | | diff --git a/docs/content/connection-options.md b/docs/content/connection-options.md index ea8aaf3d4..1f9cfe813 100644 --- a/docs/content/connection-options.md +++ b/docs/content/connection-options.md @@ -130,7 +130,10 @@ These are the options that need to be used in order to configure a connection to SSL CA, CA Certificate File, CACertificateFile, SslCa, Ssl-Ca - The path to a CA certificate file in a PEM Encoded (.pem) format. This should be used with SslMode=VerifyCA or SslMode=VerifyFull to enable verification of a CA certificate that is not trusted by the operating system’s certificate store. + +

The path to a CA certificate file in a PEM Encoded (.pem) format. This should be used with SslMode=VerifyCA or SslMode=VerifyFull to enable verification of a CA certificate that is not trusted by the operating system’s certificate store.

+

To provide a custom callback to validate the remote certificate, leave this option empty and set SslMode to Required (or Preferred), then set MySqlConnection.RemoteCertificateValidationCallback before calling MySqlConnection.Open. The property should be set to a delegate that will validate the remote certificate, as per the documentation.

+ Certificate Store Location, CertificateStoreLocation diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index 971fcb02f..1b5571d68 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -1369,8 +1369,28 @@ caCertificateChain is not null && return rcbPolicyErrors == SslPolicyErrors.None; } - var sslStream = clientCertificates is null ? new SslStream(m_stream!, false, ValidateRemoteCertificate) : - new SslStream(m_stream!, false, ValidateRemoteCertificate, ValidateLocalCertificate); + // use the client's callback (if any) for Preferred or Required mode + RemoteCertificateValidationCallback validateRemoteCertificate = ValidateRemoteCertificate; + if (connection.RemoteCertificateValidationCallback is not null) + { + if (caCertificateChain is not null) + { + Log.Warn("Session{0} not using client-provided RemoteCertificateValidationCallback because SslCA is specified", m_logArguments); + } + else if (cs.SslMode is not MySqlSslMode.Preferred and not MySqlSslMode.Required) + { + m_logArguments[1] = cs.SslMode; + Log.Warn("Session{0} not using client-provided RemoteCertificateValidationCallback because SslMode is {1}", m_logArguments); + } + else + { + Log.Debug("Session{0} using client-provided RemoteCertificateValidationCallback", m_logArguments); + validateRemoteCertificate = connection.RemoteCertificateValidationCallback; + } + } + + var sslStream = clientCertificates is null ? new SslStream(m_stream!, false, validateRemoteCertificate) : + new SslStream(m_stream!, false, validateRemoteCertificate, ValidateLocalCertificate); var checkCertificateRevocation = cs.SslMode == MySqlSslMode.VerifyFull; diff --git a/src/MySqlConnector/MySqlConnection.cs b/src/MySqlConnector/MySqlConnection.cs index 288b8f2ba..e6d8d618f 100644 --- a/src/MySqlConnector/MySqlConnection.cs +++ b/src/MySqlConnector/MySqlConnection.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; @@ -522,6 +523,14 @@ public override string ConnectionString /// public Func? ProvidePasswordCallback { get; set;} + /// + /// Gets or sets the delegate used to verify that the server's certificate is valid. + /// + /// must be set to + /// or in order for this delegate to be invoked. See the documentation for + /// for more information on the values passed to this delegate. + public RemoteCertificateValidationCallback? RemoteCertificateValidationCallback { get; set; } + /// /// Clears the connection pool that belongs to. /// diff --git a/tests/SideBySide/SslTests.cs b/tests/SideBySide/SslTests.cs index 46b74b7e1..7f9838c9b 100644 --- a/tests/SideBySide/SslTests.cs +++ b/tests/SideBySide/SslTests.cs @@ -192,6 +192,27 @@ public async Task ConnectSslBadCaCertificate() await Assert.ThrowsAsync(async () => await connection.OpenAsync()); } +#if !BASELINE + [SkippableTheory(ServerFeatures.KnownCertificateAuthority, ConfigSettings.RequiresSsl)] + [InlineData(MySqlSslMode.VerifyCA, false, false)] + [InlineData(MySqlSslMode.VerifyCA, true, false)] + [InlineData(MySqlSslMode.Required, true, true)] + public async Task ConnectSslRemoteCertificateValidationCallback(MySqlSslMode sslMode, bool clearCA, bool expectedSuccess) + { + var csb = AppConfig.CreateConnectionStringBuilder(); + csb.CertificateFile = Path.Combine(AppConfig.CertsPath, "ssl-client.pfx"); + csb.SslMode = sslMode; + csb.SslCa = clearCA ? "" : Path.Combine(AppConfig.CertsPath, "non-ca-client-cert.pem"); + using var connection = new MySqlConnection(csb.ConnectionString); + connection.RemoteCertificateValidationCallback = (s, c, h, e) => true; + + if (expectedSuccess) + await connection.OpenAsync(); + else + await Assert.ThrowsAsync(async () => await connection.OpenAsync()); + } +#endif + [SkippableFact(ConfigSettings.RequiresSsl)] public async Task ConnectSslTlsVersion() {