phpbb_pwhash/
lib.rs

1//! A re-implementation of the `phpbb_check_hash` function (from phpBB 3) in
2//! Rust. It allows verifying a salted hash against a password.
3//!
4//! ## Usage
5//!
6//! To verify a hash against a password:
7//!
8//! ```rust
9//! use phpbb_pwhash::{check_hash, CheckHashResult};
10//!
11//! let hash = "$H$9/O41.qQjQNlleivjbckbSNpfS4xgh0";
12//! assert_eq!(
13//!     check_hash(hash, "pass1234"),
14//!     CheckHashResult::Valid
15//! );
16//! assert_eq!(
17//!     check_hash(hash, "pass1235"),
18//!     CheckHashResult::Invalid
19//! );
20//! ```
21
22/// The result type returned by [`check_hash`](crate::check_hash).
23#[derive(Debug, PartialEq)]
24pub enum CheckHashResult {
25    Valid,
26    PasswordTooLong,
27    InvalidHash(InvalidHash),
28    Invalid,
29}
30
31/// The error returned if the encoded hash is invalid.
32#[derive(Debug, PartialEq)]
33pub enum InvalidHash {
34    BadLength,
35    UnsupportedHashType,
36    InvalidRounds,
37    InvalidBase64(base64::DecodeError),
38}
39
40/// A parsed encoded phpBB3 hash
41#[derive(Debug)]
42pub struct PhpbbHash<'a> {
43    hash_type: &'a str,
44    rounds: usize,
45    salt: &'a str,
46    hashed: &'a str,
47}
48
49// Base64 alphabet
50static ALPHABET: &str = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
51
52/// Parse a phpBB3 hash.
53///
54/// A hash for the password "pass1234" can look like this:
55///
56/// ```text
57/// $H$9/O41.qQjQNlleivjbckbSNpfS4xgh0
58/// ```
59///
60/// Details:
61///
62/// - The first three characters are the hash type, should be '$H$'.
63/// - The fourth character encodes the number of hashing rounds, as a power of
64///   two. For example, if the value is '9' as above, then (1 << 11) rounds are
65///   used (because the offset from the start of the alphabet for '9' is 11).
66///   The offset must be between 7 and 30.
67/// - Characters 5-13 are the 8-byte salt.
68/// - Characters 13 and onwards are the encoded hash.
69pub fn parse_hash(salted_hash: &str) -> Result<PhpbbHash, InvalidHash> {
70    // Check for unsalted MD5 hashes
71    if salted_hash.len() != 34 {
72        return Err(InvalidHash::BadLength);
73    }
74
75    // Validate prefix
76    let hash_type = &salted_hash[0..3];
77    if hash_type != "$H$" {
78        return Err(InvalidHash::UnsupportedHashType);
79    };
80
81    // Determine rounds
82    let rounds = match ALPHABET.find(salted_hash.chars().nth(3).unwrap()) {
83        None => return Err(InvalidHash::InvalidRounds),
84        Some(offset) if offset < 7 || offset > 30 => return Err(InvalidHash::InvalidRounds),
85        Some(offset) => 1 << offset,
86    };
87
88    // Determine salt and hashed data
89    let salt = &salted_hash[4..12];
90    let hashed = &salted_hash[12..];
91
92    Ok(PhpbbHash {
93        hash_type,
94        rounds,
95        salt,
96        hashed,
97    })
98}
99
100/// Decoding function.
101///
102/// Code taken from phpass re-implementation by Joshua Koudys, licensed under
103/// the MIT license (https://github.com/clausehound/phpass).
104fn decode64(val: &[u8]) -> Result<Vec<u8>, base64::DecodeError> {
105    // We pad by 0s, encoded as .
106    let len = val.len();
107    let bytes = base64::decode_config(
108        std::iter::repeat(b'.')
109            // Base64 encodes on 3-byte boundaries
110            .take(3 - len % 3)
111            .chain(val.iter().cloned().rev())
112            .collect::<Vec<_>>(),
113        base64::CRYPT,
114    )?
115    .iter()
116    // Then those backwards-fed inputs need their outputs reversed.
117    .rev()
118    .take(16)
119    .copied()
120    .collect::<Vec<_>>();
121
122    Ok(bytes)
123}
124
125/// Validate a password against a phpBB3 salted hash.
126pub fn check_hash(salted_hash: &str, password: &str) -> CheckHashResult {
127    // Limit password length
128    if password.len() > 4096 {
129        return CheckHashResult::PasswordTooLong;
130    }
131    let password_bytes = password.as_bytes();
132    let password_bytes_len = password_bytes.len();
133
134    // Parse salted hash
135    let parsed = match parse_hash(salted_hash) {
136        Ok(p) => p,
137        Err(e) => return CheckHashResult::InvalidHash(e),
138    };
139
140    // Decode hash
141    let decoded_hashed = match decode64(parsed.hashed.as_bytes()) {
142        Ok(d) => d,
143        Err(e) => return CheckHashResult::InvalidHash(InvalidHash::InvalidBase64(e)),
144    };
145
146    // Initial hash
147    let mut buf: Vec<u8> = Vec::with_capacity(8 + password_bytes_len);
148    buf.extend_from_slice(parsed.salt.as_bytes());
149    buf.extend_from_slice(password.as_bytes());
150    let mut hash = md5::compute(&buf);
151
152    // Some additional rounds of hashing
153    // (Yeah, this re-allocates a buffer for every round, could be improved.)
154    for _ in 0..parsed.rounds {
155        let mut buf: Vec<u8> = Vec::with_capacity(16 /* md5 */ + password_bytes_len);
156        buf.extend_from_slice(&hash.0);
157        buf.extend_from_slice(password_bytes);
158        hash = md5::compute(&buf);
159    }
160
161    if hash.0.as_ref() == decoded_hashed {
162        CheckHashResult::Valid
163    } else {
164        CheckHashResult::Invalid
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[derive(Debug)]
173    struct TestCase {
174        encoded_hash: &'static str,
175        password: &'static str,
176        result: CheckHashResult,
177    }
178
179    #[test]
180    fn test_validation() {
181        let test_cases = [
182            TestCase {
183                encoded_hash: "$H$9/O41.qQjQNlleivjbckbSNpfS4xgh0",
184                password: "pass1234",
185                result: CheckHashResult::Valid,
186            },
187            TestCase {
188                encoded_hash: "$H$9PoEptdBNUJZuamBBKOr/KPdi1ZmSw1",
189                password: "pass1234",
190                result: CheckHashResult::Valid,
191            },
192            TestCase {
193                encoded_hash: "$H$94VS2e40wcTQ38TK2P2yBc0TnmMfLC1",
194                password: "pass1234",
195                result: CheckHashResult::Valid,
196            },
197            TestCase {
198                encoded_hash: "$H$9/O41.qQjQNlleivjbckbSNpfS4xgh0",
199                password: "pass1235",
200                result: CheckHashResult::Invalid,
201            },
202            TestCase {
203                encoded_hash: "$H$9/O41.qQjQNlleivjbckbSNpfS4xgh012",
204                password: "pass1234",
205                result: CheckHashResult::InvalidHash(InvalidHash::BadLength),
206            },
207            TestCase {
208                encoded_hash: "$X$9/O41.qQjQNlleivjbckbSNpfS4xgh0",
209                password: "pass1234",
210                result: CheckHashResult::InvalidHash(InvalidHash::UnsupportedHashType),
211            },
212            TestCase {
213                encoded_hash: "$H$1/O41.qQjQNlleivjbckbSNpfS4xgh0",
214                password: "pass1234",
215                result: CheckHashResult::InvalidHash(InvalidHash::InvalidRounds),
216            },
217        ];
218        for case in &test_cases {
219            let result = check_hash(case.encoded_hash, case.password);
220            assert_eq!(result, case.result, "{:?}", case);
221        }
222    }
223}