Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ on:
jobs:
build:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping --silent" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v1
- name: Setup .NET Core
Expand All @@ -29,11 +38,9 @@ jobs:
dotnet nuget locals all --clear
- name: Build
run: dotnet build -c Debug
- name: Run MySQL
- name: Setup MySQL auth
run: |
cp tests/SuperSocket.MySQL.Test/mysql.cnf ~/.my.cnf
sudo systemctl start mysql.service
mysql -V
mysql -h 127.0.0.1 -u root -proot -e "ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'root'; FLUSH PRIVILEGES;"
- name: Test
run: |
cd tests/SuperSocket.MySQL.Test
Expand Down
127 changes: 104 additions & 23 deletions src/SuperSocket.MySQL/MySQLConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,49 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
};

// Generate authentication response
handshakeResponse.AuthResponse = GenerateAuthResponse(handshakePacket);
handshakeResponse.AuthResponse = GenerateAuthResponse(handshakePacket.AuthPluginDataPart1, handshakePacket.AuthPluginDataPart2);
handshakeResponse.SequenceId = packet.SequenceId + 1;

// Send handshake response
await SendAsync(PacketEncoder, handshakeResponse).ConfigureAwait(false);

// Wait for authentication result (OK packet or Error packet)
// Wait for authentication result (OK packet, Error packet, or AuthSwitchRequest)
var authResult = await ReceiveAsync().ConfigureAwait(false);

// Handle auth switch if requested
while (authResult is AuthSwitchRequestPacket authSwitchRequest)
{
// Generate new auth response using the switched plugin's auth data
byte[] authResponse;

if (authSwitchRequest.PluginName == "mysql_native_password")
{
// Use mysql_native_password algorithm
authResponse = GenerateNativePasswordResponse(authSwitchRequest.AuthData);
}
else if (authSwitchRequest.PluginName == "caching_sha2_password")
{
// Use caching_sha2_password algorithm (same as mysql_native_password for the initial response)
authResponse = GenerateCachingSha2Response(authSwitchRequest.AuthData);
}
else
{
throw new InvalidOperationException($"Unsupported authentication plugin: {authSwitchRequest.PluginName}");
}

// Send auth switch response
var authSwitchResponse = new AuthSwitchResponsePacket
{
AuthData = authResponse,
SequenceId = authSwitchRequest.SequenceId + 1
};

await SendAsync(PacketEncoder, authSwitchResponse).ConfigureAwait(false);

// Wait for next response
authResult = await ReceiveAsync().ConfigureAwait(false);
}

switch (authResult)
{
case OKPacket okPacket:
Expand All @@ -101,27 +135,47 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
: "Authentication failed";
throw new InvalidOperationException($"MySQL authentication failed: {errorMsg} (Error {errorPacket.ErrorCode})");
case EOFPacket eofPacket:
// EOF packet received, check if it indicates success
if ((eofPacket.StatusFlags & 0x0002) != 0)
{
IsAuthenticated = true;
filterContext.State = MySQLConnectionState.Authenticated;
break;
}
else
{
throw new InvalidOperationException("Authentication failed: EOF packet received without success status. Length: " + eofPacket.Length);
}
// EOF packet during authentication indicates a protocol error
throw new InvalidOperationException("MySQL authentication failed: Unexpected EOF packet received during authentication.");
default:
throw new InvalidOperationException($"Unexpected packet received during authentication: {authResult?.GetType().Name ?? "null"}");
}
}

private byte[] GenerateAuthResponse(HandshakePacket handshakePacket)
private byte[] GenerateAuthResponse(byte[] authPluginDataPart1, byte[] authPluginDataPart2)
{
if (string.IsNullOrEmpty(_password))
return Array.Empty<byte>();

// Combine auth data parts to get the full salt
var saltLength = authPluginDataPart1.Length;
if (authPluginDataPart2 != null)
{
saltLength += Math.Min(authPluginDataPart2.Length, 12);
}

var salt = new byte[saltLength];
Array.Copy(authPluginDataPart1, 0, salt, 0, authPluginDataPart1.Length);

if (authPluginDataPart2 != null)
{
var part2Length = Math.Min(authPluginDataPart2.Length, 12);
Array.Copy(authPluginDataPart2, 0, salt, authPluginDataPart1.Length, part2Length);
}

return GenerateNativePasswordResponse(salt);
}

