Repository navigation
Phase 2: the connection phase — the driver logs in - #5
Merged
Merged
Conversation
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.
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.
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.
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.
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.
This was referenced Oct 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The driver connects to a real MySQL server and authenticates. First time this project has completed anything end to end.
49 tests with
--include integration; 46 and 3 excluded without, somix teststill needs no database.The exchange
What is here
Protocol.Handshake— parses the server greetingAuth.NativePassword—SHA1(password) XOR SHA1(scramble <> SHA1(SHA1(password)))Protocol.HandshakeResponse— builds the reply, and owns which capabilities the driver claimsClient.connect/1andClient.handshake/3— the conversation, including method switchingTransportgrowsconnect/4,send/2,close/1; the fake records what was writtentest/fixtures/handshake_v10_8.4.4.bin— 73 bytes captured from a real serverThe 2018 bugs, as the fix
server_versionis NUL-terminated. The old pattern read it as six fixed bytes, which matches MySQL 5.7.21 — exactly six characters — and crashes on anything else. Verified both ways against the captured bytes: the 2018 pattern raisesMatchErroron 8.4.4 and parses cleanly when the version is six characters. Eight years of breakage caused by a coincidence between a guessed width and one version string.What the real server corrected
I had the client refuse when the greeting named a plugin it cannot speak. That was wrong. The greeting names the server's default plugin, chosen before it knows which account is connecting; the account's actual method is settled afterwards by an AuthSwitchRequest. The check now applies to the switch, where the server has said what it needs.
No scripted test would have caught this — the fake only replays what I already believed. Worth weighing when deciding how much of a protocol can be proven without a server.
What the integration test proves that no unit test could
mysql.user.authentication_string, the outer ones were not. A server accepting the login is the first real evidence.auth_plugin_datais joined correctly from two fields nine bytes apart. If that join were wrong the login would fail as "access denied" and look like a password problem.On the capability flags
Claimed:
PROTOCOL_41,PLUGIN_AUTH,SECURE_CONNECTION,TRANSACTIONS, andCONNECT_WITH_DBwhen a database is named.Refused, with reasons in the source:
LOCAL_FILES(a hostile server can then request any file from the client),MULTI_STATEMENTS(turns one injected query into several),SESSION_TRACKandDEPRECATE_EOF(both change packet shapes this driver does not parse yet),CONNECT_ATTRS(metadata nothing here needs).capabilities/1is public because the value sent is the value theOk,ErrandEofparsers must be given. A mismatch there silently misparses every response.Running the integration tests
Pinned to 8.0 because MySQL 8.4 ships
mysql_native_passworddisabled.--mysql-native-password=ONis an 8.4-only option and makes an 8.0 server refuse to start.One expectation of mine was wrong and the server was right: a database the account cannot reach reports 1044, not 1049 — granted a single schema, the server says access denied rather than unknown database.
Not here
caching_sha2_password, so modern servers with default settings cannot be reached yet:sslbehind itCOM_QUERYis next