Skip to main content

kellnr_db/
password.rs

1use argon2::password_hash::phc::PasswordHash;
2use argon2::password_hash::{PasswordHasher, PasswordVerifier};
3use argon2::{Algorithm, Argon2, Params, Version};
4use subtle::ConstantTimeEq;
5
6/// Outcome of checking a password against its stored hash.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum PwdVerification {
9    Valid,
10    /// The password matches a legacy SHA-256 hash and should be rehashed.
11    ValidLegacy,
12    Invalid,
13}
14
15fn argon2() -> Argon2<'static> {
16    // argon2id with the OWASP recommended minimum (19 MiB, 2 iterations).
17    Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default())
18}
19
20/// Hashes a password with argon2id and returns it in PHC string format. The
21/// random salt is part of the returned string.
22///
23/// Hashing is CPU heavy by design, so it runs on the blocking thread pool to
24/// keep the async workers responsive.
25pub async fn hash_pwd(pwd: &str) -> String {
26    let pwd = pwd.to_owned();
27    run_blocking(move || hash_pwd_sync(&pwd)).await
28}
29
30/// Verifies `pwd` against `stored_hash`. Hashes in PHC format are checked with
31/// argon2. Anything else is treated as a legacy `sha256(pwd + salt)` hash
32/// created by older Kellnr versions, which uses the separately stored salt.
33pub async fn verify_pwd(pwd: &str, stored_hash: &str, legacy_salt: &str) -> PwdVerification {
34    let (pwd, stored_hash, legacy_salt) = (
35        pwd.to_owned(),
36        stored_hash.to_owned(),
37        legacy_salt.to_owned(),
38    );
39    run_blocking(move || verify_pwd_sync(&pwd, &stored_hash, &legacy_salt)).await
40}
41
42async fn run_blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
43    tokio::task::spawn_blocking(f)
44        .await
45        .expect("password hashing task panicked")
46}
47
48fn hash_pwd_sync(pwd: &str) -> String {
49    // Generates a random salt with the OS random number generator.
50    argon2()
51        .hash_password(pwd.as_bytes())
52        .expect("argon2 hashing with default parameters cannot fail")
53        .to_string()
54}
55
56fn verify_pwd_sync(pwd: &str, stored_hash: &str, legacy_salt: &str) -> PwdVerification {
57    if stored_hash.starts_with('$') {
58        return match PasswordHash::new(stored_hash) {
59            Ok(parsed) if argon2().verify_password(pwd.as_bytes(), &parsed).is_ok() => {
60                PwdVerification::Valid
61            }
62            _ => PwdVerification::Invalid,
63        };
64    }
65
66    let legacy = legacy_hash(pwd, legacy_salt);
67    if bool::from(legacy.as_bytes().ct_eq(stored_hash.as_bytes())) {
68        PwdVerification::ValidLegacy
69    } else {
70        PwdVerification::Invalid
71    }
72}
73
74pub(crate) fn legacy_hash(pwd: &str, salt: &str) -> String {
75    sha256::digest(format!("{pwd}{salt}"))
76}
77
78pub fn hash_token(token: &str) -> String {
79    sha256::digest(token)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn hash_pwd_produces_argon2id_phc_string() {
88        let hash = hash_pwd_sync("admin");
89
90        assert!(
91            hash.starts_with("$argon2id$v=19$"),
92            "unexpected hash {hash}"
93        );
94    }
95
96    #[test]
97    fn hash_pwd_uses_random_salt() {
98        assert_ne!(hash_pwd_sync("admin"), hash_pwd_sync("admin"));
99    }
100
101    #[test]
102    fn verify_pwd_accepts_argon2_hash() {
103        let hash = hash_pwd_sync("admin");
104
105        assert_eq!(verify_pwd_sync("admin", &hash, ""), PwdVerification::Valid);
106        assert_eq!(
107            verify_pwd_sync("wrong", &hash, ""),
108            PwdVerification::Invalid
109        );
110    }
111
112    #[test]
113    fn verify_pwd_accepts_stored_argon2_hash() {
114        // Created with the argon2 reference implementation and the parameters
115        // Kellnr uses, like the hashes stored by earlier Kellnr versions.
116        let hash = "$argon2id$v=19$m=19456,t=2,p=1$a2VsbG5yc2FsdHZhbHVlMTY$grIizR+2/xpDGDbimzgwYxM2uK8eVjV28lGYRXqU7cc";
117
118        assert_eq!(verify_pwd_sync("admin", hash, ""), PwdVerification::Valid);
119        assert_eq!(verify_pwd_sync("wrong", hash, ""), PwdVerification::Invalid);
120    }
121
122    #[test]
123    fn verify_pwd_accepts_legacy_hash() {
124        let salt = "C6udtgbngX";
125        let legacy = "5dcec54caf0f55652766f71c32a0eac6538e7faeeab9301f956a58b7dbad02fb";
126
127        assert_eq!(
128            verify_pwd_sync("admin", legacy, salt),
129            PwdVerification::ValidLegacy
130        );
131        assert_eq!(
132            verify_pwd_sync("wrong", legacy, salt),
133            PwdVerification::Invalid
134        );
135        assert_eq!(
136            verify_pwd_sync("admin", legacy, "other_salt"),
137            PwdVerification::Invalid
138        );
139    }
140
141    #[test]
142    fn verify_pwd_rejects_malformed_phc_string() {
143        assert_eq!(
144            verify_pwd_sync("admin", "$argon2id$garbage", ""),
145            PwdVerification::Invalid
146        );
147    }
148
149    #[test]
150    fn verify_pwd_rejects_empty_hash() {
151        assert_eq!(verify_pwd_sync("", "", ""), PwdVerification::Invalid);
152    }
153
154    #[test]
155    fn legacy_hash_computes_sha256_of_pwd_and_salt() {
156        assert_eq!(
157            legacy_hash("admin", "C6udtgbngX"),
158            "5dcec54caf0f55652766f71c32a0eac6538e7faeeab9301f956a58b7dbad02fb"
159        );
160    }
161}