From 3ea4d86c4ae8d69493b99b4a1d4b1a84c2b5d4b4 Mon Sep 17 00:00:00 2001 From: rusher Date: Sat, 20 Jul 2024 12:31:35 +0200 Subject: [PATCH 01/20] Changing Permit redirection This is based on https://jira.mariadb.org/browse/MDEV-15935: first OK_Packet can contain variable `redirect_url` using "mariadb/mysql://[{user}[:{password}]@]{host}[:{port}]/[{db}[?{opt1}={value1}[&{opt2}={value2}]]]']" format. Signed-off-by: rusher --- src/MySqlConnector/Core/ConnectionPool.cs | 98 ++------- src/MySqlConnector/Core/ConnectionSettings.cs | 8 +- src/MySqlConnector/Core/ServerSession.cs | 89 +++++++- src/MySqlConnector/Logging/Log.cs | 28 +-- src/MySqlConnector/MySqlConnection.cs | 21 +- .../Protocol/Payloads/OkPayload.cs | 20 +- src/MySqlConnector/Utilities/Utility.cs | 79 +++---- tests/IntegrationTests/RedirectionTests.cs | 199 ++++++++++++++++++ tests/MySqlConnector.Tests/UtilityTests.cs | 47 ++--- 9 files changed, 384 insertions(+), 205 deletions(-) create mode 100644 tests/IntegrationTests/RedirectionTests.cs diff --git a/src/MySqlConnector/Core/ConnectionPool.cs b/src/MySqlConnector/Core/ConnectionPool.cs index 17227b631..56d8da03b 100644 --- a/src/MySqlConnector/Core/ConnectionPool.cs +++ b/src/MySqlConnector/Core/ConnectionPool.cs @@ -68,8 +68,11 @@ public async ValueTask GetSessionAsync(MySqlConnection connection if (ConnectionSettings.ConnectionReset || session.DatabaseOverride is not null) { if (timeoutMilliseconds != 0) - session.SetTimeout(Math.Max(1, timeoutMilliseconds - Utility.GetElapsedMilliseconds(startingTimestamp))); - reuseSession = await session.TryResetConnectionAsync(ConnectionSettings, connection, ioBehavior, cancellationToken).ConfigureAwait(false); + session.SetTimeout(Math.Max(1, + timeoutMilliseconds - Utility.GetElapsedMilliseconds(startingTimestamp))); + reuseSession = await session + .TryResetConnectionAsync(ConnectionSettings, connection, ioBehavior, cancellationToken) + .ConfigureAwait(false); session.SetTimeout(Constants.InfiniteTimeout); } else @@ -95,18 +98,24 @@ public async ValueTask GetSessionAsync(MySqlConnection connection m_leasedSessions.Add(session.Id, session); leasedSessionsCountPooled = m_leasedSessions.Count; } + MetricsReporter.AddUsed(this); ActivitySourceHelper.CopyTags(session.ActivityTags, activity); Log.ReturningPooledSession(m_logger, Id, session.Id, leasedSessionsCountPooled); session.LastLeasedTimestamp = Stopwatch.GetTimestamp(); - MetricsReporter.RecordWaitTime(this, Utility.GetElapsedSeconds(startingTimestamp, session.LastLeasedTimestamp)); + MetricsReporter.RecordWaitTime(this, + Utility.GetElapsedSeconds(startingTimestamp, session.LastLeasedTimestamp)); return session; } } // create a new session - session = await ConnectSessionAsync(connection, s_createdNewSession, startingTimestamp, activity, ioBehavior, cancellationToken).ConfigureAwait(false); + session = await ServerSession.ConnectAndRedirectAsync( + () => new ServerSession(m_connectionLogger, this, m_generation, + Interlocked.Increment(ref m_lastSessionId)), m_logger, Id, ConnectionSettings, m_loadBalancer, + connection, s_createdNewSession, startingTimestamp, activity, ioBehavior, cancellationToken) + .ConfigureAwait(false); AdjustHostConnectionCount(session, 1); session.OwningConnection = new(connection); int leasedSessionsCountNew; @@ -402,7 +411,11 @@ private async Task CreateMinimumPooledSessions(MySqlConnection connection, IOBeh try { - var session = await ConnectSessionAsync(connection, s_createdToReachMinimumPoolSize, Stopwatch.GetTimestamp(), null, ioBehavior, cancellationToken).ConfigureAwait(false); + var session = await ServerSession.ConnectAndRedirectAsync( + () => new ServerSession(m_connectionLogger, this, m_generation, + Interlocked.Increment(ref m_lastSessionId)), m_logger, Id, ConnectionSettings, m_loadBalancer, + connection, s_createdToReachMinimumPoolSize, Stopwatch.GetTimestamp(), null, ioBehavior, + cancellationToken).ConfigureAwait(false); AdjustHostConnectionCount(session, 1); lock (m_sessions) _ = m_sessions.AddFirst(session); @@ -416,81 +429,6 @@ private async Task CreateMinimumPooledSessions(MySqlConnection connection, IOBeh } } - private async ValueTask ConnectSessionAsync(MySqlConnection connection, Action logMessage, long startingTimestamp, Activity? activity, IOBehavior ioBehavior, CancellationToken cancellationToken) - { - var session = new ServerSession(m_connectionLogger, this, m_generation, Interlocked.Increment(ref m_lastSessionId)); - if (m_logger.IsEnabled(LogLevel.Debug)) - logMessage(m_logger, Id, session.Id, null); - string? statusInfo; - try - { - statusInfo = await session.ConnectAsync(ConnectionSettings, connection, startingTimestamp, m_loadBalancer, activity, ioBehavior, cancellationToken).ConfigureAwait(false); - } - catch (Exception) - { - await session.DisposeAsync(ioBehavior, default).ConfigureAwait(false); - throw; - } - - Exception? redirectionException = null; - if (statusInfo is not null && statusInfo.StartsWith("Location: mysql://", StringComparison.Ordinal)) - { - // server redirection string has the format "Location: mysql://{host}:{port}/user={userId}[&ttl={ttl}]" - Log.HasServerRedirectionHeader(m_logger, session.Id, statusInfo); - - if (ConnectionSettings.ServerRedirectionMode == MySqlServerRedirectionMode.Disabled) - { - Log.ServerRedirectionIsDisabled(m_logger, Id); - } - else if (Utility.TryParseRedirectionHeader(statusInfo, out var host, out var port, out var user)) - { - if (host != ConnectionSettings.HostNames![0] || port != ConnectionSettings.Port || user != ConnectionSettings.UserID) - { - var redirectedSettings = ConnectionSettings.CloneWith(host, port, user); - Log.OpeningNewConnection(m_logger, Id, host, port, user); - var redirectedSession = new ServerSession(m_connectionLogger, this, m_generation, Interlocked.Increment(ref m_lastSessionId)); - try - { - _ = await redirectedSession.ConnectAsync(redirectedSettings, connection, startingTimestamp, m_loadBalancer, activity, ioBehavior, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - Log.FailedToConnectRedirectedSession(m_logger, ex, Id, redirectedSession.Id); - redirectionException = ex; - } - - if (redirectionException is null) - { - Log.ClosingSessionToUseRedirectedSession(m_logger, Id, session.Id, redirectedSession.Id); - await session.DisposeAsync(ioBehavior, cancellationToken).ConfigureAwait(false); - return redirectedSession; - } - else - { - try - { - await redirectedSession.DisposeAsync(ioBehavior, cancellationToken).ConfigureAwait(false); - } - catch (Exception) - { - } - } - } - else - { - Log.SessionAlreadyConnectedToServer(m_logger, session.Id); - } - } - } - - if (ConnectionSettings.ServerRedirectionMode == MySqlServerRedirectionMode.Required) - { - Log.RequiresServerRedirection(m_logger, Id); - throw new MySqlException(MySqlErrorCode.UnableToConnectToHost, "Server does not support redirection", redirectionException); - } - return session; - } - public static ConnectionPool? CreatePool(string connectionString, MySqlConnectorLoggingConfiguration loggingConfiguration, string? name) { // parse connection string and check for 'Pooling' setting; return 'null' if pooling is disabled diff --git a/src/MySqlConnector/Core/ConnectionSettings.cs b/src/MySqlConnector/Core/ConnectionSettings.cs index c93e16004..8b365833c 100644 --- a/src/MySqlConnector/Core/ConnectionSettings.cs +++ b/src/MySqlConnector/Core/ConnectionSettings.cs @@ -270,8 +270,12 @@ public int ConnectionTimeoutMilliseconds private ConnectionSettings(ConnectionSettings other, string host, int port, string userId) { - ConnectionStringBuilder = other.ConnectionStringBuilder; - ConnectionString = other.ConnectionString; + ConnectionStringBuilder = new MySqlConnectionStringBuilder(other.ConnectionString); + ConnectionStringBuilder.Port = (uint)port; + ConnectionStringBuilder.Server = host; + ConnectionStringBuilder.UserID = userId; + + ConnectionString = ConnectionStringBuilder.ConnectionString; ConnectionProtocol = MySqlConnectionProtocol.Sockets; HostNames = [host]; diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index dcaf1e69f..0884675ea 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -51,6 +51,7 @@ public ServerSession(ILogger logger, ConnectionPool? pool, int poolGeneration, i public bool SupportsPerQueryVariables => ServerVersion.IsMariaDb && ServerVersion.Version >= ServerVersions.MariaDbSupportsPerQueryVariables; public int ActiveCommandId { get; private set; } public int CancellationTimeout { get; private set; } + public string? ConnectionString { get; private set; } public int ConnectionId { get; set; } public byte[]? AuthPluginData { get; set; } public long CreatedTimestamp { get; } @@ -391,7 +392,7 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella m_state = State.Closed; } - public async Task ConnectAsync(ConnectionSettings cs, MySqlConnection connection, long startingTimestamp, ILoadBalancer? loadBalancer, Activity? activity, IOBehavior ioBehavior, CancellationToken cancellationToken) + private async Task ConnectAsync(ConnectionSettings cs, MySqlConnection connection, long startingTimestamp, ILoadBalancer? loadBalancer, Activity? activity, IOBehavior ioBehavior, CancellationToken cancellationToken) { try { @@ -403,16 +404,16 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella // set activity tags { - var connectionString = cs.ConnectionStringBuilder.GetConnectionString(cs.ConnectionStringBuilder.PersistSecurityInfo); + ConnectionString = cs.ConnectionStringBuilder.GetConnectionString(cs.ConnectionStringBuilder.PersistSecurityInfo); m_activityTags.Add(ActivitySourceHelper.DatabaseSystemTagName, ActivitySourceHelper.DatabaseSystemValue); - m_activityTags.Add(ActivitySourceHelper.DatabaseConnectionStringTagName, connectionString); + m_activityTags.Add(ActivitySourceHelper.DatabaseConnectionStringTagName, ConnectionString); m_activityTags.Add(ActivitySourceHelper.DatabaseUserTagName, cs.UserID); if (cs.Database.Length != 0) m_activityTags.Add(ActivitySourceHelper.DatabaseNameTagName, cs.Database); if (activity is { IsAllDataRequested: true }) { activity.SetTag(ActivitySourceHelper.DatabaseSystemTagName, ActivitySourceHelper.DatabaseSystemValue) - .SetTag(ActivitySourceHelper.DatabaseConnectionStringTagName, connectionString) + .SetTag(ActivitySourceHelper.DatabaseConnectionStringTagName, ConnectionString) .SetTag(ActivitySourceHelper.DatabaseUserTagName, cs.UserID); if (cs.Database.Length != 0) activity.SetTag(ActivitySourceHelper.DatabaseNameTagName, cs.Database); @@ -533,7 +534,7 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella } var ok = OkPayload.Create(payload.Span, this); - var statusInfo = ok.StatusInfo; + var redirectionUrl = ok.RedirectionUrl; if (m_useCompression) m_payloadHandler = new CompressedPayloadHandler(m_payloadHandler.ByteHandler); @@ -558,7 +559,7 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella } m_payloadHandler.ByteHandler.RemainingTimeout = Constants.InfiniteTimeout; - return statusInfo; + return redirectionUrl; } catch (ArgumentException ex) { @@ -572,6 +573,82 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella } } + public static async ValueTask ConnectAndRedirectAsync(Func createSession, ILogger logger, int? poolId, ConnectionSettings cs, ILoadBalancer? loadBalancer, MySqlConnection connection, Action? logMessage, long startingTimestamp, Activity? activity, IOBehavior ioBehavior, CancellationToken cancellationToken) + { + var session = createSession(); + if (poolId is not null) + { + if (logger.IsEnabled(LogLevel.Debug)) logMessage!(logger, poolId.Value, session.Id, null); + } + else + { + Log.CreatedNonPooledSession(logger, session.Id); + } + + string? redirectionUrl; + try + { + redirectionUrl = await session.ConnectAsync(cs, connection, startingTimestamp, loadBalancer, activity, ioBehavior, cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + await session.DisposeAsync(ioBehavior, default).ConfigureAwait(false); + throw; + } + + Exception? redirectionException = null; + var poolPrefix = poolId is not null ? "Pool {PoolId} " : ""; + if (redirectionUrl is not null) + { + Log.HasServerRedirectionHeader(logger, poolPrefix, session.Id, redirectionUrl!); + if (cs.ServerRedirectionMode == MySqlServerRedirectionMode.Disabled) + { + Log.ServerRedirectionIsDisabled(logger, poolPrefix); + return session; + } + + if (Utility.TryParseRedirectionHeader(redirectionUrl, cs.UserID, out var host, out var port, out var user)) + { + if (host != cs.HostNames![0] || port != cs.Port || user != cs.UserID) + { + var redirectedSettings = cs.CloneWith(host, port, user); + Log.OpeningNewConnection(logger, poolPrefix, host, port, user); + var redirectedSession = createSession(); + try + { + await redirectedSession.ConnectAsync(redirectedSettings, connection, startingTimestamp, loadBalancer, activity, ioBehavior, cancellationToken).ConfigureAwait(false); + Log.ClosingSessionToUseRedirectedSession(logger, poolPrefix, session.Id, redirectedSession.Id); + await session.DisposeAsync(ioBehavior, cancellationToken).ConfigureAwait(false); + return redirectedSession; + } + catch (Exception ex) + { + redirectionException = ex; + Log.FailedToConnectRedirectedSession(logger, ex, poolPrefix, redirectedSession.Id); + try + { + await redirectedSession.DisposeAsync(ioBehavior, cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + } + } + } + else + { + Log.SessionAlreadyConnectedToServer(logger, poolPrefix, session.Id); + } + } + } + + if (cs.ServerRedirectionMode == MySqlServerRedirectionMode.Required) + { + Log.RequiresServerRedirection(logger, poolPrefix); + throw new MySqlException(MySqlErrorCode.UnableToConnectToHost, "Server does not support redirection", redirectionException); + } + return session; + } + public async Task TryResetConnectionAsync(ConnectionSettings cs, MySqlConnection connection, IOBehavior ioBehavior, CancellationToken cancellationToken) { VerifyState(State.Connected); diff --git a/src/MySqlConnector/Logging/Log.cs b/src/MySqlConnector/Logging/Log.cs index 7cb3bd432..b83db5092 100644 --- a/src/MySqlConnector/Logging/Log.cs +++ b/src/MySqlConnector/Logging/Log.cs @@ -402,26 +402,26 @@ internal static partial class Log [LoggerMessage(EventIds.FoundSessionToCleanUp, LogLevel.Debug, "Pool {PoolId} found session {SessionId} to clean up")] public static partial void FoundSessionToCleanUp(ILogger logger, int poolId, string sessionId); - [LoggerMessage(EventIds.HasServerRedirectionHeader, LogLevel.Trace, "Session {SessionId} has server redirection header {Header}")] - public static partial void HasServerRedirectionHeader(ILogger logger, string sessionId, string header); + [LoggerMessage(EventIds.HasServerRedirectionHeader, LogLevel.Trace, "{poolPrefix}Session {SessionId} has server redirection header {Header}")] + public static partial void HasServerRedirectionHeader(ILogger logger, string poolPrefix, string sessionId, string header); - [LoggerMessage(EventIds.ServerRedirectionIsDisabled, LogLevel.Trace, "Pool {PoolId} server redirection is disabled; ignoring redirection")] - public static partial void ServerRedirectionIsDisabled(ILogger logger, int poolId); + [LoggerMessage(EventIds.ServerRedirectionIsDisabled, LogLevel.Trace, "{poolPrefix}server redirection is disabled; ignoring redirection")] + public static partial void ServerRedirectionIsDisabled(ILogger logger, string poolPrefix); - [LoggerMessage(EventIds.OpeningNewConnection, LogLevel.Debug, "Pool {PoolId} opening new connection to {Host}:{Port} as {User}")] - public static partial void OpeningNewConnection(ILogger logger, int poolId, string host, int port, string user); + [LoggerMessage(EventIds.OpeningNewConnection, LogLevel.Debug, "{poolPrefix}opening new connection to {Host}:{Port} as {User}")] + public static partial void OpeningNewConnection(ILogger logger, string poolPrefix, string host, int port, string user); - [LoggerMessage(EventIds.FailedToConnectRedirectedSession, LogLevel.Information, "Pool {PoolId} failed to connect redirected session {SessionId}")] - public static partial void FailedToConnectRedirectedSession(ILogger logger, Exception ex, int poolId, string sessionId); + [LoggerMessage(EventIds.FailedToConnectRedirectedSession, LogLevel.Information, "{poolPrefix}failed to connect redirected session {SessionId}")] + public static partial void FailedToConnectRedirectedSession(ILogger logger, Exception ex, string poolPrefix, string sessionId); - [LoggerMessage(EventIds.ClosingSessionToUseRedirectedSession, LogLevel.Trace, "Pool {PoolId} closing session {SessionId} to use redirected session {RedirectedSessionId} instead")] - public static partial void ClosingSessionToUseRedirectedSession(ILogger logger, int poolId, string sessionId, string redirectedSessionId); + [LoggerMessage(EventIds.ClosingSessionToUseRedirectedSession, LogLevel.Trace, "{poolPrefix}closing session {SessionId} to use redirected session {RedirectedSessionId} instead")] + public static partial void ClosingSessionToUseRedirectedSession(ILogger logger, string poolPrefix, string sessionId, string redirectedSessionId); - [LoggerMessage(EventIds.SessionAlreadyConnectedToServer, LogLevel.Trace, "Session {SessionId} is already connected to this server; ignoring redirection")] - public static partial void SessionAlreadyConnectedToServer(ILogger logger, string sessionId); + [LoggerMessage(EventIds.SessionAlreadyConnectedToServer, LogLevel.Trace, "{poolPrefix}Session {SessionId} is already connected to this server; ignoring redirection")] + public static partial void SessionAlreadyConnectedToServer(ILogger logger, string poolPrefix, string sessionId); - [LoggerMessage(EventIds.RequiresServerRedirection, LogLevel.Error, "Pool {PoolId} requires server redirection but server doesn't support it")] - public static partial void RequiresServerRedirection(ILogger logger, int poolId); + [LoggerMessage(EventIds.RequiresServerRedirection, LogLevel.Error, "{poolPrefix}new connection requires server redirection but server doesn't support it")] + public static partial void RequiresServerRedirection(ILogger logger, string poolPrefix); [LoggerMessage(EventIds.CreatedPoolWillNotBeUsed, LogLevel.Debug, "Pool {PoolId} was created but will not be used (due to race)")] public static partial void CreatedPoolWillNotBeUsed(ILogger logger, int poolId); diff --git a/src/MySqlConnector/MySqlConnection.cs b/src/MySqlConnector/MySqlConnection.cs index 7a835d202..371303ade 100644 --- a/src/MySqlConnector/MySqlConnection.cs +++ b/src/MySqlConnector/MySqlConnection.cs @@ -628,6 +628,8 @@ public override string ConnectionString } } + public string? SessionConnectionString => m_session?.ConnectionString; + public override string Database => m_session?.DatabaseOverride ?? GetConnectionSettings().Database; public override ConnectionState State => m_connectionState; @@ -1062,22 +1064,9 @@ private async ValueTask CreateSessionAsync(ConnectionPool? pool, // only "fail over" and "random" load balancers supported without connection pooling var loadBalancer = connectionSettings.LoadBalance == MySqlLoadBalance.Random && connectionSettings.HostNames!.Count > 1 ? RandomLoadBalancer.Instance : FailOverLoadBalancer.Instance; - - var session = new ServerSession(m_logger) - { - OwningConnection = new WeakReference(this), - }; - Log.CreatedNonPooledSession(m_logger, session.Id); - try - { - _ = await session.ConnectAsync(connectionSettings, this, startingTimestamp, loadBalancer, activity, actualIOBehavior, connectToken).ConfigureAwait(false); - return session; - } - catch (Exception) - { - await session.DisposeAsync(actualIOBehavior, default).ConfigureAwait(false); - throw; - } + var session = await ServerSession.ConnectAndRedirectAsync(() => new ServerSession(m_logger), m_logger, null, connectionSettings, loadBalancer, this, null, startingTimestamp, null, actualIOBehavior, cancellationToken).ConfigureAwait(false); + session.OwningConnection = new WeakReference(this); + return session; } } catch (OperationCanceledException) when (timeoutSource?.IsCancellationRequested is true) diff --git a/src/MySqlConnector/Protocol/Payloads/OkPayload.cs b/src/MySqlConnector/Protocol/Payloads/OkPayload.cs index a08a3195d..37db8862f 100644 --- a/src/MySqlConnector/Protocol/Payloads/OkPayload.cs +++ b/src/MySqlConnector/Protocol/Payloads/OkPayload.cs @@ -16,6 +16,7 @@ internal sealed class OkPayload public string? NewSchema { get; } public CharacterSet? NewCharacterSet { get; } public int? NewConnectionId { get; } + public string? RedirectionUrl { get; } public const byte Signature = 0x00; @@ -64,6 +65,7 @@ public static void Verify(ReadOnlySpan span, IServerCapabilities serverCap CharacterSet clientCharacterSet = default; CharacterSet connectionCharacterSet = default; CharacterSet resultsCharacterSet = default; + string? redirectionUrl = default; int? connectionId = null; ReadOnlySpan statusBytes; @@ -115,6 +117,13 @@ public static void Verify(ReadOnlySpan span, IServerCapabilities serverCap { connectionId = Utf8Parser.TryParse(systemVariableValue, out int parsedConnectionId, out var bytesConsumed) && bytesConsumed == systemVariableValue.Length ? parsedConnectionId : default(int?); } + else if (systemVariableName.SequenceEqual("redirect_url"u8)) + { + if (systemVariableValue.Length > 0) + { + redirectionUrl = Encoding.UTF8.GetString(systemVariableValue); + } + } } while (reader.Offset < systemVariablesEndOffset); break; @@ -150,7 +159,7 @@ public static void Verify(ReadOnlySpan span, IServerCapabilities serverCap clientCharacterSet == CharacterSet.Utf8Mb3Binary && connectionCharacterSet == CharacterSet.Utf8Mb3Binary && resultsCharacterSet == CharacterSet.Utf8Mb3Binary ? CharacterSet.Utf8Mb3Binary : CharacterSet.None; - if (affectedRowCount == 0 && lastInsertId == 0 && warningCount == 0 && statusInfo is null && newSchema is null && clientCharacterSet is CharacterSet.None && connectionId is null) + if (affectedRowCount == 0 && lastInsertId == 0 && warningCount == 0 && statusInfo is null && newSchema is null && clientCharacterSet is CharacterSet.None && connectionId is null && redirectionUrl is null) { if (serverStatus == ServerStatus.AutoCommit) return s_autoCommitOk; @@ -158,7 +167,7 @@ public static void Verify(ReadOnlySpan span, IServerCapabilities serverCap return s_autoCommitSessionStateChangedOk; } - return new OkPayload(affectedRowCount, lastInsertId, serverStatus, warningCount, statusInfo, newSchema, characterSet, connectionId); + return new OkPayload(affectedRowCount, lastInsertId, serverStatus, warningCount, statusInfo, newSchema, characterSet, connectionId, redirectionUrl); } else { @@ -166,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) + private OkPayload(ulong affectedRowCount, ulong lastInsertId, ServerStatus serverStatus, int warningCount, string? statusInfo, string? newSchema, CharacterSet newCharacterSet, int? connectionId, string? redirectionUrl) { AffectedRowCount = affectedRowCount; LastInsertId = lastInsertId; @@ -176,8 +185,9 @@ private OkPayload(ulong affectedRowCount, ulong lastInsertId, ServerStatus serve NewSchema = newSchema; NewCharacterSet = newCharacterSet; NewConnectionId = connectionId; + RedirectionUrl = redirectionUrl; } - private static readonly OkPayload s_autoCommitOk = new(0, 0, ServerStatus.AutoCommit, 0, default, default, default, default); - private static readonly OkPayload s_autoCommitSessionStateChangedOk = new(0, 0, ServerStatus.AutoCommit | ServerStatus.SessionStateChanged, 0, default, default, default, default); + private static readonly OkPayload s_autoCommitOk = new(0, 0, ServerStatus.AutoCommit, 0, default, default, default, default, default); + private static readonly OkPayload s_autoCommitSessionStateChangedOk = new(0, 0, ServerStatus.AutoCommit | ServerStatus.SessionStateChanged, 0, default, default, default, default, default); } diff --git a/src/MySqlConnector/Utilities/Utility.cs b/src/MySqlConnector/Utilities/Utility.cs index d1d3ce02b..63f07c8c6 100644 --- a/src/MySqlConnector/Utilities/Utility.cs +++ b/src/MySqlConnector/Utilities/Utility.cs @@ -336,68 +336,45 @@ public static void Resize([NotNull] ref ResizableArray? resizableArray, in resizableArray.DoResize(newLength); } - public static bool TryParseRedirectionHeader(string header, out string host, out int port, out string user) + public static bool TryParseRedirectionHeader(string redirectUrl, string initialUser, out string host, out int port, out string user) { host = ""; port = 0; user = ""; - if (!header.StartsWith("Location: mysql://", StringComparison.Ordinal) || header.Length < 22) + // "mariadb/mysql://[{user}[:{password}]@]{host}[:{port}]/[{db}[?{opt1}={value1}[&{opt2}={value2}]]]']" + if (!redirectUrl.StartsWith("mysql://", StringComparison.Ordinal) && !redirectUrl.StartsWith("mariadb://", StringComparison.Ordinal)) return false; - bool isCommunityFormat; - int portIndex; - if (header[18] == '[') - { - // Community protocol: - // Location: mysql://[redirectedHostName]:redirectedPort/?user=redirectedUser&ttl=%d\n - isCommunityFormat = true; - - var hostIndex = 19; - var closeSquareBracketIndex = header.IndexOf(']', hostIndex); - if (closeSquareBracketIndex == -1) - return false; - - host = header[hostIndex..closeSquareBracketIndex]; - if (header.Length <= closeSquareBracketIndex + 2) - return false; - if (header[closeSquareBracketIndex + 1] != ':') - return false; - portIndex = closeSquareBracketIndex + 2; - } - else + try { - // Azure protocol: - // Location: mysql://redirectedHostName:redirectedPort/user=redirectedUser&ttl=%d (where ttl is optional) - isCommunityFormat = false; - - var hostIndex = 18; - var colonIndex = header.IndexOf(':', hostIndex); - if (colonIndex == -1) - return false; + var uri = new Uri(redirectUrl); + host = uri.Host; + if (string.IsNullOrEmpty(host)) return false; + if (host.StartsWith('[') && host.EndsWith("]", StringComparison.InvariantCulture)) host = host.Substring(1, host.Length - 2); + + port = uri.Port; + user = Uri.UnescapeDataString(uri.UserInfo.Split(':')[0]); + if (string.IsNullOrEmpty(user) && !string.IsNullOrEmpty(uri.Query)) + { + // query format "?{opt1}={value1}[&{opt2}={value2}]" + var q = uri.Query.Substring(1); + foreach (var token in q.Split('&')) + { + if (token.StartsWith("user=", StringComparison.InvariantCulture)) + { + user = Uri.UnescapeDataString(token.Substring(5)); + } + } + } - host = header[hostIndex..colonIndex]; - portIndex = colonIndex + 1; + if (string.IsNullOrEmpty(user)) user = initialUser; + return true; } - - var userIndex = header.IndexOf(isCommunityFormat ? "/?user=" : "/user=", StringComparison.Ordinal); - if (userIndex == -1) - return false; - -#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_1_OR_GREATER - if (!int.TryParse(header.AsSpan(portIndex, userIndex - portIndex), out port) || port <= 0) -#else - if (!int.TryParse(header[portIndex..userIndex], out port) || port <= 0) -#endif + catch (UriFormatException) + { return false; - - userIndex += isCommunityFormat ? 7 : 6; - var ampersandIndex = header.IndexOf('&', userIndex); - var newlineIndex = header.IndexOf('\n', userIndex); - var terminatorIndex = ampersandIndex == -1 ? (newlineIndex == -1 ? header.Length : newlineIndex) : - (newlineIndex == -1 ? ampersandIndex : Math.Min(ampersandIndex, newlineIndex)); - user = header[userIndex..terminatorIndex]; - return user.Length != 0; + } } public static TimeSpan ParseTimeSpan(ReadOnlySpan value) diff --git a/tests/IntegrationTests/RedirectionTests.cs b/tests/IntegrationTests/RedirectionTests.cs new file mode 100644 index 000000000..3d4c7bd58 --- /dev/null +++ b/tests/IntegrationTests/RedirectionTests.cs @@ -0,0 +1,199 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; + +namespace IntegrationTests; + +public class RedirectionTests : IClassFixture, IDisposable +{ + public RedirectionTests(DatabaseFixture database) + { + m_database = database; + m_database.Connection.Open(); + } + + public void Dispose() + { + m_database.Connection.Close(); + } + + + [Fact] + public void RedirectionTest() + { + StartProxy(); + + // wait for proxy to launch + Thread.Sleep(50); + var csb = AppConfig.CreateConnectionStringBuilder(); + var initialServer = csb.Server; + var initialPort = csb.Port; + var permitRedirection = true; + try + { + m_database.Connection.Execute( + $"set @@global.redirect_url=\"mariadb://{initialServer}:{initialPort}\""); + } + catch (Exception) + { + permitRedirection = false; + } + + if (permitRedirection) + { + try + { + // changing to proxy port + csb.Server = "localhost"; + csb.Port = (uint)proxy.ListenPort; + csb.ServerRedirectionMode = MySqlServerRedirectionMode.Preferred; + + // ensure that connection has been redirected + using (var db = new MySqlConnection(csb.ConnectionString)) + { + db.Open(); + using (var cmd = db.CreateCommand()) + { + cmd.CommandText = "SELECT 1"; + cmd.ExecuteNonQuery(); + } + + Assert.Contains(";Port=" + initialPort + ";", db.SessionConnectionString, + StringComparison.OrdinalIgnoreCase); + db.Close(); + } + + // ensure that connection has been redirected with Required + csb.ServerRedirectionMode = MySqlServerRedirectionMode.Required; + using (var db = new MySqlConnection(csb.ConnectionString)) + { + db.Open(); + using (var cmd = db.CreateCommand()) + { + cmd.CommandText = "SELECT 1"; + cmd.ExecuteNonQuery(); + } + + Assert.Contains(";Port=" + initialPort + ";", db.SessionConnectionString, + StringComparison.OrdinalIgnoreCase); + db.Close(); + } + + // ensure that redirection is not done + csb.ServerRedirectionMode = MySqlServerRedirectionMode.Disabled; + using (var db = new MySqlConnection(csb.ConnectionString)) + { + db.Open(); + using (var cmd = db.CreateCommand()) + { + cmd.CommandText = "SELECT 1"; + cmd.ExecuteNonQuery(); + } + + Assert.Contains(";Port=" + proxy.ListenPort + ";", db.SessionConnectionString, + StringComparison.OrdinalIgnoreCase); + db.Close(); + } + + } finally{ + m_database.Connection.Execute( + $"set @@global.redirect_url=\"\""); + } + MySqlConnection.ClearAllPools(); + // ensure that when required, throwing error if no redirection + csb.ServerRedirectionMode = MySqlServerRedirectionMode.Required; + using (var db = new MySqlConnection(csb.ConnectionString)) + { + try + { + db.Open(); + Assert.Fail("must have thrown error"); + } + catch (MySqlException ex) + { + Assert.Equal((int) MySqlErrorCode.UnableToConnectToHost, ex.Number); + } + } + } + StopProxy(); + } + + protected void StartProxy() + { + var csb = AppConfig.CreateConnectionStringBuilder(); + proxy = new ServerConfiguration( csb.Server, (int)csb.Port ); + Thread serverThread = new Thread( ServerThread ); + serverThread.Start( proxy ); + } + + protected void StopProxy() + { + proxy.RunServer = false; + proxy.ServerSocket.Close(); + } + + private class ServerConfiguration { + + public IPAddress RemoteAddress; + public int RemotePort; + public int ListenPort; + public Socket ServerSocket; + public ServerConfiguration(String remoteAddress, int remotePort) { + RemoteAddress = IPAddress.Parse( remoteAddress ); + RemotePort = remotePort; + ListenPort = 0; + } + public bool RunServer = true; + } + + private static void ServerThread(Object configObj) { + ServerConfiguration config = (ServerConfiguration)configObj; + Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + serverSocket.Bind( new IPEndPoint( IPAddress.Any, 0 ) ); + serverSocket.Listen(1); + config.ListenPort = ((IPEndPoint) serverSocket.LocalEndPoint).Port; + config.ServerSocket = serverSocket; + while( config.RunServer ) { + Socket client = serverSocket.Accept(); + Thread clientThread = new Thread( ClientThread ); + clientThread.Start( new ClientContext() { Config = config, Client = client } ); + } + } + + private class ClientContext { + public ServerConfiguration Config; + public Socket Client; + } + + private static void ClientThread(Object contextObj) { + ClientContext context = (ClientContext)contextObj; + Socket client = context.Client; + ServerConfiguration config = context.Config; + IPEndPoint remoteEndPoint = new IPEndPoint( config.RemoteAddress, config.RemotePort ); + Socket remote = new Socket( remoteEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + remote.Connect( remoteEndPoint ); + Byte[] buffer = new Byte[4096]; + for(;;) { + if (!config.RunServer) + { + remote.Close(); + client.Close(); + return; + } + if( client.Available > 0 ) { + var count = client.Receive( buffer ); + if( count == 0 ) return; + remote.Send( buffer, count, SocketFlags.None ); + } + if( remote.Available > 0 ) { + var count = remote.Receive( buffer ); + if( count == 0 ) return; + client.Send( buffer, count, SocketFlags.None ); + } + } + } + + readonly DatabaseFixture m_database; + private ServerConfiguration proxy; +} diff --git a/tests/MySqlConnector.Tests/UtilityTests.cs b/tests/MySqlConnector.Tests/UtilityTests.cs index 57cdcc1b1..676ccd663 100644 --- a/tests/MySqlConnector.Tests/UtilityTests.cs +++ b/tests/MySqlConnector.Tests/UtilityTests.cs @@ -7,22 +7,19 @@ namespace MySqlConnector.Tests; public class UtilityTests { + [Theory] - [InlineData("Location: mysql://host.example.com:1234/user=user@host", "host.example.com", 1234, "user@host")] - [InlineData("Location: mysql://host.example.com:1234/user=user@host\n", "host.example.com", 1234, "user@host")] - [InlineData("Location: mysql://host.example.com:1234/user=user@host&ttl=60", "host.example.com", 1234, "user@host")] - [InlineData("Location: mysql://host.example.com:1234/user=user@host&ttl=60\n", "host.example.com", 1234, "user@host")] - [InlineData("Location: mysql://[host.example.com]:1234/?user=abcd", "host.example.com", 1234, "abcd")] - [InlineData("Location: mysql://[host.example.com]:1234/?user=abcd\n", "host.example.com", 1234, "abcd")] - [InlineData("Location: mysql://[host.example.com]:1234/?user=abcd&ttl=60", "host.example.com", 1234, "abcd")] - [InlineData("Location: mysql://[host.example.com]:1234/?user=abcd&ttl=60\n", "host.example.com", 1234, "abcd")] - [InlineData("Location: mysql://[2001:4860:4860::8888]:1234/?user=abcd", "2001:4860:4860::8888", 1234, "abcd")] - [InlineData("Location: mysql://[2001:4860:4860::8888]:1234/?user=abcd\n", "2001:4860:4860::8888", 1234, "abcd")] - [InlineData("Location: mysql://[2001:4860:4860::8888]:1234/?user=abcd&ttl=60", "2001:4860:4860::8888", 1234, "abcd")] - [InlineData("Location: mysql://[2001:4860:4860::8888]:1234/?user=abcd&ttl=60\n", "2001:4860:4860::8888", 1234, "abcd")] + [InlineData("mariadb://host.example.com:1234/?user=user@host", "host.example.com", 1234, "user@host")] + [InlineData("mariadb://user%40host:password@host.example.com:1234/", "host.example.com", 1234, "user@host")] + [InlineData("mariadb://host.example.com:1234/?user=user@host&ttl=60", "host.example.com", 1234, "user@host")] + [InlineData("mariadb://someuser:password@host.example.com:1234/?user=user@host&ttl=60\n", "host.example.com", 1234, "someuser")] + [InlineData("mysql://[2001:4860:4860::8888]:1234/?user=abcd", "2001:4860:4860::8888", 1234, "abcd")] + [InlineData("mysql://[2001:4860:4860::8888]:1234/?user=abcd\n", "2001:4860:4860::8888", 1234, "abcd")] + [InlineData("mysql://[2001:4860:4860::8888]:1234/?user=abcd&ttl=60", "2001:4860:4860::8888", 1234, "abcd")] + [InlineData("mysql://[2001:4860:4860::8888]:1234/?user=abcd&ttl=60\n", "2001:4860:4860::8888", 1234, "abcd")] public void ParseRedirectionHeader(string input, string expectedHost, int expectedPort, string expectedUser) { - Assert.True(Utility.TryParseRedirectionHeader(input, out var host, out var port, out var user)); + Assert.True(Utility.TryParseRedirectionHeader(input, null, out var host, out var port, out var user)); Assert.Equal(expectedHost, host); Assert.Equal(expectedPort, port); Assert.Equal(expectedUser, user); @@ -30,26 +27,14 @@ public void ParseRedirectionHeader(string input, string expectedHost, int expect [Theory] [InlineData("")] - [InlineData("Location: mysql")] - [InlineData("Location: mysql://host.example.com")] - [InlineData("Location: mysql://host.example.com:")] - [InlineData("Location: mysql://[host.example.com")] - [InlineData("Location: mysql://[host.example.com]")] - [InlineData("Location: mysql://[host.example.com]:")] - [InlineData("Location: mysql://host.example.com:123")] - [InlineData("Location: mysql://host.example.com:123/")] - [InlineData("Location: mysql://[host.example.com]:123")] - [InlineData("Location: mysql://[host.example.com]:123/")] - [InlineData("Location: mysql://host.example.com:/user=")] - [InlineData("Location: mysql://host.example.com:123/user=")] - [InlineData("Location: mysql://[host.example.com]:123/?user=")] - [InlineData("Location: mysql://host.example.com:/user=user@host")] - [InlineData("Location: mysql://host.example.com:-1/user=user@host")] - [InlineData("Location: mysql://host.example.com:0/user=user@host")] - [InlineData("Location: mysql://[host.example.com]:123/user=abcd")] + [InlineData("not formated")] + [InlineData("mysql")] + [InlineData("mysql://[host.example.com")] + [InlineData("mysql://host.example.com:-1/user=user@host")] + [InlineData("mysql://[host.example.com]:123/user=abcd")] public void ParseRedirectionHeaderFails(string input) { - Assert.False(Utility.TryParseRedirectionHeader(input, out _, out _, out _)); + Assert.False(Utility.TryParseRedirectionHeader(input, null, out _, out _, out _)); } [Theory] From 4e58e4225e62025c8aa4ad1a7295c73bf8a2d1eb Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sun, 21 Jul 2024 14:01:29 -0700 Subject: [PATCH 02/20] Allow 'localhost' as a server address. Signed-off-by: Bradley Grainger --- tests/IntegrationTests/RedirectionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/IntegrationTests/RedirectionTests.cs b/tests/IntegrationTests/RedirectionTests.cs index 3d4c7bd58..1d373206e 100644 --- a/tests/IntegrationTests/RedirectionTests.cs +++ b/tests/IntegrationTests/RedirectionTests.cs @@ -139,7 +139,8 @@ private class ServerConfiguration { public int ListenPort; public Socket ServerSocket; public ServerConfiguration(String remoteAddress, int remotePort) { - RemoteAddress = IPAddress.Parse( remoteAddress ); + var ipHostEntry = Dns.GetHostEntry(remoteAddress); + RemoteAddress = ipHostEntry.AddressList[0]; RemotePort = remotePort; ListenPort = 0; } From e7b1fc7a5f53f85e8c1f1ff4d265198c31f861fe Mon Sep 17 00:00:00 2001 From: rusher Date: Tue, 23 Jul 2024 16:17:15 +0200 Subject: [PATCH 03/20] permit skipping redirection test change redirection logging Signed-off-by: rusher --- azure-pipelines.yml | 12 +++++----- src/MySqlConnector/Core/ServerSession.cs | 24 +++++++------------ src/MySqlConnector/Logging/Log.cs | 28 +++++++++++----------- src/MySqlConnector/MySqlConnection.cs | 1 + tests/IntegrationTests/RedirectionTests.cs | 3 +-- tests/IntegrationTests/ServerFeatures.cs | 6 +++++ 6 files changed, 36 insertions(+), 38 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index e14ef7d82..dbe7aaba9 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -187,27 +187,27 @@ jobs: 'MySQL 8.0': image: 'mysql:8.0' connectionStringExtra: 'AllowPublicKeyRetrieval=True' - unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime' + unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime,Redirection' 'MySQL 8.4': image: 'mysql:8.4' connectionStringExtra: 'AllowPublicKeyRetrieval=True' - unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime' + unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime,Redirection' 'MySQL 9.0': image: 'mysql:9.0' connectionStringExtra: 'AllowPublicKeyRetrieval=True' - unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime' + unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime,Redirection' 'MariaDB 10.6': image: 'mariadb:10.6' connectionStringExtra: '' - unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin' + unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin,Redirection' 'MariaDB 10.11': image: 'mariadb:10.11' connectionStringExtra: '' - unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin' + unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin,Redirection' 'MariaDB 11.4': image: 'mariadb:11.4' connectionStringExtra: '' - unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin' + unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin,Redirection' steps: - template: '.ci/integration-tests-steps.yml' parameters: diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index 0884675ea..4ac7675dc 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -576,14 +576,7 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella public static async ValueTask ConnectAndRedirectAsync(Func createSession, ILogger logger, int? poolId, ConnectionSettings cs, ILoadBalancer? loadBalancer, MySqlConnection connection, Action? logMessage, long startingTimestamp, Activity? activity, IOBehavior ioBehavior, CancellationToken cancellationToken) { var session = createSession(); - if (poolId is not null) - { - if (logger.IsEnabled(LogLevel.Debug)) logMessage!(logger, poolId.Value, session.Id, null); - } - else - { - Log.CreatedNonPooledSession(logger, session.Id); - } + if (poolId is not null && logger.IsEnabled(LogLevel.Debug)) logMessage!(logger, poolId.Value, session.Id, null); string? redirectionUrl; try @@ -597,13 +590,12 @@ public static async ValueTask ConnectAndRedirectAsync(Func ConnectAndRedirectAsync(Func ConnectAndRedirectAsync(Func CreateSessionAsync(ConnectionPool? pool, RandomLoadBalancer.Instance : FailOverLoadBalancer.Instance; var session = await ServerSession.ConnectAndRedirectAsync(() => new ServerSession(m_logger), m_logger, null, connectionSettings, loadBalancer, this, null, startingTimestamp, null, actualIOBehavior, cancellationToken).ConfigureAwait(false); session.OwningConnection = new WeakReference(this); + Log.CreatedNonPooledSession(m_logger, session.Id); return session; } } diff --git a/tests/IntegrationTests/RedirectionTests.cs b/tests/IntegrationTests/RedirectionTests.cs index 1d373206e..181d6bf10 100644 --- a/tests/IntegrationTests/RedirectionTests.cs +++ b/tests/IntegrationTests/RedirectionTests.cs @@ -17,8 +17,7 @@ public void Dispose() m_database.Connection.Close(); } - - [Fact] + [SkippableFact(ServerFeatures.Redirection)] public void RedirectionTest() { StartProxy(); diff --git a/tests/IntegrationTests/ServerFeatures.cs b/tests/IntegrationTests/ServerFeatures.cs index cefe5d377..bfbec9fe8 100644 --- a/tests/IntegrationTests/ServerFeatures.cs +++ b/tests/IntegrationTests/ServerFeatures.cs @@ -35,4 +35,10 @@ public enum ServerFeatures /// A "SLEEP" command produces a result set when it is cancelled, not an error payload. /// CancelSleepSuccessfully = 0x40_0000, + + /// + /// Server permit redirection, available on first OK_Packet + /// + Redirection = 0x80_0000, + } From 89beadf74ec648d5314d86901a682a5c3c0e6720 Mon Sep 17 00:00:00 2001 From: rusher Date: Fri, 26 Jul 2024 16:23:13 +0200 Subject: [PATCH 04/20] Using TLS without configuration Connector implementation of https://jira.mariadb.org/browse/MDEV-31855 Since MariaDB 11.4.1, TLS use has greatly been simplified. Connector side doesn't require TLS configuration anymore, even with self-signed certificates. connectors now validate ssl certificates using client password (using seed and server certificate SHA256 thumbprint). limitations: * only possible when using mysql_native_password/client_ed25519 authentication * password is required see https://mariadb.org/mission-impossible-zero-configuration-ssl/ Signed-off-by: rusher --- .ci/config/config.compression+ssl.json | 2 +- .ci/config/config.compression.json | 2 +- .ci/config/config.json | 2 +- .ci/config/config.ssl.json | 2 +- azure-pipelines.yml | 10 +- .../Ed25519AuthenticationPlugin.cs | 23 ++++ .../Authentication/IAuthenticationPlugin.cs | 10 ++ src/MySqlConnector/Core/ServerSession.cs | 111 ++++++++++++++++++ .../Protocol/Payloads/OkPayload.cs | 6 +- .../Serialization/AuthenticationUtility.cs | 6 +- tests/IntegrationTests/Attributes.cs | 15 +++ tests/IntegrationTests/ServerFeatures.cs | 4 + tests/IntegrationTests/SslTests.cs | 62 ++++++++++ tests/README.md | 1 + 14 files changed, 242 insertions(+), 14 deletions(-) diff --git a/.ci/config/config.compression+ssl.json b/.ci/config/config.compression+ssl.json index 8a11c95bf..07597b147 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": "RsaEncryption,CachingSha2Password,Tls12,Tls13,UuidToBin", + "UnsupportedFeatures": "RsaEncryption,CachingSha2Password,Tls12,Tls13,UuidToBin,TlsFingerprintValidation", "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 f42f53ab6..0d16fa8c4 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,StreamingResults,Tls11,UnixDomainSocket,ZeroDateTime", + "UnsupportedFeatures": "Ed25519,QueryAttributes,StreamingResults,Tls11,UnixDomainSocket,ZeroDateTime,TlsFingerprintValidation", "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 183b2299c..cbd796729 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,StreamingResults,Tls11,UnixDomainSocket,ZeroDateTime", + "UnsupportedFeatures": "Ed25519,QueryAttributes,StreamingResults,Tls11,UnixDomainSocket,ZeroDateTime,TlsFingerprintValidation", "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 84261b1be..094cd9f51 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": "RsaEncryption,CachingSha2Password,Tls12,Tls13,UuidToBin", + "UnsupportedFeatures": "RsaEncryption,CachingSha2Password,Tls12,Tls13,UuidToBin,TlsFingerprintValidation", "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 dbe7aaba9..d8e7c23d0 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -187,23 +187,23 @@ jobs: 'MySQL 8.0': image: 'mysql:8.0' connectionStringExtra: 'AllowPublicKeyRetrieval=True' - unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime,Redirection' + unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime,Redirection,TlsFingerprintValidation' 'MySQL 8.4': image: 'mysql:8.4' connectionStringExtra: 'AllowPublicKeyRetrieval=True' - unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime,Redirection' + unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime,Redirection,TlsFingerprintValidation' 'MySQL 9.0': image: 'mysql:9.0' connectionStringExtra: 'AllowPublicKeyRetrieval=True' - unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime,Redirection' + unsupportedFeatures: 'Ed25519,StreamingResults,Tls11,ZeroDateTime,Redirection,TlsFingerprintValidation' 'MariaDB 10.6': image: 'mariadb:10.6' connectionStringExtra: '' - unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin,Redirection' + unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin,Redirection,TlsFingerprintValidation' 'MariaDB 10.11': image: 'mariadb:10.11' connectionStringExtra: '' - unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin,Redirection' + unsupportedFeatures: 'CachingSha2Password,CancelSleepSuccessfully,Json,RoundDateTime,QueryAttributes,Sha256Password,Tls11,UuidToBin,Redirection,TlsFingerprintValidation' '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..70a60ba72 100644 --- a/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs +++ b/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs @@ -157,6 +157,29 @@ public byte[] CreateResponse(string password, ReadOnlySpan authenticationD return result; } + /// + /// Creates the ed25519 password hash. + /// + public byte[] CreatePasswordHash(string password, ReadOnlySpan authenticationData) + { + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + using var sha512 = SHA512.Create(); + byte[] az = sha512.ComputeHash(passwordBytes); + ScalarOperations.sc_clamp(az, 0); + + byte[] sm = new byte[64 + authenticationData.Length]; + authenticationData.CopyTo(sm.AsSpan().Slice(64)); + Buffer.BlockCopy(az, 32, sm, 32, 32); + sha512.ComputeHash(sm, 32, authenticationData.Length + 32); + + GroupOperations.ge_scalarmult_base(out var A, az, 0); + GroupOperations.ge_p3_tobytes(sm, 32, ref A); + + byte[] res = new byte[32]; + Array.Copy(sm, 32, res, 0, 32); + return res; + } + private Ed25519AuthenticationPlugin() { } diff --git a/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs b/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs index 063bd4c8b..0a1ba005a 100644 --- a/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs +++ b/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs @@ -19,4 +19,14 @@ public interface IAuthenticationPlugin /// Method Switch Request Packet. /// The authentication response. byte[] CreateResponse(string password, ReadOnlySpan authenticationData); + + /// + /// create password hash for 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. + /// password hash + byte[] CreatePasswordHash(string password, ReadOnlySpan authenticationData); } diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index 4ac7675dc..34784c318 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -18,6 +18,9 @@ using MySqlConnector.Protocol.Payloads; using MySqlConnector.Protocol.Serialization; using MySqlConnector.Utilities; +#if NET5_0_OR_GREATER +using System.Runtime.CompilerServices; +#endif namespace MySqlConnector.Core; @@ -534,6 +537,44 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella } var ok = OkPayload.Create(payload.Span, this); + if (m_rcbPolicyErrors != SslPolicyErrors.None) + { + // SSL would normally have thrown error, so connector need to ensure server certificates + // 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) + if (cs.ConnectionProtocol != MySqlConnectionProtocol.UnixSocket) + { + if (string.IsNullOrEmpty(password) || + !ValidateFingerPrint(ok.StatusInfo, initialHandshake.AuthPluginData, password!)) + { + // fingerprint validation fail. + // now throwing SSL exception depending on m_rcbPolicyErrors + ShutdownSocket(); + HostName = ""; + lock (m_lock) m_state = State.Failed; + MySqlException ex; + switch (m_rcbPolicyErrors) + { + case SslPolicyErrors.RemoteCertificateNotAvailable: + // impossible + ex = new MySqlException(MySqlErrorCode.UnableToConnectToHost, "SSL not validated, no remote certificate available"); + break; + + case SslPolicyErrors.RemoteCertificateNameMismatch: + ex = new MySqlException(MySqlErrorCode.UnableToConnectToHost, "SSL not validated, certificate name mismatch"); + break; + + default: + ex = new MySqlException(MySqlErrorCode.UnableToConnectToHost, "SSL not validated, certificate chain validation fail"); + break; + } + Log.CouldNotInitializeTlsConnection(m_logger, ex, Id); + throw ex; + } + } + } + var redirectionUrl = ok.RedirectionUrl; if (m_useCompression) @@ -573,6 +614,57 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella } } + /// + /// Validate SSL validation has + /// + /// received validation hash + /// initial seed + /// password + /// true if validated + private bool ValidateFingerPrint(byte[]? validationHash, ReadOnlySpan challenge, string password) + { + if (validationHash is null || validationHash.Length == 0) return false; + + // ensure using SHA256 encryption + if (validationHash[0] != 0x01) + throw new FormatException($"Unexpected validation hash format. expected 0x01 but got 0x{validationHash[0]:X2}"); + + byte[] passwordHashResult; + switch (m_pluginName) + { + case "mysql_native_password": + passwordHashResult = AuthenticationUtility.HashPassword(challenge, password, false); + break; + + case "client_ed25519": + AuthenticationPlugins.TryGetPlugin("client_ed25519", out var ed25519Plugin); + passwordHashResult = ed25519Plugin!.CreatePasswordHash(password, challenge); + break; + + default: + return false; + } + + Span combined = stackalloc byte[32 + (challenge.Length - 1) + passwordHashResult.Length]; + passwordHashResult.CopyTo(combined); + challenge.CopyTo(combined[passwordHashResult.Length..]); + m_sha2Thumbprint!.CopyTo(combined[(passwordHashResult.Length + challenge.Length - 1)..]); + + byte[] hashBytes; +#if NET5_0_OR_GREATER + hashBytes = SHA256.HashData(combined); +#else + using (var sha256 = SHA256.Create()) + { + hashBytes = sha256.ComputeHash(combined.ToArray()); + } +#endif + + var clientGeneratedHash = hashBytes.Aggregate(string.Empty, (str, hashByte) => str + hashByte.ToString("X2", CultureInfo.InvariantCulture)); + var serverGeneratedHash = Encoding.ASCII.GetString(validationHash, 1, validationHash.Length - 1); + return string.Equals(clientGeneratedHash, serverGeneratedHash, StringComparison.Ordinal); + } + public static async ValueTask ConnectAndRedirectAsync(Func createSession, ILogger logger, int? poolId, ConnectionSettings cs, ILoadBalancer? loadBalancer, MySqlConnection connection, Action? logMessage, long startingTimestamp, Activity? activity, IOBehavior ioBehavior, CancellationToken cancellationToken) { var session = createSession(); @@ -734,6 +826,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_pluginName = switchRequest.Name; switch (switchRequest.Name) { case "mysql_native_password": @@ -1490,6 +1583,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 !NET5_0_OR_GREATER + using (var sha256 = SHA256.Create()) + { + m_sha2Thumbprint = sha256.ComputeHash(cert2.RawData); + } +#else + m_sha2Thumbprint = SHA256.HashData(cert2.RawData); +#endif + m_rcbPolicyErrors = rcbPolicyErrors; + return true; + } + return rcbPolicyErrors == SslPolicyErrors.None; } @@ -2012,4 +2120,7 @@ protected override void OnStatementBegin(int index) private PayloadData m_setNamesPayload; private byte[]? m_pipelinedResetConnectionBytes; private Dictionary? m_preparedStatements; + private string m_pluginName = "mysql_native_password"; + private byte[]? m_sha2Thumbprint; + private SslPolicyErrors m_rcbPolicyErrors; } 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..6e8c7e0b8 100644 --- a/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs +++ b/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs @@ -25,19 +25,20 @@ 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, true); /// /// 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. + /// must xor results. /// 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 withXor) { #if !NET5_0_OR_GREATER using var sha1 = SHA1.Create(); @@ -56,6 +57,7 @@ public static byte[] HashPassword(ReadOnlySpan challenge, string password) sha1.TryComputeHash(passwordBytes, hashedPassword, out _); sha1.TryComputeHash(hashedPassword, combined[20..], out _); #endif + if (!withXor) return combined[20..].ToArray(); Span xorBytes = stackalloc byte[20]; #if NET5_0_OR_GREATER diff --git a/tests/IntegrationTests/Attributes.cs b/tests/IntegrationTests/Attributes.cs index 17a6b101f..d7401f54d 100644 --- a/tests/IntegrationTests/Attributes.cs +++ b/tests/IntegrationTests/Attributes.cs @@ -12,6 +12,11 @@ public SkippableFactAttribute(ServerFeatures serverFeatures) { } + public SkippableFactAttribute(ServerFeatures[] serverFeatureList) + : this(serverFeatureList, ConfigSettings.None) + { + } + public SkippableFactAttribute(ConfigSettings configSettings) : this(ServerFeatures.None, configSettings) { @@ -22,6 +27,16 @@ public SkippableFactAttribute(ServerFeatures serverFeatures, ConfigSettings conf Skip = TestUtilities.GetSkipReason(serverFeatures, configSettings); } + public SkippableFactAttribute(ServerFeatures[] serverFeatureList, ConfigSettings configSettings) + { + Skip = null; + foreach (ServerFeatures serverFeatures in serverFeatureList) + { + Skip = TestUtilities.GetSkipReason(serverFeatures, configSettings); + if (Skip is not null) break; + } + } + public string MySqlData { get => null; diff --git a/tests/IntegrationTests/ServerFeatures.cs b/tests/IntegrationTests/ServerFeatures.cs index bfbec9fe8..503f53151 100644 --- a/tests/IntegrationTests/ServerFeatures.cs +++ b/tests/IntegrationTests/ServerFeatures.cs @@ -41,4 +41,8 @@ public enum ServerFeatures /// Redirection = 0x80_0000, + /// + /// Server permit redirection, available on first OK_Packet + /// + TlsFingerprintValidation = 0x100_0000, } diff --git a/tests/IntegrationTests/SslTests.cs b/tests/IntegrationTests/SslTests.cs index e22c034fb..8ac988a70 100644 --- a/tests/IntegrationTests/SslTests.cs +++ b/tests/IntegrationTests/SslTests.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; +using MySqlConnector.Authentication.Ed25519; namespace IntegrationTests; @@ -205,6 +206,67 @@ public async Task ConnectSslRemoteCertificateValidationCallback(MySqlSslMode ssl } #endif +#if !MYSQL_DATA + [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(); + await m_database.Connection.ExecuteAsync( + @"CREATE USER IF NOT EXISTS 'sslUser'@'%' IDENTIFIED WITH mysql_native_password USING PASSWORD('!Passw0rd3Works') REQUIRE SSL; +GRANT SELECT ON *.* TO 'sslUser'@'%'"); + try { + csb.CertificateFile = null; + csb.SslMode = MySqlSslMode.VerifyFull; + csb.SslCa = ""; + csb.UserID = "sslUser"; + csb.Password = "!Passw0rd3Works"; + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + connection.Close(); + } + finally + { + await m_database.Connection.ExecuteAsync("DROP USER IF EXISTS 'sslUser'@'%'"); + m_database.Connection.Close(); + } + } + + [SkippableFact(new ServerFeatures[]{ServerFeatures.TlsFingerprintValidation,ServerFeatures.Ed25519})] + public async Task ConnectZeroConfigurationSslEd25519() + { + Ed25519AuthenticationPlugin.Install(); + var csb = AppConfig.CreateConnectionStringBuilder(); + try + { + await m_database.Connection.ExecuteAsync("INSTALL SONAME 'auth_ed25519'"); + } + catch (Exception) + { + // eat if already installed + } + await m_database.Connection.ExecuteAsync( + @"CREATE USER IF NOT EXISTS 'sslUser'@'%' IDENTIFIED WITH ed25519 USING PASSWORD('!Passw0rd3Works') REQUIRE SSL; +GRANT SELECT ON *.* TO 'sslUser'@'%'"); + try { + csb.CertificateFile = null; + csb.SslMode = MySqlSslMode.VerifyFull; + csb.SslCa = ""; + csb.UserID = "sslUser"; + csb.Password = "!Passw0rd3Works"; + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + connection.Close(); + } + finally + { + await m_database.Connection.ExecuteAsync("DROP USER IF EXISTS 'sslUser'@'%'"); + m_database.Connection.Close(); + } + } +#endif + [SkippableFact(ConfigSettings.RequiresSsl)] public async Task ConnectSslTlsVersion() { diff --git a/tests/README.md b/tests/README.md index bd5267639..c0fa0443b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -38,6 +38,7 @@ Otherwise, set the following options appropriately: * `Tls13`: server supports TLS 1.3 * `UnixDomainSocket`: server is accessible via a Unix domain socket * `UuidToBin`: server supports `UUID_TO_BIN` (MySQL 8.0 and later) + * `UnsupportedFeatures`: server supports ## Running Tests From d429b35a5f0eea68873e6801b80c4575674cca1a Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 27 Jul 2024 18:29:07 -0700 Subject: [PATCH 05/20] Delete setup step that should be done outside of tests. Signed-off-by: Bradley Grainger --- tests/IntegrationTests/SslTests.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/IntegrationTests/SslTests.cs b/tests/IntegrationTests/SslTests.cs index 8ac988a70..1813d8850 100644 --- a/tests/IntegrationTests/SslTests.cs +++ b/tests/IntegrationTests/SslTests.cs @@ -238,14 +238,6 @@ public async Task ConnectZeroConfigurationSslEd25519() { Ed25519AuthenticationPlugin.Install(); var csb = AppConfig.CreateConnectionStringBuilder(); - try - { - await m_database.Connection.ExecuteAsync("INSTALL SONAME 'auth_ed25519'"); - } - catch (Exception) - { - // eat if already installed - } await m_database.Connection.ExecuteAsync( @"CREATE USER IF NOT EXISTS 'sslUser'@'%' IDENTIFIED WITH ed25519 USING PASSWORD('!Passw0rd3Works') REQUIRE SSL; GRANT SELECT ON *.* TO 'sslUser'@'%'"); From 45dddae2ec1d2b0c0e1cd149cc52df000bdfb51c Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 27 Jul 2024 18:33:02 -0700 Subject: [PATCH 06/20] Delete unnecessary attribute constructor. Signed-off-by: Bradley Grainger --- tests/IntegrationTests/Attributes.cs | 15 --------------- tests/IntegrationTests/SslTests.cs | 2 +- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/tests/IntegrationTests/Attributes.cs b/tests/IntegrationTests/Attributes.cs index d7401f54d..17a6b101f 100644 --- a/tests/IntegrationTests/Attributes.cs +++ b/tests/IntegrationTests/Attributes.cs @@ -12,11 +12,6 @@ public SkippableFactAttribute(ServerFeatures serverFeatures) { } - public SkippableFactAttribute(ServerFeatures[] serverFeatureList) - : this(serverFeatureList, ConfigSettings.None) - { - } - public SkippableFactAttribute(ConfigSettings configSettings) : this(ServerFeatures.None, configSettings) { @@ -27,16 +22,6 @@ public SkippableFactAttribute(ServerFeatures serverFeatures, ConfigSettings conf Skip = TestUtilities.GetSkipReason(serverFeatures, configSettings); } - public SkippableFactAttribute(ServerFeatures[] serverFeatureList, ConfigSettings configSettings) - { - Skip = null; - foreach (ServerFeatures serverFeatures in serverFeatureList) - { - Skip = TestUtilities.GetSkipReason(serverFeatures, configSettings); - if (Skip is not null) break; - } - } - public string MySqlData { get => null; diff --git a/tests/IntegrationTests/SslTests.cs b/tests/IntegrationTests/SslTests.cs index 1813d8850..608052b01 100644 --- a/tests/IntegrationTests/SslTests.cs +++ b/tests/IntegrationTests/SslTests.cs @@ -233,7 +233,7 @@ await m_database.Connection.ExecuteAsync( } } - [SkippableFact(new ServerFeatures[]{ServerFeatures.TlsFingerprintValidation,ServerFeatures.Ed25519})] + [SkippableFact(ServerFeatures.TlsFingerprintValidation | ServerFeatures.Ed25519)] public async Task ConnectZeroConfigurationSslEd25519() { Ed25519AuthenticationPlugin.Install(); From aff3418382c19c909adb20fdc7b22319d980769b Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 27 Jul 2024 18:52:06 -0700 Subject: [PATCH 07/20] Make Ed25519AuthenticationPlugin.Install threadsafe. This can be executed concurrently by multithreaded tests. Signed-off-by: Bradley Grainger --- .../Ed25519AuthenticationPlugin.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs b/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs index 70a60ba72..06db1e6c0 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; @@ -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; - } } /// @@ -184,5 +182,5 @@ private Ed25519AuthenticationPlugin() { } - private static bool s_isInstalled; + private static int s_isInstalled; } From ec7f8e0da8a8b36c61cb3af86aba3459f4b844cd Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 27 Jul 2024 18:52:36 -0700 Subject: [PATCH 08/20] Use existing test accounts instead of creating new ones. Signed-off-by: Bradley Grainger --- tests/IntegrationTests/SslTests.cs | 50 +++++++++--------------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/tests/IntegrationTests/SslTests.cs b/tests/IntegrationTests/SslTests.cs index 608052b01..6611e89e5 100644 --- a/tests/IntegrationTests/SslTests.cs +++ b/tests/IntegrationTests/SslTests.cs @@ -213,24 +213,13 @@ public async Task ConnectZeroConfigurationSslNative() // permit connection without any Ssl configuration. // reference https://mariadb.org/mission-impossible-zero-configuration-ssl/ var csb = AppConfig.CreateConnectionStringBuilder(); - await m_database.Connection.ExecuteAsync( - @"CREATE USER IF NOT EXISTS 'sslUser'@'%' IDENTIFIED WITH mysql_native_password USING PASSWORD('!Passw0rd3Works') REQUIRE SSL; -GRANT SELECT ON *.* TO 'sslUser'@'%'"); - try { - csb.CertificateFile = null; - csb.SslMode = MySqlSslMode.VerifyFull; - csb.SslCa = ""; - csb.UserID = "sslUser"; - csb.Password = "!Passw0rd3Works"; - using var connection = new MySqlConnection(csb.ConnectionString); - await connection.OpenAsync(); - connection.Close(); - } - finally - { - await m_database.Connection.ExecuteAsync("DROP USER IF EXISTS 'sslUser'@'%'"); - m_database.Connection.Close(); - } + 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(); } [SkippableFact(ServerFeatures.TlsFingerprintValidation | ServerFeatures.Ed25519)] @@ -238,24 +227,13 @@ public async Task ConnectZeroConfigurationSslEd25519() { Ed25519AuthenticationPlugin.Install(); var csb = AppConfig.CreateConnectionStringBuilder(); - await m_database.Connection.ExecuteAsync( - @"CREATE USER IF NOT EXISTS 'sslUser'@'%' IDENTIFIED WITH ed25519 USING PASSWORD('!Passw0rd3Works') REQUIRE SSL; -GRANT SELECT ON *.* TO 'sslUser'@'%'"); - try { - csb.CertificateFile = null; - csb.SslMode = MySqlSslMode.VerifyFull; - csb.SslCa = ""; - csb.UserID = "sslUser"; - csb.Password = "!Passw0rd3Works"; - using var connection = new MySqlConnection(csb.ConnectionString); - await connection.OpenAsync(); - connection.Close(); - } - finally - { - await m_database.Connection.ExecuteAsync("DROP USER IF EXISTS 'sslUser'@'%'"); - m_database.Connection.Close(); - } + 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 From c354752d6429f61bc8cf19ba4af05a9f458432ad Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 27 Jul 2024 18:56:15 -0700 Subject: [PATCH 09/20] Fix MySql.Data build. Signed-off-by: Bradley Grainger --- azure-pipelines.yml | 6 +++--- tests/IntegrationTests/SslTests.cs | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 81bbe1352..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 diff --git a/tests/IntegrationTests/SslTests.cs b/tests/IntegrationTests/SslTests.cs index 6611e89e5..7c3759624 100644 --- a/tests/IntegrationTests/SslTests.cs +++ b/tests/IntegrationTests/SslTests.cs @@ -1,7 +1,6 @@ using System.Runtime.InteropServices; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; -using MySqlConnector.Authentication.Ed25519; namespace IntegrationTests; @@ -206,7 +205,6 @@ public async Task ConnectSslRemoteCertificateValidationCallback(MySqlSslMode ssl } #endif -#if !MYSQL_DATA [SkippableFact(ServerFeatures.TlsFingerprintValidation)] public async Task ConnectZeroConfigurationSslNative() { @@ -222,10 +220,11 @@ public async Task ConnectZeroConfigurationSslNative() await connection.OpenAsync(); } +#if !MYSQL_DATA [SkippableFact(ServerFeatures.TlsFingerprintValidation | ServerFeatures.Ed25519)] public async Task ConnectZeroConfigurationSslEd25519() { - Ed25519AuthenticationPlugin.Install(); + MySqlConnector.Authentication.Ed25519.Ed25519AuthenticationPlugin.Install(); var csb = AppConfig.CreateConnectionStringBuilder(); csb.CertificateFile = null; csb.SslMode = MySqlSslMode.VerifyFull; From 003934c029cb27dda85136af78cbe4d37a5dfa0c Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 27 Jul 2024 18:58:40 -0700 Subject: [PATCH 10/20] Delete SessionConnectionString property. This was a result of a bad merge. Signed-off-by: Bradley Grainger --- src/MySqlConnector/Core/ServerSession.cs | 7 +++---- src/MySqlConnector/MySqlConnection.cs | 2 -- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index 4265d627d..a3d292104 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -49,7 +49,6 @@ public ServerSession(ILogger logger, IConnectionPoolMetadata pool) public bool SupportsPerQueryVariables => ServerVersion.IsMariaDb && ServerVersion.Version >= ServerVersions.MariaDbSupportsPerQueryVariables; public int ActiveCommandId { get; private set; } public int CancellationTimeout { get; private set; } - public string? ConnectionString { get; private set; } public int ConnectionId { get; set; } public byte[]? AuthPluginData { get; set; } public long CreatedTimestamp { get; } @@ -402,16 +401,16 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella // set activity tags { - ConnectionString = cs.ConnectionStringBuilder.GetConnectionString(cs.ConnectionStringBuilder.PersistSecurityInfo); + var connectionString = cs.ConnectionStringBuilder.GetConnectionString(cs.ConnectionStringBuilder.PersistSecurityInfo); m_activityTags.Add(ActivitySourceHelper.DatabaseSystemTagName, ActivitySourceHelper.DatabaseSystemValue); - m_activityTags.Add(ActivitySourceHelper.DatabaseConnectionStringTagName, ConnectionString); + m_activityTags.Add(ActivitySourceHelper.DatabaseConnectionStringTagName, connectionString); m_activityTags.Add(ActivitySourceHelper.DatabaseUserTagName, cs.UserID); if (cs.Database.Length != 0) m_activityTags.Add(ActivitySourceHelper.DatabaseNameTagName, cs.Database); if (activity is { IsAllDataRequested: true }) { activity.SetTag(ActivitySourceHelper.DatabaseSystemTagName, ActivitySourceHelper.DatabaseSystemValue) - .SetTag(ActivitySourceHelper.DatabaseConnectionStringTagName, ConnectionString) + .SetTag(ActivitySourceHelper.DatabaseConnectionStringTagName, connectionString) .SetTag(ActivitySourceHelper.DatabaseUserTagName, cs.UserID); if (cs.Database.Length != 0) activity.SetTag(ActivitySourceHelper.DatabaseNameTagName, cs.Database); diff --git a/src/MySqlConnector/MySqlConnection.cs b/src/MySqlConnector/MySqlConnection.cs index 2d7170e51..571af0e51 100644 --- a/src/MySqlConnector/MySqlConnection.cs +++ b/src/MySqlConnector/MySqlConnection.cs @@ -629,8 +629,6 @@ public override string ConnectionString } } - public string? SessionConnectionString => m_session?.ConnectionString; - public override string Database => m_session?.DatabaseOverride ?? GetConnectionSettings().Database; public override ConnectionState State => m_connectionState; From 71e680a3de7c2f7a89febd1dfe5f1952151ecb80 Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 27 Jul 2024 18:59:54 -0700 Subject: [PATCH 11/20] Revert word-wrapping. Signed-off-by: Bradley Grainger --- src/MySqlConnector/Core/ConnectionPool.cs | 10 +++------- tests/MySqlConnector.Tests/UtilityTests.cs | 1 - 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/MySqlConnector/Core/ConnectionPool.cs b/src/MySqlConnector/Core/ConnectionPool.cs index 1aab23e39..a11391cd5 100644 --- a/src/MySqlConnector/Core/ConnectionPool.cs +++ b/src/MySqlConnector/Core/ConnectionPool.cs @@ -74,11 +74,8 @@ public async ValueTask GetSessionAsync(MySqlConnection connection if (ConnectionSettings.ConnectionReset || session.DatabaseOverride is not null) { if (timeoutMilliseconds != 0) - session.SetTimeout(Math.Max(1, - timeoutMilliseconds - Utility.GetElapsedMilliseconds(startingTimestamp))); - reuseSession = await session - .TryResetConnectionAsync(ConnectionSettings, connection, ioBehavior, cancellationToken) - .ConfigureAwait(false); + session.SetTimeout(Math.Max(1, timeoutMilliseconds - Utility.GetElapsedMilliseconds(startingTimestamp))); + reuseSession = await session.TryResetConnectionAsync(ConnectionSettings, connection, ioBehavior, cancellationToken).ConfigureAwait(false); session.SetTimeout(Constants.InfiniteTimeout); } else @@ -110,8 +107,7 @@ public async ValueTask GetSessionAsync(MySqlConnection connection Log.ReturningPooledSession(m_logger, Id, session.Id, leasedSessionsCountPooled); session.LastLeasedTimestamp = Stopwatch.GetTimestamp(); - MetricsReporter.RecordWaitTime(this, - Utility.GetElapsedSeconds(startingTimestamp, session.LastLeasedTimestamp)); + MetricsReporter.RecordWaitTime(this, Utility.GetElapsedSeconds(startingTimestamp, session.LastLeasedTimestamp)); return session; } } diff --git a/tests/MySqlConnector.Tests/UtilityTests.cs b/tests/MySqlConnector.Tests/UtilityTests.cs index 676ccd663..8040c2284 100644 --- a/tests/MySqlConnector.Tests/UtilityTests.cs +++ b/tests/MySqlConnector.Tests/UtilityTests.cs @@ -7,7 +7,6 @@ namespace MySqlConnector.Tests; public class UtilityTests { - [Theory] [InlineData("mariadb://host.example.com:1234/?user=user@host", "host.example.com", 1234, "user@host")] [InlineData("mariadb://user%40host:password@host.example.com:1234/", "host.example.com", 1234, "user@host")] From 43ee64d038087bc3e4ea1a33fc9d4775b600c84a Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 27 Jul 2024 20:31:08 -0700 Subject: [PATCH 12/20] Allow SSL tests to pass via fingerprint validation. Signed-off-by: Bradley Grainger --- tests/IntegrationTests/MySqlDataSourceTests.cs | 2 +- tests/IntegrationTests/SslTests.cs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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/SslTests.cs b/tests/IntegrationTests/SslTests.cs index 7c3759624..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,7 +201,7 @@ 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()); From 4a99d6aedb0308b30c1617723a851c7cfe23f423 Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 27 Jul 2024 21:25:56 -0700 Subject: [PATCH 13/20] Add IAuthenticationMethod2 interface to avoid breaking change. Refactor duplicated code in Ed25519AuthenticationPlugin. Signed-off-by: Bradley Grainger --- .../Ed25519AuthenticationPlugin.cs | 48 +++++++++---------- .../Authentication/IAuthenticationPlugin.cs | 10 +++- src/MySqlConnector/Core/ServerSession.cs | 25 +++++----- 3 files changed, 42 insertions(+), 41 deletions(-) diff --git a/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs b/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs index 06db1e6c0..7d362a08c 100644 --- a/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs +++ b/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs @@ -10,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 @@ -31,6 +31,24 @@ 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; + } + + /// + /// Creates the authentication response. + /// + 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 @@ -109,6 +127,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); @@ -152,30 +173,7 @@ public byte[] CreateResponse(string password, ReadOnlySpan authenticationD var result = new byte[64]; Buffer.BlockCopy(sm, 0, result, 0, result.Length); - return result; - } - - /// - /// Creates the ed25519 password hash. - /// - public byte[] CreatePasswordHash(string password, ReadOnlySpan authenticationData) - { - byte[] passwordBytes = Encoding.UTF8.GetBytes(password); - using var sha512 = SHA512.Create(); - byte[] az = sha512.ComputeHash(passwordBytes); - ScalarOperations.sc_clamp(az, 0); - - byte[] sm = new byte[64 + authenticationData.Length]; - authenticationData.CopyTo(sm.AsSpan().Slice(64)); - Buffer.BlockCopy(az, 32, sm, 32, 32); - sha512.ComputeHash(sm, 32, authenticationData.Length + 32); - - GroupOperations.ge_scalarmult_base(out var A, az, 0); - GroupOperations.ge_p3_tobytes(sm, 32, ref A); - - byte[] res = new byte[32]; - Array.Copy(sm, 32, res, 0, 32); - return res; + authenticationResponse = result; } private Ed25519AuthenticationPlugin() diff --git a/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs b/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs index 0a1ba005a..6dfface4e 100644 --- a/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs +++ b/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs @@ -19,14 +19,20 @@ public interface IAuthenticationPlugin /// Method Switch Request Packet. /// 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 +{ /// - /// create password hash for fingerprint verification + /// 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. - /// password hash + /// 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 a3d292104..b65140554 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -18,9 +18,6 @@ using MySqlConnector.Protocol.Payloads; using MySqlConnector.Protocol.Serialization; using MySqlConnector.Utilities; -#if NET5_0_OR_GREATER -using System.Runtime.CompilerServices; -#endif namespace MySqlConnector.Core; @@ -539,8 +536,7 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella // * auth plugin is MitM-proof and check SHA2(user's hashed password, scramble, certificate fingerprint) if (cs.ConnectionProtocol != MySqlConnectionProtocol.UnixSocket) { - if (string.IsNullOrEmpty(password) || - !ValidateFingerPrint(ok.StatusInfo, initialHandshake.AuthPluginData, password!)) + if (string.IsNullOrEmpty(password) || !ValidateFingerprint(ok.StatusInfo, initialHandshake.AuthPluginData.AsSpan(0, 20), password!)) { // fingerprint validation fail. // now throwing SSL exception depending on m_rcbPolicyErrors @@ -615,15 +611,16 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella /// initial seed /// password /// true if validated - private bool ValidateFingerPrint(byte[]? validationHash, ReadOnlySpan challenge, string password) + private bool ValidateFingerprint(byte[]? validationHash, ReadOnlySpan challenge, string password) { - if (validationHash is null || validationHash.Length == 0) return false; + if (validationHash?.Length != 65) + return false; // ensure using SHA256 encryption if (validationHash[0] != 0x01) throw new FormatException($"Unexpected validation hash format. expected 0x01 but got 0x{validationHash[0]:X2}"); - byte[] passwordHashResult; + byte[]? passwordHashResult = null; switch (m_pluginName) { case "mysql_native_password": @@ -632,17 +629,17 @@ private bool ValidateFingerPrint(byte[]? validationHash, ReadOnlySpan chal case "client_ed25519": AuthenticationPlugins.TryGetPlugin("client_ed25519", out var ed25519Plugin); - passwordHashResult = ed25519Plugin!.CreatePasswordHash(password, challenge); + if (ed25519Plugin is IAuthenticationPlugin2 plugin2) + passwordHashResult = plugin2!.CreatePasswordHash(password, challenge); break; - - default: - return false; } + if (passwordHashResult is null) + return false; - Span combined = stackalloc byte[32 + (challenge.Length - 1) + passwordHashResult.Length]; + Span combined = stackalloc byte[32 + challenge.Length + passwordHashResult.Length]; passwordHashResult.CopyTo(combined); challenge.CopyTo(combined[passwordHashResult.Length..]); - m_sha2Thumbprint!.CopyTo(combined[(passwordHashResult.Length + challenge.Length - 1)..]); + m_sha2Thumbprint!.CopyTo(combined[(passwordHashResult.Length + challenge.Length)..]); byte[] hashBytes; #if NET5_0_OR_GREATER From 03d179ef0aac49c68a0900cf943f107f4b7fc9fc Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 27 Jul 2024 21:27:00 -0700 Subject: [PATCH 14/20] Defer copy of challenge until it's needed. Signed-off-by: Bradley Grainger --- src/MySqlConnector/Core/ServerSession.cs | 2 +- .../Protocol/Serialization/AuthenticationUtility.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index b65140554..e382786a0 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -624,7 +624,7 @@ private bool ValidateFingerprint(byte[]? validationHash, ReadOnlySpan chal switch (m_pluginName) { case "mysql_native_password": - passwordHashResult = AuthenticationUtility.HashPassword(challenge, password, false); + passwordHashResult = AuthenticationUtility.HashPassword([], password, false); break; case "client_ed25519": diff --git a/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs b/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs index 6e8c7e0b8..7f553c1b6 100644 --- a/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs +++ b/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs @@ -44,7 +44,6 @@ public static byte[] HashPassword(ReadOnlySpan challenge, string password, 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]; @@ -57,8 +56,10 @@ public static byte[] HashPassword(ReadOnlySpan challenge, string password, sha1.TryComputeHash(passwordBytes, hashedPassword, out _); sha1.TryComputeHash(hashedPassword, combined[20..], out _); #endif - if (!withXor) return combined[20..].ToArray(); + if (!withXor) + return combined[20..].ToArray(); + challenge[..20].CopyTo(combined); Span xorBytes = stackalloc byte[20]; #if NET5_0_OR_GREATER SHA1.TryHashData(combined, xorBytes, out _); From 2aea924edfd615a7b7c881a0ee5d2b9b905ddfe0 Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sat, 27 Jul 2024 21:49:08 -0700 Subject: [PATCH 15/20] Change parameter name for clarity and update documentation. Signed-off-by: Bradley Grainger --- src/MySqlConnector/Core/ServerSession.cs | 2 +- .../Protocol/Serialization/AuthenticationUtility.cs | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index e382786a0..69da4446d 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -624,7 +624,7 @@ private bool ValidateFingerprint(byte[]? validationHash, ReadOnlySpan chal switch (m_pluginName) { case "mysql_native_password": - passwordHashResult = AuthenticationUtility.HashPassword([], password, false); + passwordHashResult = AuthenticationUtility.HashPassword([], password, onlyHashPassword: true); break; case "client_ed25519": diff --git a/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs b/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs index 7f553c1b6..e0b6ca0eb 100644 --- a/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs +++ b/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs @@ -25,20 +25,22 @@ public static byte[] GetNullTerminatedPasswordBytes(string password) } public static byte[] CreateAuthenticationResponse(ReadOnlySpan challenge, string password) => - string.IsNullOrEmpty(password) ? [] : HashPassword(challenge, password, true); + string.IsNullOrEmpty(password) ? [] : HashPassword(challenge, password, 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. - /// must xor results. + /// 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, bool withXor) + public static byte[] HashPassword(ReadOnlySpan challenge, string password, bool onlyHashPassword) { #if !NET5_0_OR_GREATER using var sha1 = SHA1.Create(); @@ -56,7 +58,7 @@ public static byte[] HashPassword(ReadOnlySpan challenge, string password, sha1.TryComputeHash(passwordBytes, hashedPassword, out _); sha1.TryComputeHash(hashedPassword, combined[20..], out _); #endif - if (!withXor) + if (onlyHashPassword) return combined[20..].ToArray(); challenge[..20].CopyTo(combined); From b4d8bafba9fec2282c143fbb2c0e7bb7230cebc1 Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sun, 28 Jul 2024 12:12:38 -0700 Subject: [PATCH 16/20] Eliminate allocations when computing fingerprint hash. Signed-off-by: Bradley Grainger --- src/MySqlConnector/Core/ServerSession.cs | 37 +++++++++++++++++------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index 69da4446d..691fd08f9 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -628,9 +628,9 @@ private bool ValidateFingerprint(byte[]? validationHash, ReadOnlySpan chal break; case "client_ed25519": - AuthenticationPlugins.TryGetPlugin("client_ed25519", out var ed25519Plugin); + AuthenticationPlugins.TryGetPlugin(m_pluginName, out var ed25519Plugin); if (ed25519Plugin is IAuthenticationPlugin2 plugin2) - passwordHashResult = plugin2!.CreatePasswordHash(password, challenge); + passwordHashResult = plugin2.CreatePasswordHash(password, challenge); break; } if (passwordHashResult is null) @@ -641,19 +641,34 @@ private bool ValidateFingerprint(byte[]? validationHash, ReadOnlySpan chal challenge.CopyTo(combined[passwordHashResult.Length..]); m_sha2Thumbprint!.CopyTo(combined[(passwordHashResult.Length + challenge.Length)..]); - byte[] hashBytes; + Span hashBytes = stackalloc byte[32]; #if NET5_0_OR_GREATER - hashBytes = SHA256.HashData(combined); + SHA256.TryHashData(combined, hashBytes, out _); #else - using (var sha256 = SHA256.Create()) - { - hashBytes = sha256.ComputeHash(combined.ToArray()); - } + using var sha256 = SHA256.Create(); + sha256.TryComputeHash(combined, hashBytes, out _); #endif - var clientGeneratedHash = hashBytes.Aggregate(string.Empty, (str, hashByte) => str + hashByte.ToString("X2", CultureInfo.InvariantCulture)); - var serverGeneratedHash = Encoding.ASCII.GetString(validationHash, 1, validationHash.Length - 1); - return string.Equals(clientGeneratedHash, serverGeneratedHash, StringComparison.Ordinal); + 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) From b58af51a9213a5751e87a16337feadd7e2a8aafa Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sun, 28 Jul 2024 14:39:16 -0700 Subject: [PATCH 17/20] Optimise thumbprint creation under .NET 7. Signed-off-by: Bradley Grainger --- src/MySqlConnector/Core/ServerSession.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index 691fd08f9..22dc72945 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -639,7 +639,7 @@ private bool ValidateFingerprint(byte[]? validationHash, ReadOnlySpan chal Span combined = stackalloc byte[32 + challenge.Length + passwordHashResult.Length]; passwordHashResult.CopyTo(combined); challenge.CopyTo(combined[passwordHashResult.Length..]); - m_sha2Thumbprint!.CopyTo(combined[(passwordHashResult.Length + challenge.Length)..]); + m_remoteCertificateSha2Thumbprint!.CopyTo(combined[(passwordHashResult.Length + challenge.Length)..]); Span hashBytes = stackalloc byte[32]; #if NET5_0_OR_GREATER @@ -1593,13 +1593,13 @@ caCertificateChain is not null && if (rcbCertificate is X509Certificate2 cert2) { // saving sha256 thumbprint and SSL errors until thumbprint validation -#if !NET5_0_OR_GREATER - using (var sha256 = SHA256.Create()) - { - m_sha2Thumbprint = sha256.ComputeHash(cert2.RawData); - } +#if NET7_0_OR_GREATER + m_remoteCertificateSha2Thumbprint = SHA256.HashData(cert2.RawDataMemory.Span); +#elif NET5_0_OR_GREATER + m_remoteCertificateSha2Thumbprint = SHA256.HashData(cert2.RawData); #else - m_sha2Thumbprint = SHA256.HashData(cert2.RawData); + using var sha256 = SHA256.Create(); + m_remoteCertificateSha2Thumbprint = sha256.ComputeHash(cert2.RawData); #endif m_rcbPolicyErrors = rcbPolicyErrors; return true; @@ -2127,6 +2127,6 @@ protected override void OnStatementBegin(int index) private byte[]? m_pipelinedResetConnectionBytes; private Dictionary? m_preparedStatements; private string m_pluginName = "mysql_native_password"; - private byte[]? m_sha2Thumbprint; + private byte[]? m_remoteCertificateSha2Thumbprint; private SslPolicyErrors m_rcbPolicyErrors; } From 34524efd3f3d345e8de26d1972e886154135bb50 Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sun, 28 Jul 2024 14:39:48 -0700 Subject: [PATCH 18/20] Restore AuthenticationException as inner exception. Signed-off-by: Bradley Grainger --- src/MySqlConnector/Core/ServerSession.cs | 34 +++++++----------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index 22dc72945..0dabccbe9 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -528,7 +528,7 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella } var ok = OkPayload.Create(payload.Span, this); - if (m_rcbPolicyErrors != SslPolicyErrors.None) + if (m_sslPolicyErrors != SslPolicyErrors.None) { // SSL would normally have thrown error, so connector need to ensure server certificates // pass only if : @@ -538,29 +538,15 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella { if (string.IsNullOrEmpty(password) || !ValidateFingerprint(ok.StatusInfo, initialHandshake.AuthPluginData.AsSpan(0, 20), password!)) { - // fingerprint validation fail. - // now throwing SSL exception depending on m_rcbPolicyErrors ShutdownSocket(); HostName = ""; - lock (m_lock) m_state = State.Failed; - MySqlException ex; - switch (m_rcbPolicyErrors) - { - case SslPolicyErrors.RemoteCertificateNotAvailable: - // impossible - ex = new MySqlException(MySqlErrorCode.UnableToConnectToHost, "SSL not validated, no remote certificate available"); - break; - - case SslPolicyErrors.RemoteCertificateNameMismatch: - ex = new MySqlException(MySqlErrorCode.UnableToConnectToHost, "SSL not validated, certificate name mismatch"); - break; - - default: - ex = new MySqlException(MySqlErrorCode.UnableToConnectToHost, "SSL not validated, certificate chain validation fail"); - break; - } - Log.CouldNotInitializeTlsConnection(m_logger, ex, Id); - throw ex; + 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); } } } @@ -1601,7 +1587,7 @@ caCertificateChain is not null && using var sha256 = SHA256.Create(); m_remoteCertificateSha2Thumbprint = sha256.ComputeHash(cert2.RawData); #endif - m_rcbPolicyErrors = rcbPolicyErrors; + m_sslPolicyErrors = rcbPolicyErrors; return true; } @@ -2128,5 +2114,5 @@ protected override void OnStatementBegin(int index) private Dictionary? m_preparedStatements; private string m_pluginName = "mysql_native_password"; private byte[]? m_remoteCertificateSha2Thumbprint; - private SslPolicyErrors m_rcbPolicyErrors; + private SslPolicyErrors m_sslPolicyErrors; } From abee0c7fb1bbd88b387c5405c357568849cbf24f Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sun, 28 Jul 2024 15:05:27 -0700 Subject: [PATCH 19/20] Add logging for provisional TLS connection and failure reasons. Signed-off-by: Bradley Grainger --- src/MySqlConnector/Core/ServerSession.cs | 58 ++++++++++++++++++------ src/MySqlConnector/Logging/EventIds.cs | 5 ++ src/MySqlConnector/Logging/Log.cs | 17 +++++++ 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index 0dabccbe9..757417fb4 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -528,26 +528,43 @@ 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, so connector need to ensure server certificates + // 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) - if (cs.ConnectionProtocol != MySqlConnectionProtocol.UnixSocket) + // see https://mariadb.org/mission-impossible-zero-configuration-ssl/ + var ignoreCertificateError = false; + + if (cs.ConnectionProtocol == MySqlConnectionProtocol.UnixSocket) { - if (string.IsNullOrEmpty(password) || !ValidateFingerprint(ok.StatusInfo, initialHandshake.AuthPluginData.AsSpan(0, 20), password!)) - { - ShutdownSocket(); - HostName = ""; - lock (m_lock) - m_state = State.Failed; + 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; + } - // 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); - } + 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); } } @@ -1664,11 +1681,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.ConnectedTlsBasicPreliminary(m_logger, Id, m_sslPolicyErrors, sslStream.SslProtocol, sslStream.NegotiatedCipherSuite); +#else + 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); + 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); + Log.ConnectedTlsDetailed(m_logger, Id, sslStream.SslProtocol, sslStream.CipherAlgorithm, sslStream.HashAlgorithm, sslStream.KeyExchangeAlgorithm, sslStream.KeyExchangeStrength); #endif + } } catch (Exception ex) { 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); From 7c2964f1452a660c15baba1626295650558a3eef Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Sun, 28 Jul 2024 15:23:10 -0700 Subject: [PATCH 20/20] Minor code cleanup. Signed-off-by: Bradley Grainger --- .../Ed25519AuthenticationPlugin.cs | 3 -- src/MySqlConnector/Core/ServerSession.cs | 33 +++++++++---------- .../Serialization/AuthenticationUtility.cs | 2 +- tests/IntegrationTests/ServerFeatures.cs | 4 +-- tests/README.md | 3 +- 5 files changed, 20 insertions(+), 25 deletions(-) diff --git a/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs b/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs index 7d362a08c..f2ea62c3e 100644 --- a/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs +++ b/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs @@ -45,9 +45,6 @@ public byte[] CreatePasswordHash(string password, ReadOnlySpan authenticat return passwordHash; } - /// - /// Creates the authentication response. - /// 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 diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index 757417fb4..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."); } @@ -608,30 +608,27 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella } /// - /// Validate SSL validation has + /// Validate SSL validation hash (from OK packet). /// - /// received validation hash - /// initial seed - /// password - /// true if validated + /// 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) { - if (validationHash?.Length != 65) + // expect 0x01 followed by 64 hex characters giving a SHA2 hash + if (validationHash?.Length != 65 || validationHash[0] != 1) return false; - // ensure using SHA256 encryption - if (validationHash[0] != 0x01) - throw new FormatException($"Unexpected validation hash format. expected 0x01 but got 0x{validationHash[0]:X2}"); - byte[]? passwordHashResult = null; - switch (m_pluginName) + switch (m_currentAuthenticationMethod) { case "mysql_native_password": passwordHashResult = AuthenticationUtility.HashPassword([], password, onlyHashPassword: true); break; case "client_ed25519": - AuthenticationPlugins.TryGetPlugin(m_pluginName, out var ed25519Plugin); + AuthenticationPlugins.TryGetPlugin(m_currentAuthenticationMethod, out var ed25519Plugin); if (ed25519Plugin is IAuthenticationPlugin2 plugin2) passwordHashResult = plugin2.CreatePasswordHash(password, challenge); break; @@ -836,7 +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_pluginName = switchRequest.Name; + m_currentAuthenticationMethod = switchRequest.Name; switch (switchRequest.Name) { case "mysql_native_password": @@ -2140,7 +2137,7 @@ protected override void OnStatementBegin(int index) private PayloadData m_setNamesPayload; private byte[]? m_pipelinedResetConnectionBytes; private Dictionary? m_preparedStatements; - private string m_pluginName = "mysql_native_password"; + private string? m_currentAuthenticationMethod; private byte[]? m_remoteCertificateSha2Thumbprint; private SslPolicyErrors m_sslPolicyErrors; } diff --git a/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs b/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs index e0b6ca0eb..659d2350d 100644 --- a/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs +++ b/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs @@ -25,7 +25,7 @@ public static byte[] GetNullTerminatedPasswordBytes(string password) } public static byte[] CreateAuthenticationResponse(ReadOnlySpan challenge, string password) => - string.IsNullOrEmpty(password) ? [] : HashPassword(challenge, password, false); + string.IsNullOrEmpty(password) ? [] : HashPassword(challenge, password, onlyHashPassword: false); /// /// Hashes a password with the "Secure Password Authentication" method. diff --git a/tests/IntegrationTests/ServerFeatures.cs b/tests/IntegrationTests/ServerFeatures.cs index 503f53151..120b541bf 100644 --- a/tests/IntegrationTests/ServerFeatures.cs +++ b/tests/IntegrationTests/ServerFeatures.cs @@ -37,12 +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 permit redirection, available on first OK_Packet + /// Server provides hash of TLS certificate in first OK packet. /// TlsFingerprintValidation = 0x100_0000, } diff --git a/tests/README.md b/tests/README.md index c0fa0443b..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,9 +37,9 @@ 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) - * `UnsupportedFeatures`: server supports ## Running Tests