Skip to content

Commit f214991

Browse files
Copilotkerryjiang
andauthored
Handle MySQL 8.0 AuthSwitchRequest during authentication (#5)
* Initial plan * Fix authentication bug where EOF packet incorrectly sets IsAuthenticated to true Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> * Skip integration tests when MySQL server is not available Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> * Address code review feedback: remove redundant Skip=null and obsolete comment Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> * Add AuthSwitchRequest handling for MySQL 8.0 authentication Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> * Address code review: fix code duplication and null byte handling in caching_sha2 Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> * Remove MySQL availability checks from tests - MySQL should always be available for integration tests Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> * Fix duplicate comment in MySQLIntegrationTest Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> * Fix MySQL CI: configure root user with mysql_native_password Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> * Fix CI: use sudo mysql to alter root user authentication Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> * Use MySQL Docker service in CI for reliable authentication setup Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kerryjiang <456060+kerryjiang@users.noreply.github.com>
1 parent c8ea2ad commit f214991

9 files changed

Lines changed: 322 additions & 32 deletions

File tree

.github/workflows/build.yml

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ on:
1111
jobs:
1212
build:
1313
runs-on: ubuntu-latest
14+
services:
15+
mysql:
16+
image: mysql:8.0
17+
env:
18+
MYSQL_ROOT_PASSWORD: root
19+
MYSQL_DATABASE: test
20+
ports:
21+
- 3306:3306
22+
options: --health-cmd="mysqladmin ping --silent" --health-interval=10s --health-timeout=5s --health-retries=3
1423
steps:
1524
- uses: actions/checkout@v1
1625
- name: Setup .NET Core
@@ -29,11 +38,9 @@ jobs:
2938
dotnet nuget locals all --clear
3039
- name: Build
3140
run: dotnet build -c Debug
32-
- name: Run MySQL
41+
- name: Setup MySQL auth
3342
run: |
34-
cp tests/SuperSocket.MySQL.Test/mysql.cnf ~/.my.cnf
35-
sudo systemctl start mysql.service
36-
mysql -V
43+
mysql -h 127.0.0.1 -u root -proot -e "ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'root'; FLUSH PRIVILEGES;"
3744
- name: Test
3845
run: |
3946
cd tests/SuperSocket.MySQL.Test

src/SuperSocket.MySQL/MySQLConnection.cs

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,49 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
7878
};
7979

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

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

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

90+
// Handle auth switch if requested
91+
while (authResult is AuthSwitchRequestPacket authSwitchRequest)
92+
{
93+
// Generate new auth response using the switched plugin's auth data
94+
byte[] authResponse;
95+
96+
if (authSwitchRequest.PluginName == "mysql_native_password")
97+
{
98+
// Use mysql_native_password algorithm
99+
authResponse = GenerateNativePasswordResponse(authSwitchRequest.AuthData);
100+
}
101+
else if (authSwitchRequest.PluginName == "caching_sha2_password")
102+
{
103+
// Use caching_sha2_password algorithm (same as mysql_native_password for the initial response)
104+
authResponse = GenerateCachingSha2Response(authSwitchRequest.AuthData);
105+
}
106+
else
107+
{
108+
throw new InvalidOperationException($"Unsupported authentication plugin: {authSwitchRequest.PluginName}");
109+
}
110+
111+
// Send auth switch response
112+
var authSwitchResponse = new AuthSwitchResponsePacket
113+
{
114+
AuthData = authResponse,
115+
SequenceId = authSwitchRequest.SequenceId + 1
116+
};
117+
118+
await SendAsync(PacketEncoder, authSwitchResponse).ConfigureAwait(false);
119+
120+
// Wait for next response
121+
authResult = await ReceiveAsync().ConfigureAwait(false);
122+
}
123+
90124
switch (authResult)
91125
{
92126
case OKPacket okPacket:
@@ -101,27 +135,47 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
101135
: "Authentication failed";
102136
throw new InvalidOperationException($"MySQL authentication failed: {errorMsg} (Error {errorPacket.ErrorCode})");
103137
case EOFPacket eofPacket:
104-
// EOF packet received, check if it indicates success
105-
if ((eofPacket.StatusFlags & 0x0002) != 0)
106-
{
107-
IsAuthenticated = true;
108-
filterContext.State = MySQLConnectionState.Authenticated;
109-
break;
110-
}
111-
else
112-
{
113-
throw new InvalidOperationException("Authentication failed: EOF packet received without success status. Length: " + eofPacket.Length);
114-
}
138+
// EOF packet during authentication indicates a protocol error
139+
throw new InvalidOperationException("MySQL authentication failed: Unexpected EOF packet received during authentication.");
115140
default:
116141
throw new InvalidOperationException($"Unexpected packet received during authentication: {authResult?.GetType().Name ?? "null"}");
117142
}
118143
}
119144

