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//! Each successful check also seals the password for Subsonic token auth
10//! (`koan_core::auth::seal_password`), so an account that has signed in once
11//! by password can then use clients that only speak `t`/`s`.
12
13use std::num::NonZeroUsize;
14use std::path::PathBuf;
15use std::time::{Duration, Instant};
16
17use koan_core::auth::{self, Role};
18use koan_core::db::connection::Database;
19use koan_core::db::queries::auth as auth_queries;
20use lru::LruCache;
21use parking_lot::Mutex;
22use sha2::{Digest, Sha256};
23
24const REMEMBER: Duration = Duration::from_secs(600);
25
26pub struct PasswordVerifier {
27    db_path: PathBuf,
28    verified: Mutex<LruCache<[u8; 32], Instant>>,
29    /// Seals passwords for token auth; `None` if it could not be loaded, which
30    /// leaves password auth working and token auth refused.
31    sealing: Option<[u8; 32]>,
32}
33
34impl PasswordVerifier {
35    pub fn new(db_path: PathBuf) -> Self {
36        let sealing = auth::subsonic_key()
37            .inspect_err(|e| log::warn!("Subsonic token auth for accounts is off: {e}"))
38            .ok();
39        Self::with_key(db_path, sealing)
40    }
41
42    pub fn with_key(db_path: PathBuf, sealing: Option<[u8; 32]>) -> Self {
43        Self {
44            db_path,
45            verified: Mutex::new(LruCache::new(NonZeroUsize::new(256).expect("non-zero"))),
46            sealing,
47        }
48    }
49
50    /// The user's role when `token` is `md5(password + salt)` for their
51    /// password. Needs the sealed copy a password sign-in leaves; the opened
52    /// password is then checked like any other, so a stale copy fails.
53    pub fn verify_token(&self, username: &str, token: &str, salt: &str) -> Option<Role> {
54        use subtle::ConstantTimeEq;
55        let key = self.sealing.as_ref()?;
56        let db = Database::open(&self.db_path).ok()?;
57        let sealed = auth_queries::sealed_password(&db.conn, username).ok()??;
58        let password = auth::open_password(key, username, &sealed)?;
59        let expected = format!("{:x}", md5::compute(format!("{password}{salt}")));
60        if !bool::from(
61            token
62                .to_ascii_lowercase()
63                .as_bytes()
64                .ct_eq(expected.as_bytes()),
65        ) {
66            return None;
67        }
68        self.verify(username, &password)
69    }
70
71    /// Whether token auth could work for this user: a key and a sealed copy.
72    pub fn has_sealed(&self, username: &str) -> bool {
73        self.sealing.is_some()
74            && Database::open(&self.db_path)
75                .ok()
76                .and_then(|db| auth_queries::sealed_password(&db.conn, username).ok())
77                .flatten()
78                .is_some()
79    }
80
81    /// The user's role when the password is theirs.
82    pub fn verify(&self, username: &str, password: &str) -> Option<Role> {
83        let db = Database::open(&self.db_path).ok()?;
84        let Some(user) = auth_queries::get_user_by_username(&db.conn, username).ok()? else {
85            // Pay for a verify anyway, so response time doesn't say which
86            // usernames exist.
87            let _ = auth::verify_password(password, super::routes::dummy_password_hash());
88            return None;
89        };
90        let key: [u8; 32] = Sha256::new()
91            .chain_update(username)
92            .chain_update([0])
93            .chain_update(password)
94            .chain_update([0])
95            .chain_update(&user.password_hash)
96            .finalize()
97            .into();
98        let fresh = self
99            .verified
100            .lock()
101            .get(&key)
102            .is_some_and(|at| at.elapsed() < REMEMBER);
103        if !fresh {
104            auth::verify_password(password, &user.password_hash).ok()?;
105            self.verified.lock().put(key, Instant::now());
106            if let Some(k) = &self.sealing
107                && let Ok(sealed) = auth::seal_password(k, username, password)
108            {
109                let _ = auth_queries::set_sealed_password(&db.conn, username, &sealed);
110            }
111        }
112        Some(user.role)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn verifier() -> (PasswordVerifier, tempfile::TempDir) {
121        let dir = tempfile::tempdir().unwrap();
122        let path = dir.path().join("test.db");
123        let db = Database::open(&path).unwrap();
124        koan_core::db::schema::create_tables(&db.conn).unwrap();
125        auth_queries::create_user(&db.conn, "mate", "hunter22", Role::Readonly).unwrap();
126        (PasswordVerifier::with_key(path, Some([7; 32])), dir)
127    }
128
129    #[test]
130    fn right_password_gives_the_users_role() {
131        let (v, _dir) = verifier();
132        assert_eq!(v.verify("mate", "hunter22"), Some(Role::Readonly));
133        // Remembered, and still answered from the database's role.
134        assert_eq!(v.verify("mate", "hunter22"), Some(Role::Readonly));
135    }
136
137    #[test]
138    fn token_auth_works_once_a_password_sign_in_sealed_it() {
139        let (v, _dir) = verifier();
140        let token = |pw: &str, salt: &str| format!("{:x}", md5::compute(format!("{pw}{salt}")));
141        assert_eq!(
142            v.verify_token("mate", &token("hunter22", "abc"), "abc"),
143            None
144        );
145        assert!(!v.has_sealed("mate"));
146        v.verify("mate", "hunter22").unwrap();
147        assert!(v.has_sealed("mate"));
148        assert_eq!(
149            v.verify_token("mate", &token("hunter22", "abc"), "abc"),
150            Some(Role::Readonly)
151        );
152        assert_eq!(
153            v.verify_token("mate", &token("hunter2", "abc"), "abc"),
154            None
155        );
156        assert_eq!(
157            v.verify_token("nobody", &token("hunter22", "abc"), "abc"),
158            None
159        );
160    }
161
162    #[test]
163    fn a_password_changed_elsewhere_makes_the_sealed_copy_fail() {
164        let (v, dir) = verifier();
165        v.verify("mate", "hunter22").unwrap();
166        let db = Database::open(&dir.path().join("test.db")).unwrap();
167        auth_queries::update_password(&db.conn, "mate", "correct horse").unwrap();
168        let token = format!("{:x}", md5::compute("hunter22salt"));
169        assert_eq!(v.verify_token("mate", &token, "salt"), None);
170    }
171
172    #[test]
173    fn a_sealed_password_opens_only_for_its_user_and_key() {
174        let sealed = auth::seal_password(&[1; 32], "mate", "hunter22").unwrap();
175        assert_eq!(
176            auth::open_password(&[1; 32], "mate", &sealed).as_deref(),
177            Some("hunter22")
178        );
179        assert_eq!(auth::open_password(&[1; 32], "owner", &sealed), None);
180        assert_eq!(auth::open_password(&[2; 32], "mate", &sealed), None);
181    }
182
183    #[test]
184    fn wrong_password_or_unknown_user_is_refused() {
185        let (v, _dir) = verifier();
186        assert_eq!(v.verify("mate", "hunter2"), None);
187        assert_eq!(v.verify("nobody", "hunter22"), None);
188    }
189
190    #[test]
191    fn a_changed_password_forgets_the_old_one() {
192        let (v, dir) = verifier();
193        assert!(v.verify("mate", "hunter22").is_some());
194        let db = Database::open(&dir.path().join("test.db")).unwrap();
195        auth_queries::update_password(&db.conn, "mate", "correct horse").unwrap();
196        assert_eq!(v.verify("mate", "hunter22"), None);
197        assert_eq!(v.verify("mate", "correct horse"), Some(Role::Readonly));
198    }
199}