umbral_auth/password_validation.rs
1//! Password-strength validation.
2//!
3//! A small set of validators that every password is checked against before
4//! it's hashed: minimum length, a common-password denylist, an all-numeric
5//! reject, and a similarity-to-user-attributes guard. umbral provides this
6//! surface here, with one deliberate property: **it's on by default with no
7//! opt-in.** A fresh `AuthPlugin::default()` enforces the full set, so
8//! `create_user("a", ...)` is rejected out of the box. An app that genuinely
9//! wants no policy must say so explicitly via
10//! [`crate::AuthPlugin::disable_password_validation`].
11//!
12//! ## The contract
13//!
14//! A [`PasswordValidator`] is `fn validate(&self, password, ctx) -> Result<(), String>`,
15//! where the `String` is a human-readable reason the password was rejected
16//! (the message is returned directly). [`PasswordContext`] carries the username / email so the
17//! similarity validator can compare against them — both are `None` when
18//! there's no user context (e.g. a standalone strength check).
19//!
20//! A [`PasswordPolicy`] is an ordered `Vec<Box<dyn PasswordValidator>>`.
21//! [`validate_password`] runs the ambiently-installed policy (falling back
22//! to [`PasswordPolicy::default`] when none is installed, so the free-function
23//! helpers stay secure even before `on_ready` runs) and **collects every
24//! failure**, not just the first — a caller showing a form gets the full list.
25//!
26//! ## Ambient install
27//!
28//! The active policy lives in a process-wide `OnceLock`, installed once in
29//! [`crate::AuthPlugin::on_ready`]. This mirrors the sessions plugin's
30//! `SLIDING_EXPIRY_ENABLED` flag: free functions (`create_user`,
31//! `set_password`) have no handle to the `AuthPlugin`, so they read the
32//! policy ambiently.
33
34use std::sync::OnceLock;
35
36/// The user context a validator can compare a candidate password against.
37///
38/// Both fields are `Option` because not every call site has a user: a bare
39/// strength check (e.g. a password-meter endpoint) passes `PasswordContext::empty()`,
40/// while `create_user` / `set_password` fill in what they know. The
41/// similarity validator simply skips any field that's `None`.
42#[derive(Debug, Clone, Copy, Default)]
43pub struct PasswordContext<'a> {
44 /// The account's login handle, when known.
45 pub username: Option<&'a str>,
46 /// The account's email address, when known. The similarity check uses
47 /// the local-part (before the `@`) as well as the whole string.
48 pub email: Option<&'a str>,
49}
50
51impl<'a> PasswordContext<'a> {
52 /// A context with no user attributes — the similarity validator is a
53 /// no-op against it. Use for standalone strength checks.
54 pub fn empty() -> Self {
55 Self::default()
56 }
57
58 /// A context carrying just a username.
59 pub fn for_username(username: &'a str) -> Self {
60 Self {
61 username: Some(username),
62 email: None,
63 }
64 }
65
66 /// A context carrying a username and an email.
67 pub fn new(username: Option<&'a str>, email: Option<&'a str>) -> Self {
68 Self { username, email }
69 }
70}
71
72/// One password-strength rule. Implementors return `Ok(())` when the
73/// password passes and `Err(reason)` with a human-readable message when it
74/// fails. Stateless rules (most of them) are zero-cost; rules that carry
75/// config (e.g. [`MinLengthValidator`]'s threshold) own their data.
76pub trait PasswordValidator: Send + Sync + std::fmt::Debug {
77 /// Check `password` against this rule. `ctx` carries the user
78 /// attributes a rule may compare against (only the similarity rule
79 /// uses it today). Return `Err(reason)` to reject.
80 fn validate(&self, password: &str, ctx: &PasswordContext<'_>) -> Result<(), String>;
81}
82
83// =========================================================================
84// Default validators
85// =========================================================================
86
87/// Reject passwords shorter than `min` characters. The default is 8.
88///
89/// Length is counted in Unicode scalar values (`chars().count()`), not
90/// bytes, so a password of 8 emoji counts as 8.
91#[derive(Debug, Clone, Copy)]
92pub struct MinLengthValidator(pub usize);
93
94impl Default for MinLengthValidator {
95 fn default() -> Self {
96 Self(8)
97 }
98}
99
100impl PasswordValidator for MinLengthValidator {
101 fn validate(&self, password: &str, _ctx: &PasswordContext<'_>) -> Result<(), String> {
102 let len = password.chars().count();
103 if len < self.0 {
104 Err(format!(
105 "This password is too short. It must contain at least {} characters.",
106 self.0
107 ))
108 } else {
109 Ok(())
110 }
111 }
112}
113
114/// The embedded common-password denylist. One password per line, lowercase.
115/// Curated from the well-known "most common passwords" lists. Case-insensitive
116/// matching happens at validation time.
117const COMMON_PASSWORDS: &str = include_str!("common_passwords.txt");
118
119/// Reject passwords that appear in an embedded denylist of common
120/// passwords. Matching is case-insensitive (`PASSWORD` and `password` are
121/// both rejected). We embed a curated few hundred entries (the ones that
122/// matter most) to keep the binary small while still catching the obvious
123/// choices.
124#[derive(Debug, Clone, Copy, Default)]
125pub struct CommonPasswordValidator;
126
127impl PasswordValidator for CommonPasswordValidator {
128 fn validate(&self, password: &str, _ctx: &PasswordContext<'_>) -> Result<(), String> {
129 let lower = password.trim().to_lowercase();
130 // The denylist is line-delimited and stored lowercase; a linear
131 // scan over a few hundred entries is well under a microsecond and
132 // runs once per registration, so a HashSet isn't worth the
133 // allocation.
134 let hit = COMMON_PASSWORDS
135 .lines()
136 .map(str::trim)
137 .filter(|l| !l.is_empty() && !l.starts_with('#'))
138 .any(|entry| entry == lower);
139 if hit {
140 Err("This password is too common.".to_string())
141 } else {
142 Ok(())
143 }
144 }
145}
146
147/// Reject passwords made up entirely of digits (`"12345678"`). An
148/// all-numeric password — even a long one — has far less entropy than a
149/// mixed one and is trivially brute-forced. Empty strings are not numeric
150/// (the min-length rule catches those).
151#[derive(Debug, Clone, Copy, Default)]
152pub struct NumericPasswordValidator;
153
154impl PasswordValidator for NumericPasswordValidator {
155 fn validate(&self, password: &str, _ctx: &PasswordContext<'_>) -> Result<(), String> {
156 if !password.is_empty() && password.chars().all(|c| c.is_ascii_digit()) {
157 Err("This password is entirely numeric.".to_string())
158 } else {
159 Ok(())
160 }
161 }
162}
163
164/// Reject passwords that are too similar to the username or the email
165/// local-part. Uses a similarity ratio with a 0.7 threshold, approximated
166/// with a cheaper two-pronged check that catches the same real-world cases:
167///
168/// 1. **Substring containment** — the password contains the attribute (or
169/// vice-versa), case-insensitive. Catches `alice` → `alice123`.
170/// 2. **Character-overlap ratio** — the fraction of the attribute's
171/// characters present in the password is `>= threshold`. Catches
172/// rearrangements / interleavings that substring containment misses.
173///
174/// Attributes shorter than 3 characters are skipped (a 1-char username
175/// would match almost everything). The email is checked both whole and as
176/// its local-part (before `@`).
177#[derive(Debug, Clone, Copy)]
178pub struct UserAttributeSimilarityValidator {
179 /// Overlap ratio at or above which the password is rejected. The
180 /// default is 0.7.
181 pub threshold: f64,
182}
183
184impl Default for UserAttributeSimilarityValidator {
185 fn default() -> Self {
186 Self { threshold: 0.7 }
187 }
188}
189
190impl UserAttributeSimilarityValidator {
191 /// True when `password` is too similar to `attribute` under this
192 /// validator's rules. Both are lowercased by the caller.
193 fn too_similar(&self, password: &str, attribute: &str) -> bool {
194 if attribute.chars().count() < 3 {
195 return false;
196 }
197 if password.contains(attribute) || attribute.contains(password) {
198 return true;
199 }
200 // Fraction of the attribute's distinct characters that also appear
201 // in the password. A cheap stand-in for SequenceMatcher that still
202 // flags heavy reuse of the attribute's letters.
203 let pw_chars: std::collections::HashSet<char> = password.chars().collect();
204 let attr_chars: std::collections::HashSet<char> = attribute.chars().collect();
205 if attr_chars.is_empty() {
206 return false;
207 }
208 let shared = attr_chars.iter().filter(|c| pw_chars.contains(c)).count();
209 let ratio = shared as f64 / attr_chars.len() as f64;
210 ratio >= self.threshold
211 }
212}
213
214impl PasswordValidator for UserAttributeSimilarityValidator {
215 fn validate(&self, password: &str, ctx: &PasswordContext<'_>) -> Result<(), String> {
216 let pw = password.to_lowercase();
217
218 let mut attributes: Vec<String> = Vec::new();
219 if let Some(username) = ctx.username {
220 attributes.push(username.to_lowercase());
221 }
222 if let Some(email) = ctx.email {
223 let email = email.to_lowercase();
224 if let Some((local, _domain)) = email.split_once('@') {
225 attributes.push(local.to_string());
226 }
227 attributes.push(email);
228 }
229
230 for attribute in attributes {
231 if self.too_similar(&pw, &attribute) {
232 return Err("This password is too similar to your username or email.".to_string());
233 }
234 }
235 Ok(())
236 }
237}
238
239// =========================================================================
240// PasswordPolicy
241// =========================================================================
242
243/// An ordered set of [`PasswordValidator`]s applied to every password.
244///
245/// [`PasswordPolicy::default`] is the default set: min-length 8,
246/// common-password denylist, all-numeric reject, and user-attribute
247/// similarity. Construct a custom policy with [`PasswordPolicy::new`] +
248/// [`PasswordPolicy::with`], or start from the defaults and tweak.
249#[derive(Debug)]
250pub struct PasswordPolicy {
251 validators: Vec<Box<dyn PasswordValidator>>,
252}
253
254impl PasswordPolicy {
255 /// An empty policy — no validators, every password passes. Used as the
256 /// base for a hand-built policy and as the marker for
257 /// [`crate::AuthPlugin::disable_password_validation`].
258 pub fn empty() -> Self {
259 Self {
260 validators: Vec::new(),
261 }
262 }
263
264 /// Alias for [`PasswordPolicy::empty`], read as "no validation".
265 pub fn none() -> Self {
266 Self::empty()
267 }
268
269 /// Start a custom policy from a vector of validators.
270 pub fn new(validators: Vec<Box<dyn PasswordValidator>>) -> Self {
271 Self { validators }
272 }
273
274 /// Append a validator, returning `self` for chaining.
275 pub fn with(mut self, validator: Box<dyn PasswordValidator>) -> Self {
276 self.validators.push(validator);
277 self
278 }
279
280 /// The number of validators in the policy. `0` means no enforcement.
281 pub fn len(&self) -> usize {
282 self.validators.len()
283 }
284
285 /// Whether the policy is empty (no enforcement).
286 pub fn is_empty(&self) -> bool {
287 self.validators.is_empty()
288 }
289
290 /// Run every validator against `password` and collect **all** failure
291 /// reasons. Returns `Ok(())` only when every validator passes.
292 pub fn check(&self, password: &str, ctx: &PasswordContext<'_>) -> Result<(), Vec<String>> {
293 let mut reasons = Vec::new();
294 for validator in &self.validators {
295 if let Err(reason) = validator.validate(password, ctx) {
296 reasons.push(reason);
297 }
298 }
299 if reasons.is_empty() {
300 Ok(())
301 } else {
302 Err(reasons)
303 }
304 }
305}
306
307/// The default policy IS the secure-by-default set: a sensible group of
308/// password validators that are enabled out of the box.
309impl PasswordPolicy {
310 /// The four built-in validators with their default settings. This is
311 /// what an unconfigured `AuthPlugin` enforces.
312 pub fn recommended_defaults() -> Self {
313 Self::new(vec![
314 Box::new(MinLengthValidator::default()),
315 Box::new(CommonPasswordValidator),
316 Box::new(NumericPasswordValidator),
317 Box::new(UserAttributeSimilarityValidator::default()),
318 ])
319 }
320}
321
322// `Default` and `recommended_defaults` are the same thing; keep both so callers
323// can read whichever is clearer at the call site.
324impl PasswordPolicy {
325 /// Construct the default secure policy. Named separately from the
326 /// `Default` trait impl so it reads clearly in the `OnceLock` fallback.
327 fn default_secure() -> Self {
328 Self::recommended_defaults()
329 }
330}
331
332impl std::default::Default for PasswordPolicy {
333 fn default() -> Self {
334 // SECURE BY DEFAULT: a default policy is the full validator set, NOT
335 // an empty one. An app that wants no validation must ask explicitly.
336 Self::recommended_defaults()
337 }
338}
339
340// =========================================================================
341// Ambient install + free-function entry point
342// =========================================================================
343
344/// The process-wide active policy. Installed once in
345/// [`crate::AuthPlugin::on_ready`]. Mirrors the sessions plugin's
346/// `SLIDING_EXPIRY_ENABLED` ambient flag.
347static PASSWORD_POLICY: OnceLock<PasswordPolicy> = OnceLock::new();
348
349/// Install the active policy. Called once at boot from `on_ready`.
350/// Idempotent — the first install wins (same "first wins" contract as the
351/// ambient pool / sliding-expiry flag), so a second plugin or a test that
352/// boots twice in one process can't clobber it.
353pub(crate) fn install_policy(policy: PasswordPolicy) {
354 let _ = PASSWORD_POLICY.set(policy);
355}
356
357/// Validate `password` against the active policy, collecting every failure.
358///
359/// Falls back to [`PasswordPolicy::default`] (the full secure set) when no
360/// policy has been installed yet — so the free-function helpers
361/// (`create_user`, `set_password`) are enforced even before `on_ready`
362/// runs, and in tests that exercise the helpers without a full boot.
363///
364/// Returns `Err(reasons)` listing all the rules the password failed.
365pub fn validate_password(password: &str, ctx: &PasswordContext<'_>) -> Result<(), Vec<String>> {
366 match PASSWORD_POLICY.get() {
367 Some(policy) => policy.check(password, ctx),
368 None => {
369 // Build the default lazily rather than installing it: an
370 // explicit `on_ready` install must still win, so we don't seed
371 // the OnceLock here.
372 PasswordPolicy::default_secure().check(password, ctx)
373 }
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn min_length_rejects_short() {
383 let v = MinLengthValidator::default();
384 assert!(v.validate("abc", &PasswordContext::empty()).is_err());
385 assert!(v.validate("abcdefgh", &PasswordContext::empty()).is_ok());
386 }
387
388 #[test]
389 fn min_length_honours_custom_threshold() {
390 let v = MinLengthValidator(12);
391 assert!(v.validate("abcdefgh", &PasswordContext::empty()).is_err());
392 assert!(
393 v.validate("abcdefghijkl", &PasswordContext::empty())
394 .is_ok()
395 );
396 }
397
398 #[test]
399 fn common_rejects_password_case_insensitive() {
400 let v = CommonPasswordValidator;
401 assert!(v.validate("password", &PasswordContext::empty()).is_err());
402 assert!(v.validate("PASSWORD", &PasswordContext::empty()).is_err());
403 assert!(v.validate("qwerty", &PasswordContext::empty()).is_err());
404 assert!(v.validate("letmein", &PasswordContext::empty()).is_err());
405 assert!(
406 v.validate("Tr0ub4dour&3xpl", &PasswordContext::empty())
407 .is_ok()
408 );
409 }
410
411 #[test]
412 fn numeric_rejects_all_digits() {
413 let v = NumericPasswordValidator;
414 assert!(v.validate("12345678", &PasswordContext::empty()).is_err());
415 assert!(v.validate("0000000000", &PasswordContext::empty()).is_err());
416 assert!(v.validate("abc12345", &PasswordContext::empty()).is_ok());
417 // Empty is handled by min-length, not the numeric rule.
418 assert!(v.validate("", &PasswordContext::empty()).is_ok());
419 }
420
421 #[test]
422 fn similarity_rejects_username_in_password() {
423 let v = UserAttributeSimilarityValidator::default();
424 let ctx = PasswordContext::for_username("alice");
425 assert!(v.validate("alice123", &ctx).is_err());
426 assert!(v.validate("Tr0ub4dour&3xpl", &ctx).is_ok());
427 }
428
429 #[test]
430 fn similarity_uses_email_local_part() {
431 let v = UserAttributeSimilarityValidator::default();
432 let ctx = PasswordContext::new(None, Some("bob.smith@example.com"));
433 assert!(v.validate("bob.smith99", &ctx).is_err());
434 }
435
436 #[test]
437 fn similarity_skips_short_attributes() {
438 let v = UserAttributeSimilarityValidator::default();
439 let ctx = PasswordContext::for_username("ab");
440 // A 2-char username must not flag an unrelated strong password.
441 assert!(v.validate("Tr0ub4dour&3xpl", &ctx).is_ok());
442 }
443
444 #[test]
445 fn policy_aggregates_multiple_failures() {
446 let policy = PasswordPolicy::default();
447 // "alice" → too short, similar to username, and (lowercased) a
448 // common-ish weak choice. Expect at least two distinct reasons.
449 let ctx = PasswordContext::for_username("alice");
450 let err = policy
451 .check("alice", &ctx)
452 .expect_err("weak password must fail");
453 assert!(
454 err.len() >= 2,
455 "expected multiple failure reasons, got {err:?}"
456 );
457 }
458
459 #[test]
460 fn strong_password_passes_all() {
461 let policy = PasswordPolicy::default();
462 let ctx = PasswordContext::new(Some("alice"), Some("alice@example.com"));
463 assert!(
464 policy.check("Tr0ub4dour&3xpl", &ctx).is_ok(),
465 "a strong password must pass the default policy"
466 );
467 }
468
469 #[test]
470 fn default_policy_is_not_empty() {
471 // The load-bearing secure-by-default guarantee.
472 assert!(!PasswordPolicy::default().is_empty());
473 assert_eq!(PasswordPolicy::default().len(), 4);
474 }
475
476 #[test]
477 fn empty_policy_passes_everything() {
478 let policy = PasswordPolicy::empty();
479 assert!(policy.check("a", &PasswordContext::empty()).is_ok());
480 }
481
482 #[test]
483 fn validate_password_falls_back_to_secure_default() {
484 // Even with no install, a weak password is rejected.
485 assert!(validate_password("a", &PasswordContext::empty()).is_err());
486 assert!(validate_password("Tr0ub4dour&3xpl", &PasswordContext::empty()).is_ok());
487 }
488}