wisegate_core/auth/
hash.rs

1//! Native password hash verification for Apache htpasswd formats.
2//!
3//! Supported formats:
4//! - bcrypt ($2y$, $2a$, $2b$)
5//! - APR1 MD5 ($apr1$)
6//! - SHA1 ({SHA})
7//! - Plain text (fallback)
8
9use base64::{Engine, engine::general_purpose::STANDARD};
10use md5::{Digest, Md5};
11use sha1::Sha1;
12
13/// Verifies a password against a stored hash.
14/// Automatically detects the hash format and uses the appropriate algorithm.
15pub fn verify(password: &str, hash: &str) -> bool {
16    if hash.starts_with("$2y$") || hash.starts_with("$2a$") || hash.starts_with("$2b$") {
17        verify_bcrypt(password, hash)
18    } else if hash.starts_with("$apr1$") {
19        verify_apr1(password, hash)
20    } else if hash.starts_with("{SHA}") {
21        verify_sha1(password, hash)
22    } else {
23        // Plain text comparison using constant-time comparison
24        constant_time_eq(password.as_bytes(), hash.as_bytes())
25    }
26}
27
28/// Constant-time byte comparison to prevent timing attacks.
29/// Does not leak length information through timing.
30pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
31    let len_eq = a.len() == b.len();
32    let max_len = a.len().max(b.len());
33    let mut result = 0u8;
34
35    for i in 0..max_len {
36        let x = a.get(i).copied().unwrap_or(0);
37        let y = b.get(i).copied().unwrap_or(0);
38        result |= x ^ y;
39    }
40
41    len_eq && result == 0
42}
43
44/// Verifies a password against a bcrypt hash.
45fn verify_bcrypt(password: &str, hash: &str) -> bool {
46    bcrypt::verify(password, hash).unwrap_or(false)
47}
48
49/// Verifies a password against a SHA1 hash ({SHA} prefix, Base64-encoded).
50fn verify_sha1(password: &str, hash: &str) -> bool {
51    let Some(encoded) = hash.strip_prefix("{SHA}") else {
52        return false;
53    };
54
55    let Ok(stored_digest) = STANDARD.decode(encoded) else {
56        return false;
57    };
58
59    let computed_digest = Sha1::digest(password.as_bytes());
60    constant_time_eq(computed_digest.as_slice(), &stored_digest)
61}
62
63/// Verifies a password against an APR1 MD5 hash ($apr1$ prefix).
64/// Implements Apache's modified MD5-crypt algorithm.
65fn verify_apr1(password: &str, hash: &str) -> bool {
66    let Some(rest) = hash.strip_prefix("$apr1$") else {
67        return false;
68    };
69
70    let Some((salt, _)) = rest.split_once('$') else {
71        return false;
72    };
73
74    let computed = apr1_hash(password, salt);
75    constant_time_eq(computed.as_bytes(), hash.as_bytes())
76}
77
78/// Computes an APR1 MD5 hash for a password with the given salt.
79/// Based on Apache's apr_md5.c implementation.
80fn apr1_hash(password: &str, salt: &str) -> String {
81    let password = password.as_bytes();
82    let salt = salt.as_bytes();
83
84    // Initial hash: password + $apr1$ + salt
85    let mut ctx = Md5::new();
86    ctx.update(password);
87    ctx.update(b"$apr1$");
88    ctx.update(salt);
89
90    // Alternate hash: password + salt + password
91    let mut ctx1 = Md5::new();
92    ctx1.update(password);
93    ctx1.update(salt);
94    ctx1.update(password);
95    let fin = ctx1.finalize();
96
97    // Add alternate hash bytes based on password length
98    let mut pl = password.len();
99    let mut i = 0;
100    while pl > 0 {
101        let len = if pl > 16 { 16 } else { pl };
102        ctx.update(&fin[i..i + len]);
103        pl -= len;
104        i += len;
105        if i >= 16 {
106            i = 0;
107        }
108    }
109
110    // Add null or first password char based on password length bits
111    let mut pl = password.len();
112    while pl > 0 {
113        if pl & 1 != 0 {
114            ctx.update([0u8]);
115        } else {
116            ctx.update(&password[0..1]);
117        }
118        pl >>= 1;
119    }
120
121    let mut fin = ctx.finalize();
122
123    // 1000 rounds of MD5
124    for i in 0..1000 {
125        let mut ctx1 = Md5::new();
126
127        if i & 1 != 0 {
128            ctx1.update(password);
129        } else {
130            ctx1.update(fin.as_slice());
131        }
132
133        if i % 3 != 0 {
134            ctx1.update(salt);
135        }
136
137        if i % 7 != 0 {
138            ctx1.update(password);
139        }
140
141        if i & 1 != 0 {
142            ctx1.update(fin.as_slice());
143        } else {
144            ctx1.update(password);
145        }
146
147        fin = ctx1.finalize();
148    }
149
150    // Convert GenericArray to fixed-size array for encoding
151    let fin_arr: [u8; 16] = fin.into();
152    let encoded = apr1_encode(&fin_arr);
153    format!(
154        "$apr1${salt}${encoded}",
155        salt = std::str::from_utf8(salt).unwrap_or("")
156    )
157}
158
159/// Custom Base64 encoding for APR1 MD5 hashes.
160/// Uses a different alphabet and byte ordering than standard Base64.
161fn apr1_encode(digest: &[u8; 16]) -> String {
162    const ITOA64: &[u8] = b"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
163
164    let mut result = String::with_capacity(22);
165
166    // APR1 uses a specific byte ordering for the final encoding
167    let encode_triple = |a: u8, b: u8, c: u8| -> [char; 4] {
168        let v = (u32::from(a) << 16) | (u32::from(b) << 8) | u32::from(c);
169        [
170            ITOA64[(v & 0x3f) as usize] as char,
171            ITOA64[((v >> 6) & 0x3f) as usize] as char,
172            ITOA64[((v >> 12) & 0x3f) as usize] as char,
173            ITOA64[((v >> 18) & 0x3f) as usize] as char,
174        ]
175    };
176
177    // Encode in the specific APR1 byte order
178    for chars in encode_triple(digest[0], digest[6], digest[12]) {
179        result.push(chars);
180    }
181    for chars in encode_triple(digest[1], digest[7], digest[13]) {
182        result.push(chars);
183    }
184    for chars in encode_triple(digest[2], digest[8], digest[14]) {
185        result.push(chars);
186    }
187    for chars in encode_triple(digest[3], digest[9], digest[15]) {
188        result.push(chars);
189    }
190    for chars in encode_triple(digest[4], digest[10], digest[5]) {
191        result.push(chars);
192    }
193
194    // Last byte only needs 2 characters
195    let v = u32::from(digest[11]);
196    result.push(ITOA64[(v & 0x3f) as usize] as char);
197    result.push(ITOA64[((v >> 6) & 0x3f) as usize] as char);
198
199    result
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_verify_plain_text() {
208        assert!(verify("secret", "secret"));
209        assert!(!verify("secret", "wrong"));
210    }
211
212    #[test]
213    fn test_verify_sha1_password() {
214        // "password" hashed with SHA1: echo -n "password" | openssl dgst -sha1 -binary | base64
215        let hash = "{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=";
216        assert!(verify("password", hash));
217        assert!(!verify("wrong", hash));
218    }
219
220    #[test]
221    fn test_verify_bcrypt_password() {
222        // "password" hashed with bcrypt (cost 5)
223        let hash = "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe";
224        assert!(verify("password", hash));
225        assert!(!verify("wrong", hash));
226    }
227
228    #[test]
229    fn test_verify_apr1_password() {
230        // "password" hashed with apr1: htpasswd -nbm user password
231        let hash = "$apr1$lZL6V/ci$eIMz/iKDkbtys/uU7LEK00";
232        assert!(verify("password", hash));
233        assert!(!verify("wrong", hash));
234    }
235
236    #[test]
237    fn test_apr1_known_hash() {
238        // Test against a known APR1 hash
239        let computed = apr1_hash("password", "lZL6V/ci");
240        assert_eq!(computed, "$apr1$lZL6V/ci$eIMz/iKDkbtys/uU7LEK00");
241    }
242
243    #[test]
244    fn test_constant_time_eq() {
245        assert!(constant_time_eq(b"hello", b"hello"));
246        assert!(!constant_time_eq(b"hello", b"world"));
247        assert!(!constant_time_eq(b"hello", b"hell"));
248        assert!(!constant_time_eq(b"", b"a"));
249        assert!(constant_time_eq(b"", b""));
250    }
251}