private byte[] GenerateNativePasswordResponse(byte[] salt)
{
if (string.IsNullOrEmpty(_password))
return Array.Empty<byte>();

// Remove trailing null if present (MySQL sends 20-byte salt with null terminator)
var saltLength = salt.Length;
if (saltLength > 0 && salt[saltLength - 1] == 0)
saltLength--;

// MySQL native password authentication algorithm:
// SHA1(password) XOR SHA1(salt + SHA1(SHA1(password)))
using (var sha1 = SHA1.Create())
Expand All @@ -130,20 +184,12 @@ private byte[] GenerateAuthResponse(HandshakePacket handshakePacket)
var sha1Password = sha1.ComputeHash(passwordBytes);
var sha1Sha1Password = sha1.ComputeHash(sha1Password);

sha1.TransformBlock(handshakePacket.AuthPluginDataPart1, 0, handshakePacket.AuthPluginDataPart1.Length, null, 0);

if (handshakePacket.AuthPluginDataPart2 != null)
{
var part2Length = Math.Min(handshakePacket.AuthPluginDataPart2.Length, 12);
sha1.TransformBlock(handshakePacket.AuthPluginDataPart2, 0, part2Length, null, 0);
}

sha1.TransformBlock(salt, 0, saltLength, null, 0);
sha1.TransformFinalBlock(sha1Sha1Password, 0, sha1Sha1Password.Length);

var sha1Combined = sha1.Hash;

var result = new byte[sha1Password.Length];

for (int i = 0; i < sha1Password.Length; i++)
{
result[i] = (byte)(sha1Password[i] ^ sha1Combined[i]);
Expand All @@ -153,6 +199,41 @@ private byte[] GenerateAuthResponse(HandshakePacket handshakePacket)
}
}

private byte[] GenerateCachingSha2Response(byte[] salt)
{
if (string.IsNullOrEmpty(_password))
return Array.Empty<byte>();

// Remove trailing null if present (MySQL sends salt with null terminator)
var saltLength = salt.Length;
if (saltLength > 0 && salt[saltLength - 1] == 0)
saltLength--;

// caching_sha2_password uses SHA256 instead of SHA1:
// SHA256(password) XOR SHA256(SHA256(SHA256(password)) + salt)
using (var sha256 = SHA256.Create())
{
var passwordBytes = Encoding.UTF8.GetBytes(_password);
var sha256Password = sha256.ComputeHash(passwordBytes);
var sha256Sha256Password = sha256.ComputeHash(sha256Password);

// Compute SHA256(SHA256(SHA256(password)) + salt)
var hashAndSalt = new byte[sha256Sha256Password.Length + saltLength];
Array.Copy(sha256Sha256Password, 0, hashAndSalt, 0, sha256Sha256Password.Length);
Array.Copy(salt, 0, hashAndSalt, sha256Sha256Password.Length, saltLength);
var sha256Combined = sha256.ComputeHash(hashAndSalt);

// XOR the results
var result = new byte[sha256Password.Length];
for (int i = 0; i < sha256Password.Length; i++)
{
result[i] = (byte)(sha256Password[i] ^ sha256Combined[i]);
}

return result;
}
}

/// <summary>
/// Executes a SQL query and returns the result
/// </summary>
Expand Down
24 changes: 23 additions & 1 deletion src/SuperSocket.MySQL/MySQLPacketDecoder.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Buffers;
using System.IO;
using SuperSocket.MySQL.Packets;
using SuperSocket.ProtoBase;

namespace SuperSocket.MySQL
Expand Down Expand Up @@ -44,7 +45,28 @@ public MySQLPacket Decode(ref ReadOnlySequence<byte> buffer, object context)
packetType = (int)packetTypeByte;
}

var package = _packetFactory.Create(packetType);
MySQLPacket package;

