Skip to main content

acme_proxy_admin/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//! The other half of this module is [`check_password_policy`], which is the
24//! single place every rule about an *acceptable* password lives. Three of them:
25//! length, a list of words naming this deployment ([`PasswordContext`]), and a
26//! corpus of common passwords compiled in from `corpus/common-passwords.txt`.
27//! The last two are ASVS 5.0 V6.2.11 and V6.2.4/V6.2.12, and both rest on the
28//! same observation -- **the length rule has already refused everything short**,
29//! so a corpus filtered at [`MIN_PASSWORD_LEN`] is 195 KB where the list it was
30//! derived from is 8.5 MB. `corpus/README.md` has the provenance and the
31//! budget.
32//!
33//! This module holds no database access and no I/O: it is shared by the CLI
34//! (`admin user create`/`passwd`) and the web login path, which is why it lives
35//! under `admin::` beside the other logic both front ends use rather than
36//! inside `webadmin::`.
37
38use std::collections::BTreeSet;
39use std::num::NonZeroU32;
40use std::sync::LazyLock;
41
42use base64::Engine as _;
43use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_SAFE_NO_PAD;
44use ring::pbkdf2;
45use ring::rand::{SecureRandom, SystemRandom};
46use url::Url;
47
48use acme_proxy_core::config::Config;
49use acme_proxy_core::config::LocalCaSubjectConfig;
50
51/// The only algorithm this version writes. `verify_password` matches on it, so
52/// adding a second is additive.
53const ALGORITHM: &str = "pbkdf2-sha256";
54
55/// OWASP's current recommendation for PBKDF2-HMAC-SHA256.
56///
57/// Measured at ~85 ms per verification in a release build on a 2020s desktop
58/// core. A debug build matches it only because the workspace `Cargo.toml`
59/// builds `ring` optimised; unoptimised it is ~1.1 s. Even at 85 ms, the tests
60/// below mostly go through a cheap [`hash_with_iterations`] rather than paying
61/// it dozens of times. That is the login latency, and it is a small denial-of-service
62/// lever -- which is why `webadmin::session` rate-limits login *before* it
63/// reaches here rather than after.
64const ITERATIONS: u32 = 600_000;
65
66/// 128 bits. Salts are per-row and public; their only job is to make one
67/// precomputed table useless against every row at once.
68const SALT_LEN: usize = 16;
69
70/// 256 bits, matching the underlying PRF's output.
71const HASH_LEN: usize = 32;
72
73/// Shortest password accepted. Length is the only rule -- composition rules
74/// ("one digit, one symbol") measurably push people towards weaker, more
75/// guessable passwords, and this is an operator-facing surface with a handful
76/// of accounts, not a consumer signup.
77pub const MIN_PASSWORD_LEN: usize = 12;
78
79/// Longest password accepted. A DoS control, not a security one: without it a
80/// login request could hand 600 000 iterations a multi-megabyte input.
81pub const MAX_PASSWORD_LEN: usize = 1024;
82
83/// Shortest context word that can bar a password.
84///
85/// Three characters is noise: a subject holding `CA`, or a host label `io`,
86/// would refuse a large share of every password anyone typed and buy nothing.
87/// Four is the shortest word this list actually needs -- `acme`.
88const MIN_CONTEXT_WORD_LEN: usize = 4;
89
90/// The words every deployment bars, whatever it happens to be called.
91const UNIVERSAL_CONTEXT_WORDS: [&str; 2] = ["acme", "proxy"];
92
93/// Common passwords: one per line, lowercase, sorted, and every one of them at
94/// least [`MIN_PASSWORD_LEN`] characters.
95///
96/// **The length filter is what makes a compiled-in corpus affordable.**
97/// `password`, `qwerty` and `123456` never reach this check -- the length rule
98/// above has already refused them -- so carrying them would add bytes to every
99/// deployment, including the ones with `admin.enabled = false`, in exchange for
100/// nothing. Filtering the upstream million at twelve characters is what turns
101/// 8.5 MB into 195 KB.
102///
103/// Provenance, the rank cut, the budget it was derived from and the refresh
104/// command are in `crates/admin/src/admin/corpus/README.md`. The invariants this module
105/// relies on are asserted by the tests below rather than trusted.
106const COMMON_PASSWORDS: &str = include_str!("corpus/common-passwords.txt");
107
108/// The words that name *this* deployment, barred from an operator's password.
109///
110/// ASVS 5.0 **V6.1.2** asks for such a list to be documented and **V6.2.11**
111/// for it to be enforced; the operator-facing copy is
112/// `doc/src/operations/webadmin_users.md`. It is *derived* rather than
113/// hardcoded because the name of the thing being protected is the first
114/// password anybody reaches for, and that name differs per deployment.
115///
116/// Two limits are deliberate and worth knowing before trusting it:
117///
118/// * **A CA already on disk is not described here.**
119///   `[signer.local_ca.subject]` is read only when this server *generates* a
120///   CA, so an adopted `ca.pem` carries a subject configuration never sees.
121///   Reading it back would mean parsing every mounted profile's certificate on
122///   a CLI path that has not otherwise opened one. When `common_name` is unset
123///   the built-in default is `acme-proxy local CA`, whose only words worth
124///   barring are already in [`UNIVERSAL_CONTEXT_WORDS`].
125/// * **`country` is excluded**, being two characters and so below
126///   [`MIN_CONTEXT_WORD_LEN`] whatever it holds.
127#[derive(Debug, Clone, Default, PartialEq, Eq)]
128pub struct PasswordContext {
129    /// Lowercase, deduplicated, sorted, each at least
130    /// [`MIN_CONTEXT_WORD_LEN`] characters.
131    words: Vec<String>,
132}
133
134impl PasswordContext {
135    /// No words: every password passes the context rule.
136    ///
137    /// What a caller with no configuration in hand uses. It is a *weaker*
138    /// check, never a wrong one -- the length and corpus rules still run.
139    #[must_use]
140    pub fn empty() -> Self {
141        Self::default()
142    }
143
144    /// Derives the list from the deployment's own configuration and the
145    /// operator's own name.
146    ///
147    /// **Cannot fail.** A `[profiles]` table that will not resolve contributes
148    /// nothing and the global `[signer]` still does: refusing a password change
149    /// because an unrelated profile is misconfigured would be a lockout caused
150    /// by the control that exists to prevent one.
151    #[must_use]
152    pub fn from_config(config: &Config, username: &str) -> Self {
153        let mut words = BTreeSet::new();
154
155        for word in UNIVERSAL_CONTEXT_WORDS {
156            words.insert(word.to_string());
157        }
158        push_tokens(&mut words, username);
159        push_host(&mut words, &config.server.base_url);
160        push_host(&mut words, &config.admin.base_url);
161        push_subject(&mut words, &config.signer.local_ca.subject);
162
163        // The profile name is in every `kid` and order URL this endpoint ever
164        // issued, which makes it public and memorable -- exactly the shape of
165        // word this list is for.
166        for profile in config.resolve_profiles().unwrap_or_default() {
167            push_tokens(&mut words, &profile.name);
168            push_subject(&mut words, &profile.sections.signer.local_ca.subject);
169        }
170
171        Self {
172            words: words.into_iter().collect(),
173        }
174    }
175
176    /// The first barred word `folded` contains, if any.
177    ///
178    /// Substring, not equality: `acmeproxy2026!` is the guess this rule exists
179    /// to refuse, and it contains no barred word as a whole password.
180    fn first_match(&self, folded: &str) -> Option<&str> {
181        self.words
182            .iter()
183            .find(|word| folded.contains(word.as_str()))
184            .map(String::as_str)
185    }
186
187    /// The derived words, for the tests that assert what each source
188    /// contributed.
189    #[cfg(test)]
190    fn words(&self) -> &[String] {
191        &self.words
192    }
193}
194
195/// Splits `value` on everything that is not a letter or a digit, keeping the
196/// tokens long enough to be worth barring.
197///
198/// A CommonName is a phrase ("Example Corp Issuing CA"), a host is dotted and a
199/// username may be hyphenated: one splitter serves all three, and it is what
200/// turns `ca.example.com` into `example` rather than into a string no password
201/// would ever contain whole.
202fn push_tokens(words: &mut BTreeSet<String>, value: &str) {
203    for token in value.split(|c: char| !c.is_alphanumeric()) {
204        if token.chars().count() >= MIN_CONTEXT_WORD_LEN {
205            words.insert(token.to_lowercase());
206        }
207    }
208}
209
210/// The host of a configured base URL, tokenized.
211///
212/// Parsed rather than split by hand: [`push_tokens`] over the whole URL would
213/// bar `http`, which is not this deployment's name. A value that will not parse
214/// contributes nothing -- `webadmin::check_config` refuses one at startup, so
215/// this is reached only by a CLI run against a configuration the server would
216/// not have accepted.
217fn push_host(words: &mut BTreeSet<String>, base_url: &str) {
218    if let Some(host) = Url::parse(base_url)
219        .ok()
220        .and_then(|url| url.host_str().map(str::to_string))
221    {
222        push_tokens(words, &host);
223    }
224}
225
226/// Every subject attribute except `country` -- see [`PasswordContext`].
227fn push_subject(words: &mut BTreeSet<String>, subject: &LocalCaSubjectConfig) {
228    for value in [
229        &subject.common_name,
230        &subject.organization,
231        &subject.organizational_unit,
232        &subject.state,
233        &subject.locality,
234    ]
235    .into_iter()
236    .flatten()
237    {
238        push_tokens(words, value);
239    }
240}
241
242/// Whether `folded` is one of the [`COMMON_PASSWORDS`] entries.
243///
244/// A linear scan over `include_str!`, deliberately: no `LazyLock`, no heap, no
245/// perfect-hash dependency -- the `metrics.rs` and `cli/style.rs` call, made
246/// here for a concrete reason. This runs **once per password set and never on
247/// login**, and `str`'s `PartialEq` compares lengths before bytes, so 14 000
248/// comparisons cost microseconds beside the 600 000-iteration derivation the
249/// caller is about to pay.
250///
251/// Whole-password equality, **never a substring**: `a-long-enough-password`
252/// contains `password`, and refusing it would be refusing a good password for
253/// the sins of a bad one.
254fn is_common(folded: &str) -> bool {
255    COMMON_PASSWORDS.lines().any(|entry| entry == folded)
256}
257
258/// A stored hash that could not be read back.
259///
260/// Every variant means the `admin_users` row is corrupt, never that the
261/// password was wrong -- callers must not fold this into "authentication
262/// failed", or a mangled row would read as a bad password forever.
263#[derive(Debug, PartialEq, Eq, thiserror::Error)]
264pub enum PasswordError {
265    /// Not the four `$`-separated fields the format defines.
266    #[error("stored password hash is not in the expected format")]
267    Malformed,
268    /// A prefix this build does not implement.
269    #[error("unknown password hash algorithm `{0}`")]
270    UnknownAlgorithm(String),
271    /// The iteration field was not a positive integer.
272    #[error("stored password hash has an invalid iteration count")]
273    BadIterations,
274    /// Salt or hash was not valid unpadded base64url, or was the wrong length.
275    #[error("stored password hash has an invalid salt or digest")]
276    BadEncoding,
277}
278
279/// Rejects a password before it is ever hashed.
280///
281/// Three rules, in this order, each of which ends the check:
282///
283/// 1. **Length** -- [`MIN_PASSWORD_LEN`] characters to [`MAX_PASSWORD_LEN`]
284///    bytes. Still the only rule about a password's *shape*; composition rules
285///    remain deliberately absent.
286/// 2. **Context** -- it must not contain a word naming this deployment
287///    ([`PasswordContext`], ASVS V6.2.11).
288/// 3. **Corpus** -- it must not be a known common password ([`is_common`],
289///    ASVS V6.2.4/V6.2.12).
290///
291/// The order is cheapest-first, and each rule returning immediately is the
292/// point: a password refused for being eight characters must not also be told
293/// it is common, which would be a second sentence about a string that was
294/// never going to be accepted.
295///
296/// Returns the operator-facing message, so the CLI and the API report the same
297/// words. **No message ever echoes the password** -- the context one names the
298/// offending *word*, which the operator configured and can see anyway.
299///
300/// Runs on `create`/`passwd`, never on login: an existing password that
301/// predates a rule change must still work, and a corpus refresh must never
302/// lock an operator out of a panel they can no longer sign in to fix.
303pub fn check_password_policy(password: &str, context: &PasswordContext) -> Result<(), String> {
304    // Characters, not bytes: a 12-character passphrase in a non-Latin script
305    // would otherwise be measured as comfortably long by accident.
306    let length = password.chars().count();
307    if length < MIN_PASSWORD_LEN {
308        return Err(format!(
309            "password must be at least {MIN_PASSWORD_LEN} characters (got {length})"
310        ));
311    }
312    if password.len() > MAX_PASSWORD_LEN {
313        return Err(format!(
314            "password must be at most {MAX_PASSWORD_LEN} bytes (got {})",
315            password.len()
316        ));
317    }
318
319    // One fold, shared by both remaining rules. Neither is case-sensitive:
320    // `Passwordpassword` is the same guess as `passwordpassword`, and a
321    // deployment's name is no less its name in capitals.
322    let folded = password.to_lowercase();
323
324    if let Some(word) = context.first_match(&folded) {
325        return Err(format!(
326            "password must not contain `{word}`, which names this deployment"
327        ));
328    }
329    if is_common(&folded) {
330        return Err("password appears in a list of commonly used passwords".to_string());
331    }
332
333    Ok(())
334}
335
336/// Hashes `password` under the current parameters, returning the encoded form
337/// to store.
338///
339/// Does **not** check the policy: callers that accept a new password call
340/// [`check_password_policy`] first, and the login path's rehash must be able to
341/// re-encode a password that predates the current rules.
342#[must_use]
343pub fn hash_password(password: &str) -> String {
344    hash_with_iterations(password, ITERATIONS)
345}
346
347/// Hashes a **high-entropy generated secret**, at a cost matched to the fact
348/// that it is one.
349///
350/// [`ITERATIONS`] exists to slow a dictionary down. A recovery code
351/// ([`crate::admin::recovery`]) has no dictionary: it is CSPRNG output from a
352/// 32-symbol alphabet, so the cheapest attack on the stored form is a
353/// brute-force over its own keyspace, which [`RECOVERY_ITERATIONS`] widens by
354/// another ~13 bits on top.
355///
356/// The reason not to spend more is specific, and worth stating so it is not
357/// "hardened" later by reflex: **the attacker this would defend against already
358/// has a better route.** Recovery codes only matter to somebody holding the
359/// database file, and that same file holds `admin_users.totp_secret` in the
360/// clear -- it must, since verifying a code means recomputing the HMAC. Paying
361/// 600 000 iterations ten times per enrolment buys nothing against a reader who
362/// can simply take the factor itself.
363///
364/// The stored form is self-describing, so the two costs coexist with no
365/// migration and no second format: [`verify_password`] reads the count back out
366/// of the string. Do **not** run [`needs_rehash`] against one of these -- it
367/// compares against [`ITERATIONS`] and would report every recovery code as
368/// stale forever.
369#[must_use]
370pub fn hash_generated_secret(secret: &str) -> String {
371    hash_with_iterations(secret, RECOVERY_ITERATIONS)
372}
373
374/// The cost [`hash_generated_secret`] uses. Named so the reasoning above has
375/// something to point at.
376pub const RECOVERY_ITERATIONS: u32 = 10_000;
377
378/// [`hash_password`] with the cost as a parameter.
379///
380/// Exists so the tests can exercise this exact path -- the salt generation and
381/// the encoding, which is where the bugs would be -- without paying 600 000
382/// iterations a dozen times over. Private: nothing outside this module gets to
383/// choose a cost, only to pick one of the two named above.
384fn hash_with_iterations(password: &str, iterations: u32) -> String {
385    let mut salt = [0u8; SALT_LEN];
386    // Same trade-off as `acme_proxy_core::random`: an unavailable system RNG
387    // is unrecoverable, and threading the error out would only move the panic.
388    SystemRandom::new()
389        .fill(&mut salt)
390        .expect("system RNG unavailable");
391
392    encode(&salt, &derive(password, &salt, iterations), iterations)
393}
394
395/// Verifies `password` against a stored hash, in constant time
396/// (`ring::pbkdf2::verify` compares that way).
397///
398/// `Ok(false)` is a wrong password; `Err` is a corrupt row. Keeping them apart
399/// is the point -- see [`PasswordError`].
400pub fn verify_password(stored: &str, password: &str) -> Result<bool, PasswordError> {
401    let (iterations, salt, expected) = decode(stored)?;
402    Ok(pbkdf2::verify(
403        pbkdf2::PBKDF2_HMAC_SHA256,
404        iterations,
405        &salt,
406        password.as_bytes(),
407        &expected,
408    )
409    .is_ok())
410}
411
412/// [`verify_password`] on tokio's blocking pool.
413///
414/// 600 000 iterations is ~85 ms of one core. Run inline in an async handler it
415/// holds a runtime worker for all of that, and a burst of logins stalls every
416/// other task scheduled on the same workers — the ACME listener included. Every
417/// verification an async caller makes goes through here; the synchronous
418/// function stays for the CLI and the tests.
419///
420/// A panic in the KDF resumes on the caller, exactly as it would have inline. A
421/// task cancelled because the runtime is shutting down verified nothing, and
422/// answers `Ok(false)`.
423pub async fn verify_password_off_runtime(
424    stored: &str,
425    password: &str,
426) -> Result<bool, PasswordError> {
427    let (stored, password) = (stored.to_owned(), password.to_owned());
428    match tokio::task::spawn_blocking(move || verify_password(&stored, &password)).await {
429        Ok(verified) => verified,
430        Err(error) => match error.try_into_panic() {
431            Ok(panic) => std::panic::resume_unwind(panic),
432            Err(_) => Ok(false),
433        },
434    }
435}
436
437/// [`hash_password`] on tokio's blocking pool, for the login path's rehash.
438/// `None` only when the runtime is shutting down; the rehash then waits for the
439/// next login.
440pub async fn hash_password_off_runtime(password: &str) -> Option<String> {
441    let password = password.to_owned();
442    match tokio::task::spawn_blocking(move || hash_password(&password)).await {
443        Ok(hash) => Some(hash),
444        Err(error) => match error.try_into_panic() {
445            Ok(panic) => std::panic::resume_unwind(panic),
446            Err(_) => None,
447        },
448    }
449}
450
451/// Whether `stored` was written under parameters this build has since moved
452/// past -- a different algorithm, or a lower iteration count.
453///
454/// A row that cannot be decoded reports `true`: it is already unusable, and
455/// re-encoding it on the next successful login is the only way it ever gets
456/// fixed. (`verify_password` will have returned `Err` for the same row, so
457/// this is reached only where a caller chose to carry on regardless.)
458#[must_use]
459pub fn needs_rehash(stored: &str) -> bool {
460    match decode(stored) {
461        Ok((iterations, _, _)) => iterations.get() < ITERATIONS,
462        Err(_) => true,
463    }
464}
465
466/// A stored hash no password matches: given to the login path to verify against
467/// when the username does not exist, so an unknown user costs the same one
468/// derivation as a known one.
469///
470/// Without it, login latency enumerates the user table -- a fast rejection
471/// means "no such user", a slow one means "wrong password".
472///
473/// **Encoded, never derived, and that is the whole point.** Calling
474/// [`hash_password`] here costs a full [`ITERATIONS`]-round `pbkdf2::derive`
475/// that the caller's [`verify_password`] then pays *again*, making the unknown
476/// branch twice the known one -- the enumeration oracle inverted rather than
477/// closed, and pointing the expensive direction at the branch an unauthenticated
478/// caller picks. The digest is never matched against anything, so it only has to
479/// be well-formed and carry the current cost; the bytes being zero is not a
480/// weakness, since the value is in the binary either way and `pbkdf2::verify`
481/// costs the same for any salt. [`encode`] is this module's own writer, so the
482/// shape cannot drift from what [`decode`] expects, and reading [`ITERATIONS`]
483/// here means the cost tracks a change to it rather than needing a second
484/// spelling.
485static DUMMY_HASH: LazyLock<String> =
486    LazyLock::new(|| encode(&[0u8; SALT_LEN], &[0u8; HASH_LEN], ITERATIONS));
487
488/// A well-formed hash of nothing, verified against when the username is
489/// unknown so a miss costs the same KDF time as a wrong password.
490#[must_use]
491pub fn dummy_hash() -> &'static str {
492    &DUMMY_HASH
493}
494
495fn derive(password: &str, salt: &[u8], iterations: u32) -> [u8; HASH_LEN] {
496    let mut out = [0u8; HASH_LEN];
497    pbkdf2::derive(
498        pbkdf2::PBKDF2_HMAC_SHA256,
499        nonzero(iterations),
500        salt,
501        password.as_bytes(),
502        &mut out,
503    );
504    out
505}
506
507/// `iterations` is a compile-time constant everywhere it matters, and `decode`
508/// has already refused a zero, so this cannot fail in practice -- but a
509/// silently-clamped iteration count would be a real weakening, so clamp
510/// upwards rather than downwards.
511fn nonzero(iterations: u32) -> NonZeroU32 {
512    NonZeroU32::new(iterations).unwrap_or(NonZeroU32::MIN)
513}
514
515fn encode(salt: &[u8], hash: &[u8], iterations: u32) -> String {
516    format!(
517        "{ALGORITHM}${iterations}${}${}",
518        BASE64_URL_SAFE_NO_PAD.encode(salt),
519        BASE64_URL_SAFE_NO_PAD.encode(hash),
520    )
521}
522
523fn decode(stored: &str) -> Result<(NonZeroU32, Vec<u8>, Vec<u8>), PasswordError> {
524    let mut fields = stored.split('$');
525    let (Some(algorithm), Some(iterations), Some(salt), Some(hash), None) = (
526        fields.next(),
527        fields.next(),
528        fields.next(),
529        fields.next(),
530        fields.next(),
531    ) else {
532        return Err(PasswordError::Malformed);
533    };
534
535    if algorithm != ALGORITHM {
536        return Err(PasswordError::UnknownAlgorithm(algorithm.to_string()));
537    }
538
539    let iterations = iterations
540        .parse::<u32>()
541        .ok()
542        .and_then(NonZeroU32::new)
543        .ok_or(PasswordError::BadIterations)?;
544
545    let salt = BASE64_URL_SAFE_NO_PAD
546        .decode(salt)
547        .map_err(|_| PasswordError::BadEncoding)?;
548    let hash = BASE64_URL_SAFE_NO_PAD
549        .decode(hash)
550        .map_err(|_| PasswordError::BadEncoding)?;
551
552    // A truncated digest would otherwise verify against a truncated
553    // derivation, which is a weaker hash accepted silently.
554    if salt.len() != SALT_LEN || hash.len() != HASH_LEN {
555        return Err(PasswordError::BadEncoding);
556    }
557
558    Ok((iterations, salt, hash))
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    /// The real cost parameters run ~250 ms in release and ~1.6 s in a debug
566    /// build, which is the point in production and far too slow for a suite
567    /// that wants a dozen of them. Only the two tests that assert on the real
568    /// constants pay it; everything else goes through here, which is the same
569    /// code path at a cost the tests can afford.
570    const TEST_ITERATIONS: u32 = 1_000;
571
572    fn cheap_hash(password: &str) -> String {
573        hash_with_iterations(password, TEST_ITERATIONS)
574    }
575
576    /// An encoded hash at an arbitrary cost, with a digest that was never
577    /// derived. For [`needs_rehash`], which only ever decodes -- deriving one
578    /// at `ITERATIONS` purely to read its header back would be the slowest
579    /// possible way to parse a string.
580    fn stored_at(iterations: u32) -> String {
581        encode(&[7u8; SALT_LEN], &[0u8; HASH_LEN], iterations)
582    }
583
584    #[test]
585    fn hash_then_verify_round_trips() {
586        let stored = cheap_hash("correct horse battery");
587        assert_eq!(verify_password(&stored, "correct horse battery"), Ok(true));
588    }
589
590    #[test]
591    fn a_wrong_password_is_false_and_not_an_error() {
592        let stored = cheap_hash("correct horse battery");
593        assert_eq!(verify_password(&stored, "wrong"), Ok(false));
594        assert_eq!(verify_password(&stored, ""), Ok(false));
595    }
596
597    #[test]
598    fn two_hashes_of_one_password_differ_by_salt() {
599        let first = cheap_hash("a-long-enough-password");
600        let second = cheap_hash("a-long-enough-password");
601        assert_ne!(first, second, "each hash must carry its own random salt");
602        // Specifically the salt field, not just the string as a whole -- a
603        // constant salt with a differing digest would be a much stranger bug
604        // and this pins which one is being ruled out.
605        assert_ne!(
606            first.split('$').nth(2).unwrap(),
607            second.split('$').nth(2).unwrap()
608        );
609        assert_eq!(verify_password(&first, "a-long-enough-password"), Ok(true));
610        assert_eq!(verify_password(&second, "a-long-enough-password"), Ok(true));
611    }
612
613    #[test]
614    fn the_encoded_form_is_self_describing() {
615        let stored = hash_password("a-long-enough-password");
616        let fields: Vec<&str> = stored.split('$').collect();
617        assert_eq!(fields.len(), 4);
618        assert_eq!(fields[0], "pbkdf2-sha256");
619        assert_eq!(fields[1], ITERATIONS.to_string());
620        assert_eq!(
621            BASE64_URL_SAFE_NO_PAD.decode(fields[2]).unwrap().len(),
622            SALT_LEN
623        );
624        assert_eq!(
625            BASE64_URL_SAFE_NO_PAD.decode(fields[3]).unwrap().len(),
626            HASH_LEN
627        );
628        // No padding and no `+`/`/`: the value travels in JSON and, later, in
629        // a template.
630        assert!(!stored.contains('='));
631        assert!(!stored.contains('+'));
632    }
633
634    #[test]
635    fn every_decode_failure_is_its_own_variant() {
636        let good = cheap_hash("pw");
637        let salt = good.split('$').nth(2).unwrap().to_string();
638        let hash = good.split('$').nth(3).unwrap().to_string();
639
640        let cases: Vec<(&str, String, PasswordError)> = vec![
641            ("empty", String::new(), PasswordError::Malformed),
642            (
643                "too few fields",
644                format!("pbkdf2-sha256$1000${salt}"),
645                PasswordError::Malformed,
646            ),
647            (
648                "too many fields",
649                format!("pbkdf2-sha256$1000${salt}${hash}$extra"),
650                PasswordError::Malformed,
651            ),
652            (
653                "unknown algorithm",
654                format!("argon2id$1000${salt}${hash}"),
655                PasswordError::UnknownAlgorithm("argon2id".to_string()),
656            ),
657            (
658                "non-numeric iterations",
659                format!("pbkdf2-sha256$many${salt}${hash}"),
660                PasswordError::BadIterations,
661            ),
662            (
663                "zero iterations",
664                format!("pbkdf2-sha256$0${salt}${hash}"),
665                PasswordError::BadIterations,
666            ),
667            (
668                "salt is not base64url",
669                format!("pbkdf2-sha256$1000$not base64${hash}"),
670                PasswordError::BadEncoding,
671            ),
672            (
673                "digest is not base64url",
674                format!("pbkdf2-sha256$1000${salt}$not base64"),
675                PasswordError::BadEncoding,
676            ),
677            (
678                "short salt",
679                format!(
680                    "pbkdf2-sha256$1000${}${hash}",
681                    BASE64_URL_SAFE_NO_PAD.encode([1u8; 4])
682                ),
683                PasswordError::BadEncoding,
684            ),
685            (
686                "truncated digest",
687                format!(
688                    "pbkdf2-sha256$1000${salt}${}",
689                    BASE64_URL_SAFE_NO_PAD.encode([1u8; 8])
690                ),
691                PasswordError::BadEncoding,
692            ),
693        ];
694
695        for (name, stored, expected) in cases {
696            assert_eq!(
697                verify_password(&stored, "pw"),
698                Err(expected),
699                "case `{name}` decoded differently than expected"
700            );
701        }
702    }
703
704    #[test]
705    fn every_error_renders() {
706        let rendered: Vec<String> = [
707            PasswordError::Malformed,
708            PasswordError::UnknownAlgorithm("scrypt".to_string()),
709            PasswordError::BadIterations,
710            PasswordError::BadEncoding,
711        ]
712        .iter()
713        .map(ToString::to_string)
714        .collect();
715
716        assert!(rendered.iter().all(|line| !line.is_empty()));
717        assert!(rendered[1].contains("scrypt"));
718    }
719
720    /// The two costs have to coexist in one format, since recovery codes and
721    /// passwords both live in `<algo>$<iters>$…` columns and one `verify` reads
722    /// both.
723    #[test]
724    fn a_generated_secret_hashes_cheaper_and_still_verifies() {
725        let stored = hash_generated_secret("K7QF23BXTM");
726        assert_eq!(stored.split('$').nth(1), Some("10000"));
727        assert_eq!(verify_password(&stored, "K7QF23BXTM"), Ok(true));
728        assert_eq!(verify_password(&stored, "K7QF23BXTN"), Ok(false));
729
730        // The trap this documents: `needs_rehash` compares against the
731        // *password* cost, so it reports true for every recovery code. Nothing
732        // may call it on one.
733        assert!(needs_rehash(&stored));
734    }
735
736    #[test]
737    fn needs_rehash_tracks_the_current_parameters() {
738        assert!(!needs_rehash(&stored_at(ITERATIONS)));
739        assert!(needs_rehash(&stored_at(ITERATIONS - 1)));
740        assert!(needs_rehash(&stored_at(TEST_ITERATIONS)));
741        // Already stronger than this build asks for: leave it alone rather
742        // than re-encoding it weaker.
743        assert!(!needs_rehash(&stored_at(ITERATIONS + 1)));
744        // A row that cannot be read is due a rewrite by definition.
745        assert!(needs_rehash("nonsense"));
746        assert!(needs_rehash(""));
747        assert!(needs_rehash("argon2id$1$c2FsdA$aGFzaA"));
748    }
749
750    /// Exactly [`MIN_PASSWORD_LEN`] characters and **not a corpus entry**.
751    ///
752    /// The obvious spelling, `"x".repeat(MIN_PASSWORD_LEN)`, is no longer
753    /// available: `xxxxxxxxxxxx` is in the corpus, which is the whole point of
754    /// having one. Anything at the top end is safe by construction -- the
755    /// longest corpus entry is 29 characters.
756    const SHORTEST_ACCEPTABLE: &str = "Zq7-Kx2-Mp9v";
757
758    #[test]
759    fn the_policy_enforces_length_at_both_ends() {
760        let none = PasswordContext::empty();
761        assert_eq!(SHORTEST_ACCEPTABLE.chars().count(), MIN_PASSWORD_LEN);
762        assert!(check_password_policy(SHORTEST_ACCEPTABLE, &none).is_ok());
763        assert!(check_password_policy(&"a".repeat(MAX_PASSWORD_LEN), &none).is_ok());
764
765        let too_short =
766            check_password_policy(&"x".repeat(MIN_PASSWORD_LEN - 1), &none).unwrap_err();
767        assert!(too_short.contains("at least 12"), "got: {too_short}");
768        let too_long = check_password_policy(&"a".repeat(MAX_PASSWORD_LEN + 1), &none).unwrap_err();
769        assert!(too_long.contains("at most 1024"), "got: {too_long}");
770    }
771
772    /// Composition rules stay deliberately absent. What changed is only that
773    /// the run has to be one the corpus has not heard of: sixteen `a`s used to
774    /// stand here and is a corpus entry, twenty-four is not.
775    #[test]
776    fn the_policy_still_has_no_composition_rules() {
777        let none = PasswordContext::empty();
778        assert!(check_password_policy(&"a".repeat(24), &none).is_ok());
779        assert!(check_password_policy("Aa1!Aa1!", &none).is_err());
780    }
781
782    #[test]
783    fn the_policy_counts_characters_not_bytes() {
784        let none = PasswordContext::empty();
785        // 12 characters, 36 bytes in UTF-8. Measured as bytes this passes for
786        // the wrong reason; measured as characters it passes for the right one.
787        let passphrase = "日本語日本語日本語日本語";
788        assert_eq!(passphrase.chars().count(), 12);
789        assert!(passphrase.len() > MIN_PASSWORD_LEN);
790        assert!(check_password_policy(passphrase, &none).is_ok());
791
792        // 11 characters is short whatever its byte length.
793        assert!(check_password_policy("日本語日本語日本語日本", &none).is_err());
794    }
795
796    // ---- the corpus (ASVS V6.2.4 / V6.2.12) ------------------------------
797
798    /// Every invariant the lookup and the size budget rest on.
799    ///
800    /// A refresh that drops `awk`, `tr` or `LC_ALL=C` from the pipeline in
801    /// `corpus/README.md` reintroduces exactly what the filter exists to
802    /// remove, and nothing else in the tree would notice.
803    #[test]
804    fn the_corpus_holds_its_shape() {
805        let mut previous = "";
806        let mut entries = 0usize;
807        for entry in COMMON_PASSWORDS.lines() {
808            assert!(
809                entry.is_ascii(),
810                "non-ASCII entry `{entry}`: the >= 12 filter counts bytes, which \
811                 equals characters only for ASCII"
812            );
813            assert_eq!(
814                entry,
815                entry.to_lowercase(),
816                "entry `{entry}` is not folded, so the folded lookup can never match it"
817            );
818            assert!(
819                entry.chars().count() >= MIN_PASSWORD_LEN,
820                "entry `{entry}` is shorter than the length rule already refuses, \
821                 so it is bytes spent on an unreachable comparison"
822            );
823            assert!(
824                previous < entry,
825                "`{previous}` then `{entry}`: the corpus must be `LC_ALL=C sort -u`ed"
826            );
827            previous = entry;
828            entries += 1;
829        }
830
831        // A floor against a truncated or half-written file, not a claim about
832        // V6.2.4 -- that requirement is met by construction, every top-3000
833        // password being either below the length floor or in here.
834        assert!(
835            entries > 10_000,
836            "only {entries} entries: the file looks truncated"
837        );
838        assert!(
839            COMMON_PASSWORDS.len() < 200 * 1024,
840            "corpus is {} bytes, past the 200 KiB budget the rank cut was derived from",
841            COMMON_PASSWORDS.len()
842        );
843    }
844
845    /// The fixture roughly fifty integration tests sign in with.
846    ///
847    /// If a corpus refresh ever swallows it they all fail at once, and not one
848    /// of them says why. This one does.
849    #[test]
850    fn the_test_fixture_passwords_are_not_in_the_corpus() {
851        for fixture in [
852            "a-long-enough-password",
853            "correct horse battery",
854            SHORTEST_ACCEPTABLE,
855        ] {
856            assert!(
857                !is_common(&fixture.to_lowercase()),
858                "`{fixture}` is now a corpus entry, and every test that uses it is \
859                 about to fail somewhere else"
860            );
861        }
862    }
863
864    #[test]
865    fn a_common_password_is_refused_however_it_is_capitalized() {
866        let none = PasswordContext::empty();
867        for spelling in ["passwordpassword", "PasswordPassword", "PASSWORDPASSWORD"] {
868            let error = check_password_policy(spelling, &none).unwrap_err();
869            assert!(error.contains("commonly used"), "got: {error}");
870            assert!(
871                !error.contains(spelling),
872                "the message must never echo the password: {error}"
873            );
874        }
875    }
876
877    /// Whole-password equality, never a substring -- otherwise
878    /// `a-long-enough-password` would be refused for containing `password`,
879    /// which is a good password refused for the sins of a bad one.
880    #[test]
881    fn the_corpus_rule_does_not_match_a_substring() {
882        assert!(is_common("passwordpassword"));
883        assert!(!is_common("a-long-enough-password"));
884        assert!(!is_common("xx-passwordpassword-xx"));
885    }
886
887    // ---- the context list (ASVS V6.1.2 / V6.2.11) ------------------------
888
889    /// Loads a `Config` the way the server does, so `resolve_profiles` has the
890    /// raw sources per-key inheritance needs -- the `cli::filter` helper
891    /// verbatim, and for the same reason: a `Config` deserialized directly
892    /// carries no raw layer and resolves no profiles at all.
893    fn load(body: &str) -> Config {
894        let _lock = acme_proxy_core::config::ENV_LOCK
895            .lock()
896            .unwrap_or_else(std::sync::PoisonError::into_inner);
897        let dir = acme_proxy_core::testutil::TempDir::new("password-context");
898        std::fs::write(dir.join("config.toml"), body).unwrap();
899        // SAFETY: single-threaded test holding ENV_LOCK; removed before return.
900        unsafe {
901            std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
902        }
903        let config = Config::load().expect("the configuration must load");
904        unsafe {
905            std::env::remove_var("ACME_PROXY_CONFIG");
906        }
907        config
908    }
909
910    #[test]
911    fn the_context_list_is_derived_from_the_deployment() {
912        let mut config = Config::default();
913        config.server.base_url = "https://ca.example.com:3000".to_string();
914        config.admin.base_url = "https://panel.internal.test".to_string();
915        config.signer.local_ca.subject.common_name = Some("Example Corp Issuing CA".to_string());
916        config.signer.local_ca.subject.organizational_unit = Some("Platform".to_string());
917        config.signer.local_ca.subject.state = Some("Noord-Holland".to_string());
918        config.signer.local_ca.subject.locality = Some("Amsterdam".to_string());
919        // Two characters, so below the floor whatever it holds -- which is
920        // why `push_subject` does not read it at all.
921        config.signer.local_ca.subject.country = Some("NL".to_string());
922
923        let context = PasswordContext::from_config(&config, "operator");
924        let words = context.words();
925
926        for expected in [
927            "acme",
928            "proxy",
929            "operator",
930            "example",
931            "panel",
932            "internal",
933            "test",
934            "issuing",
935            "platform",
936            "noord",
937            "holland",
938            "amsterdam",
939        ] {
940            assert!(
941                words.iter().any(|word| word == expected),
942                "expected `{expected}` among {words:?}"
943            );
944        }
945
946        // The scheme is not this deployment's name, and parsing the URL rather
947        // than splitting it is what keeps it out.
948        for absent in ["http", "https"] {
949            assert!(
950                !words.iter().any(|word| word == absent),
951                "`{absent}` came from a URL scheme: {words:?}"
952            );
953        }
954        // Under MIN_CONTEXT_WORD_LEN: `com` from the host, `ca` from both the
955        // host and the CommonName, `nl` from the country.
956        for absent in ["com", "ca", "nl"] {
957            assert!(
958                !words.iter().any(|word| word == absent),
959                "`{absent}` is under the floor and must bar nothing: {words:?}"
960            );
961        }
962
963        let mut expected = words.to_vec();
964        expected.sort();
965        expected.dedup();
966        assert_eq!(
967            words,
968            expected.as_slice(),
969            "words must be sorted and unique"
970        );
971    }
972
973    #[test]
974    fn a_context_word_is_refused_as_a_substring_and_named_in_the_message() {
975        let mut config = Config::default();
976        config.server.base_url = "https://ca.example.com".to_string();
977        let context = PasswordContext::from_config(&config, "operator");
978
979        let error = check_password_policy("acmeproxy2026!!", &context).unwrap_err();
980        assert!(
981            error.contains("acme"),
982            "the message must name the word: {error}"
983        );
984        assert!(error.contains("names this deployment"), "got: {error}");
985        assert!(
986            !error.contains("acmeproxy2026!!"),
987            "the message must name the word, never the password: {error}"
988        );
989
990        // Folded on both sides.
991        assert!(check_password_policy("XXXX-ExAmPlE-XXXX", &context).is_err());
992        // And a password naming nothing is accepted.
993        assert!(check_password_policy("a-long-enough-password", &context).is_ok());
994    }
995
996    /// The empty context is a *weaker* check, never a wrong one.
997    #[test]
998    fn an_empty_context_bars_nothing_and_keeps_the_other_rules() {
999        let none = PasswordContext::empty();
1000        assert!(none.words().is_empty());
1001        assert!(check_password_policy("acmeproxy2026!!", &none).is_ok());
1002        assert!(check_password_policy("passwordpassword", &none).is_err());
1003        assert!(check_password_policy("short", &none).is_err());
1004    }
1005
1006    /// Cheapest first, and each rule ends the check: one refusal names one
1007    /// reason, about a string that was never going to be accepted anyway.
1008    #[test]
1009    fn each_rule_ends_the_check() {
1010        let mut config = Config::default();
1011        config.server.base_url = "https://ca.example.com".to_string();
1012        let context = PasswordContext::from_config(&config, "operator");
1013
1014        // Length before context: `acme` is barred and also four characters.
1015        let error = check_password_policy("acme", &context).unwrap_err();
1016        assert!(error.contains("at least 12"), "got: {error}");
1017
1018        // Context before the corpus: `passwordpassword` is a corpus entry, and
1019        // a list barring `word` reaches it first.
1020        let barring_word = PasswordContext {
1021            words: vec!["word".to_string()],
1022        };
1023        assert!(is_common("passwordpassword"));
1024        let error = check_password_policy("passwordpassword", &barring_word).unwrap_err();
1025        assert!(error.contains("names this deployment"), "got: {error}");
1026    }
1027
1028    /// A profile contributes its name -- which is in every `kid` and order URL
1029    /// that endpoint ever issued -- and its own resolved CA subject, not just
1030    /// the global one.
1031    #[test]
1032    fn profiles_contribute_their_names_and_their_own_ca_subjects() {
1033        let config = load(
1034            r#"
1035            [profiles.staging]
1036            [profiles.staging.signer.local_ca.subject]
1037            common_name = "Contoso Staging Root"
1038        "#,
1039        );
1040
1041        let context = PasswordContext::from_config(&config, "op");
1042        let words = context.words();
1043        for expected in ["staging", "contoso", "root"] {
1044            assert!(
1045                words.iter().any(|word| word == expected),
1046                "expected `{expected}` among {words:?}"
1047            );
1048        }
1049        // Two characters: a short username contributes nothing.
1050        assert!(!words.iter().any(|word| word == "op"));
1051    }
1052
1053    /// **A configuration resolving no profiles must still yield a word list**,
1054    /// and this is the common case rather than a corner one: a bare
1055    /// `Config::default()` resolves none, and the CLI legitimately runs
1056    /// `admin user create` against a configuration the server would refuse to
1057    /// start on. Refusing a password change because an unrelated section is
1058    /// missing would be a lockout caused by the control meant to prevent one.
1059    #[test]
1060    fn a_configuration_with_no_resolvable_profiles_still_yields_words() {
1061        let config = Config::default();
1062        assert!(
1063            config.resolve_profiles().is_err(),
1064            "a default configuration resolves no profiles -- if that ever changes, \
1065             this test stops proving the fallback works"
1066        );
1067
1068        let context = PasswordContext::from_config(&config, "operator");
1069        let words = context.words();
1070        for expected in ["acme", "proxy", "operator", "localhost"] {
1071            assert!(
1072                words.iter().any(|word| word == expected),
1073                "expected `{expected}` among {words:?}"
1074            );
1075        }
1076    }
1077
1078    /// A `base_url` that will not parse contributes nothing, rather than
1079    /// contributing its scheme or a fragment of itself.
1080    #[test]
1081    fn an_unparseable_base_url_contributes_nothing() {
1082        let mut config = Config::default();
1083        config.server.base_url = "not a url".to_string();
1084        config.admin.base_url = String::new();
1085
1086        let context = PasswordContext::from_config(&config, "operator");
1087        assert_eq!(context.words(), ["acme", "operator", "proxy"]);
1088    }
1089
1090    /// The dummy must cost the login path exactly **one** derivation -- the same
1091    /// as a known username -- and carry the current cost while doing it.
1092    ///
1093    /// No longer one of the tests that pays the real cost: the assertions below
1094    /// are string comparisons, because `dummy_hash` no longer derives anything.
1095    #[test]
1096    fn the_dummy_hash_is_precomputed_and_matches_no_password() {
1097        // The one that catches a `hash_password`-based dummy: that spelling
1098        // salts randomly, so it returns a different string every call *and*
1099        // makes the unknown-username branch pay a derivation the caller's
1100        // `verify_password` then pays again -- twice a known username, i.e. the
1101        // enumeration oracle inverted rather than closed.
1102        assert_eq!(
1103            dummy_hash(),
1104            dummy_hash(),
1105            "a dummy computed per call costs the unknown-username branch an \
1106             extra derivation, which is the enumeration oracle it exists to close"
1107        );
1108
1109        // Exact equality, not `!needs_rehash`: that only checks `<`, so it would
1110        // accept a dummy at twice the real cost -- the very shape of the bug.
1111        let (iterations, _, _) = decode(dummy_hash()).expect("the dummy is well-formed");
1112        assert_eq!(iterations.get(), ITERATIONS);
1113
1114        assert!(!needs_rehash(dummy_hash()));
1115        assert_eq!(verify_password(dummy_hash(), "hunter2"), Ok(false));
1116    }
1117}