Skip to main content

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}