Skip to main content

pylon_plugin/builtin/
password_auth.rs

1use std::collections::HashMap;
2use std::sync::{Mutex, OnceLock};
3
4use argon2::password_hash::{rand_core::OsRng, SaltString};
5use argon2::{Argon2, PasswordHasher, PasswordVerifier};
6
7use crate::Plugin;
8
9/// A lazily-computed dummy Argon2 hash used to equalize verify timing for
10/// unknown emails. Generated once per process against a fixed random
11/// password; the password we verify against it at runtime will never match,
12/// so the returned value is always `false`.
13fn dummy_hash() -> &'static str {
14    static CELL: OnceLock<String> = OnceLock::new();
15    CELL.get_or_init(|| {
16        // Hash of a high-entropy throwaway string. We don't care what it is;
17        // we only need a real PHC-format Argon2id hash so `verify_password`
18        // runs through the same code path as a real verify would.
19        hash_password("dummy-password-for-timing-equalization")
20    })
21}
22
23/// A stored password entry.
24#[derive(Debug, Clone)]
25#[allow(dead_code)]
26struct PasswordEntry {
27    user_id: String,
28    email: String,
29    /// Argon2 PHC-format hash string (includes salt, algorithm, and parameters).
30    hash: String,
31}
32
33/// Password auth plugin. Stores hashed passwords using Argon2id.
34///
35/// Passwords are hashed with Argon2id (the recommended variant for password
36/// hashing). The hash output is a PHC-format string that embeds the salt,
37/// algorithm, memory/time parameters, and hash value.
38pub struct PasswordAuthPlugin {
39    entries: Mutex<HashMap<String, PasswordEntry>>,
40}
41
42impl PasswordAuthPlugin {
43    pub fn new() -> Self {
44        Self {
45            entries: Mutex::new(HashMap::new()),
46        }
47    }
48
49    /// Register a new user with email + password.
50    pub fn register(&self, email: &str, password: &str, user_id: &str) -> Result<(), String> {
51        let mut entries = self.entries.lock().unwrap();
52        if entries.contains_key(email) {
53            return Err("Email already registered".into());
54        }
55
56        let hash = hash_password(password);
57
58        entries.insert(
59            email.to_string(),
60            PasswordEntry {
61                user_id: user_id.to_string(),
62                email: email.to_string(),
63                hash,
64            },
65        );
66
67        Ok(())
68    }
69
70    /// Verify email + password. Returns the user_id if valid.
71    ///
72    /// Timing-equalized: when the email is unknown we still run a throwaway
73    /// Argon2 verify against a fixed dummy hash. Otherwise an attacker can
74    /// distinguish "known email, wrong password" (takes ~50ms) from "unknown
75    /// email" (<1ms) and enumerate registered addresses.
76    pub fn verify(&self, email: &str, password: &str) -> Option<String> {
77        let entries = self.entries.lock().unwrap();
78        match entries.get(email) {
79            Some(entry) => {
80                if verify_password(password, &entry.hash) {
81                    Some(entry.user_id.clone())
82                } else {
83                    None
84                }
85            }
86            None => {
87                // Dummy verify to pay the Argon2 cost even for unknown emails.
88                // dummy_hash() returns a real Argon2id hash of a random string;
89                // the password we pass won't match, so this always returns
90                // false. What matters is the compute time, not the result.
91                let _ = verify_password(password, dummy_hash());
92                None
93            }
94        }
95    }
96
97    /// Change a user's password.
98    pub fn change_password(
99        &self,
100        email: &str,
101        old_password: &str,
102        new_password: &str,
103    ) -> Result<(), String> {
104        let mut entries = self.entries.lock().unwrap();
105        let entry = entries.get_mut(email).ok_or("User not found")?;
106
107        if !verify_password(old_password, &entry.hash) {
108            return Err("Incorrect password".into());
109        }
110
111        entry.hash = hash_password(new_password);
112        Ok(())
113    }
114
115    /// Check if an email is registered.
116    pub fn is_registered(&self, email: &str) -> bool {
117        self.entries.lock().unwrap().contains_key(email)
118    }
119
120    /// Reset password (admin/magic-code flow — no old password needed).
121    pub fn reset_password(&self, email: &str, new_password: &str) -> Result<(), String> {
122        let mut entries = self.entries.lock().unwrap();
123        let entry = entries.get_mut(email).ok_or("User not found")?;
124        entry.hash = hash_password(new_password);
125        Ok(())
126    }
127}
128
129impl Plugin for PasswordAuthPlugin {
130    fn name(&self) -> &str {
131        "password-auth"
132    }
133}
134
135/// Hash a password using Argon2id with a random salt.
136///
137/// Returns a PHC-format string that includes the algorithm, version,
138/// parameters, salt, and hash — everything needed for verification.
139pub fn hash_password(password: &str) -> String {
140    let salt = SaltString::generate(&mut OsRng);
141    let argon2 = Argon2::default();
142    argon2
143        .hash_password(password.as_bytes(), &salt)
144        .expect("Argon2 hash should succeed")
145        .to_string()
146}
147
148/// Verify a password against an Argon2 PHC-format hash string.
149///
150/// Argon2's verify_password performs constant-time comparison internally,
151/// so no separate constant_time_eq is needed.
152pub fn verify_password(password: &str, hash: &str) -> bool {
153    use argon2::PasswordHash;
154    let parsed = match PasswordHash::new(hash) {
155        Ok(h) => h,
156        Err(_) => return false,
157    };
158    Argon2::default()
159        .verify_password(password.as_bytes(), &parsed)
160        .is_ok()
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn register_and_verify() {
169        let plugin = PasswordAuthPlugin::new();
170        plugin
171            .register("alice@test.com", "password123", "user-1")
172            .unwrap();
173
174        let user_id = plugin.verify("alice@test.com", "password123").unwrap();
175        assert_eq!(user_id, "user-1");
176    }
177
178    #[test]
179    fn wrong_password_rejected() {
180        let plugin = PasswordAuthPlugin::new();
181        plugin
182            .register("alice@test.com", "password123", "user-1")
183            .unwrap();
184
185        assert!(plugin.verify("alice@test.com", "wrong").is_none());
186    }
187
188    #[test]
189    fn unknown_email_rejected() {
190        let plugin = PasswordAuthPlugin::new();
191        assert!(plugin.verify("nobody@test.com", "password").is_none());
192    }
193
194    #[test]
195    fn duplicate_email_rejected() {
196        let plugin = PasswordAuthPlugin::new();
197        plugin
198            .register("alice@test.com", "pass1", "user-1")
199            .unwrap();
200        let result = plugin.register("alice@test.com", "pass2", "user-2");
201        assert!(result.is_err());
202    }
203
204    #[test]
205    fn change_password() {
206        let plugin = PasswordAuthPlugin::new();
207        plugin
208            .register("alice@test.com", "old-pass", "user-1")
209            .unwrap();
210
211        plugin
212            .change_password("alice@test.com", "old-pass", "new-pass")
213            .unwrap();
214
215        assert!(plugin.verify("alice@test.com", "old-pass").is_none());
216        assert!(plugin.verify("alice@test.com", "new-pass").is_some());
217    }
218
219    #[test]
220    fn change_password_wrong_old() {
221        let plugin = PasswordAuthPlugin::new();
222        plugin
223            .register("alice@test.com", "password", "user-1")
224            .unwrap();
225
226        let result = plugin.change_password("alice@test.com", "wrong", "new");
227        assert!(result.is_err());
228    }
229
230    #[test]
231    fn reset_password() {
232        let plugin = PasswordAuthPlugin::new();
233        plugin
234            .register("alice@test.com", "old-pass", "user-1")
235            .unwrap();
236
237        plugin
238            .reset_password("alice@test.com", "reset-pass")
239            .unwrap();
240        assert!(plugin.verify("alice@test.com", "reset-pass").is_some());
241    }
242
243    #[test]
244    fn is_registered() {
245        let plugin = PasswordAuthPlugin::new();
246        assert!(!plugin.is_registered("alice@test.com"));
247        plugin.register("alice@test.com", "pass", "user-1").unwrap();
248        assert!(plugin.is_registered("alice@test.com"));
249    }
250
251    #[test]
252    fn hash_is_phc_format() {
253        let h = hash_password("test-password");
254        // PHC format starts with "$argon2"
255        assert!(h.starts_with("$argon2"), "Expected PHC format, got: {}", h);
256    }
257
258    #[test]
259    fn same_password_different_hashes() {
260        // Each call generates a new random salt, so hashes differ.
261        let h1 = hash_password("password");
262        let h2 = hash_password("password");
263        assert_ne!(h1, h2);
264        // But both verify correctly.
265        assert!(verify_password("password", &h1));
266        assert!(verify_password("password", &h2));
267    }
268}