use anyhow::{Context, Result};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use md5::{Digest, Md5};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use tracing::warn;
#[derive(Debug, Clone)]
pub struct PasswordStore {
passwords: HashMap<String, String>,
}
impl PasswordStore {
pub fn new() -> Self {
Self { passwords: HashMap::new() }
}
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(path.as_ref())
.with_context(|| format!("Failed to read password file: {:?}", path.as_ref()))?;
let mut passwords = HashMap::new();
for (line_num, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(anyhow::anyhow!(
"Invalid password file format at line {}: expected 'username:password'",
line_num + 1
));
}
let username = parts[0].trim().to_string();
let password_value = parts[1].trim();
if username.is_empty() {
return Err(anyhow::anyhow!("Empty username at line {}", line_num + 1));
}
let stored_password = if password_value.starts_with("$argon2") {
password_value.to_string()
} else if password_value.starts_with("{MD5}") {
password_value.to_string()
} else {
warn!(
"Password for user '{}' in cleartext, hashing with Argon2 (update your password file with pre-hashed passwords)",
username
);
hash_password_argon2(password_value)?
};
passwords.insert(username, stored_password);
}
Ok(Self { passwords })
}
#[allow(dead_code)]
pub fn add_user(&mut self, username: String, password: &str) -> Result<()> {
let hashed = hash_password_argon2(password)?;
self.passwords.insert(username, hashed);
Ok(())
}
#[allow(dead_code)]
pub fn add_user_hashed(&mut self, username: String, password_hash: String) {
self.passwords.insert(username, password_hash);
}
pub fn get_password(&self, username: &str) -> Option<&String> {
self.passwords.get(username)
}
pub fn verify_cleartext(&self, username: &str, password: &str) -> bool {
if let Some(stored) = self.get_password(username) {
if stored.starts_with("$argon2") {
if let Ok(parsed_hash) = PasswordHash::new(stored) {
return Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok();
}
} else if stored.starts_with("{MD5}") {
warn!(
"Cannot verify cleartext password for user '{}': password stored in MD5 format",
username
);
return false;
}
}
false
}
pub fn verify_md5(&self, username: &str, password_hash: &str, salt: &[u8; 4]) -> bool {
if let Some(stored) = self.get_password(username) {
if let Some(md5_password) = stored.strip_prefix("{MD5}") {
let expected = compute_md5_password(md5_password, username, salt);
let hash_to_compare = password_hash.strip_prefix("md5").unwrap_or(password_hash);
return expected == hash_to_compare;
} else {
warn!(
"Cannot verify MD5 password for user '{}': password stored in Argon2 format (use cleartext wire protocol instead)",
username
);
}
}
false
}
}
impl Default for PasswordStore {
fn default() -> Self {
Self::new()
}
}
pub fn hash_password_argon2(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("Failed to hash password: {}", e))?;
Ok(password_hash.to_string())
}
pub fn compute_md5_password(password: &str, username: &str, salt: &[u8; 4]) -> String {
let mut hasher = Md5::new();
hasher.update(password.as_bytes());
hasher.update(username.as_bytes());
let inner_hash = hasher.finalize();
let inner_hex = format!("{:x}", inner_hash);
let mut hasher = Md5::new();
hasher.update(inner_hex.as_bytes());
hasher.update(salt);
let outer_hash = hasher.finalize();
format!("{:x}", outer_hash)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_password_store_new() {
let store = PasswordStore::new();
assert_eq!(store.passwords.len(), 0);
}
#[test]
fn test_add_user_with_argon2() {
let mut store = PasswordStore::new();
store.add_user("postgres".to_string(), "secret123").unwrap();
let stored = store.get_password("postgres").unwrap();
assert!(stored.starts_with("$argon2"));
assert_ne!(stored, "secret123");
assert!(store.verify_cleartext("postgres", "secret123"));
assert!(!store.verify_cleartext("postgres", "wrong"));
}
#[test]
fn test_verify_cleartext_with_argon2() {
let mut store = PasswordStore::new();
let hash = hash_password_argon2("secret123").unwrap();
store.add_user_hashed("postgres".to_string(), hash);
assert!(store.verify_cleartext("postgres", "secret123"));
assert!(!store.verify_cleartext("postgres", "wrong"));
assert!(!store.verify_cleartext("nonexistent", "secret123"));
}
#[test]
fn test_hash_password_argon2() {
let hash1 = hash_password_argon2("secret").unwrap();
let hash2 = hash_password_argon2("secret").unwrap();
assert_ne!(hash1, hash2);
assert!(hash1.starts_with("$argon2"));
assert!(hash2.starts_with("$argon2"));
let parsed1 = PasswordHash::new(&hash1).unwrap();
let parsed2 = PasswordHash::new(&hash2).unwrap();
assert!(Argon2::default().verify_password(b"secret", &parsed1).is_ok());
assert!(Argon2::default().verify_password(b"secret", &parsed2).is_ok());
}
#[test]
fn test_compute_md5_password() {
let password = "secret";
let username = "postgres";
let salt: [u8; 4] = [1, 2, 3, 4];
let hash1 = compute_md5_password(password, username, &salt);
let hash2 = compute_md5_password(password, username, &salt);
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 32); }
#[test]
fn test_verify_md5_with_md5_storage() {
let mut store = PasswordStore::new();
store.add_user_hashed("postgres".to_string(), "{MD5}secret".to_string());
let salt: [u8; 4] = [1, 2, 3, 4];
let hash = compute_md5_password("secret", "postgres", &salt);
assert!(store.verify_md5("postgres", &hash, &salt));
assert!(store.verify_md5("postgres", &format!("md5{}", hash), &salt));
assert!(!store.verify_md5("postgres", "wronghash", &salt));
}
#[test]
fn test_md5_wire_protocol_not_supported_with_argon2() {
let mut store = PasswordStore::new();
store.add_user("postgres".to_string(), "secret").unwrap();
assert!(store.get_password("postgres").unwrap().starts_with("$argon2"));
let salt: [u8; 4] = [1, 2, 3, 4];
let hash = compute_md5_password("secret", "postgres", &salt);
assert!(!store.verify_md5("postgres", &hash, &salt));
}
#[test]
fn test_load_from_file_argon2() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
let hash = hash_password_argon2("secret123").unwrap();
writeln!(file, "# Comment line").unwrap();
writeln!(file).unwrap();
writeln!(file, "postgres:{}", hash).unwrap();
file.flush().unwrap();
let store = PasswordStore::load_from_file(file.path()).unwrap();
assert_eq!(store.passwords.len(), 1);
assert!(store.verify_cleartext("postgres", "secret123"));
}
#[test]
fn test_load_from_file_md5_format() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "postgres:{{MD5}}secret").unwrap();
file.flush().unwrap();
let store = PasswordStore::load_from_file(file.path()).unwrap();
assert_eq!(store.passwords.len(), 1);
assert_eq!(store.get_password("postgres"), Some(&"{MD5}secret".to_string()));
let salt: [u8; 4] = [1, 2, 3, 4];
let hash = compute_md5_password("secret", "postgres", &salt);
assert!(store.verify_md5("postgres", &hash, &salt));
}
#[test]
fn test_load_from_file_cleartext_gets_hashed() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "postgres:secret123").unwrap();
file.flush().unwrap();
let store = PasswordStore::load_from_file(file.path()).unwrap();
assert_eq!(store.passwords.len(), 1);
let stored = store.get_password("postgres").unwrap();
assert!(stored.starts_with("$argon2"));
assert!(store.verify_cleartext("postgres", "secret123"));
}
#[test]
fn test_load_from_file_invalid_format() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "invalid_line_without_colon").unwrap();
file.flush().unwrap();
let result = PasswordStore::load_from_file(file.path());
assert!(result.is_err());
}
}