From d4fe6aed92661f0452a801db03817ffbd353877d Mon Sep 17 00:00:00 2001 From: Maqbool Date: Thu, 29 Oct 2026 22:14:14 +0400 Subject: [PATCH 1/5] Parse the server handshake The packet this project stopped at in 2018, rewritten against the two codecs that make its variable-length fields tractable. Both of the original bugs are here as the fix. server_version is NUL-terminated rather than six bytes wide: the old pattern happened to match MySQL 5.7.21, whose version string is exactly six characters, and crashes on anything else. The scramble arrives in two pieces nine bytes apart, and the second is MAX(13, auth_plugin_data_len - 8) bytes with a terminator at the end, so the scramble is auth_plugin_data_len - 1 bytes once joined. Tested against bytes captured from a real MySQL 8.4.4, saved as a fixture. A fixture built by hand would have encoded the same misreading as the parser and passed. --- lib/mysqlex/protocol/handshake.ex | 66 +++++++++++++++++++++++ test/fixtures/handshake_v10_8.4.4.bin | Bin 0 -> 73 bytes test/mysqlex/protocol/handshake_test.exs | 30 +++++++++++ 3 files changed, 96 insertions(+) create mode 100644 lib/mysqlex/protocol/handshake.ex create mode 100644 test/fixtures/handshake_v10_8.4.4.bin create mode 100644 test/mysqlex/protocol/handshake_test.exs diff --git a/lib/mysqlex/protocol/handshake.ex b/lib/mysqlex/protocol/handshake.ex new file mode 100644 index 0000000..9daed5c --- /dev/null +++ b/lib/mysqlex/protocol/handshake.ex @@ -0,0 +1,66 @@ +defmodule Mysqlex.Protocol.Handshake do + @moduledoc """ + `Protocol::HandshakeV10`, the greeting the server sends on connect. + + MySQL speaks first, so this is the first packet any client sees and the one + that decides everything after it: which capabilities are on offer, and which + authentication method the server wants. + + See `docs/mysql-protocol/page_protocol_connection_phase_packets_protocol_handshake_v10.md`. + """ + + import Bitwise + + alias Mysqlex.Protocol.Codec + + defstruct [ + :protocol_version, + :server_version, + :connection_id, + :capabilities, + :character_set, + :status_flags, + :auth_plugin_name, + :auth_plugin_data + ] + + @doc """ + Decodes a HandshakeV10 payload. + + Two fields are easy to get wrong and both are sized by the protocol rather + than fixed. `server_version` is NUL-terminated, not a fixed width — it is + "8.4.4" on one server and "8.0.33-0ubuntu0.22.04.2" on another. The scramble + arrives in two pieces with unrelated fields between them, and the second piece + is `MAX(13, auth_plugin_data_len - 8)` bytes of which the last is a + terminator, so the scramble itself is `auth_plugin_data_len - 1` bytes. + """ + def decode(<<10, rest::binary>>) do + {server_version, rest} = Codec.decode_string_nul(rest) + + <> = rest + + tail_length = max(13, auth_plugin_data_len - 8) + <> = rest + {auth_plugin_name, _rest} = Codec.decode_string_nul(rest) + + scramble_length = auth_plugin_data_len - 1 + + <> = + scramble_head <> scramble_tail + + {:ok, + %__MODULE__{ + protocol_version: 10, + server_version: server_version, + connection_id: connection_id, + # the server splits its capability bitmask across two fields, nine bytes apart + capabilities: capabilities_upper <<< 16 ||| capabilities_lower, + character_set: character_set, + status_flags: status_flags, + auth_plugin_name: auth_plugin_name, + auth_plugin_data: auth_plugin_data + }} + end +end diff --git a/test/fixtures/handshake_v10_8.4.4.bin b/test/fixtures/handshake_v10_8.4.4.bin new file mode 100644 index 0000000000000000000000000000000000000000..3a8647287bb862a07a3d185dca2b0daafb3d8ced GIT binary patch literal 73 zcmdWi|Mx{1;J`{w&^;+vJ)hs7yM!S*F*zeM VFFn3EBhe_nAhEc(JijP~0RYg16yE>< literal 0 HcmV?d00001 diff --git a/test/mysqlex/protocol/handshake_test.exs b/test/mysqlex/protocol/handshake_test.exs new file mode 100644 index 0000000..d380123 --- /dev/null +++ b/test/mysqlex/protocol/handshake_test.exs @@ -0,0 +1,30 @@ +defmodule Mysqlex.Protocol.HandshakeTest do + use ExUnit.Case, async: true + + alias Mysqlex.Protocol.Handshake + + test "parses the handshake captured from MySQL 8.4.4" do + payload = File.read!("test/fixtures/handshake_v10_8.4.4.bin") + + assert {:ok, handshake} = Handshake.decode(payload) + + assert handshake.protocol_version == 10 + assert handshake.server_version == "8.4.4" + assert handshake.connection_id == 78_814 + assert handshake.character_set == 255 + assert handshake.status_flags == 0x0002 + assert handshake.capabilities == 0xDFFFFFFF + assert handshake.auth_plugin_name == "caching_sha2_password" + end + + test "joins the scramble that the server splits across two fields" do + payload = File.read!("test/fixtures/handshake_v10_8.4.4.bin") + + assert {:ok, handshake} = Handshake.decode(payload) + + assert byte_size(handshake.auth_plugin_data) == 20 + + assert Base.encode16(handshake.auth_plugin_data, case: :lower) == + "6344555b255d623b3a1e1147626d276f0f4f0b74" + end +end From 065ecd42b7f04a494042306ca22e3bf94c83c9b1 Mon Sep 17 00:00:00 2001 From: Maqbool Date: Thu, 29 Oct 2026 22:16:25 +0400 Subject: [PATCH 2/5] Add the mysql_native_password scramble SHA1(password) XOR SHA1(scramble <> SHA1(SHA1(password))). The password never crosses the wire, and because the scramble is per-connection the reply cannot be replayed. This is the one piece so far with no byte-level example in the spec, so the vectors come from elsewhere. The inner stages are checked against MySQL itself: SHA1(SHA1("secret")) is what the server keeps in mysql.user.authentication_string, and MySQL 8.4.4 computing it with its own functions returns the value asserted here. The full reply is checked against an independent implementation. Neither of those verifies the outer XOR against a real exchange. The test that does is a server accepting a login, which needs the handshake response next. An empty password answers with no bytes rather than a hash of the empty string, which is a rule of the method rather than an optimisation. --- lib/mysqlex/auth/native_password.ex | 40 ++++++++++++++++++++++ test/mysqlex/auth/native_password_test.exs | 39 +++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 lib/mysqlex/auth/native_password.ex create mode 100644 test/mysqlex/auth/native_password_test.exs diff --git a/lib/mysqlex/auth/native_password.ex b/lib/mysqlex/auth/native_password.ex new file mode 100644 index 0000000..0aeca04 --- /dev/null +++ b/lib/mysqlex/auth/native_password.ex @@ -0,0 +1,40 @@ +defmodule Mysqlex.Auth.NativePassword do + @moduledoc """ + The `mysql_native_password` authentication method. + + A challenge-response scheme: the server sends a random 20-byte scramble in its + handshake, the client answers with a value derived from both the scramble and + the password. The password itself never crosses the wire, and because the + scramble differs per connection the answer cannot be replayed. + + Not the default since MySQL 8 — `caching_sha2_password` is — but it is the + simplest method to get a connection working, and the server will use it for any + account created with `IDENTIFIED WITH mysql_native_password`. + """ + + @doc """ + Builds the reply to the server's scramble. + + `SHA1(password) XOR SHA1(scramble <> SHA1(SHA1(password)))`. + + The server holds only `SHA1(SHA1(password))`, which is enough to compute the + same value and compare, and not enough to recover the password. + + An account with no password answers with no bytes at all rather than with a + hash of the empty string. + """ + def reply("", _scramble), do: "" + + def reply(password, scramble) do + stage1 = :crypto.hash(:sha, password) + token = :crypto.hash(:sha, scramble <> :crypto.hash(:sha, stage1)) + + :crypto.exor(stage1, token) + end + + @doc """ + `SHA1(SHA1(password))` — what the server keeps in + `mysql.user.authentication_string`, prefixed there with `*` and hex encoded. + """ + def stored_hash(password), do: :crypto.hash(:sha, :crypto.hash(:sha, password)) +end diff --git a/test/mysqlex/auth/native_password_test.exs b/test/mysqlex/auth/native_password_test.exs new file mode 100644 index 0000000..5c1e004 --- /dev/null +++ b/test/mysqlex/auth/native_password_test.exs @@ -0,0 +1,39 @@ +defmodule Mysqlex.Auth.NativePasswordTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Mysqlex.Auth.NativePassword + + @scramble Base.decode16!("6344555B255D623B3A1E1147626D276F0F4F0B74") + + test "the doubly hashed password is what the server stores" do + # mysql.user.authentication_string for a native-password account is '*' + # followed by SHA1(SHA1(password)) in uppercase hex. MySQL 8.4.4 computing + # it with its own functions returns exactly this. + assert Base.encode16(NativePassword.stored_hash("secret")) == + "14E65567ABDB5135D0CFD9A70B3032C179A49EE7" + end + + test "produces the expected reply for a known scramble" do + assert Base.encode16(NativePassword.reply("secret", @scramble), case: :lower) == + "ec75977987f3398d9333ef89de2a34c5bded7ca7" + end + + test "sends nothing at all when the account has no password" do + assert NativePassword.reply("", @scramble) == "" + end + + property "a reply is always 20 bytes and changes with the scramble" do + check all( + password <- string(:alphanumeric, min_length: 1), + scramble <- binary(length: 20), + other <- binary(length: 20), + scramble != other + ) do + reply = NativePassword.reply(password, scramble) + + assert byte_size(reply) == 20 + assert reply != NativePassword.reply(password, other) + end + end +end From 6bd1bb977e327157e3ce9f1c33dd00fa5225bbd3 Mon Sep 17 00:00:00 2001 From: Maqbool Date: Thu, 29 Oct 2026 22:19:01 +0400 Subject: [PATCH 3/5] Build the client handshake response The first packet the driver sends. Its capability flags are a claim rather than a question: what is set here decides the shape of nearly every packet afterwards, so capabilities/1 is public and the response parsers are meant to be given the same value. Claimed: PROTOCOL_41, PLUGIN_AUTH, SECURE_CONNECTION, TRANSACTIONS, and CONNECT_WITH_DB only when a database is named. Left off on purpose, with reasons in the source: LOCAL_FILES, because a malicious server can then ask the client for any file it likes; MULTI_STATEMENTS, because it turns one injected query into several; SESSION_TRACK and DEPRECATE_EOF, because both change packet shapes this driver does not parse yet; CONNECT_ATTRS, because it sends metadata nothing here needs. The tests assert the exact byte layout, since for a packet the driver emits there is nothing else to check it against until a server accepts it. --- lib/mysqlex/protocol/handshake_response.ex | 70 +++++++++++++++++++ .../protocol/handshake_response_test.exs | 52 ++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 lib/mysqlex/protocol/handshake_response.ex create mode 100644 test/mysqlex/protocol/handshake_response_test.exs diff --git a/lib/mysqlex/protocol/handshake_response.ex b/lib/mysqlex/protocol/handshake_response.ex new file mode 100644 index 0000000..cbb7cb1 --- /dev/null +++ b/lib/mysqlex/protocol/handshake_response.ex @@ -0,0 +1,70 @@ +defmodule Mysqlex.Protocol.HandshakeResponse do + @moduledoc """ + `Protocol::HandshakeResponse41` — the client's answer to the server's greeting, + and the first packet this driver ever sends. + + The capability flags here are a claim rather than a question. Whatever is set + decides the shape of nearly every packet that follows, which is why + `capabilities/1` is public: the same value has to be handed to the response + parsers so they read the right fields. + + See + `docs/mysql-protocol/page_protocol_connection_phase_packets_protocol_handshake_response.md`. + """ + + import Bitwise + + alias Mysqlex.Protocol.Codec + + @connect_with_db 1 <<< 3 + @protocol_41 1 <<< 9 + @transactions 1 <<< 13 + # named CLIENT_RESERVED2 in current docs; modern servers behave as if it is + # always set, older ones require it for 4.1-style authentication + @secure_connection 1 <<< 15 + @plugin_auth 1 <<< 19 + + @capabilities @protocol_41 ||| @transactions ||| @secure_connection ||| @plugin_auth + + # Deliberately not claimed: + # CLIENT_LOCAL_FILES a malicious server can ask the client for any file + # CLIENT_MULTI_STATEMENTS turns one injected query into several + # CLIENT_SESSION_TRACK changes the OK packet's shape; not parsed yet + # CLIENT_DEPRECATE_EOF changes how resultsets terminate; a phase 3 call + # CLIENT_CONNECT_ATTRS sends client metadata the driver has no use for + + # utf8mb4_general_ci, understood by every server since 5.5 + @character_set 45 + @max_packet_size 0xFFFFFF + @auth_plugin_name "mysql_native_password" + + @doc """ + The capability flags the driver claims, which depend only on whether a + database is being selected on connect. + """ + def capabilities(nil), do: @capabilities + def capabilities(_database), do: @capabilities ||| @connect_with_db + + @doc """ + Builds the response payload as iodata. + + `auth_response` is the output of the authentication method — for + `mysql_native_password` that is `Mysqlex.Auth.NativePassword.reply/2`. It is + length-prefixed with a single byte, which is the form used when + `CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA` is not claimed. + """ + def encode(username, auth_response, database) do + [ + <>, + Codec.encode_string_nul(username), + <>, + auth_response, + database_field(database), + Codec.encode_string_nul(@auth_plugin_name) + ] + end + + defp database_field(nil), do: [] + defp database_field(database), do: Codec.encode_string_nul(database) +end diff --git a/test/mysqlex/protocol/handshake_response_test.exs b/test/mysqlex/protocol/handshake_response_test.exs new file mode 100644 index 0000000..f5412a8 --- /dev/null +++ b/test/mysqlex/protocol/handshake_response_test.exs @@ -0,0 +1,52 @@ +defmodule Mysqlex.Protocol.HandshakeResponseTest do + use ExUnit.Case, async: true + + import Bitwise + + alias Mysqlex.Protocol.HandshakeResponse + + @auth_response Base.decode16!("EC75977987F3398D9333EF89DE2A34C5BDED7CA7") + + test "claims only the capabilities the driver implements" do + caps = HandshakeResponse.capabilities(nil) + + assert band(caps, 0x0200) != 0, "CLIENT_PROTOCOL_41" + assert band(caps, 0x8000) != 0, "CLIENT_SECURE_CONNECTION" + assert band(caps, 0x2000) != 0, "CLIENT_TRANSACTIONS" + assert band(caps, 1 <<< 19) != 0, "CLIENT_PLUGIN_AUTH" + + refute band(caps, 0x0080) != 0, "CLIENT_LOCAL_FILES must stay off" + refute band(caps, 1 <<< 16) != 0, "CLIENT_MULTI_STATEMENTS must stay off" + refute band(caps, 1 <<< 23) != 0, "CLIENT_SESSION_TRACK is not parsed" + refute band(caps, 1 <<< 24) != 0, "CLIENT_DEPRECATE_EOF is a phase 3 decision" + refute band(caps, 0x0008) != 0, "CLIENT_CONNECT_WITH_DB without a database" + end + + test "announces a database only when one is given" do + assert band(HandshakeResponse.capabilities("mysqlex_test"), 0x0008) != 0 + end + + test "builds the packet the server expects" do + payload = + HandshakeResponse.encode("mysqlex", @auth_response, "mysqlex_test") + |> IO.iodata_to_binary() + + capabilities = HandshakeResponse.capabilities("mysqlex_test") + + assert payload == + <> + end + + test "omits the database when there is none" do + payload = + HandshakeResponse.encode("mysqlex", @auth_response, nil) + |> IO.iodata_to_binary() + + capabilities = HandshakeResponse.capabilities(nil) + + assert payload == + <> + end +end From 1f2617e8a86e098c1135566d18b8f282813d4a3f Mon Sep 17 00:00:00 2001 From: Maqbool Date: Thu, 29 Oct 2026 22:21:26 +0400 Subject: [PATCH 4/5] Complete the connection phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client.handshake/3 runs the conversation: read the greeting, check the server wants a method we can speak, answer its scramble, read what it makes of the answer. Dispatching on the reply's first byte — 0x00 for success, 0xFF for refusal, 0xFE for an auth switch we do not yet handle. Split from connect/1 so the conversation is testable over a scripted transport, which is what makes the failure branches reachable at all: a refused login and an unsupported plugin are both tested without a server. The plugin check refuses rather than guesses. Sending a native-password reply to a server asking for caching_sha2 produces a rejection that looks exactly like a wrong password, which is a bad hour to spend. Transport grows connect/4, send/2 and close/1 now that the client needs them, and the fake records what was written so a test can assert the exact bytes of the response as sequence id 1. --- lib/mysqlex/client.ex | 88 ++++++++++++++++++++++++++++++ lib/mysqlex/tcp.ex | 3 + lib/mysqlex/transport.ex | 15 +++-- test/mysqlex/client_login_test.exs | 72 ++++++++++++++++++++++++ test/support/fake_transport.ex | 31 ++++++++--- 5 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 test/mysqlex/client_login_test.exs diff --git a/lib/mysqlex/client.ex b/lib/mysqlex/client.ex index 3285efa..c6b4573 100644 --- a/lib/mysqlex/client.ex +++ b/lib/mysqlex/client.ex @@ -6,7 +6,95 @@ defmodule Mysqlex.Client do message, so no parser above it ever sees a partial read. """ + alias Mysqlex.Auth.NativePassword alias Mysqlex.Packet + alias Mysqlex.Protocol.{Err, Handshake, HandshakeResponse, Ok} + + defstruct [:transport, :socket, :capabilities, :connection_id, :server_version, buffer: ""] + + @default_port 3306 + @default_timeout 5_000 + + @doc """ + Opens a connection and completes the connection phase. + """ + def connect(opts) do + transport = Keyword.get(opts, :transport, Mysqlex.Tcp) + host = opts |> Keyword.fetch!(:hostname) |> to_charlist() + port = Keyword.get(opts, :port, @default_port) + timeout = Keyword.get(opts, :connect_timeout, @default_timeout) + socket_opts = Keyword.get(opts, :socket_options, []) + + with {:ok, socket} <- transport.connect(host, port, socket_opts, timeout) do + handshake(transport, socket, opts) + end + end + + @doc """ + Runs the connection phase over an already-open socket. + + The server speaks first, so this reads before it writes: greeting, then the + response, then whatever the server makes of it. Separate from `connect/1` so + the conversation can be driven by a scripted transport. + """ + def handshake(transport, socket, opts) do + timeout = Keyword.get(opts, :connect_timeout, @default_timeout) + username = Keyword.fetch!(opts, :username) + password = Keyword.get(opts, :password) || "" + database = Keyword.get(opts, :database) + capabilities = HandshakeResponse.capabilities(database) + + with {:ok, sequence_id, greeting, buffer} <- recv_message(transport, socket, "", timeout), + {:ok, handshake} <- Handshake.decode(greeting), + :ok <- supported_plugin(handshake.auth_plugin_name), + response = + HandshakeResponse.encode( + username, + NativePassword.reply(password, handshake.auth_plugin_data), + database + ), + :ok <- send_message(transport, socket, response, sequence_id + 1), + {:ok, _sequence_id, reply, buffer} <- + recv_message(transport, socket, buffer, timeout), + {:ok, _ok} <- decode_login_reply(reply, capabilities) do + {:ok, + %__MODULE__{ + transport: transport, + socket: socket, + capabilities: capabilities, + connection_id: handshake.connection_id, + server_version: handshake.server_version, + buffer: buffer + }} + end + end + + @doc """ + Frames a payload and writes it to the socket. + """ + def send_message(transport, socket, payload, sequence_id) do + payload + |> IO.iodata_to_binary() + |> Packet.encode(sequence_id) + |> then(&transport.send(socket, &1)) + end + + # Only one method is implemented so far. Saying so beats sending a reply the + # server will reject for reasons that look like a bad password. + defp supported_plugin("mysql_native_password"), do: :ok + defp supported_plugin(other), do: {:error, {:unsupported_auth_plugin, other}} + + defp decode_login_reply(<<0x00, _rest::binary>> = payload, capabilities), + do: Ok.decode(payload, capabilities) + + defp decode_login_reply(<<0xFF, _rest::binary>> = payload, capabilities) do + {:ok, error} = Err.decode(payload, capabilities) + + {:error, error} + end + + defp decode_login_reply(<<0xFE, _rest::binary>>, _capabilities), + do: {:error, :auth_switch_not_supported} @doc """ Reads one message, reading again for as long as the buffer holds less than a diff --git a/lib/mysqlex/tcp.ex b/lib/mysqlex/tcp.ex index eda2002..ab5d60d 100644 --- a/lib/mysqlex/tcp.ex +++ b/lib/mysqlex/tcp.ex @@ -7,6 +7,7 @@ defmodule Mysqlex.Tcp do @socket_opts [packet: :raw, mode: :binary, active: false] + @impl true def connect(host, port, opts \\ [], timeout \\ 5_000) do :gen_tcp.connect(host, port, @socket_opts ++ opts, timeout) end @@ -16,10 +17,12 @@ defmodule Mysqlex.Tcp do :gen_tcp.recv(socket, bytes, timeout) end + @impl true def send(socket, packet) do :gen_tcp.send(socket, packet) end + @impl true def close(socket) do :gen_tcp.close(socket) end diff --git a/lib/mysqlex/transport.ex b/lib/mysqlex/transport.ex index 9644408..faeda85 100644 --- a/lib/mysqlex/transport.ex +++ b/lib/mysqlex/transport.ex @@ -2,14 +2,21 @@ defmodule Mysqlex.Transport do @moduledoc """ The socket operations the client needs. - Reading is expressed against this behaviour rather than `:gen_tcp` directly so - that framing and, later, the connection phase can be driven by a scripted - transport in tests. `Mysqlex.Tcp` is the real implementation. + The client is written against this rather than `:gen_tcp` directly so the + connection phase can be driven by a scripted transport in tests — capability + negotiation, authentication failures and plugin mismatches are all branches + that would otherwise need specially configured live servers. - Callbacks are added as the client comes to need them. + `Mysqlex.Tcp` is the real implementation. `:ssl` has the same shape, so TLS + will be another module behind the same behaviour rather than a branch threaded + through every read and write. """ @type socket :: term + @callback connect(host :: charlist, port :: :inet.port_number(), opts :: keyword, timeout) :: + {:ok, socket} | {:error, term} @callback recv(socket, bytes :: non_neg_integer, timeout) :: {:ok, binary} | {:error, term} + @callback send(socket, iodata) :: :ok | {:error, term} + @callback close(socket) :: :ok end diff --git a/test/mysqlex/client_login_test.exs b/test/mysqlex/client_login_test.exs new file mode 100644 index 0000000..e539d37 --- /dev/null +++ b/test/mysqlex/client_login_test.exs @@ -0,0 +1,72 @@ +defmodule Mysqlex.ClientLoginTest do + use ExUnit.Case, async: true + + alias Mysqlex.Auth.NativePassword + alias Mysqlex.{Client, Error, FakeTransport} + alias Mysqlex.Packet + alias Mysqlex.Protocol.HandshakeResponse + + @handshake File.read!("test/fixtures/handshake_v10_8.4.4.bin") + # the same captured bytes with the plugin name swapped, since the server this + # was taken from offers caching_sha2_password and MySQL 8.4 ships + # mysql_native_password disabled + @native_handshake String.replace(@handshake, "caching_sha2_password", "mysql_native_password") + + @scramble Base.decode16!("6344555B255D623B3A1E1147626D276F0F4F0B74") + + defp framed(payload, sequence_id), do: IO.iodata_to_binary(Packet.encode(payload, sequence_id)) + + defp opts(socket) do + [ + transport: FakeTransport, + socket: socket, + username: "mysqlex", + password: "secret", + database: "mysqlex_test" + ] + end + + test "completes a login and reports what the server said about itself" do + ok = <<0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00>> + socket = FakeTransport.script([framed(@native_handshake, 0), framed(ok, 2)]) + + assert {:ok, client} = Client.handshake(FakeTransport, socket, opts(socket)) + + assert client.connection_id == 78_814 + assert client.server_version == "8.4.4" + assert client.capabilities == HandshakeResponse.capabilities("mysqlex_test") + end + + test "answers the server's scramble with the right bytes, as sequence id 1" do + ok = <<0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00>> + socket = FakeTransport.script([framed(@native_handshake, 0), framed(ok, 2)]) + + assert {:ok, _client} = Client.handshake(FakeTransport, socket, opts(socket)) + + expected = + "mysqlex" + |> HandshakeResponse.encode(NativePassword.reply("secret", @scramble), "mysqlex_test") + |> IO.iodata_to_binary() + |> framed(1) + + assert FakeTransport.sent(socket) == expected + end + + test "returns the server's error when the login is refused" do + err = <<0xFF, 0x15, 0x04, ?#, "28000", "Access denied for user 'mysqlex'@'localhost'">> + socket = FakeTransport.script([framed(@native_handshake, 0), framed(err, 2)]) + + assert {:error, %Error{} = error} = Client.handshake(FakeTransport, socket, opts(socket)) + + assert error.code == 1045 + assert error.sql_state == "28000" + assert error.message =~ "Access denied" + end + + test "refuses a plugin it cannot speak, rather than guessing" do + socket = FakeTransport.script([framed(@handshake, 0)]) + + assert {:error, {:unsupported_auth_plugin, "caching_sha2_password"}} = + Client.handshake(FakeTransport, socket, opts(socket)) + end +end diff --git a/test/support/fake_transport.ex b/test/support/fake_transport.ex index c67a0b0..def32a4 100644 --- a/test/support/fake_transport.ex +++ b/test/support/fake_transport.ex @@ -1,24 +1,41 @@ defmodule Mysqlex.FakeTransport do @moduledoc """ - A transport that replays a scripted list of byte chunks, so framing and the - connection phase can be driven without a server. + A transport that replays scripted byte chunks and records what was sent, so + the connection phase can be driven without a server. - Each `recv/3` returns the next chunk, which lets a test deliver one packet - across several reads, or several packets in one read. + Each `recv/3` hands back the next chunk, which lets a test deliver one packet + across several reads or several packets in one read. """ @behaviour Mysqlex.Transport def script(chunks) do - {:ok, agent} = Agent.start_link(fn -> chunks end) + {:ok, agent} = Agent.start_link(fn -> %{chunks: chunks, sent: []} end) agent end + @doc "Everything the client has written to this socket, in order." + def sent(agent) do + agent |> Agent.get(& &1.sent) |> Enum.reverse() |> IO.iodata_to_binary() + end + + @impl true + def connect(_host, _port, _opts, _timeout), do: {:error, :use_script_instead} + @impl true def recv(agent, _bytes, _timeout) do Agent.get_and_update(agent, fn - [chunk | rest] -> {{:ok, chunk}, rest} - [] -> {{:error, :closed}, []} + %{chunks: [chunk | rest]} = state -> {{:ok, chunk}, %{state | chunks: rest}} + %{chunks: []} = state -> {{:error, :closed}, state} end) end + + @impl true + def send(agent, data) do + Agent.update(agent, fn state -> %{state | sent: [data | state.sent]} end) + :ok + end + + @impl true + def close(_agent), do: :ok end From 843510ed379e4f1c4ebcdacf5a5d143662eb9384 Mon Sep 17 00:00:00 2001 From: Maqbool Date: Thu, 29 Oct 2026 22:28:32 +0400 Subject: [PATCH 5/5] Switch authentication methods when the server asks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The greeting names the server's default plugin, chosen before it knows which account is connecting. A real MySQL 8.0 offering caching_sha2_password still accepts a native-password account — it answers the client's response with an AuthSwitchRequest naming the method it actually wants and a fresh scramble, then validates the reply to that. So refusing on the plugin named in the greeting was wrong: it rejected connections the server would have completed. The check now applies to the switch instead, where the server has stated what it needs. Found by running against a real server, which no scripted test would have revealed: the fake only ever replays what I already believed. Integration tests arrive with it, excluded from mix test by default and run with --include integration. They cover a real login, a wrong password, and a database the account cannot reach. The last one reports 1044 rather than 1049 — the account is granted a single schema, so the server says access denied rather than unknown database. switch_auth carries a partial client struct rather than nine positional arguments, which credo rightly objected to. --- lib/mysqlex/client.ex | 108 +++++++++++++++++------------ test/integration/login_test.exs | 50 +++++++++++++ test/mysqlex/client_login_test.exs | 25 ++++++- test/test_helper.exs | 3 + 4 files changed, 139 insertions(+), 47 deletions(-) create mode 100644 test/integration/login_test.exs diff --git a/lib/mysqlex/client.ex b/lib/mysqlex/client.ex index c6b4573..a5b240e 100644 --- a/lib/mysqlex/client.ex +++ b/lib/mysqlex/client.ex @@ -1,6 +1,7 @@ defmodule Mysqlex.Client do @moduledoc """ - Reads and writes packets over a `Mysqlex.Transport`. + Reads and writes packets over a `Mysqlex.Transport`, and runs the connection + phase. This is where the socket lives. Everything it hands upwards is a whole message, so no parser above it ever sees a partial read. @@ -8,7 +9,7 @@ defmodule Mysqlex.Client do alias Mysqlex.Auth.NativePassword alias Mysqlex.Packet - alias Mysqlex.Protocol.{Err, Handshake, HandshakeResponse, Ok} + alias Mysqlex.Protocol.{Codec, Err, Handshake, HandshakeResponse} defstruct [:transport, :socket, :capabilities, :connection_id, :server_version, buffer: ""] @@ -33,20 +34,24 @@ defmodule Mysqlex.Client do @doc """ Runs the connection phase over an already-open socket. - The server speaks first, so this reads before it writes: greeting, then the - response, then whatever the server makes of it. Separate from `connect/1` so - the conversation can be driven by a scripted transport. + The server speaks first, so this reads before it writes: greeting, response, + then however the server answers. Separate from `connect/1` so the conversation + can be driven by a scripted transport. """ def handshake(transport, socket, opts) do timeout = Keyword.get(opts, :connect_timeout, @default_timeout) username = Keyword.fetch!(opts, :username) password = Keyword.get(opts, :password) || "" database = Keyword.get(opts, :database) - capabilities = HandshakeResponse.capabilities(database) + + client = %__MODULE__{ + transport: transport, + socket: socket, + capabilities: HandshakeResponse.capabilities(database) + } with {:ok, sequence_id, greeting, buffer} <- recv_message(transport, socket, "", timeout), {:ok, handshake} <- Handshake.decode(greeting), - :ok <- supported_plugin(handshake.auth_plugin_name), response = HandshakeResponse.encode( username, @@ -54,48 +59,17 @@ defmodule Mysqlex.Client do database ), :ok <- send_message(transport, socket, response, sequence_id + 1), - {:ok, _sequence_id, reply, buffer} <- - recv_message(transport, socket, buffer, timeout), - {:ok, _ok} <- decode_login_reply(reply, capabilities) do + {:ok, buffer} <- authenticate(client, password, buffer, timeout) do {:ok, - %__MODULE__{ - transport: transport, - socket: socket, - capabilities: capabilities, - connection_id: handshake.connection_id, - server_version: handshake.server_version, - buffer: buffer + %{ + client + | connection_id: handshake.connection_id, + server_version: handshake.server_version, + buffer: buffer }} end end - @doc """ - Frames a payload and writes it to the socket. - """ - def send_message(transport, socket, payload, sequence_id) do - payload - |> IO.iodata_to_binary() - |> Packet.encode(sequence_id) - |> then(&transport.send(socket, &1)) - end - - # Only one method is implemented so far. Saying so beats sending a reply the - # server will reject for reasons that look like a bad password. - defp supported_plugin("mysql_native_password"), do: :ok - defp supported_plugin(other), do: {:error, {:unsupported_auth_plugin, other}} - - defp decode_login_reply(<<0x00, _rest::binary>> = payload, capabilities), - do: Ok.decode(payload, capabilities) - - defp decode_login_reply(<<0xFF, _rest::binary>> = payload, capabilities) do - {:ok, error} = Err.decode(payload, capabilities) - - {:error, error} - end - - defp decode_login_reply(<<0xFE, _rest::binary>>, _capabilities), - do: {:error, :auth_switch_not_supported} - @doc """ Reads one message, reading again for as long as the buffer holds less than a whole message. @@ -115,4 +89,50 @@ defmodule Mysqlex.Client do end end end + + @doc """ + Frames a payload and writes it to the socket. + """ + def send_message(transport, socket, payload, sequence_id) do + payload + |> IO.iodata_to_binary() + |> Packet.encode(sequence_id) + |> then(&transport.send(socket, &1)) + end + + # The greeting names the server's default authentication plugin, chosen before + # it knows which account is connecting. When the account uses a different one + # the server answers with an AuthSwitchRequest naming what it actually wants, + # with a fresh scramble. So the plugin in the greeting is not something to + # refuse on — switching is how the protocol settles the mismatch. + defp authenticate(%__MODULE__{} = client, password, buffer, timeout) do + with {:ok, sequence_id, payload, buffer} <- + recv_message(client.transport, client.socket, buffer, timeout) do + case payload do + <<0x00, _rest::binary>> -> + {:ok, buffer} + + <<0xFF, _rest::binary>> -> + {:ok, error} = Err.decode(payload, client.capabilities) + {:error, error} + + <<0xFE, rest::binary>> -> + {plugin, data} = Codec.decode_string_nul(rest) + switch_auth(client, plugin, data, password, buffer, sequence_id, timeout) + end + end + end + + defp switch_auth(client, "mysql_native_password", data, password, buffer, sequence_id, timeout) + when byte_size(data) >= 20 do + # the fresh scramble is 20 bytes and a terminator, as in the greeting + reply = NativePassword.reply(password, :binary.part(data, 0, 20)) + + with :ok <- send_message(client.transport, client.socket, reply, sequence_id + 1) do + authenticate(client, password, buffer, timeout) + end + end + + defp switch_auth(_client, plugin, _data, _password, _buffer, _sequence_id, _timeout), + do: {:error, {:unsupported_auth_plugin, plugin}} end diff --git a/test/integration/login_test.exs b/test/integration/login_test.exs new file mode 100644 index 0000000..98a5176 --- /dev/null +++ b/test/integration/login_test.exs @@ -0,0 +1,50 @@ +defmodule Mysqlex.LoginIntegrationTest do + use ExUnit.Case, async: false + + @moduletag :integration + + alias Mysqlex.{Client, Error, Tcp} + + # docker run -d --name mysqlex-dev -p 3307:3306 \ + # -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=mysqlex_test \ + # mysql:8.0 --mysql-native-password=ON + # + # CREATE USER 'mysqlex'@'%' IDENTIFIED WITH mysql_native_password BY 'secret'; + # GRANT ALL ON mysqlex_test.* TO 'mysqlex'@'%'; + # + # MySQL 8.4 ships mysql_native_password disabled, which is why this pins 8.0. + @opts [ + hostname: "127.0.0.1", + port: 3307, + username: "mysqlex", + password: "secret", + database: "mysqlex_test" + ] + + test "logs in to a real server" do + assert {:ok, client} = Client.connect(@opts) + + on_exit(fn -> Tcp.close(client.socket) end) + + assert client.server_version =~ "8.0" + assert is_integer(client.connection_id) and client.connection_id > 0 + end + + test "a wrong password is refused in the server's own words" do + assert {:error, %Error{} = error} = Client.connect(Keyword.put(@opts, :password, "wrong")) + + assert error.code == 1045 + assert error.sql_state == "28000" + assert error.message =~ "Access denied" + end + + test "a database the account cannot reach is refused after authentication" do + assert {:error, %Error{} = error} = Client.connect(Keyword.put(@opts, :database, "nope")) + + # 1044 rather than 1049: the account is granted only mysqlex_test, so the + # server reports access denied to the database rather than that it is unknown + assert error.code == 1044 + assert error.sql_state == "42000" + assert error.message =~ "denied" + end +end diff --git a/test/mysqlex/client_login_test.exs b/test/mysqlex/client_login_test.exs index e539d37..e4c68be 100644 --- a/test/mysqlex/client_login_test.exs +++ b/test/mysqlex/client_login_test.exs @@ -63,10 +63,29 @@ defmodule Mysqlex.ClientLoginTest do assert error.message =~ "Access denied" end - test "refuses a plugin it cannot speak, rather than guessing" do - socket = FakeTransport.script([framed(@handshake, 0)]) + test "answers an auth switch with a reply built from the fresh scramble" do + fresh = :binary.copy(<<9>>, 20) + switch = <<0xFE, "mysql_native_password", 0, fresh::binary, 0>> + ok = <<0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00>> + + socket = + FakeTransport.script([framed(@handshake, 0), framed(switch, 2), framed(ok, 4)]) + + assert {:ok, _client} = Client.handshake(FakeTransport, socket, opts(socket)) + + # the switch reply is the bare auth data, as sequence id 3 + assert String.ends_with?( + FakeTransport.sent(socket), + framed(NativePassword.reply("secret", fresh), 3) + ) + end + + test "refuses a switch to a method it cannot speak, rather than guessing" do + scramble = :binary.copy(<<7>>, 20) + switch = <<0xFE, "sha256_password", 0, scramble::binary, 0>> + socket = FakeTransport.script([framed(@handshake, 0), framed(switch, 2)]) - assert {:error, {:unsupported_auth_plugin, "caching_sha2_password"}} = + assert {:error, {:unsupported_auth_plugin, "sha256_password"}} = Client.handshake(FakeTransport, socket, opts(socket)) end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..584eef3 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,4 @@ +# Integration tests need a MySQL server; see test/integration for how to start +# one. Run them with: mix test --include integration +ExUnit.configure(exclude: [:integration]) ExUnit.start()