use crate::base64;
use hmac::{Hmac, Mac};
use md5::Md5;
use rustlavel_core::{Error, Result};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
pub fn md5_password(user: &str, password: &str, salt: &[u8; 4]) -> String {
let inner = hex(&{
let mut hasher = Md5::new();
hasher.update(password.as_bytes());
hasher.update(user.as_bytes());
hasher.finalize()
});
let outer = hex(&{
let mut hasher = Md5::new();
hasher.update(inner.as_bytes());
hasher.update(salt);
hasher.finalize()
});
format!("md5{outer}")
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub struct Scram {
password: String,
client_nonce: String,
client_first_bare: String,
server_signature: Option<Vec<u8>>,
}
impl Scram {
pub const MECHANISM: &'static str = "SCRAM-SHA-256";
pub fn new(password: &str, nonce: String) -> Self {
Scram::with_username(password, "", nonce)
}
pub fn with_username(password: &str, username: &str, nonce: String) -> Self {
Scram {
password: password.to_string(),
client_first_bare: format!("n={username},r={nonce}"),
client_nonce: nonce,
server_signature: None,
}
}
pub fn client_first(&self) -> String {
format!("n,,{}", self.client_first_bare)
}
pub fn client_final(&mut self, server_first: &str) -> Result<String> {
let attributes = parse_attributes(server_first);
let combined_nonce = attributes
.iter()
.find(|(key, _)| *key == 'r')
.map(|(_, value)| value.clone())
.ok_or_else(|| Error::Protocol("SCRAM server-first has no nonce".into()))?;
if !combined_nonce.starts_with(&self.client_nonce) {
return Err(Error::Protocol("SCRAM server did not echo the client nonce".into()));
}
let salt = attributes
.iter()
.find(|(key, _)| *key == 's')
.and_then(|(_, value)| base64::decode(value))
.ok_or_else(|| Error::Protocol("SCRAM server-first has no salt".into()))?;
let iterations: u32 = attributes
.iter()
.find(|(key, _)| *key == 'i')
.and_then(|(_, value)| value.parse().ok())
.ok_or_else(|| Error::Protocol("SCRAM server-first has no iteration count".into()))?;
let salted = pbkdf2_sha256(self.password.as_bytes(), &salt, iterations);
let client_key = hmac(&salted, b"Client Key");
let stored_key = Sha256::digest(&client_key);
let client_final_without_proof = format!("c=biws,r={combined_nonce}");
let auth_message =
format!("{},{server_first},{client_final_without_proof}", self.client_first_bare);
let client_signature = hmac(&stored_key, auth_message.as_bytes());
let proof: Vec<u8> = client_key
.iter()
.zip(client_signature.iter())
.map(|(key, signature)| key ^ signature)
.collect();
let server_key = hmac(&salted, b"Server Key");
self.server_signature = Some(hmac(&server_key, auth_message.as_bytes()));
Ok(format!("{client_final_without_proof},p={}", base64::encode(&proof)))
}
pub fn verify(&self, server_final: &str) -> Result<()> {
let expected = self
.server_signature
.as_ref()
.ok_or_else(|| Error::Protocol("SCRAM finished before it started".into()))?;
let attributes = parse_attributes(server_final);
if let Some((_, message)) = attributes.iter().find(|(key, _)| *key == 'e') {
return Err(Error::msg(format!("authentication failed: {message}")));
}
let received = attributes
.iter()
.find(|(key, _)| *key == 'v')
.and_then(|(_, value)| base64::decode(value))
.ok_or_else(|| Error::Protocol("SCRAM server-final has no verifier".into()))?;
if received != *expected {
return Err(Error::msg(
"the database server failed SCRAM verification; it may not be the server you think it is"
.to_string(),
));
}
Ok(())
}
}
fn parse_attributes(message: &str) -> Vec<(char, String)> {
message
.split(',')
.filter_map(|part| {
let mut chars = part.chars();
let key = chars.next()?;
let rest = chars.as_str();
rest.strip_prefix('=').map(|value| (key, value.to_string()))
})
.collect()
}
fn hmac(key: &[u8], message: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(message);
mac.finalize().into_bytes().to_vec()
}
fn pbkdf2_sha256(password: &[u8], salt: &[u8], iterations: u32) -> Vec<u8> {
let mut salted = Vec::with_capacity(salt.len() + 4);
salted.extend_from_slice(salt);
salted.extend_from_slice(&1u32.to_be_bytes());
let mut previous = hmac(password, &salted);
let mut result = previous.clone();
for _ in 1..iterations {
previous = hmac(password, &previous);
for (accumulated, block) in result.iter_mut().zip(previous.iter()) {
*accumulated ^= block;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn md5_matches_the_documented_construction() {
let digest = md5_password("postgres", "secret", &[0x01, 0x02, 0x03, 0x04]);
assert!(digest.starts_with("md5"));
assert_eq!(digest.len(), 35);
assert!(digest[3..].chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn pbkdf2_matches_a_known_vector() {
let salt = base64::decode("W22ZaJ0SNY7soEsUEjb6gQ==").unwrap();
let salted = pbkdf2_sha256(b"pencil", &salt, 4096);
assert_eq!(base64::encode(&salted), "xKSVEDI6tPlSysH6mUQZOeeOp01r6B3fcJbodRPcYV0=");
}
#[test]
fn produces_the_rfc_7677_client_proof() {
let mut scram = Scram::with_username("pencil", "user", "rOprNGfwEbeRWgbNEkqO".to_string());
assert_eq!(scram.client_first(), "n,,n=user,r=rOprNGfwEbeRWgbNEkqO");
let server_first =
"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096";
let client_final = scram.client_final(server_first).unwrap();
assert_eq!(
client_final,
"c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,\
p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ="
);
scram.verify("v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=").unwrap();
}
#[test]
fn postgres_exchanges_leave_the_username_empty() {
let scram = Scram::new("pencil", "abc".to_string());
assert_eq!(scram.client_first(), "n,,n=,r=abc");
}
#[test]
fn rejects_a_server_that_does_not_echo_the_nonce() {
let mut scram = Scram::new("pencil", "mynonce".to_string());
let error = scram.client_final("r=someoneelse,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096").unwrap_err();
assert!(error.to_string().contains("echo the client nonce"));
}
#[test]
fn rejects_a_bad_server_signature() {
let mut scram = Scram::with_username("pencil", "user", "rOprNGfwEbeRWgbNEkqO".to_string());
scram
.client_final(
"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096",
)
.unwrap();
let error = scram.verify("v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").unwrap_err();
assert!(error.to_string().contains("failed SCRAM verification"));
}
#[test]
fn surfaces_a_server_reported_authentication_error() {
let mut scram = Scram::with_username("pencil", "user", "rOprNGfwEbeRWgbNEkqO".to_string());
scram
.client_final(
"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096",
)
.unwrap();
let error = scram.verify("e=invalid-proof").unwrap_err();
assert!(error.to_string().contains("invalid-proof"));
}
}