Skip to main content

parse_rust_auth/
password.rs

1//! Password hashing.
2//!
3//! Upstream is `src/password.js`: `bcrypt.hash(password, 10)`, using `bcryptjs` by default and
4//! `@node-rs/bcrypt` when it can be required.
5//!
6//! **The cost factor and the output format are interop contract, not implementation detail.** A
7//! `_User` row written by parse-rust must be loginable by parse-server and the reverse, on the
8//! same database. That is a mixed-fleet requirement, and it is the kind of thing that looks fine
9//! until a second server exists. `tests/bcrypt_interop.rs` checks both directions against Node.
10
11use parse_rust_core::{ErrorCode, ParseError};
12
13/// Upstream's cost factor (`password.js`, `bcrypt.hash(password, 10)`).
14///
15/// Not raised. A higher cost would be better practice and would produce hashes parse-server can
16/// still verify, but it would change login latency in a way an operator did not ask for, and the
17/// benchmark story would then be comparing different work. Revisit deliberately, not silently.
18pub const BCRYPT_COST: u32 = 10;
19
20/// Hash a password for storage in `_User._hashed_password`.
21pub fn hash(password: &str) -> Result<String, ParseError> {
22    bcrypt::hash(password, BCRYPT_COST).map_err(|e| {
23        // Never include the password or the error's inner detail in a client-visible message.
24        ParseError::new(
25            ErrorCode::InternalServerError,
26            format!("password hashing failed: {}", kind_of(&e)),
27        )
28    })
29}
30
31/// Verify a password against a stored hash.
32///
33/// Returns `false` rather than an error for a malformed or empty hash, matching upstream:
34/// `compare` resolves `false` when either side is falsy rather than throwing
35/// (`password.js:24-29`). A stored hash that cannot be parsed is a failed login, not a 500.
36pub fn verify(password: &str, hashed: &str) -> bool {
37    if password.is_empty() || hashed.is_empty() {
38        return false;
39    }
40    bcrypt::verify(password, hashed).unwrap_or(false)
41}
42
43fn kind_of(e: &bcrypt::BcryptError) -> &'static str {
44    match e {
45        bcrypt::BcryptError::CostNotAllowed(_) => "cost not allowed",
46        bcrypt::BcryptError::InvalidHash(_) => "invalid hash",
47        _ => "internal",
48    }
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn round_trips() {
57        let h = hash("hunter2").expect("hash");
58        assert!(verify("hunter2", &h));
59        assert!(!verify("hunter3", &h));
60    }
61
62    #[test]
63    fn uses_upstreams_cost_factor() {
64        let h = hash("x").expect("hash");
65        // bcrypt encodes the cost in the third field: $2b$10$...
66        let cost = h.split('$').nth(2).expect("cost field");
67        assert_eq!(
68            cost, "10",
69            "cost must match upstream's bcrypt.hash(password, 10)"
70        );
71    }
72
73    #[test]
74    fn empty_inputs_are_a_failed_login_not_an_error() {
75        let h = hash("x").expect("hash");
76        assert!(!verify("", &h));
77        assert!(!verify("x", ""));
78    }
79
80    #[test]
81    fn a_corrupt_stored_hash_fails_login_rather_than_panicking() {
82        // A row written by something else, or truncated in transit. Must not take down a worker.
83        assert!(!verify("x", "not-a-bcrypt-hash"));
84        assert!(!verify("x", "$2b$10$tooshort"));
85    }
86}