// Special handling for 0xFE during authentication phase
// During HandshakeInitiated state, 0xFE means AuthSwitchRequest, not EOF
if (packetType == 0xFE && filterContext.State == MySQLConnectionState.HandshakeInitiated)
{
// Check if this is an AuthSwitchRequest (longer than 4 bytes) or a real EOF (4 bytes)
// EOF packet has exactly 4 bytes (2 bytes warning count + 2 bytes status flags)
// AuthSwitchRequest has variable length (plugin name + auth data)
if (reader.Remaining > 4)
{
package = new AuthSwitchRequestPacket();
}
else
{
package = _packetFactory.Create(packetType);
}
}
else
{
package = _packetFactory.Create(packetType);
}

package = package.Decode(ref reader, context);
package.SequenceId = sequenceId;
Expand Down
79 changes: 79 additions & 0 deletions src/SuperSocket.MySQL/Packets/AuthSwitchRequestPacket.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System;
using System.Buffers;
using System.Text;
using SuperSocket.ProtoBase;

namespace SuperSocket.MySQL.Packets
{
/// <summary>
/// Represents an authentication switch request from the MySQL server.
/// This packet is sent when the server wants the client to use a different
/// authentication plugin than the one initially specified.
/// </summary>
public class AuthSwitchRequestPacket : MySQLPacket, IPacketWithHeaderByte
{
public byte Header { get; set; } = 0xFE;

/// <summary>
/// The name of the authentication plugin to switch to.
/// </summary>
public string PluginName { get; set; }

/// <summary>
/// The authentication data (salt) for the new plugin.
/// </summary>
public byte[] AuthData { get; set; }

protected internal override MySQLPacket Decode(ref SequenceReader<byte> reader, object context)
{
// Read plugin name (null-terminated string)
var startPosition = reader.Consumed;

// Find the null terminator
if (reader.TryAdvanceTo(0x00, advancePastDelimiter: false))
{
var pluginNameLength = reader.Consumed - startPosition;
reader.Rewind(pluginNameLength);

var pluginNameBytes = new byte[pluginNameLength];
reader.TryCopyTo(pluginNameBytes.AsSpan());
reader.Advance(pluginNameLength);

PluginName = Encoding.UTF8.GetString(pluginNameBytes);

// Skip the null terminator
reader.Advance(1);
}
else
{
// No null terminator found - read rest as plugin name
reader.Rewind(reader.Consumed - startPosition);
var remaining = new byte[reader.Remaining];
reader.TryCopyTo(remaining.AsSpan());
reader.Advance(remaining.Length);
PluginName = Encoding.UTF8.GetString(remaining);
AuthData = Array.Empty<byte>();
return this;
}

// Read remaining bytes as auth data
if (reader.Remaining > 0)
{
AuthData = new byte[reader.Remaining];
reader.TryCopyTo(AuthData.AsSpan());
reader.Advance(AuthData.Length);
}
else
{
AuthData = Array.Empty<byte>();
}

return this;
}

protected internal override int Encode(IBufferWriter<byte> writer)
{
throw new NotImplementedException();
}
}
}
36 changes: 36 additions & 0 deletions src/SuperSocket.MySQL/Packets/AuthSwitchResponsePacket.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.Buffers;
using SuperSocket.ProtoBase;

namespace SuperSocket.MySQL.Packets
{
/// <summary>
/// Represents the client's response to an authentication switch request.
/// Contains the authentication data for the new plugin.
/// </summary>
public class AuthSwitchResponsePacket : MySQLPacket
{
/// <summary>
/// The authentication response data for the switched plugin.
/// </summary>
public byte[] AuthData { get; set; }

protected internal override MySQLPacket Decode(ref SequenceReader<byte> reader, object context)
{
throw new NotImplementedException();
}

protected internal override int Encode(IBufferWriter<byte> writer)
{
var bytesWritten = 0;

if (AuthData != null && AuthData.Length > 0)
{
writer.Write(AuthData.AsSpan());
bytesWritten += AuthData.Length;
}

return bytesWritten;
}
}
}
Loading