diff --git a/src/MySqlConnector.Authentication.Ed25519/Chaos.NaCl/Ed25519.cs b/src/MySqlConnector.Authentication.Ed25519/Chaos.NaCl/Ed25519.cs index 613f8330a..f3a0b7011 100644 --- a/src/MySqlConnector.Authentication.Ed25519/Chaos.NaCl/Ed25519.cs +++ b/src/MySqlConnector.Authentication.Ed25519/Chaos.NaCl/Ed25519.cs @@ -34,19 +34,6 @@ public static byte[] Sign(byte[] message, byte[] expandedPrivateKey) return signature; } - public static byte[] ExpandedPrivateKeyFromSeed(byte[] privateKeySeed) - { - byte[] privateKey; - byte[] publicKey; - KeyPairFromSeed(out publicKey, out privateKey, privateKeySeed); -#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_1_OR_GREATER - CryptographicOperations.ZeroMemory(publicKey); -#else - CryptoBytes.Wipe(publicKey); -#endif - return privateKey; - } - public static void KeyPairFromSeed(out byte[] publicKey, out byte[] expandedPrivateKey, byte[] privateKeySeed) { if (privateKeySeed == null) diff --git a/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs b/src/MySqlConnector.Authentication.Ed25519/Ed25519AuthenticationPlugin.cs index f2ea62c3e..6728b2b9d 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 : IAuthenticationPlugin2 +public sealed class Ed25519AuthenticationPlugin : IAuthenticationPlugin3 { /// /// Registers the Ed25519 authentication plugin with MySqlConnector. You must call this method once before @@ -32,20 +32,20 @@ public static void Install() /// public byte[] CreateResponse(string password, ReadOnlySpan authenticationData) { - CreateResponseAndHash(password, authenticationData, out _, out var authenticationResponse); + CreateResponseAndPasswordHash(password, authenticationData, out var authenticationResponse, out _); return authenticationResponse; } /// - /// Creates the Ed25519 password hash. + /// Creates the authentication response and hashes the client's password (e.g., for TLS certificate fingerprint verification). /// - public byte[] CreatePasswordHash(string password, ReadOnlySpan authenticationData) - { - CreateResponseAndHash(password, authenticationData, out var passwordHash, out _); - return passwordHash; - } - - private static void CreateResponseAndHash(string password, ReadOnlySpan authenticationData, out byte[] passwordHash, out byte[] authenticationResponse) + /// The client's password. + /// The authentication data supplied by the server; this is the auth method data + /// from the Authentication + /// Method Switch Request Packet. + /// The authentication response. + /// The authentication-method-specific hash of the client's password. + public void CreateResponseAndPasswordHash(string password, ReadOnlySpan authenticationData, out byte[] authenticationResponse, out byte[] passwordHash) { // 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 diff --git a/src/MySqlConnector.Authentication.Ed25519/ParsecAuthenticationPlugin.cs b/src/MySqlConnector.Authentication.Ed25519/ParsecAuthenticationPlugin.cs index 4c7adc3ac..742f829c0 100644 --- a/src/MySqlConnector.Authentication.Ed25519/ParsecAuthenticationPlugin.cs +++ b/src/MySqlConnector.Authentication.Ed25519/ParsecAuthenticationPlugin.cs @@ -8,7 +8,7 @@ namespace MySqlConnector.Authentication.Ed25519; /// /// Provides an implementation of the Parsec authentication plugin for MariaDB. /// -public sealed class ParsecAuthenticationPlugin : IAuthenticationPlugin +public sealed class ParsecAuthenticationPlugin : IAuthenticationPlugin3 { /// /// Registers the Parsec authentication plugin with MySqlConnector. You must call this method once before @@ -29,6 +29,15 @@ public static void Install() /// Creates the authentication response. /// public byte[] CreateResponse(string password, ReadOnlySpan authenticationData) + { + CreateResponseAndPasswordHash(password, authenticationData, out var response, out _); + return response; + } + + /// + /// Creates the authentication response. + /// + public void CreateResponseAndPasswordHash(string password, ReadOnlySpan authenticationData, out byte[] authenticationResponse, out byte[] passwordHash) { // first 32 bytes are server scramble var serverScramble = authenticationData.Slice(0, 32); @@ -54,28 +63,33 @@ public byte[] CreateResponse(string password, ReadOnlySpan authenticationD var salt = extendedSalt.Slice(2); // derive private key using PBKDF2-SHA512 - byte[] privateKey; + byte[] privateKeySeed; #if NET6_0_OR_GREATER - privateKey = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterationCount, HashAlgorithmName.SHA512, 32); + privateKeySeed = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterationCount, HashAlgorithmName.SHA512, 32); #else using (var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(password), salt.ToArray(), iterationCount, HashAlgorithmName.SHA512)) - privateKey = pbkdf2.GetBytes(32); + privateKeySeed = pbkdf2.GetBytes(32); #endif - var expandedPrivateKey = Chaos.NaCl.Ed25519.ExpandedPrivateKeyFromSeed(privateKey); + Chaos.NaCl.Ed25519.KeyPairFromSeed(out var publicKey, out var privateKey, privateKeySeed); // generate Ed25519 keypair and sign concatenated scrambles var message = new byte[serverScramble.Length + clientScramble.Length]; serverScramble.CopyTo(message); clientScramble.CopyTo(message.AsSpan(serverScramble.Length)); - var signature = Chaos.NaCl.Ed25519.Sign(message, expandedPrivateKey); + var signature = Chaos.NaCl.Ed25519.Sign(message, privateKey); + +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_1_OR_GREATER + CryptographicOperations.ZeroMemory(privateKey); +#endif // return client scramble followed by signature - var response = new byte[clientScramble.Length + signature.Length]; - clientScramble.CopyTo(response.AsSpan()); - signature.CopyTo(response.AsSpan(clientScramble.Length)); - - return response; + authenticationResponse = new byte[clientScramble.Length + signature.Length]; + clientScramble.CopyTo(authenticationResponse.AsSpan()); + signature.CopyTo(authenticationResponse.AsSpan(clientScramble.Length)); + + // "password hash" for parsec is the extended salt followed by the public key + passwordHash = [(byte) 'P', (byte) iterationCount, .. salt, .. publicKey]; } private ParsecAuthenticationPlugin() diff --git a/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs b/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs index 6dfface4e..bc432c0d1 100644 --- a/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs +++ b/src/MySqlConnector/Authentication/IAuthenticationPlugin.cs @@ -24,6 +24,7 @@ public interface IAuthenticationPlugin /// /// is an extension to that returns a hash of the client's password. /// +[Obsolete("Use IAuthenticationPlugin3 instead.")] public interface IAuthenticationPlugin2 : IAuthenticationPlugin { /// @@ -36,3 +37,21 @@ public interface IAuthenticationPlugin2 : IAuthenticationPlugin /// The authentication-method-specific hash of the client's password. byte[] CreatePasswordHash(string password, ReadOnlySpan authenticationData); } + +/// +/// is an extension to that also returns a hash of the client's password. +/// +/// If an authentication plugin supports this interface, the base method will not be called. +public interface IAuthenticationPlugin3 : IAuthenticationPlugin +{ + /// + /// Creates the authentication response and hashes the client's password (e.g., for TLS certificate fingerprint verification). + /// + /// The client's password. + /// The authentication data supplied by the server; this is the auth method data + /// from the Authentication + /// Method Switch Request Packet. + /// The authentication response. + /// The authentication-method-specific hash of the client's password. + void CreateResponseAndPasswordHash(string password, ReadOnlySpan authenticationData, out byte[] authenticationResponse, out byte[] passwordHash); +} diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index cfb6e9a8c..e5ade4084 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -448,13 +448,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 - m_currentAuthenticationMethod = (initialHandshake.ProtocolCapabilities & ProtocolCapabilities.PluginAuth) != 0 ? initialHandshake.AuthPluginName! : + var currentAuthenticationMethod = (initialHandshake.ProtocolCapabilities & ProtocolCapabilities.PluginAuth) != 0 ? initialHandshake.AuthPluginName! : (initialHandshake.ProtocolCapabilities & ProtocolCapabilities.SecureConnection) == 0 ? "mysql_old_password" : "mysql_native_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.ServerSentAuthPluginName(m_logger, Id, currentAuthenticationMethod); + if (currentAuthenticationMethod is not "mysql_native_password" and not "sha256_password" and not "caching_sha2_password") { - Log.UnsupportedAuthenticationMethod(m_logger, Id, m_currentAuthenticationMethod); + Log.UnsupportedAuthenticationMethod(m_logger, Id, currentAuthenticationMethod); throw new NotSupportedException($"Authentication method '{initialHandshake.AuthPluginName}' is not supported."); } @@ -529,7 +529,8 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella cs.ConnectionAttributes = CreateConnectionAttributes(cs.ApplicationName); var password = GetPassword(cs, connection); - using (var handshakeResponsePayload = HandshakeResponse41Payload.Create(initialHandshake, cs, password, m_compressionMethod, connection.ZstandardPlugin?.CompressionLevel, m_characterSet, m_supportsConnectionAttributes ? cs.ConnectionAttributes : null)) + AuthenticationUtility.CreateResponseAndPasswordHash(password, initialHandshake.AuthPluginData, out var authenticationResponse, out m_passwordHash); + using (var handshakeResponsePayload = HandshakeResponse41Payload.Create(initialHandshake, cs, authenticationResponse, m_compressionMethod, connection.ZstandardPlugin?.CompressionLevel, m_characterSet, m_supportsConnectionAttributes ? cs.ConnectionAttributes : null)) await SendReplyAsync(handshakeResponsePayload, ioBehavior, cancellationToken).ConfigureAwait(false); payload = await ReceiveReplyAsync(ioBehavior, cancellationToken).ConfigureAwait(false); @@ -560,7 +561,7 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella // 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)) + else if (ValidateFingerprint(ok.StatusInfo, initialHandshake.AuthPluginData.AsSpan(0, 20))) { Log.CertificateErrorValidThumbprint(m_logger, Id, m_sslPolicyErrors); ignoreCertificateError = true; @@ -626,36 +627,20 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella /// /// 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) + private bool ValidateFingerprint(byte[]? validationHash, ReadOnlySpan challenge) { // expect 0x01 followed by 64 hex characters giving a SHA2 hash if (validationHash?.Length != 65 || validationHash[0] != 1) return false; - byte[]? passwordHashResult = null; - switch (m_currentAuthenticationMethod) - { - case "mysql_native_password": - passwordHashResult = AuthenticationUtility.HashPassword([], password, onlyHashPassword: true); - break; - - case "client_ed25519": - AuthenticationPlugins.TryGetPlugin(m_currentAuthenticationMethod, out var ed25519Plugin); - if (ed25519Plugin is IAuthenticationPlugin2 plugin2) - passwordHashResult = plugin2.CreatePasswordHash(password, challenge); - break; - } - if (passwordHashResult is null) + // the authentication plugin must have provided a password hash (via IAuthenticationPlugin3) that we saved for future use + if (m_passwordHash is null) return false; - Span combined = stackalloc byte[32 + challenge.Length + passwordHashResult.Length]; - passwordHashResult.CopyTo(combined); - challenge.CopyTo(combined[passwordHashResult.Length..]); - m_remoteCertificateSha2Thumbprint!.CopyTo(combined[(passwordHashResult.Length + challenge.Length)..]); - + // hash password hash || scramble || certificate thumbprint Span hashBytes = stackalloc byte[32]; + Span combined = [.. m_passwordHash, .. challenge, .. m_remoteCertificateSha2Thumbprint!]; #if NET5_0_OR_GREATER SHA256.TryHashData(combined, hashBytes, out _); #else @@ -804,8 +789,8 @@ public async Task TryResetConnectionAsync(ConnectionSettings cs, MySqlConn DatabaseOverride = null; } var password = GetPassword(cs, connection); - var hashedPassword = AuthenticationUtility.CreateAuthenticationResponse(AuthPluginData!, password); - using (var changeUserPayload = ChangeUserPayload.Create(cs.UserID, hashedPassword, cs.Database, m_characterSet, m_supportsConnectionAttributes ? cs.ConnectionAttributes : null)) + AuthenticationUtility.CreateResponseAndPasswordHash(password, AuthPluginData, out var nativeResponse, out m_passwordHash); + using (var changeUserPayload = ChangeUserPayload.Create(cs.UserID, nativeResponse, cs.Database, m_characterSet, m_supportsConnectionAttributes ? cs.ConnectionAttributes : null)) await SendAsync(changeUserPayload, ioBehavior, cancellationToken).ConfigureAwait(false); payload = await ReceiveReplyAsync(ioBehavior, cancellationToken).ConfigureAwait(false); if (payload.HeaderByte == AuthenticationMethodSwitchRequestPayload.Signature) @@ -849,13 +834,12 @@ private async Task SwitchAuthenticationAsync(ConnectionSettings cs, // if the server didn't support the hashed password; rehash with the new challenge var switchRequest = AuthenticationMethodSwitchRequestPayload.Create(payload.Span); Log.SwitchingToAuthenticationMethod(m_logger, Id, switchRequest.Name); - m_currentAuthenticationMethod = switchRequest.Name; switch (switchRequest.Name) { case "mysql_native_password": AuthPluginData = switchRequest.Data; - var hashedPassword = AuthenticationUtility.CreateAuthenticationResponse(AuthPluginData, password); - payload = new(hashedPassword); + AuthenticationUtility.CreateResponseAndPasswordHash(password, AuthPluginData, out var nativeResponse, out m_passwordHash); + payload = new(nativeResponse); await SendReplyAsync(payload, ioBehavior, cancellationToken).ConfigureAwait(false); return await ReceiveReplyAsync(ioBehavior, cancellationToken).ConfigureAwait(false); @@ -908,14 +892,15 @@ private async Task SwitchAuthenticationAsync(ConnectionSettings cs, throw new NotSupportedException("'MySQL Server is requesting the insecure pre-4.1 auth mechanism (mysql_old_password). The user password must be upgraded; see https://dev.mysql.com/doc/refman/5.7/en/account-upgrades.html."); case "client_ed25519": - if (!AuthenticationPlugins.TryGetPlugin(switchRequest.Name, out var ed25519Plugin)) + if (!AuthenticationPlugins.TryGetPlugin(switchRequest.Name, out var ed25519Plugin) || ed25519Plugin is not IAuthenticationPlugin3 ed25519Plugin3) throw new NotSupportedException("You must install the MySqlConnector.Authentication.Ed25519 package and call Ed25519AuthenticationPlugin.Install to use client_ed25519 authentication."); - payload = new(ed25519Plugin.CreateResponse(password, switchRequest.Data)); + ed25519Plugin3.CreateResponseAndPasswordHash(password, switchRequest.Data, out var ed25519Response, out m_passwordHash); + payload = new(ed25519Response); await SendReplyAsync(payload, ioBehavior, cancellationToken).ConfigureAwait(false); return await ReceiveReplyAsync(ioBehavior, cancellationToken).ConfigureAwait(false); case "parsec": - if (!AuthenticationPlugins.TryGetPlugin(switchRequest.Name, out var parsecPlugin)) + if (!AuthenticationPlugins.TryGetPlugin(switchRequest.Name, out var parsecPlugin) || parsecPlugin is not IAuthenticationPlugin3 parsecPlugin3) throw new NotSupportedException("You must install the MySqlConnector.Authentication.Ed25519 package and call ParsecAuthenticationPlugin.Install to use parsec authentication."); payload = new([]); await SendReplyAsync(payload, ioBehavior, cancellationToken).ConfigureAwait(false); @@ -925,7 +910,8 @@ private async Task SwitchAuthenticationAsync(ConnectionSettings cs, switchRequest.Data.CopyTo(combinedData); payload.Span.CopyTo(combinedData.Slice(switchRequest.Data.Length)); - payload = new(parsecPlugin.CreateResponse(password, combinedData)); + parsecPlugin3.CreateResponseAndPasswordHash(password, combinedData, out var parsecResponse, out m_passwordHash); + payload = new(parsecResponse); await SendReplyAsync(payload, ioBehavior, cancellationToken).ConfigureAwait(false); return await ReceiveReplyAsync(ioBehavior, cancellationToken).ConfigureAwait(false); @@ -2192,7 +2178,7 @@ protected override void OnStatementBegin(int index) private PayloadData m_setNamesPayload; private byte[]? m_pipelinedResetConnectionBytes; private Dictionary? m_preparedStatements; - private string? m_currentAuthenticationMethod; + private byte[]? m_passwordHash; private byte[]? m_remoteCertificateSha2Thumbprint; private SslPolicyErrors m_sslPolicyErrors; } diff --git a/src/MySqlConnector/Protocol/Payloads/HandshakeResponse41Payload.cs b/src/MySqlConnector/Protocol/Payloads/HandshakeResponse41Payload.cs index 609fcfd45..048459357 100644 --- a/src/MySqlConnector/Protocol/Payloads/HandshakeResponse41Payload.cs +++ b/src/MySqlConnector/Protocol/Payloads/HandshakeResponse41Payload.cs @@ -55,12 +55,11 @@ private static ByteBufferWriter CreateCapabilitiesPayload(ProtocolCapabilities s public static PayloadData CreateWithSsl(ProtocolCapabilities serverCapabilities, ConnectionSettings cs, CompressionMethod compressionMethod, CharacterSet characterSet) => CreateCapabilitiesPayload(serverCapabilities, cs, compressionMethod, characterSet, ProtocolCapabilities.Ssl).ToPayloadData(); - public static PayloadData Create(InitialHandshakePayload handshake, ConnectionSettings cs, string password, CompressionMethod compressionMethod, int? compressionLevel, CharacterSet characterSet, byte[]? connectionAttributes) + public static PayloadData Create(InitialHandshakePayload handshake, ConnectionSettings cs, byte[] authenticationResponse, CompressionMethod compressionMethod, int? compressionLevel, CharacterSet characterSet, byte[]? connectionAttributes) { // TODO: verify server capabilities var writer = CreateCapabilitiesPayload(handshake.ProtocolCapabilities, cs, compressionMethod, characterSet); writer.WriteNullTerminatedString(cs.UserID); - var authenticationResponse = AuthenticationUtility.CreateAuthenticationResponse(handshake.AuthPluginData, password); writer.Write((byte) authenticationResponse.Length); writer.Write(authenticationResponse); diff --git a/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs b/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs index 659d2350d..d1f325682 100644 --- a/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs +++ b/src/MySqlConnector/Protocol/Serialization/AuthenticationUtility.cs @@ -24,24 +24,26 @@ public static byte[] GetNullTerminatedPasswordBytes(string password) return passwordBytes; } - public static byte[] CreateAuthenticationResponse(ReadOnlySpan challenge, string password) => - string.IsNullOrEmpty(password) ? [] : HashPassword(challenge, password, onlyHashPassword: false); - /// /// Hashes a password with the "Secure Password Authentication" method. /// - /// The 20-byte random challenge (from the "auth-plugin-data" in the initial handshake). /// The password to hash. - /// If true, is ignored and only the twice-hashed password - /// is returned, instead of performing the full "secure password authentication" algorithm that XORs the hashed password against - /// a hash derived from the challenge. - /// A 20-byte password hash. + /// The 20-byte random challenge (from the "auth-plugin-data" in the initial handshake). + /// The authentication response. + /// The twice-hashed password. /// See Secure Password Authentication. #if NET5_0_OR_GREATER [SkipLocalsInit] #endif - public static byte[] HashPassword(ReadOnlySpan challenge, string password, bool onlyHashPassword) + public static void CreateResponseAndPasswordHash(string password, ReadOnlySpan authenticationData, out byte[] authenticationResponse, out byte[] passwordHash) { + if (string.IsNullOrEmpty(password)) + { + authenticationResponse = []; + passwordHash = []; + return; + } + #if !NET5_0_OR_GREATER using var sha1 = SHA1.Create(); #endif @@ -58,10 +60,9 @@ public static byte[] HashPassword(ReadOnlySpan challenge, string password, sha1.TryComputeHash(passwordBytes, hashedPassword, out _); sha1.TryComputeHash(hashedPassword, combined[20..], out _); #endif - if (onlyHashPassword) - return combined[20..].ToArray(); + passwordHash = combined[20..].ToArray(); - challenge[..20].CopyTo(combined); + authenticationData[..20].CopyTo(combined); Span xorBytes = stackalloc byte[20]; #if NET5_0_OR_GREATER SHA1.TryHashData(combined, xorBytes, out _); @@ -71,7 +72,7 @@ public static byte[] HashPassword(ReadOnlySpan challenge, string password, for (var i = 0; i < hashedPassword.Length; i++) hashedPassword[i] ^= xorBytes[i]; - return hashedPassword.ToArray(); + authenticationResponse = hashedPassword.ToArray(); } public static byte[] CreateScrambleResponse(ReadOnlySpan nonce, string password) => diff --git a/tests/IntegrationTests/SslTests.cs b/tests/IntegrationTests/SslTests.cs index 6ce73b803..506b3203a 100644 --- a/tests/IntegrationTests/SslTests.cs +++ b/tests/IntegrationTests/SslTests.cs @@ -250,6 +250,20 @@ public async Task ConnectZeroConfigurationSslEd25519() using var connection = new MySqlConnection(csb.ConnectionString); await connection.OpenAsync(); } + + [SkippableFact(ServerFeatures.TlsFingerprintValidation | ServerFeatures.ParsecAuthentication)] + public async Task ConnectZeroConfigurationSslParsec() + { + MySqlConnector.Authentication.Ed25519.ParsecAuthenticationPlugin.Install(); + var csb = AppConfig.CreateConnectionStringBuilder(); + csb.CertificateFile = null; + csb.SslMode = MySqlSslMode.VerifyFull; + csb.SslCa = ""; + csb.UserID = "parsec-user"; + csb.Password = "P@rs3c-Pa55"; + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + } #endif #endif