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