120-
private byte[] GenerateAuthResponse(HandshakePacket handshakePacket)
145+
private byte[] GenerateAuthResponse(byte[] authPluginDataPart1, byte[] authPluginDataPart2)
146+
{
147+
if (string.IsNullOrEmpty(_password))
148+
return Array.Empty<byte>();
149+
150+
// Combine auth data parts to get the full salt
151+
var saltLength = authPluginDataPart1.Length;
152+
if (authPluginDataPart2 != null)
153+
{
154+
saltLength += Math.Min(authPluginDataPart2.Length, 12);
155+
}
156+
157+
var salt = new byte[saltLength];
158+
Array.Copy(authPluginDataPart1, 0, salt, 0, authPluginDataPart1.Length);
159+
160+
if (authPluginDataPart2 != null)
161+
{
162+
var part2Length = Math.Min(authPluginDataPart2.Length, 12);
163+
Array.Copy(authPluginDataPart2, 0, salt, authPluginDataPart1.Length, part2Length);
164+
}
165+
166+
return GenerateNativePasswordResponse(salt);
167+
}
168+
169+
private byte[] GenerateNativePasswordResponse(byte[] salt)
121170
{
122171
if (string.IsNullOrEmpty(_password))
123172
return Array.Empty<byte>();
124173

174+
// Remove trailing null if present (MySQL sends 20-byte salt with null terminator)
175+
var saltLength = salt.Length;
176+
if (saltLength > 0 && salt[saltLength - 1] == 0)
177+
saltLength--;
178+
125179
// MySQL native password authentication algorithm:
126180
// SHA1(password) XOR SHA1(salt + SHA1(SHA1(password)))
127181
using (var sha1 = SHA1.Create())
@@ -130,20 +184,12 @@ private byte[] GenerateAuthResponse(HandshakePacket handshakePacket)
130184
var sha1Password = sha1.ComputeHash(passwordBytes);
131185
var sha1Sha1Password = sha1.ComputeHash(sha1Password);
132186

133-
sha1.TransformBlock(handshakePacket.AuthPluginDataPart1, 0, handshakePacket.AuthPluginDataPart1.Length, null, 0);
134-
135-
if (handshakePacket.AuthPluginDataPart2 != null)
136-
{
137-
var part2Length = Math.Min(handshakePacket.AuthPluginDataPart2.Length, 12);
138-
sha1.TransformBlock(handshakePacket.AuthPluginDataPart2, 0, part2Length, null, 0);
139-
}
140-
187+
sha1.TransformBlock(salt, 0, saltLength, null, 0);
141188
sha1.TransformFinalBlock(sha1Sha1Password, 0, sha1Sha1Password.Length);
142189

143190
var sha1Combined = sha1.Hash;
144191

145192
var result = new byte[sha1Password.Length];
146-
147193
for (int i = 0; i < sha1Password.Length; i++)
148194
{
149195
result[i] = (byte)(sha1Password[i] ^ sha1Combined[i]);
@@ -153,6 +199,41 @@ private byte[] GenerateAuthResponse(HandshakePacket handshakePacket)
153199
}
154200
}
155201

202+
private byte[] GenerateCachingSha2Response(byte[] salt)
203+
{
204+
if (string.IsNullOrEmpty(_password))
205+
return Array.Empty<byte>();
206+
207+
// Remove trailing null if present (MySQL sends salt with null terminator)
208+
var saltLength = salt.Length;
209+
if (saltLength > 0 && salt[saltLength - 1] == 0)
210+
saltLength--;
211+
212+
// caching_sha2_password uses SHA256 instead of SHA1:
213+
// SHA256(password) XOR SHA256(SHA256(SHA256(password)) + salt)
214+
using (var sha256 = SHA256.Create())
215+
{
216+
var passwordBytes = Encoding.UTF8.GetBytes(_password);
217+
var sha256Password = sha256.ComputeHash(passwordBytes);
218+
var sha256Sha256Password = sha256.ComputeHash(sha256Password);
219+
220+
// Compute SHA256(SHA256(SHA256(password)) + salt)
221+
var hashAndSalt = new byte[sha256Sha256Password.Length + saltLength];
222+
Array.Copy(sha256Sha256Password, 0, hashAndSalt, 0, sha256Sha256Password.Length);
223+
Array.Copy(salt, 0, hashAndSalt, sha256Sha256Password.Length, saltLength);
224+
var sha256Combined = sha256.ComputeHash(hashAndSalt);
225+
226+
// XOR the results
227+
var result = new byte[sha256Password.Length];
228+
for (int i = 0; i < sha256Password.Length; i++)
229+
{
230+
result[i] = (byte)(sha256Password[i] ^ sha256Combined[i]);
231+
}
232+
233+
return result;
234+
}
235+
}
236+
156237
/// <summary>
157238
/// Executes a SQL query and returns the result
158239
/// </summary>

src/SuperSocket.MySQL/MySQLPacketDecoder.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Buffers;
33
using System.IO;
4+
using SuperSocket.MySQL.Packets;
45
using SuperSocket.ProtoBase;
56

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

