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    Argon2::new(Algorithm::Argon2id, Version::V0x13, params())
17}
18
19/// argon2id with the OWASP recommended minimum (19 MiB, 2 iterations).
20#[cfg(not(all(feature = "test-support", debug_assertions)))]
21fn params() -> Params {
22    Params::default()
23}
24
25/// The cheapest valid parameters, so tests that create many users stay fast.
26/// The `test-support` feature is only enabled by dev-dependencies, and release
27/// builds never use these parameters. Verification reads the parameters from
28/// the stored hash, so hashes created with either parameters verify.
29#[cfg(all(feature = "test-support", debug_assertions))]
30fn params() -> Params {
31    Params::new(
32        Params::MIN_M_COST,
33        Params::MIN_T_COST,
34        Params::MIN_P_COST,
35        None,
36    )
37    .expect("minimal argon2 parameters are valid")
38}
39
40/// Hashes a password with argon2id and returns it in PHC string format. The
41/// random salt is part of the returned string.
42///
43/// Hashing is CPU heavy by design, so it runs on the blocking thread pool to
44/// keep the async workers responsive.
45pub async fn hash_pwd(pwd: &str) -> String {
46    let pwd = pwd.to_owned();
47    run_blocking(move || hash_pwd_sync(&pwd)).await
48}
49
50/// Verifies `pwd` against `stored_hash`. Hashes in PHC format are checked with
51/// argon2. Anything else is treated as a legacy `sha256(pwd + salt)` hash
52/// created by older Kellnr versions, which uses the separately stored salt.
53pub async fn verify_pwd(pwd: &str, stored_hash: &str, legacy_salt: &str) -> PwdVerification {
54    let (pwd, stored_hash, legacy_salt) = (
55        pwd.to_owned(),
56        stored_hash.to_owned(),
57        legacy_salt.to_owned(),
58    );
59    run_blocking(move || verify_pwd_sync(&pwd, &stored_hash, &legacy_salt)).await
60}
61
62async fn run_blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
63    tokio::task::spawn_blocking(f)
64        .await
65        .expect("password hashing task panicked")
66}
67
68fn hash_pwd_sync(pwd: &str) -> String {
69    // Generates a random salt with the OS random number generator.
70    argon2()
71        .hash_password(pwd.as_bytes())
72        .expect("argon2 hashing with valid parameters cannot fail")
73        .to_string()
74}
75
76fn verify_pwd_sync(pwd: &str, stored_hash: &str, legacy_salt: &str) -> PwdVerification {
77    if stored_hash.starts_with('$') {
78        return match PasswordHash::new(stored_hash) {
79            Ok(parsed) if argon2().verify_password(pwd.as_bytes(), &parsed).is_ok() => {
80                PwdVerification::Valid
81            }
82            _ => PwdVerification::Invalid,
83        };
84    }
85
86    let legacy = legacy_hash(pwd, legacy_salt);
87    if bool::from(legacy.as_bytes().ct_eq(stored_hash.as_bytes())) {
88        PwdVerification::ValidLegacy
89    } else {
90        PwdVerification::Invalid
91    }
92}
93
94pub(crate) fn legacy_hash(pwd: &str, salt: &str) -> String {
95    sha256::digest(format!("{pwd}{salt}"))
96}
97
98pub fn hash_token(token: &str) -> String {
99    sha256::digest(token)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn hash_pwd_produces_argon2id_phc_string() {
108        let hash = hash_pwd_sync("admin");
109
110        assert!(
111            hash.starts_with("$argon2id$v=19$"),
112            "unexpected hash {hash}"
113        );
114    }
115
116    #[test]
117    fn hash_pwd_uses_random_salt() {
118        assert_ne!(hash_pwd_sync("admin"), hash_pwd_sync("admin"));
119    }
120
121    #[test]
122    fn verify_pwd_accepts_argon2_hash() {
123        let hash = hash_pwd_sync("admin");
124
125        assert_eq!(verify_pwd_sync("admin", &hash, ""), PwdVerification::Valid);
126        assert_eq!(
127            verify_pwd_sync("wrong", &hash, ""),
128            PwdVerification::Invalid
129        );
130    }
131
132    #[test]
133    fn verify_pwd_accepts_stored_argon2_hash() {
134        // Created with the argon2 reference implementation and the parameters
135        // Kellnr uses, like the hashes stored by earlier Kellnr versions.
136        let hash = "$argon2id$v=19$m=19456,t=2,p=1$a2VsbG5yc2FsdHZhbHVlMTY$grIizR+2/xpDGDbimzgwYxM2uK8eVjV28lGYRXqU7cc";
137
138        assert_eq!(verify_pwd_sync("admin", hash, ""), PwdVerification::Valid);
139        assert_eq!(verify_pwd_sync("wrong", hash, ""), PwdVerification::Invalid);
140    }
141
142    #[test]
143    fn verify_pwd_accepts_legacy_hash() {
144        let salt = "C6udtgbngX";
145        let legacy = "5dcec54caf0f55652766f71c32a0eac6538e7faeeab9301f956a58b7dbad02fb";
146
147        assert_eq!(
148            verify_pwd_sync("admin", legacy, salt),
149            PwdVerification::ValidLegacy
150        );
151        assert_eq!(
152            verify_pwd_sync("wrong", legacy, salt),
153            PwdVerification::Invalid
154        );
155        assert_eq!(
156            verify_pwd_sync("admin", legacy, "other_salt"),
157            PwdVerification::Invalid
158        );
159    }
160
161    #[test]
162    fn verify_pwd_rejects_malformed_phc_string() {
163        assert_eq!(
164            verify_pwd_sync("admin", "$argon2id$garbage", ""),
165            PwdVerification::Invalid
166        );
167    }
168
169    #[test]
170    fn verify_pwd_rejects_empty_hash() {
171        assert_eq!(verify_pwd_sync("", "", ""), PwdVerification::Invalid);
172    }
173
174    #[test]
175    fn legacy_hash_computes_sha256_of_pwd_and_salt() {
176        assert_eq!(
177            legacy_hash("admin", "C6udtgbngX"),
178            "5dcec54caf0f55652766f71c32a0eac6538e7faeeab9301f956a58b7dbad02fb"
179        );
180    }
181}