1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
//! The handshake a *current* MySQL client can complete — and a statement of what it proves.
//!
//! # This server authenticates nothing
//!
//! Read that first, because "offers `caching_sha2_password`" reads like "checks a password"
//! and it is not. Nothing here verifies anything: no password is stored, none is compared,
//! the model is never asked, and every connection is admitted whatever it sends. What this
//! module changes is the *plugin name in the greeting* and the packet that completes the
//! plugin's exchange. A client that would previously have been refused by its own plugin
//! loader now gets as far as sending a query; that is the whole of it.
//!
//! The nonce comes from `opensrv-mysql`'s `AsyncMysqlShim::salt()`, which is a **fixed
//! string**, identical on every connection. That is harmless precisely because nothing is
//! verified against it, and it would be a real defect the moment anything were.
//!
//! # Why the plugin had to change
//!
//! `opensrv-mysql` offers `mysql_native_password` by default. The client plugin of that name
//! was removed in MySQL 9.0, so the shipping `mysql` 9.x CLI cannot load it and refuses before
//! it ever reaches the query phase:
//!
//! ```text
//! ERROR 2059 (HY000): Authentication plugin 'mysql_native_password' cannot be loaded:
//! dlopen(/usr/local/Cellar/mysql/9.3.0/lib/plugin/mysql_native_password.so, 0x0002) …
//! ```
//!
//! So the only client that could reach this server's query path was one lenient enough to
//! keep a plugin the vendor had deleted — which is exactly the "one client agreeing with one
//! bug" shape the project `CLAUDE.md` warns about.
//!
//! **It is not the greeting that kills the connection, it is the auth switch**, and that is
//! worth knowing because it decides which line the fix goes on. Captured from the wire, 9.3
//! CLI against the old server:
//!
//! ```text
//! S->C …\0mysql_native_password\0 the greeting names a plugin the client lacks
//! C->S …root\0\0caching_sha2_password\0 the client answers anyway, with its own plugin
//! S->C \xfe mysql_native_password\0 <nonce> AuthSwitchRequest — ERROR 2059 is raised here
//! ```
//!
//! The client survives a greeting it cannot honour: it names the plugin it *does* have and
//! waits to be told otherwise. `opensrv-mysql` sends an `AuthSwitchRequest` whenever
//! `auth_plugin_for_username` disagrees with the plugin the client named, and being told
//! "load mysql_native_password" is the one thing a 9.x client cannot do. Hence the empty
//! expectation below: a server that verifies nothing has no business insisting on a plugin.
//! Reverting only the greeting, and leaving that empty expectation in place, does **not**
//! reproduce the failure — which is exactly why the regression test asserts on the client's
//! output rather than on the name in the greeting.
//!
//! # The exchange, and the one packet that is easy to get wrong
//!
//! `caching_sha2_password` ends in one of two ways, and **which one depends on what the
//! client sent**:
//!
//! - **Empty password.** The client sends a zero-length auth response and then expects the
//! *next* packet to be the OK packet. An `AuthMoreData` here is read as a failure, because
//! the client checks that the first byte of the packet ending the connection phase is
//! `0x00`. So: OK, and nothing before it.
//! - **A password was given.** The client sends a 32-byte SHA-256 scramble and then **reads a
//! packet before it will accept anything else**: `AuthMoreData` (`0x01`) carrying
//! `fast_auth_success` (`0x03`) — the cache hit — or `perform_full_authentication`
//! (`0x04`). The OK packet follows the `0x01 0x03`; it does not replace it.
//!
//! `mysql_async`'s own client is an independent statement of the same two cases
//! (`continue_caching_sha2_password_auth`: `0x00` is commented "ok packet for empty password";
//! `0x01 0x03` is followed by a `drop_packet()` for the OK behind it).
//!
//! Which case applies is decided here from the **length of the auth response**, not from the
//! plugin name the client claimed, and that is deliberate: it is also what keeps an old
//! `mysql_native_password` client working. Its scramble is SHA-1 and 20 bytes long, so it
//! takes the plain-OK path it has always taken, and never sees a packet its plugin cannot
//! parse. 32 bytes means caching_sha2; anything else means "answer with OK".
//!
//! # Where the packet is injected
//!
//! `opensrv-mysql` writes the OK packet itself, from inside `init_after_ssl`, and the
//! `authenticate` hook it calls one line earlier takes `&self` and no writer — so there is no
//! seam *inside* the crate for a packet that must precede that OK. There is one *beneath* it:
//! `run_on`'s `W` is any `AsyncWrite`, the same seam the packet bound uses on the read side.
//! [`FastAuthWriter`] sits there, and when [`FastAuthGate`] has been armed by `authenticate`
//! it emits the `0x01 0x03` packet ahead of the next packet the crate writes, taking that
//! packet's sequence id for its own and handing the packet back the next one.
//!
//! **The sequence id is not a detail.** MySQL numbers packets within a phase and a conforming
//! client discards a reply that arrives out of order, so an injected packet that did not
//! renumber the OK behind it would turn a working handshake into a hang.
use IoSlice;
use Pin;
use ;
use Arc;
use ;
use AsyncWrite;
/// The plugin this server names in its greeting.
pub const CACHING_SHA2_PASSWORD: &str = "caching_sha2_password";
/// Length of the client's `caching_sha2_password` scramble: a SHA-256 digest.
///
/// This is the discriminator. `mysql_native_password` sends 20 (SHA-1) and an empty password
/// sends 0, and neither of those clients expects — or can parse — the fast-auth packet.
const CACHING_SHA2_SCRAMBLE_LEN: usize = 32;
/// First byte of an `AuthMoreData` packet.
const AUTH_MORE_DATA: u8 = 0x01;
/// `AuthMoreData` payload meaning "the cache had this password; you are in".
const FAST_AUTH_SUCCESS: u8 = 0x03;
/// The `0x01 0x03` packet, without its 4-byte header.
const FAST_AUTH_SUCCESS_PAYLOAD: = ;
/// Whether a client that sent `auth_data` is waiting to be told the fast path succeeded.
///
/// See the module docs: this is decided by length rather than by the plugin name the client
/// claimed, so that a `mysql_native_password` client keeps the plain-OK ending it expects.
/// One bit shared between the shim's `authenticate` hook and the writer beneath the crate.
///
/// `AsyncMysqlShim::authenticate` takes `&self` and returns a `bool`; it cannot write. This is
/// how it says "one more packet, before whatever you write next".
/// What the writer is doing with the bytes passing through it.
/// An `AsyncWrite` that can put one `AuthMoreData` packet in front of the crate's OK packet.
///
/// It is inert — a plain forwarder, no buffering, no parsing — until [`FastAuthGate::arm`] is
/// called, which happens at most once per connection and only during the connection phase.