acme_proxy/admin/password.rs
1//! Password hashing for the web admin's operators.
2//!
3//! PBKDF2-HMAC-SHA256 over `ring::pbkdf2`, which is already this crate's crypto
4//! backend everywhere else. Deliberately *not* Argon2id, which is the stronger
5//! primitive: it would add four crates (`argon2`, `password-hash`, `base64ct`,
6//! `blake2`) to a certificate authority's dependency graph -- all of them
7//! audited on every `cargo deny check`, since `deny.toml` runs with
8//! `all-features = true` -- for a subsystem that is `enabled = false` by
9//! default and whose password is the *bootstrap* credential in a design that
10//! ends in a second factor. PBKDF2-HMAC-SHA256 at 600 000 iterations is
11//! OWASP's current recommendation for the non-Argon2 case.
12//!
13//! The stored form is self-describing, so that trade can be revisited without
14//! a migration: raising the iteration count, or swapping the algorithm
15//! outright, needs a new branch in [`verify_password`] and nothing else --
16//! [`needs_rehash`] then re-encodes each row on its owner's next successful
17//! login.
18//!
19//! ```text
20//! pbkdf2-sha256$600000$<salt-b64url>$<hash-b64url>
21//! ```
22//!
23//! This module holds no database access and no I/O: it is shared by the CLI
24//! (`admin user create`/`passwd`) and the web login path, which is why it lives
25//! under `admin::` beside the other logic both front ends use rather than
26//! inside `webadmin::`.
27
28use std::num::NonZeroU32;
29use std::sync::LazyLock;
30
31use base64::Engine as _;
32use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_SAFE_NO_PAD;
33use ring::pbkdf2;
34use ring::rand::{SecureRandom, SystemRandom};
35
36/// The only algorithm this version writes. `verify_password` matches on it, so
37/// adding a second is additive.
38const ALGORITHM: &str = "pbkdf2-sha256";
39
40/// OWASP's current recommendation for PBKDF2-HMAC-SHA256.
41///
42/// Measured at ~85 ms per verification in a release build on a 2020s desktop
43/// core (and ~1.3 s in a debug build, which is why the tests below mostly do
44/// not use it). That is the login latency, and it is a small denial-of-service
45/// lever -- which is why `webadmin::session` rate-limits login *before* it
46/// reaches here rather than after.
47const ITERATIONS: u32 = 600_000;
48
49/// 128 bits. Salts are per-row and public; their only job is to make one
50/// precomputed table useless against every row at once.
51const SALT_LEN: usize = 16;
52
53/// 256 bits, matching the underlying PRF's output.
54const HASH_LEN: usize = 32;
55
56/// Shortest password accepted. Length is the only rule -- composition rules
57/// ("one digit, one symbol") measurably push people towards weaker, more
58/// guessable passwords, and this is an operator-facing surface with a handful
59/// of accounts, not a consumer signup.
60pub const MIN_PASSWORD_LEN: usize = 12;
61
62/// Longest password accepted. A DoS control, not a security one: without it a
63/// login request could hand 600 000 iterations a multi-megabyte input.
64pub const MAX_PASSWORD_LEN: usize = 1024;
65
66/// A stored hash that could not be read back.
67///
68/// Every variant means the `admin_users` row is corrupt, never that the
69/// password was wrong -- callers must not fold this into "authentication
70/// failed", or a mangled row would read as a bad password forever.
71#[derive(Debug, PartialEq, Eq, thiserror::Error)]
72pub enum PasswordError {
73 /// Not the four `$`-separated fields the format defines.
74 #[error("stored password hash is not in the expected format")]
75 Malformed,
76 /// A prefix this build does not implement.
77 #[error("unknown password hash algorithm `{0}`")]
78 UnknownAlgorithm(String),
79 /// The iteration field was not a positive integer.
80 #[error("stored password hash has an invalid iteration count")]
81 BadIterations,
82 /// Salt or hash was not valid unpadded base64url, or was the wrong length.
83 #[error("stored password hash has an invalid salt or digest")]
84 BadEncoding,
85}
86
87/// Rejects a password before it is ever hashed.
88///
89/// Returns the operator-facing message, so the CLI and the API report the same
90/// words. Runs on `create`/`passwd`, never on login -- an existing password
91/// that predates a rule change must still work.
92pub fn check_password_policy(password: &str) -> Result<(), String> {
93 // Characters, not bytes: a 12-character passphrase in a non-Latin script
94 // would otherwise be measured as comfortably long by accident.
95 let length = password.chars().count();
96 if length < MIN_PASSWORD_LEN {
97 return Err(format!(
98 "password must be at least {MIN_PASSWORD_LEN} characters (got {length})"
99 ));
100 }
101 if password.len() > MAX_PASSWORD_LEN {
102 return Err(format!(
103 "password must be at most {MAX_PASSWORD_LEN} bytes (got {})",
104 password.len()
105 ));
106 }
107 Ok(())
108}
109
110/// Hashes `password` under the current parameters, returning the encoded form
111/// to store.
112///
113/// Does **not** check the policy: callers that accept a new password call
114/// [`check_password_policy`] first, and the login path's rehash must be able to
115/// re-encode a password that predates the current rules.
116#[must_use]
117pub fn hash_password(password: &str) -> String {
118 hash_with_iterations(password, ITERATIONS)
119}
120
121/// Hashes a **high-entropy generated secret**, at a cost matched to the fact
122/// that it is one.
123///
124/// [`ITERATIONS`] exists to slow a dictionary down. A recovery code
125/// ([`crate::admin::recovery`]) has no dictionary: it is CSPRNG output from a
126/// 32-symbol alphabet, so the cheapest attack on the stored form is a
127/// brute-force over its own keyspace, which [`RECOVERY_ITERATIONS`] widens by
128/// another ~13 bits on top.
129///
130/// The reason not to spend more is specific, and worth stating so it is not
131/// "hardened" later by reflex: **the attacker this would defend against already
132/// has a better route.** Recovery codes only matter to somebody holding the
133/// database file, and that same file holds `admin_users.totp_secret` in the
134/// clear -- it must, since verifying a code means recomputing the HMAC. Paying
135/// 600 000 iterations ten times per enrolment buys nothing against a reader who
136/// can simply take the factor itself.
137///
138/// The stored form is self-describing, so the two costs coexist with no
139/// migration and no second format: [`verify_password`] reads the count back out
140/// of the string. Do **not** run [`needs_rehash`] against one of these -- it
141/// compares against [`ITERATIONS`] and would report every recovery code as
142/// stale forever.
143#[must_use]
144pub fn hash_generated_secret(secret: &str) -> String {
145 hash_with_iterations(secret, RECOVERY_ITERATIONS)
146}
147
148/// The cost [`hash_generated_secret`] uses. Named so the reasoning above has
149/// something to point at.
150pub const RECOVERY_ITERATIONS: u32 = 10_000;
151
152/// [`hash_password`] with the cost as a parameter.
153///
154/// Exists so the tests can exercise this exact path -- the salt generation and
155/// the encoding, which is where the bugs would be -- without paying 600 000
156/// iterations a dozen times over. Private: nothing outside this module gets to
157/// choose a cost, only to pick one of the two named above.
158fn hash_with_iterations(password: &str, iterations: u32) -> String {
159 let mut salt = [0u8; SALT_LEN];
160 // Same trade-off as `sqlite::eab::generate_secret` and
161 // `authz::generate_token`: an unavailable system RNG is unrecoverable, and
162 // threading the error out would only move the panic.
163 SystemRandom::new()
164 .fill(&mut salt)
165 .expect("system RNG unavailable");
166
167 encode(&salt, &derive(password, &salt, iterations), iterations)
168}
169
170/// Verifies `password` against a stored hash, in constant time
171/// (`ring::pbkdf2::verify` compares that way).
172///
173/// `Ok(false)` is a wrong password; `Err` is a corrupt row. Keeping them apart
174/// is the point -- see [`PasswordError`].
175pub fn verify_password(stored: &str, password: &str) -> Result<bool, PasswordError> {
176 let (iterations, salt, expected) = decode(stored)?;
177 Ok(pbkdf2::verify(
178 pbkdf2::PBKDF2_HMAC_SHA256,
179 iterations,
180 &salt,
181 password.as_bytes(),
182 &expected,
183 )
184 .is_ok())
185}
186
187/// Whether `stored` was written under parameters this build has since moved
188/// past -- a different algorithm, or a lower iteration count.
189///
190/// A row that cannot be decoded reports `true`: it is already unusable, and
191/// re-encoding it on the next successful login is the only way it ever gets
192/// fixed. (`verify_password` will have returned `Err` for the same row, so
193/// this is reached only where a caller chose to carry on regardless.)
194#[must_use]
195pub fn needs_rehash(stored: &str) -> bool {
196 match decode(stored) {
197 Ok((iterations, _, _)) => iterations.get() < ITERATIONS,
198 Err(_) => true,
199 }
200}
201
202/// A stored hash no password matches: given to the login path to verify against
203/// when the username does not exist, so an unknown user costs the same one
204/// derivation as a known one.
205///
206/// Without it, login latency enumerates the user table -- a fast rejection
207/// means "no such user", a slow one means "wrong password".
208///
209/// **Encoded, never derived, and that is the whole point.** Calling
210/// [`hash_password`] here costs a full [`ITERATIONS`]-round `pbkdf2::derive`
211/// that the caller's [`verify_password`] then pays *again*, making the unknown
212/// branch twice the known one -- the enumeration oracle inverted rather than
213/// closed, and pointing the expensive direction at the branch an unauthenticated
214/// caller picks. The digest is never matched against anything, so it only has to
215/// be well-formed and carry the current cost; the bytes being zero is not a
216/// weakness, since the value is in the binary either way and `pbkdf2::verify`
217/// costs the same for any salt. [`encode`] is this module's own writer, so the
218/// shape cannot drift from what [`decode`] expects, and reading [`ITERATIONS`]
219/// here means the cost tracks a change to it rather than needing a second
220/// spelling.
221static DUMMY_HASH: LazyLock<String> =
222 LazyLock::new(|| encode(&[0u8; SALT_LEN], &[0u8; HASH_LEN], ITERATIONS));
223
224#[must_use]
225pub fn dummy_hash() -> &'static str {
226 &DUMMY_HASH
227}
228
229fn derive(password: &str, salt: &[u8], iterations: u32) -> [u8; HASH_LEN] {
230 let mut out = [0u8; HASH_LEN];
231 pbkdf2::derive(
232 pbkdf2::PBKDF2_HMAC_SHA256,
233 nonzero(iterations),
234 salt,
235 password.as_bytes(),
236 &mut out,
237 );
238 out
239}
240
241/// `iterations` is a compile-time constant everywhere it matters, and `decode`
242/// has already refused a zero, so this cannot fail in practice -- but a
243/// silently-clamped iteration count would be a real weakening, so clamp
244/// upwards rather than downwards.
245fn nonzero(iterations: u32) -> NonZeroU32 {
246 NonZeroU32::new(iterations).unwrap_or(NonZeroU32::MIN)
247}
248
249fn encode(salt: &[u8], hash: &[u8], iterations: u32) -> String {
250 format!(
251 "{ALGORITHM}${iterations}${}${}",
252 BASE64_URL_SAFE_NO_PAD.encode(salt),
253 BASE64_URL_SAFE_NO_PAD.encode(hash),
254 )
255}
256
257fn decode(stored: &str) -> Result<(NonZeroU32, Vec<u8>, Vec<u8>), PasswordError> {
258 let mut fields = stored.split('$');
259 let (Some(algorithm), Some(iterations), Some(salt), Some(hash), None) = (
260 fields.next(),
261 fields.next(),
262 fields.next(),
263 fields.next(),
264 fields.next(),
265 ) else {
266 return Err(PasswordError::Malformed);
267 };
268
269 if algorithm != ALGORITHM {
270 return Err(PasswordError::UnknownAlgorithm(algorithm.to_string()));
271 }
272
273 let iterations = iterations
274 .parse::<u32>()
275 .ok()
276 .and_then(NonZeroU32::new)
277 .ok_or(PasswordError::BadIterations)?;
278
279 let salt = BASE64_URL_SAFE_NO_PAD
280 .decode(salt)
281 .map_err(|_| PasswordError::BadEncoding)?;
282 let hash = BASE64_URL_SAFE_NO_PAD
283 .decode(hash)
284 .map_err(|_| PasswordError::BadEncoding)?;
285
286 // A truncated digest would otherwise verify against a truncated
287 // derivation, which is a weaker hash accepted silently.
288 if salt.len() != SALT_LEN || hash.len() != HASH_LEN {
289 return Err(PasswordError::BadEncoding);
290 }
291
292 Ok((iterations, salt, hash))
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 /// The real cost parameters run ~250 ms in release and ~1.6 s in a debug
300 /// build, which is the point in production and far too slow for a suite
301 /// that wants a dozen of them. Only the two tests that assert on the real
302 /// constants pay it; everything else goes through here, which is the same
303 /// code path at a cost the tests can afford.
304 const TEST_ITERATIONS: u32 = 1_000;
305
306 fn cheap_hash(password: &str) -> String {
307 hash_with_iterations(password, TEST_ITERATIONS)
308 }
309
310 /// An encoded hash at an arbitrary cost, with a digest that was never
311 /// derived. For [`needs_rehash`], which only ever decodes -- deriving one
312 /// at `ITERATIONS` purely to read its header back would be the slowest
313 /// possible way to parse a string.
314 fn stored_at(iterations: u32) -> String {
315 encode(&[7u8; SALT_LEN], &[0u8; HASH_LEN], iterations)
316 }
317
318 #[test]
319 fn hash_then_verify_round_trips() {
320 let stored = cheap_hash("correct horse battery");
321 assert_eq!(verify_password(&stored, "correct horse battery"), Ok(true));
322 }
323
324 #[test]
325 fn a_wrong_password_is_false_and_not_an_error() {
326 let stored = cheap_hash("correct horse battery");
327 assert_eq!(verify_password(&stored, "wrong"), Ok(false));
328 assert_eq!(verify_password(&stored, ""), Ok(false));
329 }
330
331 #[test]
332 fn two_hashes_of_one_password_differ_by_salt() {
333 let first = cheap_hash("a-long-enough-password");
334 let second = cheap_hash("a-long-enough-password");
335 assert_ne!(first, second, "each hash must carry its own random salt");
336 // Specifically the salt field, not just the string as a whole -- a
337 // constant salt with a differing digest would be a much stranger bug
338 // and this pins which one is being ruled out.
339 assert_ne!(
340 first.split('$').nth(2).unwrap(),
341 second.split('$').nth(2).unwrap()
342 );
343 assert_eq!(verify_password(&first, "a-long-enough-password"), Ok(true));
344 assert_eq!(verify_password(&second, "a-long-enough-password"), Ok(true));
345 }
346
347 #[test]
348 fn the_encoded_form_is_self_describing() {
349 let stored = hash_password("a-long-enough-password");
350 let fields: Vec<&str> = stored.split('$').collect();
351 assert_eq!(fields.len(), 4);
352 assert_eq!(fields[0], "pbkdf2-sha256");
353 assert_eq!(fields[1], ITERATIONS.to_string());
354 assert_eq!(
355 BASE64_URL_SAFE_NO_PAD.decode(fields[2]).unwrap().len(),
356 SALT_LEN
357 );
358 assert_eq!(
359 BASE64_URL_SAFE_NO_PAD.decode(fields[3]).unwrap().len(),
360 HASH_LEN
361 );
362 // No padding and no `+`/`/`: the value travels in JSON and, later, in
363 // a template.
364 assert!(!stored.contains('='));
365 assert!(!stored.contains('+'));
366 }
367
368 #[test]
369 fn every_decode_failure_is_its_own_variant() {
370 let good = cheap_hash("pw");
371 let salt = good.split('$').nth(2).unwrap().to_string();
372 let hash = good.split('$').nth(3).unwrap().to_string();
373
374 let cases: Vec<(&str, String, PasswordError)> = vec![
375 ("empty", String::new(), PasswordError::Malformed),
376 (
377 "too few fields",
378 format!("pbkdf2-sha256$1000${salt}"),
379 PasswordError::Malformed,
380 ),
381 (
382 "too many fields",
383 format!("pbkdf2-sha256$1000${salt}${hash}$extra"),
384 PasswordError::Malformed,
385 ),
386 (
387 "unknown algorithm",
388 format!("argon2id$1000${salt}${hash}"),
389 PasswordError::UnknownAlgorithm("argon2id".to_string()),
390 ),
391 (
392 "non-numeric iterations",
393 format!("pbkdf2-sha256$many${salt}${hash}"),
394 PasswordError::BadIterations,
395 ),
396 (
397 "zero iterations",
398 format!("pbkdf2-sha256$0${salt}${hash}"),
399 PasswordError::BadIterations,
400 ),
401 (
402 "salt is not base64url",
403 format!("pbkdf2-sha256$1000$not base64${hash}"),
404 PasswordError::BadEncoding,
405 ),
406 (
407 "digest is not base64url",
408 format!("pbkdf2-sha256$1000${salt}$not base64"),
409 PasswordError::BadEncoding,
410 ),
411 (
412 "short salt",
413 format!(
414 "pbkdf2-sha256$1000${}${hash}",
415 BASE64_URL_SAFE_NO_PAD.encode([1u8; 4])
416 ),
417 PasswordError::BadEncoding,
418 ),
419 (
420 "truncated digest",
421 format!(
422 "pbkdf2-sha256$1000${salt}${}",
423 BASE64_URL_SAFE_NO_PAD.encode([1u8; 8])
424 ),
425 PasswordError::BadEncoding,
426 ),
427 ];
428
429 for (name, stored, expected) in cases {
430 assert_eq!(
431 verify_password(&stored, "pw"),
432 Err(expected),
433 "case `{name}` decoded differently than expected"
434 );
435 }
436 }
437
438 #[test]
439 fn every_error_renders() {
440 let rendered: Vec<String> = [
441 PasswordError::Malformed,
442 PasswordError::UnknownAlgorithm("scrypt".to_string()),
443 PasswordError::BadIterations,
444 PasswordError::BadEncoding,
445 ]
446 .iter()
447 .map(ToString::to_string)
448 .collect();
449
450 assert!(rendered.iter().all(|line| !line.is_empty()));
451 assert!(rendered[1].contains("scrypt"));
452 }
453
454 /// The two costs have to coexist in one format, since recovery codes and
455 /// passwords both live in `<algo>$<iters>$…` columns and one `verify` reads
456 /// both.
457 #[test]
458 fn a_generated_secret_hashes_cheaper_and_still_verifies() {
459 let stored = hash_generated_secret("K7QF23BXTM");
460 assert_eq!(stored.split('$').nth(1), Some("10000"));
461 assert_eq!(verify_password(&stored, "K7QF23BXTM"), Ok(true));
462 assert_eq!(verify_password(&stored, "K7QF23BXTN"), Ok(false));
463
464 // The trap this documents: `needs_rehash` compares against the
465 // *password* cost, so it reports true for every recovery code. Nothing
466 // may call it on one.
467 assert!(needs_rehash(&stored));
468 }
469
470 #[test]
471 fn needs_rehash_tracks_the_current_parameters() {
472 assert!(!needs_rehash(&stored_at(ITERATIONS)));
473 assert!(needs_rehash(&stored_at(ITERATIONS - 1)));
474 assert!(needs_rehash(&stored_at(TEST_ITERATIONS)));
475 // Already stronger than this build asks for: leave it alone rather
476 // than re-encoding it weaker.
477 assert!(!needs_rehash(&stored_at(ITERATIONS + 1)));
478 // A row that cannot be read is due a rewrite by definition.
479 assert!(needs_rehash("nonsense"));
480 assert!(needs_rehash(""));
481 assert!(needs_rehash("argon2id$1$c2FsdA$aGFzaA"));
482 }
483
484 #[test]
485 fn the_policy_enforces_length_and_nothing_else() {
486 assert!(check_password_policy("a-long-enough-password").is_ok());
487 // Exactly at the boundary, both ends.
488 assert!(check_password_policy(&"x".repeat(MIN_PASSWORD_LEN)).is_ok());
489 assert!(check_password_policy(&"x".repeat(MAX_PASSWORD_LEN)).is_ok());
490
491 let too_short = check_password_policy(&"x".repeat(MIN_PASSWORD_LEN - 1)).unwrap_err();
492 assert!(too_short.contains("at least 12"), "got: {too_short}");
493 let too_long = check_password_policy(&"x".repeat(MAX_PASSWORD_LEN + 1)).unwrap_err();
494 assert!(too_long.contains("at most 1024"), "got: {too_long}");
495
496 // No composition rules: a long run of one character is accepted, and
497 // a short but "complex" one is not.
498 assert!(check_password_policy("aaaaaaaaaaaaaaaa").is_ok());
499 assert!(check_password_policy("Aa1!Aa1!").is_err());
500 }
501
502 #[test]
503 fn the_policy_counts_characters_not_bytes() {
504 // 12 characters, 36 bytes in UTF-8. Measured as bytes this passes for
505 // the wrong reason; measured as characters it passes for the right one.
506 let passphrase = "日本語日本語日本語日本語";
507 assert_eq!(passphrase.chars().count(), 12);
508 assert!(passphrase.len() > MIN_PASSWORD_LEN);
509 assert!(check_password_policy(passphrase).is_ok());
510
511 // 11 characters is short whatever its byte length.
512 assert!(check_password_policy("日本語日本語日本語日本").is_err());
513 }
514
515 /// The dummy must cost the login path exactly **one** derivation -- the same
516 /// as a known username -- and carry the current cost while doing it.
517 ///
518 /// No longer one of the tests that pays the real cost: the assertions below
519 /// are string comparisons, because `dummy_hash` no longer derives anything.
520 #[test]
521 fn the_dummy_hash_is_precomputed_and_matches_no_password() {
522 // The one that catches a `hash_password`-based dummy: that spelling
523 // salts randomly, so it returns a different string every call *and*
524 // makes the unknown-username branch pay a derivation the caller's
525 // `verify_password` then pays again -- twice a known username, i.e. the
526 // enumeration oracle inverted rather than closed.
527 assert_eq!(
528 dummy_hash(),
529 dummy_hash(),
530 "a dummy computed per call costs the unknown-username branch an \
531 extra derivation, which is the enumeration oracle it exists to close"
532 );
533
534 // Exact equality, not `!needs_rehash`: that only checks `<`, so it would
535 // accept a dummy at twice the real cost -- the very shape of the bug.
536 let (iterations, _, _) = decode(dummy_hash()).expect("the dummy is well-formed");
537 assert_eq!(iterations.get(), ITERATIONS);
538
539 assert!(!needs_rehash(dummy_hash()));
540 assert_eq!(verify_password(dummy_hash(), "hunter2"), Ok(false));
541 }
542}