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