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}