rustlavel_db/mysql/
auth.rs1use rustlavel_core::{Error, Result};
13use sha1::Sha1;
14use sha2::{Digest, Sha256};
15
16pub const MYSQL_NATIVE_PASSWORD: &str = "mysql_native_password";
18
19pub const CACHING_SHA2_PASSWORD: &str = "caching_sha2_password";
21
22pub const MYSQL_CLEAR_PASSWORD: &str = "mysql_clear_password";
25
26pub const SHA256_PASSWORD: &str = "sha256_password";
28
29pub 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
53pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum FastAuth {
79 Succeeded,
82 FullAuthRequired,
85}
86
87pub 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
99pub 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
110pub 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
133pub 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 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
171pub fn is_supported(plugin: &str) -> bool {
173 matches!(plugin, MYSQL_NATIVE_PASSWORD | CACHING_SHA2_PASSWORD)
174}
175
176pub 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 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 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 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 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 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 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 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 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 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}