Skip to main content

vibesql_server/auth/
password.rs

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/// Password store for managing user authentication
13#[derive(Debug, Clone)]
14pub struct PasswordStore {
15    /// Map of username to stored password (either Argon2 hash or MD5 hash format)
16    passwords: HashMap<String, String>,
17}
18
19impl PasswordStore {
20    /// Create a new empty password store
21    pub fn new() -> Self {
22        Self { passwords: HashMap::new() }
23    }
24
25    /// Load passwords from a file
26    ///
27    /// File format: username:password (one per line)
28    /// Password formats supported:
29    /// - Argon2 PHC format: `username:$argon2id$v=19$m=...` (recommended, secure storage)
30    /// - Cleartext: `username:mysecret` (will be hashed with Argon2 on load)
31    /// - MD5 for wire protocol: `username:{MD5}hash` (for PostgreSQL MD5 wire protocol compatibility)
32    ///
33    /// Comments start with # and empty lines are ignored.
34    ///
35    /// Security note: Cleartext passwords will be automatically hashed with Argon2.
36    /// For PostgreSQL MD5 wire protocol, use {MD5} prefix (less secure storage).
37    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            // Skip empty lines and comments
47            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            // Determine password storage format
67            let stored_password = if password_value.starts_with("$argon2") {
68                // Already an Argon2 hash
69                password_value.to_string()
70            } else if password_value.starts_with("{MD5}") {
71                // MD5 format for PostgreSQL MD5 wire protocol
72                password_value.to_string()
73            } else {
74                // Cleartext password - hash it with Argon2
75                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    /// Add a user with a password (will be hashed with Argon2)
89    #[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    /// Add a user with an already-hashed password
97    #[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    /// Get the stored password for a user
103    pub fn get_password(&self, username: &str) -> Option<&String> {
104        self.passwords.get(username)
105    }
106
107    /// Verify a cleartext password for a user
108    /// This works with Argon2 hashes (recommended) but not with {MD5} format
109    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                // Argon2 hash - use password_hash verification
113                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                // MD5 format doesn't support cleartext verification
120                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    /// Verify an MD5 password for a user
131    /// PostgreSQL MD5 format: md5(md5(password + username) + salt)
132    /// This only works if the password is stored in {MD5}cleartext format
133    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                // MD5 format storage - we can verify MD5 wire protocol
137                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                // Argon2 format doesn't support MD5 wire protocol
142                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
158/// Hash a password using Argon2id
159pub 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
168/// Compute PostgreSQL MD5 password hash
169/// Format: "md5" + md5(md5(password + username) + salt)
170pub fn compute_md5_password(password: &str, username: &str, salt: &[u8; 4]) -> String {
171    // Step 1: md5(password + username)
172    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    // Step 2: md5(inner_hex + salt)
179    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        // Password should be hashed
203        let stored = store.get_password("postgres").unwrap();
204        assert!(stored.starts_with("$argon2"));
205        assert_ne!(stored, "secret123");
206
207        // Should verify correctly
208        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        // Should produce different hashes (different salts)
229        assert_ne!(hash1, hash2);
230
231        // Both should start with $argon2
232        assert!(hash1.starts_with("$argon2"));
233        assert!(hash2.starts_with("$argon2"));
234
235        // Both should verify the same password
236        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        // Should be deterministic
253        assert_eq!(hash1, hash2);
254        assert_eq!(hash1.len(), 32); // MD5 hex is 32 characters
255    }
256
257    #[test]
258    fn test_verify_md5_with_md5_storage() {
259        let mut store = PasswordStore::new();
260        // Store password in MD5 format (for MD5 wire protocol)
261        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        // Should be stored as Argon2
277        assert!(store.get_password("postgres").unwrap().starts_with("$argon2"));
278
279        // MD5 wire protocol should not work with Argon2 storage
280        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        // Should work with MD5 wire protocol
316        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        // Should be hashed with Argon2
334        let stored = store.get_password("postgres").unwrap();
335        assert!(stored.starts_with("$argon2"));
336
337        // Should verify
338        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}