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}