Skip to main content

sqlmodel_mysql/
auth.rs

1//! MySQL authentication implementations.
2//!
3//! This module implements the MySQL authentication plugins:
4//! - `mysql_native_password`: SHA1-based (legacy, MySQL < 8.0 default)
5//! - `caching_sha2_password`: SHA256-based (MySQL 8.0+ default)
6//!
7//! # mysql_native_password
8//!
9//! Password scramble algorithm:
10//! ```text
11//! SHA1(password) XOR SHA1(seed + SHA1(SHA1(password)))
12//! ```
13//!
14//! # caching_sha2_password
15//!
16//! Fast auth (if cached on server):
17//! ```text
18//! XOR(SHA256(password), SHA256(SHA256(SHA256(password)) + seed))
19//! ```
20//!
21//! Full auth requires TLS or RSA public key encryption.
22
23use sha1::Sha1;
24use sha2::{Digest, Sha256};
25
26use rand::rand_core::UnwrapErr;
27use rand::rngs::SysRng;
28
29use rsa::RsaPublicKey;
30use rsa::pkcs1::DecodeRsaPublicKey;
31use rsa::pkcs8::DecodePublicKey;
32
33/// Well-known authentication plugin names.
34pub mod plugins {
35    /// SHA1-based authentication (legacy default)
36    pub const MYSQL_NATIVE_PASSWORD: &str = "mysql_native_password";
37    /// SHA256-based authentication (MySQL 8.0+ default)
38    pub const CACHING_SHA2_PASSWORD: &str = "caching_sha2_password";
39    /// RSA-based SHA256 authentication
40    pub const SHA256_PASSWORD: &str = "sha256_password";
41    /// MySQL clear password (for debugging/testing only)
42    pub const MYSQL_CLEAR_PASSWORD: &str = "mysql_clear_password";
43}
44
45/// Response codes for caching_sha2_password protocol.
46pub mod caching_sha2 {
47    /// Request for public key (client should send 0x02)
48    pub const REQUEST_PUBLIC_KEY: u8 = 0x02;
49    /// Fast auth success
50    pub const FAST_AUTH_SUCCESS: u8 = 0x03;
51    /// Full auth needed (switch to secure channel or RSA)
52    pub const PERFORM_FULL_AUTH: u8 = 0x04;
53}
54
55/// Compute mysql_native_password authentication response.
56///
57/// Algorithm: `SHA1(password) XOR SHA1(seed + SHA1(SHA1(password)))`
58///
59/// # Arguments
60/// * `password` - The user's password (UTF-8)
61/// * `auth_data` - The 20-byte scramble from the server
62///
63/// # Returns
64/// The 20-byte authentication response, or empty vec if password is empty.
65pub fn mysql_native_password(password: &str, auth_data: &[u8]) -> Vec<u8> {
66    if password.is_empty() {
67        return vec![];
68    }
69
70    // Ensure we only use first 20 bytes of auth_data
71    let seed = if auth_data.len() > 20 {
72        &auth_data[..20]
73    } else {
74        auth_data
75    };
76
77    // Stage 1: SHA1(password)
78    let mut hasher = Sha1::new();
79    hasher.update(password.as_bytes());
80    let stage1: [u8; 20] = hasher.finalize().into();
81
82    // Stage 2: SHA1(SHA1(password))
83    let mut hasher = Sha1::new();
84    hasher.update(stage1);
85    let stage2: [u8; 20] = hasher.finalize().into();
86
87    // Stage 3: SHA1(seed + stage2)
88    let mut hasher = Sha1::new();
89    hasher.update(seed);
90    hasher.update(stage2);
91    let stage3: [u8; 20] = hasher.finalize().into();
92
93    // Final: stage1 XOR stage3
94    stage1
95        .iter()
96        .zip(stage3.iter())
97        .map(|(a, b)| a ^ b)
98        .collect()
99}
100
101/// Compute caching_sha2_password fast authentication response.
102///
103/// Algorithm: `XOR(SHA256(password), SHA256(SHA256(SHA256(password)) + seed))`
104///
105/// # Arguments
106/// * `password` - The user's password (UTF-8)
107/// * `auth_data` - The scramble from the server (typically 20 bytes + NUL)
108///
109/// # Returns
110/// The 32-byte authentication response, or empty vec if password is empty.
111pub fn caching_sha2_password(password: &str, auth_data: &[u8]) -> Vec<u8> {
112    if password.is_empty() {
113        return vec![];
114    }
115
116    // Remove trailing NUL if present (MySQL sends 20-byte scramble + NUL = 21 bytes)
117    // Only strip if length is 21 and ends with NUL, to avoid modifying valid 20-byte seeds
118    let seed = if auth_data.len() == 21 && auth_data.last() == Some(&0) {
119        &auth_data[..20]
120    } else {
121        auth_data
122    };
123
124    // SHA256(password)
125    let mut hasher = Sha256::new();
126    hasher.update(password.as_bytes());
127    let password_hash: [u8; 32] = hasher.finalize().into();
128
129    // SHA256(SHA256(password))
130    let mut hasher = Sha256::new();
131    hasher.update(password_hash);
132    let password_hash_hash: [u8; 32] = hasher.finalize().into();
133
134    // SHA256(SHA256(SHA256(password)) + seed)
135    let mut hasher = Sha256::new();
136    hasher.update(password_hash_hash);
137    hasher.update(seed);
138    let scramble: [u8; 32] = hasher.finalize().into();
139
140    // XOR(SHA256(password), scramble)
141    password_hash
142        .iter()
143        .zip(scramble.iter())
144        .map(|(a, b)| a ^ b)
145        .collect()
146}
147
148/// Generate a random nonce for client-side use.
149///
150/// Uses `SysRng` (OS entropy; renamed from `OsRng` in rand 0.10) for
151/// cryptographically secure random generation. `SysRng` is fallible-only
152/// (`TryRng`) in rand 0.10; `UnwrapErr` adapts it to the infallible `Rng`
153/// surface `fill_bytes` needs.
154pub fn generate_nonce(length: usize) -> Vec<u8> {
155    use rand::Rng;
156    let mut bytes = vec![0u8; length];
157    UnwrapErr(SysRng).fill_bytes(&mut bytes);
158    bytes
159}
160
161/// Scramble password for sha256_password plugin using RSA encryption.
162///
163/// This is used when full authentication is required for caching_sha2_password
164/// or sha256_password plugins without TLS.
165///
166/// # Arguments
167/// * `password` - The user's password
168/// * `seed` - The authentication seed from server
169/// * `public_key` - RSA public key from server (PEM format)
170///
171/// # Returns
172/// The encrypted password, or error if encryption fails.
173///
174/// This is used for full authentication for `caching_sha2_password`/`sha256_password`
175/// when the connection is not secured by TLS.
176pub fn sha256_password_rsa(
177    password: &str,
178    seed: &[u8],
179    public_key_pem: &[u8],
180    use_oaep: bool,
181) -> Result<Vec<u8>, String> {
182    // MySQL expects: RSA_encrypt(password_with_nul XOR seed_rotation)
183    let mut pw = password.as_bytes().to_vec();
184    pw.push(0); // NUL terminator
185
186    if seed.is_empty() {
187        return Err("Seed is empty".to_string());
188    }
189
190    for (i, b) in pw.iter_mut().enumerate() {
191        *b ^= seed[i % seed.len()];
192    }
193
194    // Server usually returns a PEM public key for sha256_password/caching_sha2_password.
195    let pem = std::str::from_utf8(public_key_pem)
196        .map_err(|e| format!("Public key is not valid UTF-8 PEM: {e}"))?;
197
198    // Try both common encodings.
199    let pub_key = RsaPublicKey::from_public_key_pem(pem)
200        .or_else(|_| RsaPublicKey::from_pkcs1_pem(pem))
201        .map_err(|e| format!("Failed to parse RSA public key PEM: {e}"))?;
202
203    let encrypted = if use_oaep {
204        // MySQL 8.0.5+ uses OAEP padding for caching_sha2_password.
205        let padding = rsa::Oaep::<Sha1>::new();
206        pub_key
207            .encrypt(&mut UnwrapErr(SysRng), padding, &pw)
208            .map_err(|e| format!("RSA OAEP encryption failed: {e}"))?
209    } else {
210        let padding = rsa::Pkcs1v15Encrypt;
211        pub_key
212            .encrypt(&mut UnwrapErr(SysRng), padding, &pw)
213            .map_err(|e| format!("RSA PKCS1v1.5 encryption failed: {e}"))?
214    };
215
216    Ok(encrypted)
217}
218
219/// XOR password with seed for cleartext transmission over TLS.
220///
221/// When the connection is secured with TLS, some auth methods allow sending
222/// the password XOR'd with the seed (or even cleartext).
223pub fn xor_password_with_seed(password: &str, seed: &[u8]) -> Vec<u8> {
224    let password_bytes = password.as_bytes();
225    let mut result = Vec::with_capacity(password_bytes.len() + 1);
226
227    for (i, &byte) in password_bytes.iter().enumerate() {
228        let seed_byte = seed.get(i % seed.len()).copied().unwrap_or(0);
229        result.push(byte ^ seed_byte);
230    }
231
232    // NUL terminator
233    result.push(0);
234
235    result
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_mysql_native_password_empty() {
244        let result = mysql_native_password("", &[0; 20]);
245        assert!(result.is_empty());
246    }
247
248    #[test]
249    fn test_mysql_native_password() {
250        // Known test vector from MySQL protocol documentation
251        // Seed: 20 bytes of zeros
252        // Password: "secret"
253        let seed = [0u8; 20];
254        let result = mysql_native_password("secret", &seed);
255
256        // Should produce 20 bytes
257        assert_eq!(result.len(), 20);
258
259        // The result should be deterministic
260        let result2 = mysql_native_password("secret", &seed);
261        assert_eq!(result, result2);
262    }
263
264    #[test]
265    fn test_mysql_native_password_real_seed() {
266        // Test with a realistic scramble
267        let seed = [
268            0x3d, 0x4c, 0x5e, 0x2f, 0x1a, 0x0b, 0x7c, 0x8d, 0x9e, 0xaf, 0x10, 0x21, 0x32, 0x43,
269            0x54, 0x65, 0x76, 0x87, 0x98, 0xa9,
270        ];
271
272        let result = mysql_native_password("mypassword", &seed);
273        assert_eq!(result.len(), 20);
274
275        // Different password should give different result
276        let result2 = mysql_native_password("otherpassword", &seed);
277        assert_ne!(result, result2);
278    }
279
280    #[test]
281    fn test_caching_sha2_password_empty() {
282        let result = caching_sha2_password("", &[0; 20]);
283        assert!(result.is_empty());
284    }
285
286    #[test]
287    fn test_caching_sha2_password() {
288        let seed = [0u8; 20];
289        let result = caching_sha2_password("secret", &seed);
290
291        // Should produce 32 bytes (SHA-256 output)
292        assert_eq!(result.len(), 32);
293
294        // Should be deterministic
295        let result2 = caching_sha2_password("secret", &seed);
296        assert_eq!(result, result2);
297    }
298
299    #[test]
300    fn test_caching_sha2_password_with_nul() {
301        // MySQL often sends seed with trailing NUL
302        let mut seed = vec![0u8; 20];
303        seed.push(0); // Trailing NUL
304
305        let result = caching_sha2_password("secret", &seed);
306        assert_eq!(result.len(), 32);
307
308        // Should be same as without NUL
309        let result2 = caching_sha2_password("secret", &seed[..20]);
310        assert_eq!(result, result2);
311    }
312
313    #[test]
314    fn test_generate_nonce() {
315        let nonce1 = generate_nonce(20);
316        let nonce2 = generate_nonce(20);
317
318        assert_eq!(nonce1.len(), 20);
319        assert_eq!(nonce2.len(), 20);
320
321        // Should be different (extremely high probability)
322        assert_ne!(nonce1, nonce2);
323    }
324
325    #[test]
326    fn test_xor_password_with_seed() {
327        let password = "test";
328        let seed = [1, 2, 3, 4, 5, 6, 7, 8];
329
330        let result = xor_password_with_seed(password, &seed);
331
332        // Should be password length + 1 (NUL terminator)
333        assert_eq!(result.len(), 5);
334
335        // Last byte should be NUL
336        assert_eq!(result[4], 0);
337
338        // XOR is reversible
339        let recovered: Vec<u8> = result[..4]
340            .iter()
341            .enumerate()
342            .map(|(i, &b)| b ^ seed[i % seed.len()])
343            .collect();
344        assert_eq!(recovered, password.as_bytes());
345    }
346
347    /// Test-only RSA public key in SPKI (`BEGIN PUBLIC KEY`) form — the shape
348    /// MySQL serves for `caching_sha2_password`/`sha256_password` full auth.
349    const SPKI_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\n\
350MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArJ59U1RtRan3BEJqsItb\n\
351tD3nDU7ThwlNaJ42vRSEt/UzFO5/yemxUz3ogTUcsDMgXeLVjQjwwV0+lh+s9IWc\n\
3529fU6nu4Q8yW7Pc/SDpDkFBdEtLAOIjfSMv0CzoPQB0A0njVFe7l7SuyrMWQ/19N5\n\
353iEtqZQmP2y7h5a23XgPGogXHm0XnKpueZn9KFXhK2lNhZj9IUuQLmhzrH0pov8Ae\n\
354FknazXZxL5aoAG+cJIoHKsf9NcGTzj0Hewb36YlgVi+yZ5NRkhgjklQ8E6IL+aaW\n\
355yEOsCBtS8kCd/nHP4t6ZeyExdpggPGQ2nJq18jG+sttI+3AnzQhXtR2Adq9LKVdN\n\
356RQIDAQAB\n\
357-----END PUBLIC KEY-----\n";
358
359    /// The same key in PKCS#1 (`BEGIN RSA PUBLIC KEY`) form — the fallback
360    /// encoding `sha256_password_rsa` accepts.
361    const PKCS1_PUBLIC_KEY: &str = "-----BEGIN RSA PUBLIC KEY-----\n\
362MIIBCgKCAQEArJ59U1RtRan3BEJqsItbtD3nDU7ThwlNaJ42vRSEt/UzFO5/yemx\n\
363Uz3ogTUcsDMgXeLVjQjwwV0+lh+s9IWc9fU6nu4Q8yW7Pc/SDpDkFBdEtLAOIjfS\n\
364Mv0CzoPQB0A0njVFe7l7SuyrMWQ/19N5iEtqZQmP2y7h5a23XgPGogXHm0XnKpue\n\
365Zn9KFXhK2lNhZj9IUuQLmhzrH0pov8AeFknazXZxL5aoAG+cJIoHKsf9NcGTzj0H\n\
366ewb36YlgVi+yZ5NRkhgjklQ8E6IL+aaWyEOsCBtS8kCd/nHP4t6ZeyExdpggPGQ2\n\
367nJq18jG+sttI+3AnzQhXtR2Adq9LKVdNRQIDAQAB\n\
368-----END RSA PUBLIC KEY-----\n";
369
370    /// The RSA full-auth path must keep working across `rsa` major bumps: the
371    /// `pem` feature was folded into `encoding` in 0.10, so both PEM encodings
372    /// MySQL can serve must still parse, and both padding modes must produce a
373    /// modulus-sized, randomized ciphertext.
374    #[test]
375    fn test_sha256_password_rsa_accepts_both_pem_encodings() {
376        let seed = [
377            0x3d, 0x4c, 0x5e, 0x2f, 0x1a, 0x0b, 0x7c, 0x8d, 0x9e, 0xaf, 0x10, 0x21, 0x32, 0x43,
378            0x54, 0x65, 0x76, 0x87, 0x98, 0xa9,
379        ];
380
381        for pem in [SPKI_PUBLIC_KEY, PKCS1_PUBLIC_KEY] {
382            for use_oaep in [true, false] {
383                let out = sha256_password_rsa("hunter2", &seed, pem.as_bytes(), use_oaep)
384                    .expect("RSA encryption with a 2048-bit MySQL-style key");
385                // RSA output is always exactly the modulus size (2048 bits).
386                assert_eq!(out.len(), 256, "pem={pem} oaep={use_oaep}");
387                // Both paddings are randomized: two encryptions must differ.
388                let again = sha256_password_rsa("hunter2", &seed, pem.as_bytes(), use_oaep)
389                    .expect("second encryption");
390                assert_ne!(out, again, "padding must be randomized");
391            }
392        }
393    }
394
395    #[test]
396    fn test_sha256_password_rsa_rejects_bad_input() {
397        assert!(sha256_password_rsa("pw", &[], SPKI_PUBLIC_KEY.as_bytes(), true).is_err());
398        assert!(sha256_password_rsa("pw", &[1, 2, 3], b"not a pem", true).is_err());
399    }
400
401    #[test]
402    fn test_plugin_names() {
403        assert_eq!(plugins::MYSQL_NATIVE_PASSWORD, "mysql_native_password");
404        assert_eq!(plugins::CACHING_SHA2_PASSWORD, "caching_sha2_password");
405        assert_eq!(plugins::SHA256_PASSWORD, "sha256_password");
406    }
407}