1use argon2::password_hash::phc::PasswordHash;
2use argon2::password_hash::{PasswordHasher, PasswordVerifier};
3use argon2::{Algorithm, Argon2, Params, Version};
4use subtle::ConstantTimeEq;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum PwdVerification {
9 Valid,
10 ValidLegacy,
12 Invalid,
13}
14
15fn argon2() -> Argon2<'static> {
16 Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default())
18}
19
20pub async fn hash_pwd(pwd: &str) -> String {
26 let pwd = pwd.to_owned();
27 run_blocking(move || hash_pwd_sync(&pwd)).await
28}
29
30pub 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 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 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}