use rustlavel_core::{Error, Result};
use sha1::Sha1;
use sha2::{Digest, Sha256};
pub const MYSQL_NATIVE_PASSWORD: &str = "mysql_native_password";
pub const CACHING_SHA2_PASSWORD: &str = "caching_sha2_password";
pub const MYSQL_CLEAR_PASSWORD: &str = "mysql_clear_password";
pub const SHA256_PASSWORD: &str = "sha256_password";
pub fn native_password(password: &str, scramble: &[u8]) -> Vec<u8> {
if password.is_empty() {
return Vec::new();
}
let stage1 = Sha1::digest(password.as_bytes());
let stage2 = Sha1::digest(stage1);
let mut hasher = Sha1::new();
hasher.update(scramble);
hasher.update(stage2);
let salted = hasher.finalize();
xor(&stage1, &salted)
}
pub fn caching_sha2_password(password: &str, scramble: &[u8]) -> Vec<u8> {
if password.is_empty() {
return Vec::new();
}
let stage1 = Sha256::digest(password.as_bytes());
let stage2 = Sha256::digest(stage1);
let mut hasher = Sha256::new();
hasher.update(stage2);
hasher.update(scramble);
let salted = hasher.finalize();
xor(&stage1, &salted)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FastAuth {
Succeeded,
FullAuthRequired,
}
pub fn fast_auth_status(data: &[u8]) -> Result<FastAuth> {
match data.first() {
Some(0x03) => Ok(FastAuth::Succeeded),
Some(0x04) => Ok(FastAuth::FullAuthRequired),
Some(other) => Err(Error::Protocol(format!(
"caching_sha2_password sent status {other:#04x}, which this driver does not understand"
))),
None => Err(Error::Protocol("caching_sha2_password sent an empty status".into())),
}
}
pub fn cleartext_password(password: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(password.len() + 1);
out.extend_from_slice(password.as_bytes());
out.push(0);
out
}
pub fn full_auth_error(user: &str, host: &str) -> Error {
Error::msg(format!(
"the server wants full caching_sha2_password authentication for `{user}`, which sends the \
password itself and so needs a channel nobody can read. This connection to {host} is \
plain TCP, so the driver will not send the password in the clear.\n \
Any of these fixes it:\n \
1. Encrypt the connection: add `?sslmode=require` to DATABASE_URL — this is the one you \
want, and it is why sslmode exists.\n \
2. Connect once with the `mysql` client (over a socket or with --get-server-public-key); \
the server then caches the account and this driver's fast path works.\n \
3. ALTER USER '{user}'@'%' IDENTIFIED WITH mysql_native_password BY '…' — available up to \
MySQL 8.0, and removed in 8.4."
))
}
pub fn insecure_plugin_error(plugin: &str) -> Error {
if plugin == MYSQL_CLEAR_PASSWORD {
return Error::msg(format!(
"the server asked for `{plugin}`, which sends the password in the clear. This driver \
refuses: a server that asks for it can read the password, and a server that has been \
replaced by someone else can too."
));
}
if plugin == SHA256_PASSWORD {
return Error::msg(format!(
"the server asked for the `{plugin}` authentication plugin, which this driver does \
not implement — but the more likely explanation is that this account does not \
exist. MySQL answers a login for an unknown user with a plugin picked from the \
user name, so that watching the handshake cannot reveal which accounts are real. \
Check the user name first; if the account really is configured for {plugin}, \
change it to {CACHING_SHA2_PASSWORD}."
));
}
Error::msg(format!(
"the server asked for the `{plugin}` authentication plugin, which this driver does not \
implement. It speaks {MYSQL_NATIVE_PASSWORD} and {CACHING_SHA2_PASSWORD}."
))
}
pub fn is_supported(plugin: &str) -> bool {
matches!(plugin, MYSQL_NATIVE_PASSWORD | CACHING_SHA2_PASSWORD)
}
pub fn respond(plugin: &str, password: &str, scramble: &[u8]) -> Result<Vec<u8>> {
match plugin {
MYSQL_NATIVE_PASSWORD => Ok(native_password(password, scramble)),
CACHING_SHA2_PASSWORD => Ok(caching_sha2_password(password, scramble)),
other => Err(insecure_plugin_error(other)),
}
}
fn xor(left: &[u8], right: &[u8]) -> Vec<u8> {
left.iter().zip(right.iter()).map(|(a, b)| a ^ b).collect()
}
#[cfg(test)]
mod tests {
use super::*;
const SCRAMBLE: &[u8] = b"01234567890123456789";
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[test]
fn native_password_matches_a_constructed_vector() {
assert_eq!(
hex(&native_password("secret", SCRAMBLE)),
"7abe1a8776b59e931059451f81e596a60dbbf7a8"
);
}
#[test]
fn native_password_is_the_documented_xor_of_two_sha1s() {
let response = native_password("secret", SCRAMBLE);
assert_eq!(response.len(), 20, "SHA-1 is 20 bytes wide");
let stage1 = Sha1::digest(b"secret");
let stage2 = Sha1::digest(stage1);
let mut hasher = Sha1::new();
hasher.update(SCRAMBLE);
hasher.update(stage2);
let recovered = xor(&response, &hasher.finalize());
assert_eq!(recovered, stage1.to_vec());
}
#[test]
fn the_server_stores_the_double_sha1_this_response_is_built_from() {
let stored = format!("*{}", hex(&Sha1::digest(Sha1::digest(b"secret"))).to_uppercase());
assert_eq!(stored, "*14E65567ABDB5135D0CFD9A70B3032C179A49EE7");
}
#[test]
fn caching_sha2_matches_a_constructed_vector() {
assert_eq!(
hex(&caching_sha2_password("secret", SCRAMBLE)),
"1a2da2573c2faa367e2afddb54cdfd11a95ed22eef0167151196a6fc8e3d3813"
);
}
#[test]
fn caching_sha2_is_the_documented_xor_of_two_sha256s() {
let response = caching_sha2_password("secret", SCRAMBLE);
assert_eq!(response.len(), 32, "SHA-256 is 32 bytes wide");
let stage1 = Sha256::digest(b"secret");
let stage2 = Sha256::digest(stage1);
let mut hasher = Sha256::new();
hasher.update(stage2);
hasher.update(SCRAMBLE);
let recovered = xor(&response, &hasher.finalize());
assert_eq!(recovered, stage1.to_vec());
}
#[test]
fn a_different_scramble_gives_a_different_response() {
let first = caching_sha2_password("secret", SCRAMBLE);
let second = caching_sha2_password("secret", b"98765432109876543210");
assert_ne!(first, second);
}
#[test]
fn an_empty_password_sends_an_empty_response() {
assert!(native_password("", SCRAMBLE).is_empty());
assert!(caching_sha2_password("", SCRAMBLE).is_empty());
}
#[test]
fn reads_the_caching_sha2_verdict() {
assert_eq!(fast_auth_status(&[0x03]).unwrap(), FastAuth::Succeeded);
assert_eq!(fast_auth_status(&[0x04]).unwrap(), FastAuth::FullAuthRequired);
assert!(fast_auth_status(&[0x09]).is_err());
assert!(fast_auth_status(&[]).is_err());
}
#[test]
fn a_cleartext_password_is_nul_terminated() {
assert_eq!(cleartext_password("secret"), b"secret\0");
assert_eq!(cleartext_password(""), b"\0");
}
#[test]
fn full_authentication_without_a_secure_channel_says_what_to_do_instead() {
let error = full_auth_error("ada", "127.0.0.1:3306").to_string();
assert!(error.contains("caching_sha2_password"), "{error}");
assert!(error.contains("ada"), "{error}");
assert!(error.contains("127.0.0.1:3306"), "{error}");
assert!(error.contains("mysql_native_password"), "{error}");
assert!(error.contains("--get-server-public-key"), "{error}");
assert!(error.contains("DATABASE_URL"), "{error}");
}
#[test]
fn refuses_a_plugin_that_would_hand_over_the_password() {
let error = insecure_plugin_error(MYSQL_CLEAR_PASSWORD).to_string();
assert!(error.contains("in the clear"), "{error}");
let error = insecure_plugin_error("some_other_plugin").to_string();
assert!(error.contains("does not implement"), "{error}");
assert!(error.contains(MYSQL_NATIVE_PASSWORD), "{error}");
}
#[test]
fn sha256_password_leads_with_the_reason_it_is_usually_seen() {
let error = insecure_plugin_error(SHA256_PASSWORD).to_string();
assert!(error.contains("does not exist"), "{error}");
assert!(error.contains("unknown user"), "{error}");
assert!(error.contains("Check the user name first"), "{error}");
}
#[test]
fn only_the_two_implemented_plugins_are_answered() {
assert!(is_supported(MYSQL_NATIVE_PASSWORD));
assert!(is_supported(CACHING_SHA2_PASSWORD));
assert!(!is_supported(MYSQL_CLEAR_PASSWORD));
assert!(!is_supported("sha256_password"));
assert_eq!(
respond(MYSQL_NATIVE_PASSWORD, "secret", SCRAMBLE).unwrap(),
native_password("secret", SCRAMBLE)
);
assert!(respond("sha256_password", "secret", SCRAMBLE).is_err());
}
}