Skip to main content

arcly_http_identity/
password.rs

1//! Password hashing behind a trait, with an Argon2id default and transparent
2//! bcrypt-verification so legacy hashes migrate on next login.
3//!
4//! All hashing/verification runs on a blocking thread (`spawn_blocking`) — the
5//! Argon2 memory-hard KDF is deliberately expensive and must never occupy the
6//! async reactor.
7
8use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString};
9use argon2::Argon2;
10use async_trait::async_trait;
11use rand::rngs::OsRng;
12
13use crate::error::{IdentityError, Result};
14
15/// Pluggable password hasher. The default is [`Argon2idHasher`]; apps may swap
16/// in a FIPS-validated implementation without touching call sites.
17#[async_trait]
18pub trait PasswordHasher: Send + Sync + 'static {
19    /// Hash `password`, returning a PHC string (`$argon2id$...`).
20    async fn hash(&self, password: String) -> Result<String>;
21
22    /// Constant-time verify `password` against a stored PHC string. Never
23    /// returns an error for a wrong password — only `Ok(false)`.
24    async fn verify(&self, password: String, phc: String) -> Result<bool>;
25
26    /// True when `phc` was produced with weaker parameters (or a different
27    /// algorithm, e.g. bcrypt) than this hasher's current policy, so the caller
28    /// should re-hash on the next successful login.
29    fn needs_rehash(&self, phc: &str) -> bool;
30}
31
32/// Argon2id hasher with OWASP-aligned defaults. Verifies both argon2 *and*
33/// bcrypt hashes (the latter marked `needs_rehash`) for zero-downtime migration
34/// off an older bcrypt store.
35pub struct Argon2idHasher {
36    argon2: Argon2<'static>,
37}
38
39impl Default for Argon2idHasher {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl Argon2idHasher {
46    pub fn new() -> Self {
47        // Argon2::default() is Argon2id v19 with sensible interactive params.
48        Self {
49            argon2: Argon2::default(),
50        }
51    }
52
53    fn is_bcrypt(phc: &str) -> bool {
54        phc.starts_with("$2a$") || phc.starts_with("$2b$") || phc.starts_with("$2y$")
55    }
56}
57
58#[async_trait]
59impl PasswordHasher for Argon2idHasher {
60    async fn hash(&self, password: String) -> Result<String> {
61        // Argon2 is cheap to clone (holds params + a ref to default table).
62        let argon2 = self.argon2.clone();
63        tokio::task::spawn_blocking(move || {
64            let salt = SaltString::generate(&mut OsRng);
65            argon2
66                .hash_password(password.as_bytes(), &salt)
67                .map(|h| h.to_string())
68                .map_err(|e| IdentityError::Internal(format!("argon2 hash: {e}")))
69        })
70        .await
71        .map_err(|e| IdentityError::Internal(format!("join: {e}")))?
72    }
73
74    async fn verify(&self, password: String, phc: String) -> Result<bool> {
75        let argon2 = self.argon2.clone();
76        tokio::task::spawn_blocking(move || {
77            if Self::is_bcrypt(&phc) {
78                // Legacy bcrypt hash — verify with the bcrypt crate.
79                return Ok(bcrypt::verify(&password, &phc).unwrap_or(false));
80            }
81            match PasswordHash::new(&phc) {
82                Ok(parsed) => Ok(argon2.verify_password(password.as_bytes(), &parsed).is_ok()),
83                // Unparseable stored hash → treat as non-match, not a 500, so a
84                // corrupt row can't lock everyone out with server errors.
85                Err(_) => Ok(false),
86            }
87        })
88        .await
89        .map_err(|e| IdentityError::Internal(format!("join: {e}")))?
90    }
91
92    fn needs_rehash(&self, phc: &str) -> bool {
93        // Any bcrypt hash should migrate; an argon2 hash from a different major
94        // param set could also be flagged here in future.
95        Self::is_bcrypt(phc)
96    }
97}
98
99// ─── Password strength policy (H4) ──────────────────────────────────────────
100
101/// Complexity policy enforced at registration / password change. Defaults align
102/// with NIST 800-63B's spirit: a meaningful minimum length and an upper bound
103/// (a hard cap guards the memory-hard hash from a CPU-DoS via megabyte passwords).
104#[derive(Debug, Clone)]
105pub struct PasswordPolicy {
106    pub min_len: usize,
107    pub max_len: usize,
108    pub require_upper: bool,
109    pub require_lower: bool,
110    pub require_digit: bool,
111    pub require_symbol: bool,
112}
113
114impl Default for PasswordPolicy {
115    fn default() -> Self {
116        // Length-first (NIST): 12+ chars, no forced composition, generous cap.
117        Self {
118            min_len: 12,
119            max_len: 4096,
120            require_upper: false,
121            require_lower: false,
122            require_digit: false,
123            require_symbol: false,
124        }
125    }
126}
127
128impl PasswordPolicy {
129    /// Validate a candidate password. Returns [`IdentityError::PasswordRejected`]
130    /// (safe to surface — this is registration, not login) on failure.
131    pub fn validate(&self, password: &str) -> Result<()> {
132        let len = password.chars().count();
133        if len < self.min_len {
134            return Err(reject(format!(
135                "must be at least {} characters",
136                self.min_len
137            )));
138        }
139        if len > self.max_len {
140            return Err(reject(format!(
141                "must be at most {} characters",
142                self.max_len
143            )));
144        }
145        if self.require_upper && !password.chars().any(|c| c.is_ascii_uppercase()) {
146            return Err(reject("must contain an uppercase letter"));
147        }
148        if self.require_lower && !password.chars().any(|c| c.is_ascii_lowercase()) {
149            return Err(reject("must contain a lowercase letter"));
150        }
151        if self.require_digit && !password.chars().any(|c| c.is_ascii_digit()) {
152            return Err(reject("must contain a digit"));
153        }
154        if self.require_symbol && password.chars().all(|c| c.is_alphanumeric()) {
155            return Err(reject("must contain a symbol"));
156        }
157        Ok(())
158    }
159}
160
161fn reject(msg: impl Into<String>) -> IdentityError {
162    IdentityError::PasswordRejected(msg.into())
163}
164
165/// Checks a password against a breach corpus (e.g. HaveIBeenPwned's k-anonymity
166/// range API). The app supplies the backend — the crate makes no network call
167/// and never sends a full password/hash off-box (HIBP uses a 5-char SHA-1
168/// prefix). Return `Ok(true)` when the password is known-breached.
169#[async_trait::async_trait]
170pub trait BreachChecker: Send + Sync + 'static {
171    async fn is_breached(&self, password: &str) -> Result<bool>;
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn policy_enforces_length_and_composition() {
180        let p = PasswordPolicy::default();
181        assert!(p.validate("short").is_err());
182        assert!(p.validate("a-sufficiently-long-passphrase").is_ok());
183
184        let strict = PasswordPolicy {
185            min_len: 8,
186            require_upper: true,
187            require_digit: true,
188            require_symbol: true,
189            ..PasswordPolicy::default()
190        };
191        assert!(strict.validate("alllowercase").is_err());
192        assert!(strict.validate("Str0ng!pass").is_ok());
193    }
194
195    #[tokio::test]
196    async fn argon2_roundtrip() {
197        let h = Argon2idHasher::new();
198        let phc = h.hash("s3cr3t".into()).await.unwrap();
199        assert!(h.verify("s3cr3t".into(), phc.clone()).await.unwrap());
200        assert!(!h.verify("wrong".into(), phc.clone()).await.unwrap());
201        assert!(!h.needs_rehash(&phc));
202    }
203
204    #[tokio::test]
205    async fn bcrypt_verifies_and_flags_rehash() {
206        let h = Argon2idHasher::new();
207        let bcrypt_hash = bcrypt::hash("legacy", 4).unwrap();
208        assert!(h
209            .verify("legacy".into(), bcrypt_hash.clone())
210            .await
211            .unwrap());
212        assert!(h.needs_rehash(&bcrypt_hash));
213    }
214}