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//!
11//! **Both functions are async and hash on the blocking pool, and that is not a style choice.**
12//! bcrypt at cost 10 is tens of milliseconds of pure CPU with no await points in it. Run inline on
13//! a tokio worker it parks that thread outright, and both entry points are reachable without
14//! credentials: `POST /users` hashes on every signup and `POST /login` verifies for any known
15//! username. As many concurrent requests as there are workers therefore stops the runtime polling
16//! anything at all, including `/health`, and a single `/batch` of signups is one request that does
17//! it. `spawn_blocking` puts the work on a bounded pool instead, which turns that into ordinary
18//! queueing. Upstream has the property for free, because its bcrypt binding hands off to libuv's
19//! threadpool rather than running on the event loop.
20//!
21//! This does not remove the need for login rate limiting. It removes the case where one caller
22//! takes the process down without needing volume.
23
24use parse_rust_core::ParseError;
25
26/// Upstream's cost factor (`password.js`, `bcrypt.hash(password, 10)`).
27///
28/// Not raised. A higher cost would be better practice and would produce hashes parse-server can
29/// still verify, but it would change login latency in a way an operator did not ask for, and the
30/// benchmark story would then be comparing different work. Revisit deliberately, not silently.
31pub const BCRYPT_COST: u32 = 10;
32
33/// Hash a password for storage in `_User._hashed_password`.
34///
35/// Takes an owned `String` because the work moves to another thread. The caller already owns one
36/// at both call sites.
37pub async fn hash(password: String) -> Result<String, ParseError> {
38    run_blocking(move || bcrypt::hash(&password, BCRYPT_COST))
39        .await?
40        .map_err(|e| {
41            // `ParseError::internal` keeps this off the wire entirely: upstream's bcrypt failure
42            // is a rejected promise carrying a plain `Error`, so a client gets the generic 500 and
43            // this text reaches the log. `kind_of` is still narrow, because a log line must not
44            // carry the password either.
45            ParseError::internal(format!("password hashing failed: {}", kind_of(&e)))
46        })
47}
48
49/// Verify a password against a stored hash.
50///
51/// Returns `false` rather than an error for a malformed or empty hash, matching upstream:
52/// `compare` resolves `false` when either side is falsy rather than throwing
53/// (`password.js:24-29`). A stored hash that cannot be parsed is a failed login, not a 500.
54///
55/// A panic or a shutdown in the blocking pool also reads as `false`. A failed login is the
56/// fail-closed answer, and it is the same answer this returns for every other way the comparison
57/// cannot be completed.
58pub async fn verify(password: String, hashed: String) -> bool {
59    if password.is_empty() || hashed.is_empty() {
60        return false;
61    }
62    matches!(
63        run_blocking(move || bcrypt::verify(&password, &hashed)).await,
64        Ok(Ok(true))
65    )
66}
67
68/// Upstream's fixed dummy hash, for timing normalization (`password.js:33`).
69///
70/// **The value is irrelevant and the cost is the point.** A login that fails before reaching bcrypt
71/// returns in microseconds while one that reaches it pays the full cost factor, and that difference
72/// is measurable over the network. It answers "does this account exist" without any response body
73/// saying so, which is exactly what the single shared `Invalid username/password.` message exists
74/// to prevent. The message alone does not close the oracle; this does.
75///
76/// Cost factor 10, matching upstream's, because a dummy compare cheaper than the real one leaks the
77/// difference just as well.
78pub const DUMMY_HASH: &str = "$2b$10$Wd1gvrMYPnQv5pHBbXCwCehxXmJSEzRqNON0ev98L6JJP5296S35i";
79
80/// Pay the bcrypt cost without having a hash to check, and discard the answer.
81///
82/// Called on every login path that fails before a real comparison: no such user, and a user with no
83/// usable stored hash. Both are `false` regardless, so the result is deliberately dropped.
84///
85/// An empty password still short-circuits, because [`verify`] short-circuits and upstream's
86/// `compare` does the same on a falsy input (`password.js:24-29`). The two branches stay
87/// indistinguishable from each other, which is what matters.
88pub async fn verify_dummy(password: String) {
89    let _ = verify(password, DUMMY_HASH.to_string()).await;
90}
91
92/// How many bcrypt calls may run at once.
93///
94/// **`spawn_blocking` alone is not a bound.** Tokio's blocking pool defaults to 512 threads, so
95/// moving the work off the async workers stops it starving the reactor and does nothing to stop
96/// hundreds of cost-10 hashes running in parallel. An anonymous flood of signups is exactly that
97/// shape: bcrypt is deliberately expensive, and an unbounded number of them is a CPU exhaustion
98/// primitive that needs no credentials.
99///
100/// Sized to the machine, with a floor of one, because bcrypt is CPU-bound and more concurrent
101/// hashes than cores makes every one of them slower without completing any sooner. Work over the
102/// limit queues on the semaphore rather than being refused: a queued login is slow, a refused one
103/// is an outage, and the queue is what makes the cost bounded rather than the client count.
104static BCRYPT_PERMITS: std::sync::LazyLock<std::sync::Arc<tokio::sync::Semaphore>> =
105    std::sync::LazyLock::new(|| {
106        let cores = std::thread::available_parallelism()
107            .map(std::num::NonZeroUsize::get)
108            .unwrap_or(1);
109        std::sync::Arc::new(tokio::sync::Semaphore::new(cores.max(1)))
110    });
111
112/// Run one bcrypt call on the blocking pool, under the concurrency bound.
113///
114/// The outer `Result` is the join result. It fails only if the task panicked or the runtime is
115/// shutting down, neither of which is a client's doing, so it renders as the generic 500 rather
116/// than naming bcrypt on the wire.
117///
118/// The permit is acquired before the task is spawned and held until it finishes, so the bound is
119/// on bcrypt calls in flight rather than on tasks queued. `acquire` fails only if the semaphore is
120/// closed, which nothing does.
121async fn run_blocking<T, F>(f: F) -> Result<T, ParseError>
122where
123    F: FnOnce() -> T + Send + 'static,
124    T: Send + 'static,
125{
126    // **The permit moves into the blocking closure, and that placement is the whole bound.** Held
127    // by this future instead, it is released the moment the future is dropped, which is what a
128    // client disconnecting mid-request does. The `spawn_blocking` task is *not* cancelled by that:
129    // bcrypt keeps running to completion on the pool with its permit already returned. An
130    // anonymous caller who connects and disconnects in a loop then accumulates as many concurrent
131    // hashes as they like, which is the exact exhaustion the semaphore was added to prevent, with
132    // the semaphore in place and reporting itself satisfied.
133    //
134    // Owned by the closure, the permit is released when bcrypt returns, so the bound is on work in
135    // flight rather than on callers still waiting for it.
136    let permit = std::sync::Arc::clone(&BCRYPT_PERMITS)
137        .acquire_owned()
138        .await
139        .map_err(|_| ParseError::internal("password hashing is unavailable"))?;
140    tokio::task::spawn_blocking(move || {
141        let _permit = permit;
142        f()
143    })
144    .await
145    .map_err(|_| ParseError::internal("password hashing task did not complete"))
146}
147
148fn kind_of(e: &bcrypt::BcryptError) -> &'static str {
149    match e {
150        bcrypt::BcryptError::CostNotAllowed(_) => "cost not allowed",
151        bcrypt::BcryptError::InvalidHash(_) => "invalid hash",
152        _ => "internal",
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[tokio::test]
161    async fn round_trips() {
162        let h = hash("hunter2".into()).await.expect("hash");
163        assert!(verify("hunter2".into(), h.clone()).await);
164        assert!(!verify("hunter3".into(), h).await);
165    }
166
167    #[tokio::test]
168    async fn uses_upstreams_cost_factor() {
169        let h = hash("x".into()).await.expect("hash");
170        // bcrypt encodes the cost in the third field: $2b$10$...
171        let cost = h.split('$').nth(2).expect("cost field");
172        assert_eq!(
173            cost, "10",
174            "cost must match upstream's bcrypt.hash(password, 10)"
175        );
176    }
177
178    #[tokio::test]
179    async fn empty_inputs_are_a_failed_login_not_an_error() {
180        let h = hash("x".into()).await.expect("hash");
181        assert!(!verify("".into(), h).await);
182        assert!(!verify("x".into(), "".into()).await);
183    }
184
185    #[tokio::test]
186    async fn a_corrupt_stored_hash_fails_login_rather_than_panicking() {
187        // A row written by something else, or truncated in transit. Must not take down a worker.
188        assert!(!verify("x".into(), "not-a-bcrypt-hash".into()).await);
189        assert!(!verify("x".into(), "$2b$10$tooshort".into()).await);
190    }
191
192    /// The reason both functions are async: hashing must not park the worker it runs on.
193    ///
194    /// **This needs a second task to be a test at all.** An earlier version hashed on a
195    /// `current_thread` runtime and asserted only the result, which passes identically with
196    /// `spawn_blocking` removed: with nothing else waiting to run, a parked worker is
197    /// unobservable. One worker thread plus a competing task is the smallest arrangement that
198    /// tells the two implementations apart.
199    ///
200    /// With `spawn_blocking`, `hash` yields at its first poll and the spawned task reaches the
201    /// flag while bcrypt runs on the blocking pool. Run inline, the first poll would carry bcrypt
202    /// to completion and the flag would still be false.
203    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
204    async fn hashing_yields_the_worker_instead_of_parking_it() {
205        use std::sync::atomic::{AtomicBool, Ordering};
206        use std::sync::Arc;
207
208        let ran = Arc::new(AtomicBool::new(false));
209        let flag = Arc::clone(&ran);
210        tokio::spawn(async move { flag.store(true, Ordering::SeqCst) });
211
212        let h = hash("hunter2".into()).await.expect("hash");
213        assert!(
214            ran.load(Ordering::SeqCst),
215            "another task must be able to run while a password is being hashed"
216        );
217        assert!(verify("hunter2".into(), h).await);
218    }
219}