Skip to main content

koan_server/auth/
password.rs

1//! Username and password checks for transports that send them with every
2//! request, such as Subsonic's `p=`.
3//!
4//! argon2 is deliberately slow, and a Subsonic client authenticates each call,
5//! so a successful check is remembered for a while. The key is a digest of the
6//! username, the password and the stored hash, so changing the password or
7//! deleting the user ends it; the role is read afresh every time.
8//!
9//! A check that misses the cache runs argon2, which costs ~19 MiB and a core
10//! for tens of milliseconds, and anyone may ask for one — an unknown username
11//! still pays, so response time does not say which exist. So only a few run at
12//! once, and a request that finds them all busy is refused rather than queued.
13//! Requests carrying the same credentials while one is being checked wait for
14//! that check instead, which is what a client's burst of requests on first
15//! contact looks like.
16//!
17//! Each successful check also seals the password for Subsonic token auth
18//! (`koan_core::auth::seal_password`), so an account that has signed in once
19//! by password can then use clients that only speak `t`/`s`.
20
21use std::collections::HashMap;
22use std::num::NonZeroUsize;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use koan_core::auth::{self, Role};
27use koan_core::db::pool::Pool;
28use koan_core::db::queries::auth as auth_queries;
29use lru::LruCache;
30use parking_lot::{Condvar, Mutex};
31use sha2::{Digest, Sha256};
32
33const REMEMBER: Duration = Duration::from_secs(600);
34
35/// How long a request waits on another's check of the same credentials.
36const WAIT_FOR_CHECK: Duration = Duration::from_secs(5);
37
38/// argon2 checks allowed at once.
39fn max_checks() -> usize {
40    std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 8))
41}
42
43/// Why a password was not accepted.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Refused {
46    /// Not this account's password, or no such account.
47    Wrong,
48    /// Every argon2 slot was taken; nothing was checked.
49    Busy,
50}
51
52/// A check in progress: its outcome once known, and a signal for those waiting.
53#[derive(Default)]
54struct Check {
55    outcome: Mutex<Option<bool>>,
56    done: Condvar,
57}
58
59pub struct PasswordVerifier {
60    pool: Arc<Pool>,
61    verified: Mutex<LruCache<[u8; 32], Instant>>,
62    /// Checks running now, by the same key as `verified`.
63    checking: Mutex<HashMap<[u8; 32], Arc<Check>>>,
64    max_checks: usize,
65    /// Seals passwords for token auth; `None` if it could not be loaded, which
66    /// leaves password auth working and token auth refused.
67    sealing: Option<[u8; 32]>,
68}
69
70impl PasswordVerifier {
71    pub fn new(pool: Arc<Pool>) -> Self {
72        let sealing = auth::subsonic_key()
73            .inspect_err(|e| log::warn!("Subsonic token auth for accounts is off: {e}"))
74            .ok();
75        Self::with_key(pool, sealing)
76    }
77
78    pub fn with_key(pool: Arc<Pool>, sealing: Option<[u8; 32]>) -> Self {
79        Self {
80            pool,
81            verified: Mutex::new(LruCache::new(NonZeroUsize::new(256).expect("non-zero"))),
82            checking: Mutex::new(HashMap::new()),
83            max_checks: max_checks(),
84            sealing,
85        }
86    }
87
88    /// The user's id and role when `token` is `md5(password + salt)` for
89    /// their password. Needs the sealed copy a password sign-in leaves; the opened
90    /// password is then checked like any other, so a stale copy fails.
91    pub fn verify_token(
92        &self,
93        username: &str,
94        token: &str,
95        salt: &str,
96    ) -> Result<(i64, Role), Refused> {
97        use subtle::ConstantTimeEq;
98        let password = (|| {
99            let key = self.sealing.as_ref()?;
100            let sealed =
101                auth_queries::sealed_password(&self.pool.get().ok()?.conn, username).ok()??;
102            auth::open_password(key, username, &sealed)
103        })()
104        .ok_or(Refused::Wrong)?;
105        let expected = format!("{:x}", md5::compute(format!("{password}{salt}")));
106        if !bool::from(
107            token
108                .to_ascii_lowercase()
109                .as_bytes()
110                .ct_eq(expected.as_bytes()),
111        ) {
112            return Err(Refused::Wrong);
113        }
114        self.verify(username, &password)
115    }
116
117    /// Whether token auth could work for this user: a key and a sealed copy.
118    pub fn has_sealed(&self, username: &str) -> bool {
119        self.sealing.is_some()
120            && self
121                .pool
122                .get()
123                .ok()
124                .and_then(|db| auth_queries::sealed_password(&db.conn, username).ok())
125                .flatten()
126                .is_some()
127    }
128
129    /// The user's id and role when the password is theirs.
130    pub fn verify(&self, username: &str, password: &str) -> Result<(i64, Role), Refused> {
131        let db = self.pool.get().map_err(|_| Refused::Wrong)?;
132        let user =
133            auth_queries::get_user_by_username(&db.conn, username).map_err(|_| Refused::Wrong)?;
134        // An unknown username is checked against the dummy hash, so response
135        // time doesn't say which usernames exist.
136        let hash = user.as_ref().map_or_else(
137            || super::routes::dummy_password_hash(),
138            |u| u.password_hash.as_str(),
139        );
140        let key: [u8; 32] = Sha256::new()
141            .chain_update(username)
142            .chain_update([0])
143            .chain_update(password)
144            .chain_update([0])
145            .chain_update(hash)
146            .finalize()
147            .into();
148        let fresh = self
149            .verified
150            .lock()
151            .get(&key)
152            .is_some_and(|at| at.elapsed() < REMEMBER);
153        if !fresh {
154            if !self.check(key, password, hash)? {
155                return Err(Refused::Wrong);
156            }
157            if let Some(k) = &self.sealing
158                && user.is_some()
159                && let Ok(sealed) = auth::seal_password(k, username, password)
160            {
161                let _ = auth_queries::set_sealed_password(&db.conn, username, &sealed);
162            }
163        }
164        user.map(|u| (u.id, u.role)).ok_or(Refused::Wrong)
165    }
166
167    /// Run argon2 for `key`, or wait on the check already running for it.
168    fn check(&self, key: [u8; 32], password: &str, hash: &str) -> Result<bool, Refused> {
169        let (check, running) = {
170            let mut checking = self.checking.lock();
171            match checking.get(&key) {
172                Some(check) => (check.clone(), true),
173                None if checking.len() >= self.max_checks => return Err(Refused::Busy),
174                None => {
175                    let check = Arc::new(Check::default());
176                    checking.insert(key, check.clone());
177                    (check, false)
178                }
179            }
180        };
181        if running {
182            let deadline = Instant::now() + WAIT_FOR_CHECK;
183            let mut outcome = check.outcome.lock();
184            while outcome.is_none() && !check.done.wait_until(&mut outcome, deadline).timed_out() {}
185            return outcome.ok_or(Refused::Busy);
186        }
187        let ok = auth::verify_password(password, hash).is_ok();
188        if ok {
189            self.verified.lock().put(key, Instant::now());
190        }
191        *check.outcome.lock() = Some(ok);
192        check.done.notify_all();
193        self.checking.lock().remove(&key);
194        Ok(ok)
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use koan_core::db::connection::Database;
202
203    fn verifier() -> (PasswordVerifier, tempfile::TempDir) {
204        let dir = tempfile::tempdir().unwrap();
205        let path = dir.path().join("test.db");
206        let db = Database::open(&path).unwrap();
207        koan_core::db::schema::create_tables(&db.conn).unwrap();
208        auth_queries::create_user(&db.conn, "mate", "hunter22", Role::Readonly).unwrap();
209        (
210            PasswordVerifier::with_key(Arc::new(Pool::new(path)), Some([7; 32])),
211            dir,
212        )
213    }
214
215    #[test]
216    fn right_password_gives_the_users_role() {
217        let (v, _dir) = verifier();
218        assert_eq!(v.verify("mate", "hunter22"), Ok((1, Role::Readonly)));
219        // Remembered, and still answered from the database's role.
220        assert_eq!(v.verify("mate", "hunter22"), Ok((1, Role::Readonly)));
221    }
222
223    #[test]
224    fn token_auth_works_once_a_password_sign_in_sealed_it() {
225        let (v, _dir) = verifier();
226        let token = |pw: &str, salt: &str| format!("{:x}", md5::compute(format!("{pw}{salt}")));
227        assert_eq!(
228            v.verify_token("mate", &token("hunter22", "abc"), "abc"),
229            Err(Refused::Wrong)
230        );
231        assert!(!v.has_sealed("mate"));
232        v.verify("mate", "hunter22").unwrap();
233        assert!(v.has_sealed("mate"));
234        assert_eq!(
235            v.verify_token("mate", &token("hunter22", "abc"), "abc"),
236            Ok((1, Role::Readonly))
237        );
238        assert_eq!(
239            v.verify_token("mate", &token("hunter2", "abc"), "abc"),
240            Err(Refused::Wrong)
241        );
242        assert_eq!(
243            v.verify_token("nobody", &token("hunter22", "abc"), "abc"),
244            Err(Refused::Wrong)
245        );
246    }
247
248    #[test]
249    fn a_password_changed_elsewhere_makes_the_sealed_copy_fail() {
250        let (v, dir) = verifier();
251        v.verify("mate", "hunter22").unwrap();
252        let db = Database::open(&dir.path().join("test.db")).unwrap();
253        auth_queries::update_password(&db.conn, "mate", "correct horse").unwrap();
254        let token = format!("{:x}", md5::compute("hunter22salt"));
255        assert_eq!(v.verify_token("mate", &token, "salt"), Err(Refused::Wrong));
256    }
257
258    #[test]
259    fn a_sealed_password_opens_only_for_its_user_and_key() {
260        let sealed = auth::seal_password(&[1; 32], "mate", "hunter22").unwrap();
261        assert_eq!(
262            auth::open_password(&[1; 32], "mate", &sealed).as_deref(),
263            Some("hunter22")
264        );
265        assert_eq!(auth::open_password(&[1; 32], "owner", &sealed), None);
266        assert_eq!(auth::open_password(&[2; 32], "mate", &sealed), None);
267    }
268
269    #[test]
270    fn wrong_password_or_unknown_user_is_refused() {
271        let (v, _dir) = verifier();
272        assert_eq!(v.verify("mate", "hunter2"), Err(Refused::Wrong));
273        assert_eq!(v.verify("nobody", "hunter22"), Err(Refused::Wrong));
274    }
275
276    #[test]
277    fn checks_beyond_the_ceiling_are_refused_without_running() {
278        let (mut v, _dir) = verifier();
279        v.max_checks = 1;
280        assert!(v.verify("mate", "hunter22").is_ok());
281        v.checking.lock().insert([0; 32], Arc::default());
282        assert_eq!(v.verify("nobody", "guess"), Err(Refused::Busy));
283        assert_eq!(v.verify("mate", "hunter2"), Err(Refused::Busy));
284        // A remembered sign-in needs no check, so it still works.
285        assert_eq!(v.verify("mate", "hunter22"), Ok((1, Role::Readonly)));
286    }
287
288    #[test]
289    fn a_burst_of_one_sign_in_waits_for_a_single_check() {
290        let (mut v, _dir) = verifier();
291        v.max_checks = 1;
292        let v = Arc::new(v);
293        let burst: Vec<_> = (0..8)
294            .map(|_| {
295                let v = v.clone();
296                std::thread::spawn(move || v.verify("mate", "hunter22"))
297            })
298            .collect();
299        for t in burst {
300            assert_eq!(t.join().unwrap(), Ok((1, Role::Readonly)));
301        }
302    }
303
304    #[test]
305    fn a_changed_password_forgets_the_old_one() {
306        let (v, dir) = verifier();
307        assert!(v.verify("mate", "hunter22").is_ok());
308        let db = Database::open(&dir.path().join("test.db")).unwrap();
309        auth_queries::update_password(&db.conn, "mate", "correct horse").unwrap();
310        assert_eq!(v.verify("mate", "hunter22"), Err(Refused::Wrong));
311        assert_eq!(v.verify("mate", "correct horse"), Ok((1, Role::Readonly)));
312    }
313}