47-
var package = _packetFactory.Create(packetType);
48+
MySQLPacket package;
49+
50+
// Special handling for 0xFE during authentication phase
51+
// During HandshakeInitiated state, 0xFE means AuthSwitchRequest, not EOF
52+
if (packetType == 0xFE && filterContext.State == MySQLConnectionState.HandshakeInitiated)
53+
{
54+
// Check if this is an AuthSwitchRequest (longer than 4 bytes) or a real EOF (4 bytes)
55+
// EOF packet has exactly 4 bytes (2 bytes warning count + 2 bytes status flags)
56+
// AuthSwitchRequest has variable length (plugin name + auth data)
57+
if (reader.Remaining > 4)
58+
{
59+
package = new AuthSwitchRequestPacket();
60+
}
61+
else
62+
{
63+
package = _packetFactory.Create(packetType);
64+
}
65+
}
66+
else
67+
{
68+
package = _packetFactory.Create(packetType);
69+
}
4870

4971
package = package.Decode(ref reader, context);
5072
package.SequenceId = sequenceId;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using System;
2+
using System.Buffers;
3+
using System.Text;
4+
using SuperSocket.ProtoBase;
5+
6+
namespace SuperSocket.MySQL.Packets
7+
{
8+
/// <summary>
9+
/// Represents an authentication switch request from the MySQL server.
10+
/// This packet is sent when the server wants the client to use a different
11+
/// authentication plugin than the one initially specified.
12+
/// </summary>
13+
public class AuthSwitchRequestPacket : MySQLPacket, IPacketWithHeaderByte
14+
{
15+
public byte Header { get; set; } = 0xFE;
16+
17+
/// <summary>
18+
/// The name of the authentication plugin to switch to.
19+
/// </summary>
20+
public string PluginName { get; set; }
21+
22+
/// <summary>
23+
/// The authentication data (salt) for the new plugin.
24+
/// </summary>
25+
public byte[] AuthData { get; set; }
26+
27+
protected internal override MySQLPacket Decode(ref SequenceReader<byte> reader, object context)
28+
{
29+
// Read plugin name (null-terminated string)
30+
var startPosition = reader.Consumed;
31+
32+
// Find the null terminator
33+
if (reader.TryAdvanceTo(0x00, advancePastDelimiter: false))
34+
{
35+
var pluginNameLength = reader.Consumed - startPosition;
36+
reader.Rewind(pluginNameLength);
37+
38+
var pluginNameBytes = new byte[pluginNameLength];
39+
reader.TryCopyTo(pluginNameBytes.AsSpan());
40+
reader.Advance(pluginNameLength);
41+
42+
PluginName = Encoding.UTF8.GetString(pluginNameBytes);
43+
44+
// Skip the null terminator
45+
reader.Advance(1);
46+
}
47+
else
48+
{
49+
// No null terminator found - read rest as plugin name
50+
reader.Rewind(reader.Consumed - startPosition);
51+
var remaining = new byte[reader.Remaining];
52+
reader.TryCopyTo(remaining.AsSpan());
53+
reader.Advance(remaining.Length);
54+
PluginName = Encoding.UTF8.GetString(remaining);
55+
AuthData = Array.Empty<byte>();
56+
return this;
57+
}
58+
59+
// Read remaining bytes as auth data
60+
if (reader.Remaining > 0)
61+
{
62+
AuthData = new byte[reader.Remaining];
63+
reader.TryCopyTo(AuthData.AsSpan());
64+
reader.Advance(AuthData.Length);
65+
}
66+
else
67+
{
68+
AuthData = Array.Empty<byte>();
69+
}
70+
71+
return this;
72+
}
73+
74+
protected internal override int Encode(IBufferWriter<byte> writer)
75+
{
76+
throw new NotImplementedException();
77+
}
78+
}
79+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System;
2+
using System.Buffers;
3+
using SuperSocket.ProtoBase;
4+
5+
namespace SuperSocket.MySQL.Packets
6+
{
7+
/// <summary>
8+
/// Represents the client's response to an authentication switch request.
9+
/// Contains the authentication data for the new plugin.
10+
/// </summary>
11+
public class AuthSwitchResponsePacket : MySQLPacket
12+
{
13+
/// <summary>
14+
/// The authentication response data for the switched plugin.
15+
/// </summary>
16+
public byte[] AuthData { get; set; }
17+
18+
protected internal override MySQLPacket Decode(ref SequenceReader<byte> reader, object context)
19+
{
20+
throw new NotImplementedException();
21+
}
22+
23+
protected internal override int Encode(IBufferWriter<byte> writer)
24+
{
25+
var bytesWritten = 0;
26+
27+
if (AuthData != null && AuthData.Length > 0)
28+
{
29+
writer.Write(AuthData.AsSpan());
30+
bytesWritten += AuthData.Length;
31+
}
32+
33+
return bytesWritten;
34+
}
35+
}
36+
}

0 commit comments

Comments
 (0)