1use anyhow::{Context, Result};
2use argon2::{
3 password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
4 Argon2,
5};
6use md5::{Digest, Md5};
7use std::collections::HashMap;
8use std::fs;
9use std::path::Path;
10use tracing::warn;
11
12#[derive(Debug, Clone)]
14pub struct PasswordStore {
15 passwords: HashMap<String, String>,
17}
18
19impl PasswordStore {
20 pub fn new() -> Self {
22 Self { passwords: HashMap::new() }
23 }
24
25 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
38 let content = fs::read_to_string(path.as_ref())
39 .with_context(|| format!("Failed to read password file: {:?}", path.as_ref()))?;
40
41 let mut passwords = HashMap::new();
42
43 for (line_num, line) in content.lines().enumerate() {
44 let line = line.trim();
45
46 if line.is_empty() || line.starts_with('#') {
48 continue;
49 }
50
51 let parts: Vec<&str> = line.splitn(2, ':').collect();
52 if parts.len() != 2 {
53 return Err(anyhow::anyhow!(
54 "Invalid password file format at line {}: expected 'username:password'",
55 line_num + 1
56 ));
57 }
58
59 let username = parts[0].trim().to_string();
60 let password_value = parts[1].trim();
61
62 if username.is_empty() {
63 return Err(anyhow::anyhow!("Empty username at line {}", line_num + 1));
64 }
65
66 let stored_password = if password_value.starts_with("$argon2") {
68 password_value.to_string()
70 } else if password_value.starts_with("{MD5}") {
71 password_value.to_string()
73 } else {
74 warn!(
76 "Password for user '{}' in cleartext, hashing with Argon2 (update your password file with pre-hashed passwords)",
77 username
78 );
79 hash_password_argon2(password_value)?
80 };
81
82 passwords.insert(username, stored_password);
83 }
84
85 Ok(Self { passwords })
86 }
87
88 #[allow(dead_code)]
90 pub fn add_user(&mut self, username: String, password: &str) -> Result<()> {
91 let hashed = hash_password_argon2(password)?;
92 self.passwords.insert(username, hashed);
93 Ok(())
94 }
95
96 #[allow(dead_code)]
98 pub fn add_user_hashed(&mut self, username: String, password_hash: String) {
99 self.passwords.insert(username, password_hash);
100 }
101
102 pub fn get_password(&self, username: &str) -> Option<&String> {
104 self.passwords.get(username)
105 }
106
107 pub fn verify_cleartext(&self, username: &str, password: &str) -> bool {
110 if let Some(stored) = self.get_password(username) {
111 if stored.starts_with("$argon2") {
112 if let Ok(parsed_hash) = PasswordHash::new(stored) {
114 return Argon2::default()
115 .verify_password(password.as_bytes(), &parsed_hash)
116 .is_ok();
117 }
118 } else if stored.starts_with("{MD5}") {
119 warn!(
121 "Cannot verify cleartext password for user '{}': password stored in MD5 format",
122 username
123 );
124 return false;
125 }
126 }
127 false
128 }
129
130 pub fn verify_md5(&self, username: &str, password_hash: &str, salt: &[u8; 4]) -> bool {
134 if let Some(stored) = self.get_password(username) {
135 if let Some(md5_password) = stored.strip_prefix("{MD5}") {
136 let expected = compute_md5_password(md5_password, username, salt);
138 let hash_to_compare = password_hash.strip_prefix("md5").unwrap_or(password_hash);
139 return expected == hash_to_compare;
140 } else {
141 warn!(
143 "Cannot verify MD5 password for user '{}': password stored in Argon2 format (use cleartext wire protocol instead)",
144 username
145 );
146 }
147 }
148 false
149 }
150}
151
152impl Default for PasswordStore {
153 fn default() -> Self {
154 Self::new()
155 }
156}
157
158pub fn hash_password_argon2(password: &str) -> Result<String> {
160 let salt = SaltString::generate(&mut OsRng);
161 let argon2 = Argon2::default();
162 let password_hash = argon2
163 .hash_password(password.as_bytes(), &salt)
164 .map_err(|e| anyhow::anyhow!("Failed to hash password: {}", e))?;
165 Ok(password_hash.to_string())
166}
167
168pub fn compute_md5_password(password: &str, username: &str, salt: &[u8; 4]) -> String {
171 let mut hasher = Md5::new();
173 hasher.update(password.as_bytes());
174 hasher.update(username.as_bytes());
175 let inner_hash = hasher.finalize();
176 let inner_hex = format!("{:x}", inner_hash);
177
178 let mut hasher = Md5::new();
180 hasher.update(inner_hex.as_bytes());
181 hasher.update(salt);
182 let outer_hash = hasher.finalize();
183
184 format!("{:x}", outer_hash)
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_password_store_new() {
193 let store = PasswordStore::new();
194 assert_eq!(store.passwords.len(), 0);
195 }
196
197 #[test]
198 fn test_add_user_with_argon2() {
199 let mut store = PasswordStore::new();
200 store.add_user("postgres".to_string(), "secret123").unwrap();
201
202 let stored = store.get_password("postgres").unwrap();
204 assert!(stored.starts_with("$argon2"));
205 assert_ne!(stored, "secret123");
206
207 assert!(store.verify_cleartext("postgres", "secret123"));
209 assert!(!store.verify_cleartext("postgres", "wrong"));
210 }
211
212 #[test]
213 fn test_verify_cleartext_with_argon2() {
214 let mut store = PasswordStore::new();
215 let hash = hash_password_argon2("secret123").unwrap();
216 store.add_user_hashed("postgres".to_string(), hash);
217
218 assert!(store.verify_cleartext("postgres", "secret123"));
219 assert!(!store.verify_cleartext("postgres", "wrong"));
220 assert!(!store.verify_cleartext("nonexistent", "secret123"));
221 }
222
223 #[test]
224 fn test_hash_password_argon2() {
225 let hash1 = hash_password_argon2("secret").unwrap();
226 let hash2 = hash_password_argon2("secret").unwrap();
227
228 assert_ne!(hash1, hash2);
230
231 assert!(hash1.starts_with("$argon2"));
233 assert!(hash2.starts_with("$argon2"));
234
235 let parsed1 = PasswordHash::new(&hash1).unwrap();
237 let parsed2 = PasswordHash::new(&hash2).unwrap();
238
239 assert!(Argon2::default().verify_password(b"secret", &parsed1).is_ok());
240 assert!(Argon2::default().verify_password(b"secret", &parsed2).is_ok());
241 }
242
243 #[test]
244 fn test_compute_md5_password() {
245 let password = "secret";
246 let username = "postgres";
247 let salt: [u8; 4] = [1, 2, 3, 4];
248
249 let hash1 = compute_md5_password(password, username, &salt);
250 let hash2 = compute_md5_password(password, username, &salt);
251
252 assert_eq!(hash1, hash2);
254 assert_eq!(hash1.len(), 32); }
256
257 #[test]
258 fn test_verify_md5_with_md5_storage() {
259 let mut store = PasswordStore::new();
260 store.add_user_hashed("postgres".to_string(), "{MD5}secret".to_string());
262
263 let salt: [u8; 4] = [1, 2, 3, 4];
264 let hash = compute_md5_password("secret", "postgres", &salt);
265
266 assert!(store.verify_md5("postgres", &hash, &salt));
267 assert!(store.verify_md5("postgres", &format!("md5{}", hash), &salt));
268 assert!(!store.verify_md5("postgres", "wronghash", &salt));
269 }
270
271 #[test]
272 fn test_md5_wire_protocol_not_supported_with_argon2() {
273 let mut store = PasswordStore::new();
274 store.add_user("postgres".to_string(), "secret").unwrap();
275
276 assert!(store.get_password("postgres").unwrap().starts_with("$argon2"));
278
279 let salt: [u8; 4] = [1, 2, 3, 4];
281 let hash = compute_md5_password("secret", "postgres", &salt);
282 assert!(!store.verify_md5("postgres", &hash, &salt));
283 }
284
285 #[test]
286 fn test_load_from_file_argon2() {
287 use std::io::Write;
288 use tempfile::NamedTempFile;
289
290 let mut file = NamedTempFile::new().unwrap();
291 let hash = hash_password_argon2("secret123").unwrap();
292 writeln!(file, "# Comment line").unwrap();
293 writeln!(file).unwrap();
294 writeln!(file, "postgres:{}", hash).unwrap();
295 file.flush().unwrap();
296
297 let store = PasswordStore::load_from_file(file.path()).unwrap();
298 assert_eq!(store.passwords.len(), 1);
299 assert!(store.verify_cleartext("postgres", "secret123"));
300 }
301
302 #[test]
303 fn test_load_from_file_md5_format() {
304 use std::io::Write;
305 use tempfile::NamedTempFile;
306
307 let mut file = NamedTempFile::new().unwrap();
308 writeln!(file, "postgres:{{MD5}}secret").unwrap();
309 file.flush().unwrap();
310
311 let store = PasswordStore::load_from_file(file.path()).unwrap();
312 assert_eq!(store.passwords.len(), 1);
313 assert_eq!(store.get_password("postgres"), Some(&"{MD5}secret".to_string()));
314
315 let salt: [u8; 4] = [1, 2, 3, 4];
317 let hash = compute_md5_password("secret", "postgres", &salt);
318 assert!(store.verify_md5("postgres", &hash, &salt));
319 }
320
321 #[test]
322 fn test_load_from_file_cleartext_gets_hashed() {
323 use std::io::Write;
324 use tempfile::NamedTempFile;
325
326 let mut file = NamedTempFile::new().unwrap();
327 writeln!(file, "postgres:secret123").unwrap();
328 file.flush().unwrap();
329
330 let store = PasswordStore::load_from_file(file.path()).unwrap();
331 assert_eq!(store.passwords.len(), 1);
332
333 let stored = store.get_password("postgres").unwrap();
335 assert!(stored.starts_with("$argon2"));
336
337 assert!(store.verify_cleartext("postgres", "secret123"));
339 }
340
341 #[test]
342 fn test_load_from_file_invalid_format() {
343 use std::io::Write;
344 use tempfile::NamedTempFile;
345
346 let mut file = NamedTempFile::new().unwrap();
347 writeln!(file, "invalid_line_without_colon").unwrap();
348 file.flush().unwrap();
349
350 let result = PasswordStore::load_from_file(file.path());
351 assert!(result.is_err());
352 }
353}