Skip to main content

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}