diff --git a/.ci/config/config.compression+ssl.json b/.ci/config/config.compression+ssl.json index 71cd817a0..f7d4263c8 100644 --- a/.ci/config/config.compression+ssl.json +++ b/.ci/config/config.compression+ssl.json @@ -4,7 +4,7 @@ "SocketPath": "./../../../../.ci/run/mysql/mysqld.sock", "PasswordlessUser": "no_password", "SecondaryDatabase": "testdb2", - "UnsupportedFeatures": "CachingSha2Password,Redirection,RsaEncryption,Tls12,Tls13,UuidToBin", + "UnsupportedFeatures": "CachingSha2Password,Redirection,RsaEncryption,Tls12,Tls13,TlsFingerprintValidation,UuidToBin", "MySqlBulkLoaderLocalCsvFile": "../../../TestData/LoadData_UTF8_BOM_Unix.CSV", "MySqlBulkLoaderLocalTsvFile": "../../../TestData/LoadData_UTF8_BOM_Unix.TSV", "CertificatesPath": "../../../../.ci/server/certs" diff --git a/.ci/config/config.compression.json b/.ci/config/config.compression.json index bf1073a12..09326f1f6 100644 --- a/.ci/config/config.compression.json +++ b/.ci/config/config.compression.json @@ -4,7 +4,7 @@ "SocketPath": "./../../../../.ci/run/mysql/mysqld.sock", "PasswordlessUser": "no_password", "SecondaryDatabase": "testdb2", - "UnsupportedFeatures": "Ed25519,QueryAttributes,Redirection,StreamingResults,Tls11,UnixDomainSocket,ZeroDateTime", + "UnsupportedFeatures": "Ed25519,QueryAttributes,Redirection,StreamingResults,Tls11,TlsFingerprintValidation,UnixDomainSocket,ZeroDateTime", "MySqlBulkLoaderLocalCsvFile": "../../../../tests/TestData/LoadData_UTF8_BOM_Unix.CSV", "MySqlBulkLoaderLocalTsvFile": "../../../../tests/TestData/LoadData_UTF8_BOM_Unix.TSV" } diff --git a/.ci/config/config.json b/.ci/config/config.json index 035c05855..bc38f605a 100644 --- a/.ci/config/config.json +++ b/.ci/config/config.json @@ -4,7 +4,7 @@ "SocketPath": "./../../../../.ci/run/mysql/mysqld.sock", "PasswordlessUser": "no_password", "SecondaryDatabase": "testdb2", - "UnsupportedFeatures": "Ed25519,QueryAttributes,Redirection,StreamingResults,Tls11,UnixDomainSocket,ZeroDateTime", + "UnsupportedFeatures": "Ed25519,QueryAttributes,Redirection,StreamingResults,Tls11,TlsFingerprintValidation,UnixDomainSocket,ZeroDateTime", "MySqlBulkLoaderLocalCsvFile": "../../../../tests/TestData/LoadData_UTF8_BOM_Unix.CSV", "MySqlBulkLoaderLocalTsvFile": "../../../../tests/TestData/LoadData_UTF8_BOM_Unix.TSV" } diff --git a/.ci/config/config.ssl.json b/.ci/config/config.ssl.json index 705e0a168..a7511faa4 100644 --- a/.ci/config/config.ssl.json +++ b/.ci/config/config.ssl.json @@ -4,7 +4,7 @@ "SocketPath": "./../../../../.ci/run/mysql/mysqld.sock", "PasswordlessUser": "no_password", "SecondaryDatabase": "testdb2", - "UnsupportedFeatures": "CachingSha2Password,Redirection,RsaEncryption,Tls12,Tls13,UuidToBin", + "UnsupportedFeatures": "CachingSha2Password,Redirection,RsaEncryption,Tls12,Tls13,TlsFingerprintValidation,UuidToBin", "MySqlBulkLoaderLocalCsvFile": "../../../../tests/TestData/LoadData_UTF8_BOM_Unix.CSV", "MySqlBulkLoaderLocalTsvFile": "../../../../tests/TestData/LoadData_UTF8_BOM_Unix.TSV", "CertificatesPath": "../../../../.ci/server/certs" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 47aa77a39..5e57f867e 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -62,7 +62,7 @@ jobs: arguments: 'tests\IntegrationTests\IntegrationTests.csproj -c MySqlData' testRunTitle: 'MySql.Data integration tests' env: - DATA__UNSUPPORTEDFEATURES: 'Ed25519,QueryAttributes,StreamingResults,UnixDomainSocket' + DATA__UNSUPPORTEDFEATURES: 'Ed25519,QueryAttributes,StreamingResults,TlsFingerprintValidation,UnixDomainSocket' DATA__CONNECTIONSTRING: 'server=localhost;port=3306;user id=root;password=test;database=mysqltest;ssl mode=none;DefaultCommandTimeout=3600' DATA__CERTIFICATESPATH: '$(Build.Repository.LocalPath)\.ci\server\certs\' DATA__MYSQLBULKLOADERLOCALCSVFILE: '$(Build.Repository.LocalPath)\tests\TestData\LoadData_UTF8_BOM_Unix.CSV' @@ -136,7 +136,7 @@ jobs: arguments: '-c Release --no-restore' testRunTitle: ${{ format('{0}, $(Agent.OS), {1}, {2}', 'mysql:8.0', 'net472/net8.0', 'No SSL') }} env: - DATA__UNSUPPORTEDFEATURES: 'Ed25519,QueryAttributes,Redirection,StreamingResults,Tls11,UnixDomainSocket' + DATA__UNSUPPORTEDFEATURES: 'Ed25519,QueryAttributes,Redirection,StreamingResults,Tls11,TlsFingerprintValidation,UnixDomainSocket' DATA__CONNECTIONSTRING: 'server=localhost;port=3306;user id=mysqltest;password=test;database=mysqltest;ssl mode=none;DefaultCommandTimeout=3600;AllowPublicKeyRetrieval=True;UseCompression=True' - job: windows_integration_tests_2 @@ -174,7 +174,7 @@ jobs: arguments: '-c Release --no-restore' testRunTitle: ${{ format('{0}, $(Agent.OS), {1}, {2}', 'mysql:8.0', 'net6.0', 'No SSL') }} env: - DATA__UNSUPPORTEDFEATURES: 'Ed25519,QueryAttributes,Redirection,StreamingResults,Tls11,UnixDomainSocket' + DATA__UNSUPPORTEDFEATURES: 'Ed25519,QueryAttributes,Redirection,StreamingResults,Tls11,TlsFingerprintValidation,UnixDomainSocket' DATA__CONNECTIONSTRING: 'server=localhost;port=3306;user id=mysqltest;password=test;database=mysqltest;ssl mode=none;DefaultCommandTimeout=3600;AllowPublicKeyRetrieval=True' - job: linux_integration_tests @@ -187,23 +187,23 @@ jobs: 'MySQL 8.0': image: 'mysql:8.0' connectionStringExtra: 'AllowPublicKeyRetrieval=True' - unsupportedFeatures: 'Ed25519,Redirection,StreamingResults,Tls11,ZeroDateTime' + unsupportedFeatures: 'Ed25519,Redirection,StreamingResults,Tls11,TlsFingerprintValidation,ZeroDateTime' 'MySQL 8.4': image: 'mysql:8.4' connectionStringExtra: 'AllowPublicKeyRetrieval=True' - unsupportedFeatures: 'Ed25519,Redirection,StreamingResults,Tls11,ZeroDateTime' + unsupportedFeatures: 'Ed25519,Redirection,StreamingResults,Tls11,TlsFingerprintValidation,ZeroDateTime' 'MySQL 9.0': image: 'mysql:9.0' connectionStringExtra: 'AllowPublicKeyRetrieval=True' - unsupportedFeatures: 'Ed25519,Redirection,StreamingResults,Tls11,ZeroDateTime' + unsupportedFeatures: 'Ed25519,Redirection,StreamingResults,Tls11,TlsFingerprintValidation,ZeroDateTime' 'MariaDB 10.6': image: 'mariadb:10.6' connectionStringExtra: '' - unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin,Redirection' + unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Redirection,Sha256Password,Tls11,TlsFingerprintValidation,UuidToBin' 'MariaDB 10.11': image: 'mariadb:10.11' connectionStringExtra: '' - unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin,Redirection' + unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Redirection,Sha256Password,Tls11,TlsFingerprintValidation,UuidToBin' 'MariaDB 11.4': image: 'mariadb:11.4' connectionStringExtra: '' diff --git a/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs b/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs index 117953366..f2ea62c3e 100644 --- a/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs +++ b/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs @@ -1,6 +1,7 @@ using System; using System.Security.Cryptography; using System.Text; +using System.Threading; using Chaos.NaCl.Internal.Ed25519Ref10; namespace MySqlConnector.Authentication.Ed25519; @@ -9,7 +10,7 @@ namespace MySqlConnector.Authentication.Ed25519; /// Provides an implementation of the client_ed25519 authentication plugin for MariaDB. /// /// See Authentication Plugin - ed25519. -public sealed class Ed25519AuthenticationPlugin : IAuthenticationPlugin +public sealed class Ed25519AuthenticationPlugin : IAuthenticationPlugin2 { /// /// Registers the Ed25519 authentication plugin with MySqlConnector. You must call this method once before @@ -17,11 +18,8 @@ public sealed class Ed25519AuthenticationPlugin : IAuthenticationPlugin /// public static void Install() { - if (!s_isInstalled) - { + if (Interlocked.CompareExchange(ref s_isInstalled, 1, 0) == 0) AuthenticationPlugins.Register(new Ed25519AuthenticationPlugin()); - s_isInstalled = true; - } } /// @@ -33,6 +31,21 @@ public static void Install() /// Creates the authentication response. /// public byte[] CreateResponse(string password, ReadOnlySpan authenticationData) + { + CreateResponseAndHash(password, authenticationData, out _, out var authenticationResponse); + return authenticationResponse; + } + + /// + /// Creates the Ed25519 password hash. + /// + public byte[] CreatePasswordHash(string password, ReadOnlySpan authenticationData) + { + CreateResponseAndHash(password, authenticationData, out var passwordHash, out _); + return passwordHash; + } + + private static void CreateResponseAndHash(string password, ReadOnlySpan authenticationData, out byte[] passwordHash, out byte[] authenticationResponse) { // Java reference: https://github.com/MariaDB/mariadb-connector-j/blob/master/src/main/java/org/mariadb/jdbc/internal/com/send/authentication/Ed25519PasswordPlugin.java // C reference: https://github.com/MariaDB/server/blob/592fe954ef82be1bc08b29a8e54f7729eb1e1343/plugin/auth_ed25519/ref10/sign.c#L7 @@ -111,6 +124,9 @@ public byte[] CreateResponse(string password, ReadOnlySpan authenticationD GroupOperations.ge_scalarmult_base(out var A, az, 0); GroupOperations.ge_p3_tobytes(sm, 32, ref A); + passwordHash = new byte[32]; + Array.Copy(sm, 32, passwordHash, 0, 32); + /*** Java nonce = scalar.reduce(nonce); GroupElement elementRvalue = spec.getB().scalarMultiply(nonce); @@ -154,12 +170,12 @@ public byte[] CreateResponse(string password, ReadOnlySpan authenticationD var result = new byte[64]; Buffer.BlockCopy(sm, 0, result, 0, result.Length); - return result; + authenticationResponse = result; } private Ed25519AuthenticationPlugin() { } - private static bool s_isInstalled; + private static int s_isInstalled; } diff --git a/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs b/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs index 063bd4c8b..6dfface4e 100644 --- a/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs +++ b/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs @@ -20,3 +20,19 @@ public interface IAuthenticationPlugin /// The authentication response. byte[] CreateResponse(string password, ReadOnlySpan authenticationData); } + +/// +/// is an extension to that returns a hash of the client's password. +/// +public interface IAuthenticationPlugin2 : IAuthenticationPlugin +{ + /// + /// Hashes the client's password (e.g., for TLS certificate fingerprint verification). + /// + /// The client's password. + /// The authentication data supplied by the server; this is the auth method data + /// from the Authentication + /// Method Switch Request Packet. + /// The authentication-method-specific hash of the client's password. + byte[] CreatePasswordHash(string password, ReadOnlySpan authenticationData); +} diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index dcd64d009..69a288345 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -438,13 +438,13 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella var initialHandshake = InitialHandshakePayload.Create(payload.Span); // if PluginAuth is supported, then use the specified auth plugin; else, fall back to protocol capabilities to determine the auth type to use - var authPluginName = (initialHandshake.ProtocolCapabilities & ProtocolCapabilities.PluginAuth) != 0 ? initialHandshake.AuthPluginName! : + m_currentAuthenticationMethod = (initialHandshake.ProtocolCapabilities & ProtocolCapabilities.PluginAuth) != 0 ? initialHandshake.AuthPluginName! : (initialHandshake.ProtocolCapabilities & ProtocolCapabilities.SecureConnection) == 0 ? "mysql_old_password" : "mysql_native_password"; - Log.ServerSentAuthPluginName(m_logger, Id, authPluginName); - if (authPluginName is not "mysql_native_password" and not "sha256_password" and not "caching_sha2_password") + Log.ServerSentAuthPluginName(m_logger, Id, m_currentAuthenticationMethod); + if (m_currentAuthenticationMethod is not "mysql_native_password" and not "sha256_password" and not "caching_sha2_password") { - Log.UnsupportedAuthenticationMethod(m_logger, Id, authPluginName); + Log.UnsupportedAuthenticationMethod(m_logger, Id, m_currentAuthenticationMethod); throw new NotSupportedException($"Authentication method '{initialHandshake.AuthPluginName}' is not supported."); } @@ -528,6 +528,46 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella } var ok = OkPayload.Create(payload.Span, this); + + if (m_sslPolicyErrors != SslPolicyErrors.None) + { + // SSL would normally have thrown error, but this was suppressed in ValidateRemoteCertificate; now we need to verify the server certificate + // pass only if : + // * connection method is MitM-proof (e.g. unix socket) + // * auth plugin is MitM-proof and check SHA2(user's hashed password, scramble, certificate fingerprint) + // see https://mariadb.org/mission-impossible-zero-configuration-ssl/ + var ignoreCertificateError = false; + + if (cs.ConnectionProtocol == MySqlConnectionProtocol.UnixSocket) + { + Log.CertificateErrorUnixSocket(m_logger, Id, m_sslPolicyErrors); + ignoreCertificateError = true; + } + else if (string.IsNullOrEmpty(password)) + { + // there is no shared secret that can be used to validate the certificate + Log.CertificateErrorNoPassword(m_logger, Id, m_sslPolicyErrors); + } + else if (ValidateFingerprint(ok.StatusInfo, initialHandshake.AuthPluginData.AsSpan(0, 20), password)) + { + Log.CertificateErrorValidThumbprint(m_logger, Id, m_sslPolicyErrors); + ignoreCertificateError = true; + } + + if (!ignoreCertificateError) + { + ShutdownSocket(); + HostName = ""; + lock (m_lock) + m_state = State.Failed; + + // throw a MySqlException with an AuthenticationException InnerException to mimic what would have happened if ValidateRemoteCertificate returned false + var innerException = new AuthenticationException($"The remote certificate was rejected due to the following error: {m_sslPolicyErrors}"); + Log.CouldNotInitializeTlsConnection(m_logger, innerException, Id); + throw new MySqlException(MySqlErrorCode.UnableToConnectToHost, "SSL Authentication Error", innerException); + } + } + var redirectionUrl = ok.RedirectionUrl; if (m_useCompression) @@ -567,6 +607,70 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella } } + /// + /// Validate SSL validation hash (from OK packet). + /// + /// The validation hash received from the server. + /// The auth plugin data from the initial handshake. + /// The user's password. + /// true if the validation hash matches the locally-computed value; otherwise, false. + private bool ValidateFingerprint(byte[]? validationHash, ReadOnlySpan challenge, string password) + { + // expect 0x01 followed by 64 hex characters giving a SHA2 hash + if (validationHash?.Length != 65 || validationHash[0] != 1) + return false; + + byte[]? passwordHashResult = null; + switch (m_currentAuthenticationMethod) + { + case "mysql_native_password": + passwordHashResult = AuthenticationUtility.HashPassword([], password, onlyHashPassword: true); + break; + + case "client_ed25519": + AuthenticationPlugins.TryGetPlugin(m_currentAuthenticationMethod, out var ed25519Plugin); + if (ed25519Plugin is IAuthenticationPlugin2 plugin2) + passwordHashResult = plugin2.CreatePasswordHash(password, challenge); + break; + } + if (passwordHashResult is null) + return false; + + Span combined = stackalloc byte[32 + challenge.Length + passwordHashResult.Length]; + passwordHashResult.CopyTo(combined); + challenge.CopyTo(combined[passwordHashResult.Length..]); + m_remoteCertificateSha2Thumbprint!.CopyTo(combined[(passwordHashResult.Length + challenge.Length)..]); + + Span hashBytes = stackalloc byte[32]; +#if NET5_0_OR_GREATER + SHA256.TryHashData(combined, hashBytes, out _); +#else + using var sha256 = SHA256.Create(); + sha256.TryComputeHash(combined, hashBytes, out _); +#endif + + Span serverHash = combined[0..32]; + return TryConvertFromHexString(validationHash.AsSpan(1), serverHash) && serverHash.SequenceEqual(hashBytes); + + static bool TryConvertFromHexString(ReadOnlySpan hexChars, Span data) + { + ReadOnlySpan hexDigits = "0123456789ABCDEFabcdef"u8; + for (var i = 0; i < hexChars.Length; i += 2) + { + var high = hexDigits.IndexOf(hexChars[i]); + var low = hexDigits.IndexOf(hexChars[i + 1]); + if (high == -1 || low == -1) + return false; + if (high > 15) + high -= 6; + if (low > 15) + low -= 6; + data[i / 2] = (byte) ((high << 4) | low); + } + return true; + } + } + public static async ValueTask ConnectAndRedirectAsync(ILogger connectionLogger, ILogger poolLogger, IConnectionPoolMetadata pool, ConnectionSettings cs, ILoadBalancer? loadBalancer, MySqlConnection connection, Action? logMessage, long startingTimestamp, Activity? activity, IOBehavior ioBehavior, CancellationToken cancellationToken) { var session = new ServerSession(connectionLogger, pool); @@ -729,6 +833,7 @@ private async Task SwitchAuthenticationAsync(ConnectionSettings cs, // if the server didn't support the hashed password; rehash with the new challenge var switchRequest = AuthenticationMethodSwitchRequestPayload.Create(payload.Span); Log.SwitchingToAuthenticationMethod(m_logger, Id, switchRequest.Name); + m_currentAuthenticationMethod = switchRequest.Name; switch (switchRequest.Name) { case "mysql_native_password": @@ -1485,6 +1590,21 @@ caCertificateChain is not null && if (cs.SslMode == MySqlSslMode.VerifyCA) rcbPolicyErrors &= ~SslPolicyErrors.RemoteCertificateNameMismatch; + if (rcbCertificate is X509Certificate2 cert2) + { + // saving sha256 thumbprint and SSL errors until thumbprint validation +#if NET7_0_OR_GREATER + m_remoteCertificateSha2Thumbprint = SHA256.HashData(cert2.RawDataMemory.Span); +#elif NET5_0_OR_GREATER + m_remoteCertificateSha2Thumbprint = SHA256.HashData(cert2.RawData); +#else + using var sha256 = SHA256.Create(); + m_remoteCertificateSha2Thumbprint = sha256.ComputeHash(cert2.RawData); +#endif + m_sslPolicyErrors = rcbPolicyErrors; + return true; + } + return rcbPolicyErrors == SslPolicyErrors.None; } @@ -1558,11 +1678,22 @@ await sslStream.AuthenticateAsClientAsync(clientAuthenticationOptions.TargetHost m_payloadHandler!.ByteHandler = sslByteHandler; m_isSecureConnection = true; m_sslStream = sslStream; + if (m_sslPolicyErrors != SslPolicyErrors.None) + { #if NETCOREAPP3_0_OR_GREATER - Log.ConnectedTlsBasic(m_logger, Id, sslStream.SslProtocol, sslStream.NegotiatedCipherSuite); + Log.ConnectedTlsBasicPreliminary(m_logger, Id, m_sslPolicyErrors, sslStream.SslProtocol, sslStream.NegotiatedCipherSuite); #else - Log.ConnectedTlsDetailed(m_logger, Id, sslStream.SslProtocol, sslStream.CipherAlgorithm, sslStream.HashAlgorithm, sslStream.KeyExchangeAlgorithm, sslStream.KeyExchangeStrength); + Log.ConnectedTlsDetailedPreliminary(m_logger, Id, m_sslPolicyErrors, sslStream.SslProtocol, sslStream.CipherAlgorithm, sslStream.HashAlgorithm, sslStream.KeyExchangeAlgorithm, sslStream.KeyExchangeStrength); #endif + } + else + { +#if NETCOREAPP3_0_OR_GREATER + Log.ConnectedTlsBasic(m_logger, Id, sslStream.SslProtocol, sslStream.NegotiatedCipherSuite); +#else + Log.ConnectedTlsDetailed(m_logger, Id, sslStream.SslProtocol, sslStream.CipherAlgorithm, sslStream.HashAlgorithm, sslStream.KeyExchangeAlgorithm, sslStream.KeyExchangeStrength); +#endif + } } catch (Exception ex) { @@ -2006,4 +2137,7 @@ protected override void OnStatementBegin(int index) private PayloadData m_setNamesPayload; private byte[]? m_pipelinedResetConnectionBytes; private Dictionary? m_preparedStatements; + private string? m_currentAuthenticationMethod; + private byte[]? m_remoteCertificateSha2Thumbprint; + private SslPolicyErrors m_sslPolicyErrors; } diff --git a/src/MySqlConnector/Logging/EventIds.cs b/src/MySqlConnector/Logging/EventIds.cs index 568acdcf3..de87e8590 100644 --- a/src/MySqlConnector/Logging/EventIds.cs +++ b/src/MySqlConnector/Logging/EventIds.cs @@ -81,6 +81,11 @@ internal static class EventIds public const int CreatingConnectionAttributes = 2153; public const int ObtainingPasswordViaProvidePasswordCallback = 2154; public const int FailedToObtainPassword = 2155; + public const int ConnectedTlsBasicPreliminary = 2156; + public const int ConnectedTlsDetailedPreliminary = 2157; + public const int CertificateErrorUnixSocket = 2158; + public const int CertificateErrorNoPassword = 2159; + public const int CertificateErrorValidThumbprint = 2160; // Command execution events, 2200-2299 public const int CannotExecuteNewCommandInState = 2200; diff --git a/src/MySqlConnector/Logging/Log.cs b/src/MySqlConnector/Logging/Log.cs index 57b10a088..e9b4f88bc 100644 --- a/src/MySqlConnector/Logging/Log.cs +++ b/src/MySqlConnector/Logging/Log.cs @@ -201,6 +201,23 @@ internal static partial class Log [LoggerMessage(EventIds.FailedToObtainPassword, LogLevel.Error, "Session {SessionId} failed to obtain password via ProvidePasswordCallback: {ExceptionMessage}")] public static partial void FailedToObtainPassword(ILogger logger, Exception exception, string sessionId, string exceptionMessage); +#if NETCOREAPP3_0_OR_GREATER + [LoggerMessage(EventIds.ConnectedTlsBasicPreliminary, LogLevel.Debug, "Session {SessionId} provisionally connected TLS with error {SslPolicyErrors} using {SslProtocol} and {NegotiatedCipherSuite}")] + public static partial void ConnectedTlsBasicPreliminary(ILogger logger, string sessionId, SslPolicyErrors sslPolicyErrors, SslProtocols sslProtocol, TlsCipherSuite negotiatedCipherSuite); +#endif + + [LoggerMessage(EventIds.ConnectedTlsDetailedPreliminary, LogLevel.Debug, "Session {SessionId} provisionally connected TLS with error {SslPolicyErrors} using {SslProtocol}, {CipherAlgorithm}, {HashAlgorithm}, {KeyExchangeAlgorithm}, {KeyExchangeStrength}")] + public static partial void ConnectedTlsDetailedPreliminary(ILogger logger, string sessionId, SslPolicyErrors sslPolicyErrors, SslProtocols sslProtocol, CipherAlgorithmType cipherAlgorithm, HashAlgorithmType hashAlgorithm, ExchangeAlgorithmType keyExchangeAlgorithm, int keyExchangeStrength); + + [LoggerMessage(EventIds.CertificateErrorUnixSocket, LogLevel.Trace, "Session {SessionId} ignoring remote certificate error {SslPolicyErrors} due to Unix socket connection")] + public static partial void CertificateErrorUnixSocket(ILogger logger, string sessionId, SslPolicyErrors sslPolicyErrors); + + [LoggerMessage(EventIds.CertificateErrorNoPassword, LogLevel.Trace, "Session {SessionId} acknowledging remote certificate error {SslPolicyErrors} due to passwordless connection")] + public static partial void CertificateErrorNoPassword(ILogger logger, string sessionId, SslPolicyErrors sslPolicyErrors); + + [LoggerMessage(EventIds.CertificateErrorValidThumbprint, LogLevel.Trace, "Session {SessionId} ignoring remote certificate error {SslPolicyErrors} due to valid signature in OK packet")] + public static partial void CertificateErrorValidThumbprint(ILogger logger, string sessionId, SslPolicyErrors sslPolicyErrors); + [LoggerMessage(EventIds.IgnoringCancellationForCommand, LogLevel.Trace, "Ignoring cancellation for closed connection or invalid command {CommandId}")] public static partial void IgnoringCancellationForCommand(ILogger logger, int commandId); diff --git a/src/MySqlConnector/Protocol/Payloads/OkPayload.cs b/src/MySqlConnector/Protocol/Payloads/OkPayload.cs index 37db8862f..b1bc70139 100644 --- a/src/MySqlConnector/Protocol/Payloads/OkPayload.cs +++ b/src/MySqlConnector/Protocol/Payloads/OkPayload.cs @@ -12,7 +12,7 @@ internal sealed class OkPayload public ulong LastInsertId { get; } public ServerStatus ServerStatus { get; } public int WarningCount { get; } - public string? StatusInfo { get; } + public byte[]? StatusInfo { get; } public string? NewSchema { get; } public CharacterSet? NewCharacterSet { get; } public int? NewConnectionId { get; } @@ -152,7 +152,7 @@ public static void Verify(ReadOnlySpan span, IServerCapabilities serverCap if (createPayload) { - var statusInfo = statusBytes.Length == 0 ? null : Encoding.UTF8.GetString(statusBytes); + var statusInfo = statusBytes.Length == 0 ? null : statusBytes.ToArray(); // detect the connection character set as utf8mb4 (or utf8) if all three system variables are set to the same value var characterSet = clientCharacterSet == CharacterSet.Utf8Mb4Binary && connectionCharacterSet == CharacterSet.Utf8Mb4Binary && resultsCharacterSet == CharacterSet.Utf8Mb4Binary ? CharacterSet.Utf8Mb4Binary : @@ -175,7 +175,7 @@ public static void Verify(ReadOnlySpan span, IServerCapabilities serverCap } } - private OkPayload(ulong affectedRowCount, ulong lastInsertId, ServerStatus serverStatus, int warningCount, string? statusInfo, string? newSchema, CharacterSet newCharacterSet, int? connectionId, string? redirectionUrl) + private OkPayload(ulong affectedRowCount, ulong lastInsertId, ServerStatus serverStatus, int warningCount, byte[]? statusInfo, string? newSchema, CharacterSet newCharacterSet, int? connectionId, string? redirectionUrl) { AffectedRowCount = affectedRowCount; LastInsertId = lastInsertId; diff --git a/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs b/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs index fc0301a90..659d2350d 100644 --- a/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs +++ b/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs @@ -25,25 +25,27 @@ public static byte[] GetNullTerminatedPasswordBytes(string password) } public static byte[] CreateAuthenticationResponse(ReadOnlySpan challenge, string password) => - string.IsNullOrEmpty(password) ? [] : HashPassword(challenge, password); + string.IsNullOrEmpty(password) ? [] : HashPassword(challenge, password, onlyHashPassword: false); /// /// Hashes a password with the "Secure Password Authentication" method. /// /// The 20-byte random challenge (from the "auth-plugin-data" in the initial handshake). /// The password to hash. + /// If true, is ignored and only the twice-hashed password + /// is returned, instead of performing the full "secure password authentication" algorithm that XORs the hashed password against + /// a hash derived from the challenge. /// A 20-byte password hash. /// See Secure Password Authentication. #if NET5_0_OR_GREATER [SkipLocalsInit] #endif - public static byte[] HashPassword(ReadOnlySpan challenge, string password) + public static byte[] HashPassword(ReadOnlySpan challenge, string password, bool onlyHashPassword) { #if !NET5_0_OR_GREATER using var sha1 = SHA1.Create(); #endif Span combined = stackalloc byte[40]; - challenge.CopyTo(combined); var passwordByteCount = Encoding.UTF8.GetByteCount(password); Span passwordBytes = stackalloc byte[passwordByteCount]; @@ -56,7 +58,10 @@ public static byte[] HashPassword(ReadOnlySpan challenge, string password) sha1.TryComputeHash(passwordBytes, hashedPassword, out _); sha1.TryComputeHash(hashedPassword, combined[20..], out _); #endif + if (onlyHashPassword) + return combined[20..].ToArray(); + challenge[..20].CopyTo(combined); Span xorBytes = stackalloc byte[20]; #if NET5_0_OR_GREATER SHA1.TryHashData(combined, xorBytes, out _); diff --git a/tests/IntegrationTests/MySqlDataSourceTests.cs b/tests/IntegrationTests/MySqlDataSourceTests.cs index dcd8d7f2e..c753fe97c 100644 --- a/tests/IntegrationTests/MySqlDataSourceTests.cs +++ b/tests/IntegrationTests/MySqlDataSourceTests.cs @@ -147,7 +147,7 @@ public async Task ConnectSslRemoteCertificateValidationCallback(MySqlSslMode ssl using var dataSource = builder.Build(); using var connection = dataSource.CreateConnection(); - if (expectedSuccess) + if (expectedSuccess || AppConfig.SupportedFeatures.HasFlag(ServerFeatures.TlsFingerprintValidation)) await connection.OpenAsync(); else await Assert.ThrowsAsync(connection.OpenAsync); diff --git a/tests/IntegrationTests/ServerFeatures.cs b/tests/IntegrationTests/ServerFeatures.cs index ac4fa4863..120b541bf 100644 --- a/tests/IntegrationTests/ServerFeatures.cs +++ b/tests/IntegrationTests/ServerFeatures.cs @@ -37,7 +37,12 @@ public enum ServerFeatures CancelSleepSuccessfully = 0x40_0000, /// - /// Server permit redirection, available on first OK_Packet + /// Server permits redirection (sent as a server variable in first OK packet). /// Redirection = 0x80_0000, + + /// + /// Server provides hash of TLS certificate in first OK packet. + /// + TlsFingerprintValidation = 0x100_0000, } diff --git a/tests/IntegrationTests/SslTests.cs b/tests/IntegrationTests/SslTests.cs index e22c034fb..6c428f386 100644 --- a/tests/IntegrationTests/SslTests.cs +++ b/tests/IntegrationTests/SslTests.cs @@ -181,7 +181,10 @@ public async Task ConnectSslBadCaCertificate() csb.SslMode = MySqlSslMode.VerifyCA; csb.SslCa = Path.Combine(AppConfig.CertsPath, "non-ca-client-cert.pem"); using var connection = new MySqlConnection(csb.ConnectionString); - await Assert.ThrowsAsync(async () => await connection.OpenAsync()); + if (AppConfig.SupportedFeatures.HasFlag(ServerFeatures.TlsFingerprintValidation)) + await connection.OpenAsync(); + else + await Assert.ThrowsAsync(async () => await connection.OpenAsync()); } #if !MYSQL_DATA @@ -198,13 +201,44 @@ public async Task ConnectSslRemoteCertificateValidationCallback(MySqlSslMode ssl using var connection = new MySqlConnection(csb.ConnectionString); connection.RemoteCertificateValidationCallback = (s, c, h, e) => true; - if (expectedSuccess) + if (expectedSuccess || AppConfig.SupportedFeatures.HasFlag(ServerFeatures.TlsFingerprintValidation)) await connection.OpenAsync(); else await Assert.ThrowsAsync(async () => await connection.OpenAsync()); } #endif + [SkippableFact(ServerFeatures.TlsFingerprintValidation)] + public async Task ConnectZeroConfigurationSslNative() + { + // permit connection without any Ssl configuration. + // reference https://mariadb.org/mission-impossible-zero-configuration-ssl/ + var csb = AppConfig.CreateConnectionStringBuilder(); + csb.CertificateFile = null; + csb.SslMode = MySqlSslMode.VerifyFull; + csb.SslCa = ""; + csb.UserID = "ssltest"; + csb.Password = "test"; + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + } + +#if !MYSQL_DATA + [SkippableFact(ServerFeatures.TlsFingerprintValidation | ServerFeatures.Ed25519)] + public async Task ConnectZeroConfigurationSslEd25519() + { + MySqlConnector.Authentication.Ed25519.Ed25519AuthenticationPlugin.Install(); + var csb = AppConfig.CreateConnectionStringBuilder(); + csb.CertificateFile = null; + csb.SslMode = MySqlSslMode.VerifyFull; + csb.SslCa = ""; + csb.UserID = "ed25519user"; + csb.Password = "Ed255!9"; + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + } +#endif + [SkippableFact(ConfigSettings.RequiresSsl)] public async Task ConnectSslTlsVersion() { diff --git a/tests/README.md b/tests/README.md index bd5267639..da0fcb0ab 100644 --- a/tests/README.md +++ b/tests/README.md @@ -27,6 +27,7 @@ Otherwise, set the following options appropriately: * `ErrorCodes`: server returns error codes in error packet (some MySQL proxies do not) * `Json`: the `JSON` data type (MySQL 5.7 and later) * `LargePackets`: large packets (over 4MB) + * `Redirection`: server supports sending redirection information in a server variable in the first OK packet * `RoundDateTime`: server rounds `datetime` values to the specified precision (not implemented in MariaDB) * `RsaEncryption`: server supports RSA public key encryption (for `sha256_password` and `caching_sha2_password`) * `SessionTrack`: server supports `CLIENT_SESSION_TRACK` capability (MySQL 5.7 and later) @@ -36,6 +37,7 @@ Otherwise, set the following options appropriately: * `Tls11`: server supports TLS 1.1 * `Tls12`: server supports TLS 1.2 * `Tls13`: server supports TLS 1.3 + * `TlsFingerprintValidation`: server provides a hash of the TLS certificate fingerprint in the first OK packet * `UnixDomainSocket`: server is accessible via a Unix domain socket * `UuidToBin`: server supports `UUID_TO_BIN` (MySQL 8.0 and later)