Skip to content

Commit

Permalink
Enable TLS validation with parsec.
Browse files Browse the repository at this point in the history
Introduce new IAuthenticationPlugin3 interface and deprecate IAuthenticationPlugin2. Authentication plugins will now compute the password hash and the authentication response in one call, and the session will cache the password hash for later use.

Signed-off-by: Bradley Grainger <[email protected]>
  • Loading branch information
bgrainger committed Jan 27, 2025
1 parent 208f2ec commit 88fad4d
Show file tree
Hide file tree
Showing 8 changed files with 99 additions and 86 deletions.
13 changes: 0 additions & 13 deletions src/MySqlConnector.Authentication.Ed25519/Chaos.NaCl/Ed25519.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace MySqlConnector.Authentication.Ed25519;
/// Provides an implementation of the <c>client_ed25519</c> authentication plugin for MariaDB.
/// </summary>
/// <remarks>See <a href="https://mariadb.com/kb/en/library/authentication-plugin-ed25519/">Authentication Plugin - ed25519</a>.</remarks>
public sealed class Ed25519AuthenticationPlugin : IAuthenticationPlugin2
public sealed class Ed25519AuthenticationPlugin : IAuthenticationPlugin3
{
/// <summary>
/// Registers the Ed25519 authentication plugin with MySqlConnector. You must call this method once before
Expand All @@ -32,20 +32,20 @@ public static void Install()
/// </summary>
public byte[] CreateResponse(string password, ReadOnlySpan<byte> authenticationData)
{
CreateResponseAndHash(password, authenticationData, out _, out var authenticationResponse);
CreateResponseAndPasswordHash(password, authenticationData, out var authenticationResponse, out _);
return authenticationResponse;
}

/// <summary>
/// Creates the Ed25519 password hash.
/// Creates the authentication response and hashes the client's password (e.g., for TLS certificate fingerprint verification).
/// </summary>
public byte[] CreatePasswordHash(string password, ReadOnlySpan<byte> authenticationData)
{
CreateResponseAndHash(password, authenticationData, out var passwordHash, out _);
return passwordHash;
}

private static void CreateResponseAndHash(string password, ReadOnlySpan<byte> authenticationData, out byte[] passwordHash, out byte[] authenticationResponse)
/// <param name="password">The client's password.</param>
/// <param name="authenticationData">The authentication data supplied by the server; this is the <code>auth method data</code>
/// from the <a href="https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::AuthSwitchRequest">Authentication
/// Method Switch Request Packet</a>.</param>
/// <param name="authenticationResponse">The authentication response.</param>
/// <param name="passwordHash">The authentication-method-specific hash of the client's password.</param>
public void CreateResponseAndPasswordHash(string password, ReadOnlySpan<byte> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace MySqlConnector.Authentication.Ed25519;
/// <summary>
/// Provides an implementation of the Parsec authentication plugin for MariaDB.
/// </summary>
public sealed class ParsecAuthenticationPlugin : IAuthenticationPlugin
public sealed class ParsecAuthenticationPlugin : IAuthenticationPlugin3
{
/// <summary>
/// Registers the Parsec authentication plugin with MySqlConnector. You must call this method once before
Expand All @@ -29,6 +29,15 @@ public static void Install()
/// Creates the authentication response.
/// </summary>
public byte[] CreateResponse(string password, ReadOnlySpan<byte> authenticationData)
{
CreateResponseAndPasswordHash(password, authenticationData, out var response, out _);
return response;
}

/// <summary>
/// Creates the authentication response.
/// </summary>
public void CreateResponseAndPasswordHash(string password, ReadOnlySpan<byte> authenticationData, out byte[] authenticationResponse, out byte[] passwordHash)
{
// first 32 bytes are server scramble
var serverScramble = authenticationData.Slice(0, 32);
Expand All @@ -54,28 +63,33 @@ public byte[] CreateResponse(string password, ReadOnlySpan<byte> 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()
Expand Down
19 changes: 19 additions & 0 deletions src/MySqlConnector/Authentication/IAuthenticationPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public interface IAuthenticationPlugin
/// <summary>
/// <see cref="IAuthenticationPlugin2"/> is an extension to <see cref="IAuthenticationPlugin"/> that returns a hash of the client's password.
/// </summary>
[Obsolete("Use IAuthenticationPlugin3 instead.")]
public interface IAuthenticationPlugin2 : IAuthenticationPlugin
{
/// <summary>
Expand All @@ -36,3 +37,21 @@ public interface IAuthenticationPlugin2 : IAuthenticationPlugin
/// <returns>The authentication-method-specific hash of the client's password.</returns>
byte[] CreatePasswordHash(string password, ReadOnlySpan<byte> authenticationData);
}

/// <summary>
/// <see cref="IAuthenticationPlugin3"/> is an extension to <see cref="IAuthenticationPlugin"/> that also returns a hash of the client's password.
/// </summary>
/// <remarks>If an authentication plugin supports this interface, the base <see cref="IAuthenticationPlugin.CreateResponse(string, ReadOnlySpan{byte})"/> method will not be called.</remarks>
public interface IAuthenticationPlugin3 : IAuthenticationPlugin
{
/// <summary>
/// Creates the authentication response and hashes the client's password (e.g., for TLS certificate fingerprint verification).
/// </summary>
/// <param name="password">The client's password.</param>
/// <param name="authenticationData">The authentication data supplied by the server; this is the <code>auth method data</code>
/// from the <a href="https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::AuthSwitchRequest">Authentication
/// Method Switch Request Packet</a>.</param>
/// <param name="authenticationResponse">The authentication response.</param>
/// <param name="passwordHash">The authentication-method-specific hash of the client's password.</param>
void CreateResponseAndPasswordHash(string password, ReadOnlySpan<byte> authenticationData, out byte[] authenticationResponse, out byte[] passwordHash);
}
60 changes: 23 additions & 37 deletions src/MySqlConnector/Core/ServerSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -626,36 +627,20 @@ public async Task DisposeAsync(IOBehavior ioBehavior, CancellationToken cancella
/// </summary>
/// <param name="validationHash">The validation hash received from the server.</param>
/// <param name="challenge">The auth plugin data from the initial handshake.</param>
/// <param name="password">The user's password.</param>
/// <returns><c>true</c> if the validation hash matches the locally-computed value; otherwise, <c>false</c>.</returns>
private bool ValidateFingerprint(byte[]? validationHash, ReadOnlySpan<byte> challenge, string password)
private bool ValidateFingerprint(byte[]? validationHash, ReadOnlySpan<byte> 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<byte> 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<byte> hashBytes = stackalloc byte[32];
Span<byte> combined = [.. m_passwordHash, .. challenge, .. m_remoteCertificateSha2Thumbprint!];
#if NET5_0_OR_GREATER
SHA256.TryHashData(combined, hashBytes, out _);
#else
Expand Down Expand Up @@ -804,8 +789,8 @@ public async Task<bool> 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)
Expand Down Expand Up @@ -849,13 +834,12 @@ private async Task<PayloadData> 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);

Expand Down Expand Up @@ -908,14 +892,15 @@ private async Task<PayloadData> 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);
Expand All @@ -925,7 +910,8 @@ private async Task<PayloadData> 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);

Expand Down Expand Up @@ -2192,7 +2178,7 @@ protected override void OnStatementBegin(int index)
private PayloadData m_setNamesPayload;
private byte[]? m_pipelinedResetConnectionBytes;
private Dictionary<string, PreparedStatements>? m_preparedStatements;
private string? m_currentAuthenticationMethod;
private byte[]? m_passwordHash;
private byte[]? m_remoteCertificateSha2Thumbprint;
private SslPolicyErrors m_sslPolicyErrors;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading

0 comments on commit 88fad4d

Please sign in to comment.