1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
//! Password hashing.
//!
//! Upstream is `src/password.js`: `bcrypt.hash(password, 10)`, using `bcryptjs` by default and
//! `@node-rs/bcrypt` when it can be required.
//!
//! **The cost factor and the output format are interop contract, not implementation detail.** A
//! `_User` row written by parse-rust must be loginable by parse-server and the reverse, on the
//! same database. That is a mixed-fleet requirement, and it is the kind of thing that looks fine
//! until a second server exists. `tests/bcrypt_interop.rs` checks both directions against Node.
//!
//! **Both functions are async and hash on the blocking pool, and that is not a style choice.**
//! bcrypt at cost 10 is tens of milliseconds of pure CPU with no await points in it. Run inline on
//! a tokio worker it parks that thread outright, and both entry points are reachable without
//! credentials: `POST /users` hashes on every signup and `POST /login` verifies for any known
//! username. As many concurrent requests as there are workers therefore stops the runtime polling
//! anything at all, including `/health`, and a single `/batch` of signups is one request that does
//! it. `spawn_blocking` puts the work on a bounded pool instead, which turns that into ordinary
//! queueing. Upstream has the property for free, because its bcrypt binding hands off to libuv's
//! threadpool rather than running on the event loop.
//!
//! This does not remove the need for login rate limiting. It removes the case where one caller
//! takes the process down without needing volume.
use ParseError;
/// Upstream's cost factor (`password.js`, `bcrypt.hash(password, 10)`).
///
/// Not raised. A higher cost would be better practice and would produce hashes parse-server can
/// still verify, but it would change login latency in a way an operator did not ask for, and the
/// benchmark story would then be comparing different work. Revisit deliberately, not silently.
pub const BCRYPT_COST: u32 = 10;
/// Hash a password for storage in `_User._hashed_password`.
///
/// Takes an owned `String` because the work moves to another thread. The caller already owns one
/// at both call sites.
pub async
/// Verify a password against a stored hash.
///
/// Returns `false` rather than an error for a malformed or empty hash, matching upstream:
/// `compare` resolves `false` when either side is falsy rather than throwing
/// (`password.js:24-29`). A stored hash that cannot be parsed is a failed login, not a 500.
///
/// A panic or a shutdown in the blocking pool also reads as `false`. A failed login is the
/// fail-closed answer, and it is the same answer this returns for every other way the comparison
/// cannot be completed.
pub async
/// Upstream's fixed dummy hash, for timing normalization (`password.js:33`).
///
/// **The value is irrelevant and the cost is the point.** A login that fails before reaching bcrypt
/// returns in microseconds while one that reaches it pays the full cost factor, and that difference
/// is measurable over the network. It answers "does this account exist" without any response body
/// saying so, which is exactly what the single shared `Invalid username/password.` message exists
/// to prevent. The message alone does not close the oracle; this does.
///
/// Cost factor 10, matching upstream's, because a dummy compare cheaper than the real one leaks the
/// difference just as well.
pub const DUMMY_HASH: &str = "$2b$10$Wd1gvrMYPnQv5pHBbXCwCehxXmJSEzRqNON0ev98L6JJP5296S35i";
/// Pay the bcrypt cost without having a hash to check, and discard the answer.
///
/// Called on every login path that fails before a real comparison: no such user, and a user with no
/// usable stored hash. Both are `false` regardless, so the result is deliberately dropped.
///
/// An empty password still short-circuits, because [`verify`] short-circuits and upstream's
/// `compare` does the same on a falsy input (`password.js:24-29`). The two branches stay
/// indistinguishable from each other, which is what matters.
pub async
/// How many bcrypt calls may run at once.
///
/// **`spawn_blocking` alone is not a bound.** Tokio's blocking pool defaults to 512 threads, so
/// moving the work off the async workers stops it starving the reactor and does nothing to stop
/// hundreds of cost-10 hashes running in parallel. An anonymous flood of signups is exactly that
/// shape: bcrypt is deliberately expensive, and an unbounded number of them is a CPU exhaustion
/// primitive that needs no credentials.
///
/// Sized to the machine, with a floor of one, because bcrypt is CPU-bound and more concurrent
/// hashes than cores makes every one of them slower without completing any sooner. Work over the
/// limit queues on the semaphore rather than being refused: a queued login is slow, a refused one
/// is an outage, and the queue is what makes the cost bounded rather than the client count.
static BCRYPT_PERMITS: LazyLock =
new;
/// Run one bcrypt call on the blocking pool, under the concurrency bound.
///
/// The outer `Result` is the join result. It fails only if the task panicked or the runtime is
/// shutting down, neither of which is a client's doing, so it renders as the generic 500 rather
/// than naming bcrypt on the wire.
///
/// The permit is acquired before the task is spawned and held until it finishes, so the bound is
/// on bcrypt calls in flight rather than on tasks queued. `acquire` fails only if the semaphore is
/// closed, which nothing does.
async