Skip to main content

rustlavel_db/mysql/
auth.rs

1//! MySQL authentication: `mysql_native_password` and `caching_sha2_password`.
2//!
3//! Both plugins work the same way in outline. The server sends a 20-byte
4//! nonce — the *scramble* — and the client answers with a digest that mixes the
5//! password with it. The password itself never crosses the wire, and the answer
6//! is useless to anyone who replays it against a different scramble.
7//!
8//! The hash functions come from crates rather than being hand-written. This is
9//! the one place where "from scratch" would be a liability instead of a
10//! feature; everything around them, including both message flows, is ours.
11
12use rustlavel_core::{Error, Result};
13use sha1::Sha1;
14use sha2::{Digest, Sha256};
15
16/// The plugin every MySQL before 8.0 defaulted to, and many still use.
17pub const MYSQL_NATIVE_PASSWORD: &str = "mysql_native_password";
18
19/// MySQL 8's default.
20pub const CACHING_SHA2_PASSWORD: &str = "caching_sha2_password";
21
22/// The plugin that sends the password in the clear. Refused outright — see
23/// [`insecure_plugin_error`].
24pub const MYSQL_CLEAR_PASSWORD: &str = "mysql_clear_password";
25
26/// The plugin MySQL names when it does not want to say the user is unknown.
27pub const SHA256_PASSWORD: &str = "sha256_password";
28
29/// `mysql_native_password`: `SHA1(password) XOR SHA1(scramble ++ SHA1(SHA1(password)))`.
30///
31/// The server stores only `SHA1(SHA1(password))`, so it can verify the answer
32/// without ever holding anything it could replay elsewhere — the outer XOR is
33/// what lets it recover `SHA1(password)` and check it hashes to what it stored.
34///
35/// An empty password sends an empty response rather than a digest of nothing,
36/// which is how the server distinguishes "no password set" from a wrong one.
37pub fn native_password(password: &str, scramble: &[u8]) -> Vec<u8> {
38    if password.is_empty() {
39        return Vec::new();
40    }
41
42    let stage1 = Sha1::digest(password.as_bytes());
43    let stage2 = Sha1::digest(stage1);
44
45    let mut hasher = Sha1::new();
46    hasher.update(scramble);
47    hasher.update(stage2);
48    let salted = hasher.finalize();
49
50    xor(&stage1, &salted)
51}
52
53/// `caching_sha2_password`, fast path:
54/// `SHA256(password) XOR SHA256(SHA256(SHA256(password)) ++ scramble)`.
55///
56/// The shape is the same as the SHA-1 plugin's with a stronger hash and the
57/// scramble on the other side of the concatenation. "Fast" is the path the
58/// server can take once it has the account in its in-memory cache; a cold cache
59/// forces the full path, which needs a channel nobody can read.
60pub fn caching_sha2_password(password: &str, scramble: &[u8]) -> Vec<u8> {
61    if password.is_empty() {
62        return Vec::new();
63    }
64
65    let stage1 = Sha256::digest(password.as_bytes());
66    let stage2 = Sha256::digest(stage1);
67
68    let mut hasher = Sha256::new();
69    hasher.update(stage2);
70    hasher.update(scramble);
71    let salted = hasher.finalize();
72
73    xor(&stage1, &salted)
74}
75
76/// What the server decided after seeing a `caching_sha2_password` response.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum FastAuth {
79    /// The account was in the cache and the digest matched: authentication is
80    /// finished, and an OK packet follows.
81    Succeeded,
82    /// The cache did not hold this account, so the server wants the password
83    /// itself over a channel nobody can read.
84    FullAuthRequired,
85}
86
87/// The two-byte verdict inside the `AuthMoreData` packet.
88pub fn fast_auth_status(data: &[u8]) -> Result<FastAuth> {
89    match data.first() {
90        Some(0x03) => Ok(FastAuth::Succeeded),
91        Some(0x04) => Ok(FastAuth::FullAuthRequired),
92        Some(other) => Err(Error::Protocol(format!(
93            "caching_sha2_password sent status {other:#04x}, which this driver does not understand"
94        ))),
95        None => Err(Error::Protocol("caching_sha2_password sent an empty status".into())),
96    }
97}
98
99/// The password itself, NUL-terminated, for the full authentication path.
100///
101/// Only ever sent over a channel that cannot be read — see
102/// [`full_auth_error`], which is what happens when there is no such channel.
103pub fn cleartext_password(password: &str) -> Vec<u8> {
104    let mut out = Vec::with_capacity(password.len() + 1);
105    out.extend_from_slice(password.as_bytes());
106    out.push(0);
107    out
108}
109
110/// The full `caching_sha2_password` path needs a secure channel, and there is
111/// none.
112///
113/// Reached only when the connection is *not* encrypted — with TLS the driver
114/// takes the full path happily, which is what MySQL's own client does. Rather
115/// than fail with "authentication failed" — which sends the developer hunting
116/// for a typo in a password that is perfectly correct — say exactly what
117/// happened and lead with the fix that is now one query parameter away.
118pub fn full_auth_error(user: &str, host: &str) -> Error {
119    Error::msg(format!(
120        "the server wants full caching_sha2_password authentication for `{user}`, which sends the \
121         password itself and so needs a channel nobody can read. This connection to {host} is \
122         plain TCP, so the driver will not send the password in the clear.\n  \
123         Any of these fixes it:\n  \
124         1. Encrypt the connection: add `?sslmode=require` to DATABASE_URL — this is the one you \
125         want, and it is why sslmode exists.\n  \
126         2. Connect once with the `mysql` client (over a socket or with --get-server-public-key); \
127         the server then caches the account and this driver's fast path works.\n  \
128         3. ALTER USER '{user}'@'%' IDENTIFIED WITH mysql_native_password BY '…' — available up to \
129         MySQL 8.0, and removed in 8.4."
130    ))
131}
132
133/// A plugin this driver refuses to speak.
134///
135/// `mysql_clear_password` puts the password on the wire verbatim, and
136/// `sha256_password` needs the RSA exchange this driver does not implement. A
137/// hostile server can *ask* for either; agreeing would hand it the password.
138pub fn insecure_plugin_error(plugin: &str) -> Error {
139    if plugin == MYSQL_CLEAR_PASSWORD {
140        return Error::msg(format!(
141            "the server asked for `{plugin}`, which sends the password in the clear. This driver \
142             refuses: a server that asks for it can read the password, and a server that has been \
143             replaced by someone else can too."
144        ));
145    }
146
147    // `sha256_password` deserves its own sentence, because the usual reason for
148    // seeing it is not that anybody configured it. MySQL answers a login for a
149    // user that does not exist with a plugin chosen from the *name*, so that a
150    // stranger cannot learn which accounts are real by watching the handshake.
151    // Taken at face value the message below sends a developer off to implement
152    // an authentication plugin when the account has simply been deleted — which
153    // is exactly what happens when a dynamic credential's lease is revoked.
154    if plugin == SHA256_PASSWORD {
155        return Error::msg(format!(
156            "the server asked for the `{plugin}` authentication plugin, which this driver does \
157             not implement — but the more likely explanation is that this account does not \
158             exist. MySQL answers a login for an unknown user with a plugin picked from the \
159             user name, so that watching the handshake cannot reveal which accounts are real. \
160             Check the user name first; if the account really is configured for {plugin}, \
161             change it to {CACHING_SHA2_PASSWORD}."
162        ));
163    }
164
165    Error::msg(format!(
166        "the server asked for the `{plugin}` authentication plugin, which this driver does not \
167         implement. It speaks {MYSQL_NATIVE_PASSWORD} and {CACHING_SHA2_PASSWORD}."
168    ))
169}
170
171/// Whether this driver can answer a plugin's challenge at all.
172pub fn is_supported(plugin: &str) -> bool {
173    matches!(plugin, MYSQL_NATIVE_PASSWORD | CACHING_SHA2_PASSWORD)
174}
175
176/// Compute a plugin's response to a scramble.
177pub fn respond(plugin: &str, password: &str, scramble: &[u8]) -> Result<Vec<u8>> {
178    match plugin {
179        MYSQL_NATIVE_PASSWORD => Ok(native_password(password, scramble)),
180        CACHING_SHA2_PASSWORD => Ok(caching_sha2_password(password, scramble)),
181        other => Err(insecure_plugin_error(other)),
182    }
183}
184
185fn xor(left: &[u8], right: &[u8]) -> Vec<u8> {
186    left.iter().zip(right.iter()).map(|(a, b)| a ^ b).collect()
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    /// Twenty bytes, the length every MySQL scramble has.
194    const SCRAMBLE: &[u8] = b"01234567890123456789";
195
196    fn hex(bytes: &[u8]) -> String {
197        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
198    }
199
200    #[test]
201    fn native_password_matches_a_constructed_vector() {
202        // SHA1("secret") XOR SHA1(scramble ++ SHA1(SHA1("secret"))), computed
203        // independently from the plugin's documented definition.
204        assert_eq!(
205            hex(&native_password("secret", SCRAMBLE)),
206            "7abe1a8776b59e931059451f81e596a60dbbf7a8"
207        );
208    }
209
210    #[test]
211    fn native_password_is_the_documented_xor_of_two_sha1s() {
212        let response = native_password("secret", SCRAMBLE);
213        assert_eq!(response.len(), 20, "SHA-1 is 20 bytes wide");
214
215        // Undo the XOR with the half the server can recompute, and what is left
216        // must be SHA1(password) — which is exactly the check the server makes.
217        let stage1 = Sha1::digest(b"secret");
218        let stage2 = Sha1::digest(stage1);
219        let mut hasher = Sha1::new();
220        hasher.update(SCRAMBLE);
221        hasher.update(stage2);
222        let recovered = xor(&response, &hasher.finalize());
223
224        assert_eq!(recovered, stage1.to_vec());
225    }
226
227    #[test]
228    fn the_server_stores_the_double_sha1_this_response_is_built_from() {
229        // What MySQL keeps in `mysql.user.authentication_string` for a
230        // mysql_native_password account with the password "secret".
231        let stored = format!("*{}", hex(&Sha1::digest(Sha1::digest(b"secret"))).to_uppercase());
232
233        assert_eq!(stored, "*14E65567ABDB5135D0CFD9A70B3032C179A49EE7");
234    }
235
236    #[test]
237    fn caching_sha2_matches_a_constructed_vector() {
238        assert_eq!(
239            hex(&caching_sha2_password("secret", SCRAMBLE)),
240            "1a2da2573c2faa367e2afddb54cdfd11a95ed22eef0167151196a6fc8e3d3813"
241        );
242    }
243
244    #[test]
245    fn caching_sha2_is_the_documented_xor_of_two_sha256s() {
246        let response = caching_sha2_password("secret", SCRAMBLE);
247        assert_eq!(response.len(), 32, "SHA-256 is 32 bytes wide");
248
249        // The scramble sits after the digest here, not before it as in the
250        // SHA-1 plugin — a difference that is invisible until it is wrong.
251        let stage1 = Sha256::digest(b"secret");
252        let stage2 = Sha256::digest(stage1);
253        let mut hasher = Sha256::new();
254        hasher.update(stage2);
255        hasher.update(SCRAMBLE);
256        let recovered = xor(&response, &hasher.finalize());
257
258        assert_eq!(recovered, stage1.to_vec());
259    }
260
261    #[test]
262    fn a_different_scramble_gives_a_different_response() {
263        // What makes the answer worthless to replay.
264        let first = caching_sha2_password("secret", SCRAMBLE);
265        let second = caching_sha2_password("secret", b"98765432109876543210");
266
267        assert_ne!(first, second);
268    }
269
270    #[test]
271    fn an_empty_password_sends_an_empty_response() {
272        // Not a digest of the empty string: the server tells the two apart.
273        assert!(native_password("", SCRAMBLE).is_empty());
274        assert!(caching_sha2_password("", SCRAMBLE).is_empty());
275    }
276
277    #[test]
278    fn reads_the_caching_sha2_verdict() {
279        assert_eq!(fast_auth_status(&[0x03]).unwrap(), FastAuth::Succeeded);
280        assert_eq!(fast_auth_status(&[0x04]).unwrap(), FastAuth::FullAuthRequired);
281        assert!(fast_auth_status(&[0x09]).is_err());
282        assert!(fast_auth_status(&[]).is_err());
283    }
284
285    #[test]
286    fn a_cleartext_password_is_nul_terminated() {
287        assert_eq!(cleartext_password("secret"), b"secret\0");
288        assert_eq!(cleartext_password(""), b"\0");
289    }
290
291    #[test]
292    fn full_authentication_without_a_secure_channel_says_what_to_do_instead() {
293        let error = full_auth_error("ada", "127.0.0.1:3306").to_string();
294
295        assert!(error.contains("caching_sha2_password"), "{error}");
296        assert!(error.contains("ada"), "{error}");
297        assert!(error.contains("127.0.0.1:3306"), "{error}");
298        // The three ways out are all named, and none of them is "give up".
299        assert!(error.contains("mysql_native_password"), "{error}");
300        assert!(error.contains("--get-server-public-key"), "{error}");
301        assert!(error.contains("DATABASE_URL"), "{error}");
302    }
303
304    #[test]
305    fn refuses_a_plugin_that_would_hand_over_the_password() {
306        let error = insecure_plugin_error(MYSQL_CLEAR_PASSWORD).to_string();
307        assert!(error.contains("in the clear"), "{error}");
308
309        let error = insecure_plugin_error("some_other_plugin").to_string();
310        assert!(error.contains("does not implement"), "{error}");
311        assert!(error.contains(MYSQL_NATIVE_PASSWORD), "{error}");
312    }
313
314    #[test]
315    fn sha256_password_leads_with_the_reason_it_is_usually_seen() {
316        // Measured against MySQL 8.4: connecting as a user whose account has
317        // been deleted — a dynamic credential whose lease was revoked — is
318        // answered with `sha256_password`, because MySQL picks a plugin from
319        // the user name rather than admit the account is unknown. The literal
320        // reading of the old message sent you off to implement a plugin.
321        let error = insecure_plugin_error(SHA256_PASSWORD).to_string();
322
323        assert!(error.contains("does not exist"), "{error}");
324        assert!(error.contains("unknown user"), "{error}");
325        assert!(error.contains("Check the user name first"), "{error}");
326    }
327
328    #[test]
329    fn only_the_two_implemented_plugins_are_answered() {
330        assert!(is_supported(MYSQL_NATIVE_PASSWORD));
331        assert!(is_supported(CACHING_SHA2_PASSWORD));
332        assert!(!is_supported(MYSQL_CLEAR_PASSWORD));
333        assert!(!is_supported("sha256_password"));
334
335        assert_eq!(
336            respond(MYSQL_NATIVE_PASSWORD, "secret", SCRAMBLE).unwrap(),
337            native_password("secret", SCRAMBLE)
338        );
339        assert!(respond("sha256_password", "secret", SCRAMBLE).is_err());
340    }
341}