Skip to main content

pylon_auth/
password.rs

1//! Argon2id password hashing + verification.
2//!
3//! Kept tiny on purpose — no in-memory store, no plugin glue. Password
4//! hashes live on the application's own entity (conventionally a
5//! `passwordHash` column on `User`), so persistence is the same story
6//! as every other row. Router endpoints under `/api/auth/password/*`
7//! call these helpers to mint the hash + verify at login.
8
9use argon2::{
10    password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
11    Argon2, PasswordHash, PasswordVerifier,
12};
13
14/// Hash a password using Argon2id with a random salt. Returns a
15/// PHC-format string carrying the algorithm, params, salt, and hash.
16pub fn hash_password(password: &str) -> String {
17    let salt = SaltString::generate(&mut OsRng);
18    Argon2::default()
19        .hash_password(password.as_bytes(), &salt)
20        .expect("argon2 hash should succeed")
21        .to_string()
22}
23
24/// Verify a password against an Argon2 PHC-format hash. Constant-time
25/// comparison is handled internally by Argon2's `verify_password`.
26pub fn verify_password(password: &str, hash: &str) -> bool {
27    let parsed = match PasswordHash::new(hash) {
28        Ok(h) => h,
29        Err(_) => return false,
30    };
31    Argon2::default()
32        .verify_password(password.as_bytes(), &parsed)
33        .is_ok()
34}
35
36/// A PHC-format hash of a throwaway string — used to equalize response
37/// timing when a login is attempted with an email that isn't registered.
38/// Without this, `known-email + wrong-password` takes ~50ms (Argon2) and
39/// `unknown-email` takes <1ms, letting an attacker enumerate the user
40/// set by response time alone.
41pub fn dummy_hash() -> &'static str {
42    "$argon2id$v=19$m=19456,t=2,p=1$YWFhYWFhYWFhYWFhYWFhYQ$b3W/3pZzm6S8w5qYvJ8y3A"
43}
44
45// ---------------------------------------------------------------------------
46// Strength validation + HIBP / pwned-password check
47// ---------------------------------------------------------------------------
48
49/// Reasons a password may be rejected at registration / change time.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum PasswordPolicyError {
52    /// Shorter than [`MIN_PASSWORD_LEN`] characters.
53    TooShort { got: usize, want: usize },
54    /// Found in the HIBP Pwned Passwords corpus. Carries the count of
55    /// times the password has appeared in a known breach so the
56    /// frontend can surface a meaningful message ("seen 1.4M times").
57    Pwned { occurrences: u64 },
58}
59
60impl std::fmt::Display for PasswordPolicyError {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            Self::TooShort { got, want } => {
64                write!(f, "password too short ({got} chars, need {want})")
65            }
66            Self::Pwned { occurrences } => write!(
67                f,
68                "password appears in {occurrences} known data breaches; choose a different password"
69            ),
70        }
71    }
72}
73
74/// Minimum password length. Better-auth and most modern stacks default
75/// to 8; OWASP says 8+ for users + a strength meter, 14+ for admins.
76/// We pick 10 as a middle ground — measurably better than 8 with no
77/// noticeable UX cost.
78pub const MIN_PASSWORD_LEN: usize = 10;
79
80/// Validate password length. Cheap, pure-rust check. Run *before*
81/// [`check_pwned`] so weak local passwords don't even hit the network.
82pub fn validate_length(password: &str) -> Result<(), PasswordPolicyError> {
83    let n = password.chars().count();
84    if n < MIN_PASSWORD_LEN {
85        return Err(PasswordPolicyError::TooShort {
86            got: n,
87            want: MIN_PASSWORD_LEN,
88        });
89    }
90    Ok(())
91}
92
93/// Check a password against the HIBP Pwned Passwords v3 API using
94/// k-anonymity — only the first 5 chars of the SHA-1 hash leave the
95/// box. Returns `Ok(0)` for "not pwned", `Ok(N)` for "pwned N times",
96/// and `Err(reason)` for HTTP failures (the caller decides whether
97/// to fail-open or fail-closed; pylon's wrappers fail-open so a
98/// service outage doesn't lock everyone out of registration).
99///
100/// API docs: <https://haveibeenpwned.com/API/v3#PwnedPasswords>
101///
102/// Privacy: SHA-1 with k-anonymity is widely audited, doesn't require
103/// an API key, and Cloudflare caches the hash-prefix endpoint for ~1
104/// hour so the actual request typically never hits HIBP itself.
105pub fn check_pwned(password: &str) -> Result<u64, String> {
106    let hash = sha1_hex_upper(password.as_bytes());
107    let (prefix, suffix) = hash.split_at(5);
108    let url = format!("https://api.pwnedpasswords.com/range/{prefix}");
109    let agent = ureq::AgentBuilder::new()
110        .timeout_connect(std::time::Duration::from_secs(5))
111        .timeout_read(std::time::Duration::from_secs(5))
112        .user_agent("pylon-auth")
113        .build();
114    let body = agent
115        .get(&url)
116        // "Add-Padding: true" makes responses constant-size so a
117        // network-level observer can't infer pwned-ness from byte
118        // counts. Free, no downside.
119        .set("Add-Padding", "true")
120        .call()
121        .map_err(|e| format!("hibp request: {e}"))?
122        .into_string()
123        .map_err(|e| format!("hibp body: {e}"))?;
124    Ok(parse_hibp_range(&body, suffix))
125}
126
127/// Parse the HIBP range response (line-separated `SUFFIX:COUNT`) and
128/// return the count for our suffix, or 0 if not found.
129fn parse_hibp_range(body: &str, suffix: &str) -> u64 {
130    for line in body.lines() {
131        let line = line.trim();
132        let Some((s, c)) = line.split_once(':') else {
133            continue;
134        };
135        if s.eq_ignore_ascii_case(suffix) {
136            return c.trim().parse().unwrap_or(0);
137        }
138    }
139    0
140}
141
142/// SHA-1 hex digest in uppercase (HIBP's range API is case-insensitive
143/// but uppercase is the canonical form they document).
144fn sha1_hex_upper(input: &[u8]) -> String {
145    use sha1::{Digest, Sha1};
146    let mut h = Sha1::new();
147    h.update(input);
148    let out = h.finalize();
149    let mut s = String::with_capacity(40);
150    for b in out {
151        use std::fmt::Write;
152        let _ = write!(s, "{b:02X}");
153    }
154    s
155}
156
157/// Combined "is this password OK?" check — length first, then HIBP.
158/// HIBP failures are propagated; the caller decides fail-open/closed.
159pub fn validate(password: &str) -> Result<(), PasswordPolicyError> {
160    validate_length(password)?;
161    match check_pwned(password) {
162        Ok(0) => Ok(()),
163        Ok(n) => Err(PasswordPolicyError::Pwned { occurrences: n }),
164        Err(_) => Ok(()), // fail-open on network error
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn validate_length_rejects_short() {
174        let err = validate_length("short").unwrap_err();
175        assert!(matches!(err, PasswordPolicyError::TooShort { .. }));
176    }
177
178    #[test]
179    fn validate_length_accepts_min_len() {
180        assert!(validate_length("0123456789").is_ok());
181    }
182
183    #[test]
184    fn sha1_known_vector() {
185        // SHA-1("password") = 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
186        let h = sha1_hex_upper(b"password");
187        assert_eq!(h, "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8");
188    }
189
190    #[test]
191    fn parse_hibp_range_finds_match() {
192        // Real shape of HIBP response — `SUFFIX:COUNT` lines.
193        let body = "0018A45C4D1DEF81644B54AB7F969B88D65:1\r\n\
194                    003D68EB55068C33ACE09247EE4C639306B:3\r\n\
195                    012345678901234567890123456789012345:42\r\n";
196        assert_eq!(parse_hibp_range(body, "012345678901234567890123456789012345"), 42);
197        assert_eq!(parse_hibp_range(body, "0018A45C4D1DEF81644B54AB7F969B88D65"), 1);
198        assert_eq!(parse_hibp_range(body, "ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEF"), 0);
199    }
200
201    #[test]
202    fn parse_hibp_range_is_case_insensitive() {
203        let body = "ABCDEF0123456789ABCDEF0123456789ABCD:7\r\n";
204        // Lowercase suffix should still match the uppercase line.
205        assert_eq!(
206            parse_hibp_range(body, "abcdef0123456789abcdef0123456789abcd"),
207            7
208        );
209    }
210}