Skip to main content

powdb_auth/
store.rs

1//! Persisted user/role store backed by `auth.json`.
2//!
3//! Passwords are never stored in plaintext — only argon2id PHC hashes.
4//! On Unix the on-disk file is written with mode `0600`. `powdb-server` loads
5//! this store at startup and authenticates every connection against it;
6//! `powdb-cli` manages it via the `useradd`/`passwd`/`userdel` subcommands.
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::io;
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14use zeroize::Zeroizing;
15
16use crate::error::AuthError;
17use crate::hash::{hash_password, verify_password};
18use crate::role::Role;
19
20const AUTH_FILE: &str = "auth.json";
21
22/// A single user record. The password is stored only as an argon2 PHC hash.
23#[derive(Serialize, Deserialize, Clone, Debug)]
24pub struct User {
25    /// Unique user name.
26    pub name: String,
27    /// Argon2id PHC hash string (never plaintext).
28    pub password_hash: String,
29    /// Name of the role assigned to this user (a builtin role name).
30    pub role: String,
31}
32
33/// In-memory, serializable collection of users keyed by name.
34#[derive(Serialize, Deserialize, Clone, Debug, Default)]
35pub struct UserStore {
36    users: BTreeMap<String, User>,
37}
38
39impl UserStore {
40    /// Create an empty store.
41    pub fn new() -> Self {
42        UserStore {
43            users: BTreeMap::new(),
44        }
45    }
46
47    /// Create a new user.
48    ///
49    /// Errors if `name` already exists or `role` is not a known builtin.
50    /// The password is hashed with argon2id before storage.
51    pub fn create_user(&mut self, name: &str, password: &str, role: &str) -> Result<(), AuthError> {
52        if self.users.contains_key(name) {
53            return Err(AuthError::UserExists(name.to_string()));
54        }
55        if Role::builtin(role).is_none() {
56            return Err(AuthError::UnknownRole(role.to_string()));
57        }
58        // Hold the plaintext in a buffer that is zeroed on drop so the
59        // password does not linger in freed memory longer than necessary.
60        let secret = Zeroizing::new(password.to_string());
61        let password_hash = hash_password(&secret)?;
62        self.users.insert(
63            name.to_string(),
64            User {
65                name: name.to_string(),
66                password_hash,
67                role: role.to_string(),
68            },
69        );
70        Ok(())
71    }
72
73    /// Authenticate a user by name + candidate password.
74    ///
75    /// Returns `Some(&User)` only on a verified match. Unknown user or wrong
76    /// password both return `None` — and both cost one argon2 verification,
77    /// so response timing cannot enumerate usernames (an unknown name used
78    /// to return in nanoseconds against ~100ms for a known one).
79    pub fn authenticate(&self, name: &str, candidate: &str) -> Option<&User> {
80        let Some(user) = self.users.get(name) else {
81            verify_password(dummy_password_hash(), candidate);
82            return None;
83        };
84        if verify_password(&user.password_hash, candidate) {
85            Some(user)
86        } else {
87            None
88        }
89    }
90
91    /// Reassign a user's role.
92    ///
93    /// Errors if the user is unknown or the role is not a known builtin.
94    pub fn set_role(&mut self, name: &str, role: &str) -> Result<(), AuthError> {
95        if Role::builtin(role).is_none() {
96            return Err(AuthError::UnknownRole(role.to_string()));
97        }
98        let user = self
99            .users
100            .get_mut(name)
101            .ok_or_else(|| AuthError::UnknownUser(name.to_string()))?;
102        user.role = role.to_string();
103        Ok(())
104    }
105
106    /// Replace a user's password with a new argon2id hash, preserving role.
107    ///
108    /// Errors if the user is unknown. The new plaintext is held in a buffer
109    /// that is zeroed on drop.
110    pub fn set_password(&mut self, name: &str, new_password: &str) -> Result<(), AuthError> {
111        let secret = Zeroizing::new(new_password.to_string());
112        let password_hash = hash_password(&secret)?;
113        let user = self
114            .users
115            .get_mut(name)
116            .ok_or_else(|| AuthError::UnknownUser(name.to_string()))?;
117        user.password_hash = password_hash;
118        Ok(())
119    }
120
121    /// Remove a user. Errors if the user is unknown.
122    pub fn delete_user(&mut self, name: &str) -> Result<(), AuthError> {
123        self.users
124            .remove(name)
125            .map(|_| ())
126            .ok_or_else(|| AuthError::UnknownUser(name.to_string()))
127    }
128
129    /// Number of users in the store.
130    pub fn len(&self) -> usize {
131        self.users.len()
132    }
133
134    /// Whether the store has no users. When empty, the server falls back to
135    /// the legacy shared-password authentication path.
136    pub fn is_empty(&self) -> bool {
137        self.users.is_empty()
138    }
139
140    /// List users as `(name, role)` pairs. Never exposes password hashes.
141    pub fn list_users(&self) -> Vec<(String, String)> {
142        self.users
143            .values()
144            .map(|u| (u.name.clone(), u.role.clone()))
145            .collect()
146    }
147
148    /// Persist the store to `dir/auth.json` as pretty JSON.
149    ///
150    /// On Unix the file is written with mode `0600`.
151    pub fn save(&self, dir: &Path) -> io::Result<()> {
152        let json = serde_json::to_string_pretty(self)
153            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
154        let path = dir.join(AUTH_FILE);
155        fs::write(&path, json)?;
156        #[cfg(unix)]
157        {
158            use std::os::unix::fs::PermissionsExt;
159            let perms = fs::Permissions::from_mode(0o600);
160            fs::set_permissions(&path, perms)?;
161        }
162        Ok(())
163    }
164
165    /// Load the store from `dir/auth.json`.
166    ///
167    /// If the file does not exist, returns an empty store.
168    pub fn load(dir: &Path) -> io::Result<Self> {
169        let path = dir.join(AUTH_FILE);
170        match fs::read_to_string(&path) {
171            Ok(json) => serde_json::from_str(&json)
172                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)),
173            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(UserStore::new()),
174            Err(e) => Err(e),
175        }
176    }
177}
178
179/// A real argon2id hash (of a value no caller can present: it is discarded
180/// after hashing) that [`UserStore::authenticate`] verifies unknown-user
181/// candidates against, so the unknown branch does the same work as the known
182/// one. Computed once per process; the one-time cost is one extra hash.
183fn dummy_password_hash() -> &'static str {
184    use std::sync::OnceLock;
185    static DUMMY: OnceLock<String> = OnceLock::new();
186    DUMMY.get_or_init(|| {
187        let throwaway = Zeroizing::new(format!(
188            "powdb-timing-equalizer-{}-{:p}",
189            std::process::id(),
190            &DUMMY
191        ));
192        // A hashing failure here cannot be surfaced (this is the "user does
193        // not exist" path); an empty PHC makes verify_password parse-fail
194        // fast, which merely restores the old timing rather than breaking
195        // authentication.
196        hash_password(&throwaway).unwrap_or_default()
197    })
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn create_then_authenticate() {
206        let mut s = UserStore::new();
207        s.create_user("alice", "pw", "admin").unwrap();
208        assert!(s.authenticate("alice", "pw").is_some());
209        assert!(s.authenticate("alice", "bad").is_none());
210        assert!(s.authenticate("nobody", "pw").is_none());
211    }
212
213    #[test]
214    fn duplicate_create_rejected() {
215        let mut s = UserStore::new();
216        s.create_user("a", "pw", "readonly").unwrap();
217        assert!(matches!(
218            s.create_user("a", "pw2", "readonly"),
219            Err(AuthError::UserExists(_))
220        ));
221    }
222
223    #[test]
224    fn empty_and_len_track_users() {
225        let mut s = UserStore::new();
226        assert!(s.is_empty());
227        assert_eq!(s.len(), 0);
228        s.create_user("alice", "pw", "readwrite").unwrap();
229        assert!(!s.is_empty());
230        assert_eq!(s.len(), 1);
231        s.create_user("bob", "pw", "readonly").unwrap();
232        assert_eq!(s.len(), 2);
233        s.delete_user("alice").unwrap();
234        assert_eq!(s.len(), 1);
235        assert!(!s.is_empty());
236        s.delete_user("bob").unwrap();
237        assert!(s.is_empty());
238    }
239
240    #[test]
241    fn set_password_changes_credential() {
242        let mut s = UserStore::new();
243        s.create_user("alice", "old", "readwrite").unwrap();
244        assert!(s.authenticate("alice", "old").is_some());
245        s.set_password("alice", "new").unwrap();
246        // Old password no longer works; new one does; role is preserved.
247        assert!(s.authenticate("alice", "old").is_none());
248        assert!(s.authenticate("alice", "new").is_some());
249        assert_eq!(s.authenticate("alice", "new").unwrap().role, "readwrite");
250    }
251
252    #[test]
253    fn set_password_unknown_user_rejected() {
254        let mut s = UserStore::new();
255        assert!(matches!(
256            s.set_password("ghost", "pw"),
257            Err(AuthError::UnknownUser(_))
258        ));
259    }
260
261    #[test]
262    fn create_with_unknown_role_rejected() {
263        let mut s = UserStore::new();
264        assert!(matches!(
265            s.create_user("a", "pw", "wizard"),
266            Err(AuthError::UnknownRole(_))
267        ));
268    }
269
270    /// Authenticating an unknown user must cost the same argon2 work as a
271    /// wrong password for a known user. Before the dummy-verify, the unknown
272    /// branch returned in microseconds while the known branch ran ~tens of
273    /// milliseconds of argon2id — a timing oracle that let a remote caller
274    /// enumerate usernames. Ratio-based with a wide margin (4x against a
275    /// ~1000x historical gap), so machine speed cannot flake it.
276    #[test]
277    fn unknown_user_costs_the_same_argon2_work_as_a_wrong_password() {
278        let mut s = UserStore::new();
279        s.create_user("alice", "correct horse", "admin").unwrap();
280
281        let median = |f: &dyn Fn()| {
282            let mut runs: Vec<std::time::Duration> = (0..3)
283                .map(|_| {
284                    let t = std::time::Instant::now();
285                    f();
286                    t.elapsed()
287                })
288                .collect();
289            runs.sort();
290            runs[1]
291        };
292
293        let wrong = median(&|| {
294            assert!(s.authenticate("alice", "wrong password").is_none());
295        });
296        let unknown = median(&|| {
297            assert!(s.authenticate("mallory", "wrong password").is_none());
298        });
299
300        assert!(
301            unknown * 4 > wrong,
302            "unknown-user auth ({unknown:?}) must not be cheaper than \
303             wrong-password auth ({wrong:?}) — that timing gap enumerates usernames"
304        );
305    }
306}