arcly-http-identity 0.9.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! Password hashing behind a trait, with an Argon2id default and transparent
//! bcrypt-verification so legacy hashes migrate on next login.
//!
//! All hashing/verification runs on a blocking thread (`spawn_blocking`) — the
//! Argon2 memory-hard KDF is deliberately expensive and must never occupy the
//! async reactor.

use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString};
use argon2::Argon2;
use async_trait::async_trait;
use rand::rngs::OsRng;

use crate::error::{IdentityError, Result};

/// Pluggable password hasher. The default is [`Argon2idHasher`]; apps may swap
/// in a FIPS-validated implementation without touching call sites.
#[async_trait]
pub trait PasswordHasher: Send + Sync + 'static {
    /// Hash `password`, returning a PHC string (`$argon2id$...`).
    async fn hash(&self, password: String) -> Result<String>;

    /// Constant-time verify `password` against a stored PHC string. Never
    /// returns an error for a wrong password — only `Ok(false)`.
    async fn verify(&self, password: String, phc: String) -> Result<bool>;

    /// True when `phc` was produced with weaker parameters (or a different
    /// algorithm, e.g. bcrypt) than this hasher's current policy, so the caller
    /// should re-hash on the next successful login.
    fn needs_rehash(&self, phc: &str) -> bool;
}

/// Argon2id hasher with OWASP-aligned defaults. Verifies both argon2 *and*
/// bcrypt hashes (the latter marked `needs_rehash`) for zero-downtime migration
/// off an older bcrypt store.
pub struct Argon2idHasher {
    argon2: Argon2<'static>,
}

impl Default for Argon2idHasher {
    fn default() -> Self {
        Self::new()
    }
}

impl Argon2idHasher {
    pub fn new() -> Self {
        // Argon2::default() is Argon2id v19 with sensible interactive params.
        Self {
            argon2: Argon2::default(),
        }
    }

    fn is_bcrypt(phc: &str) -> bool {
        phc.starts_with("$2a$") || phc.starts_with("$2b$") || phc.starts_with("$2y$")
    }
}

#[async_trait]
impl PasswordHasher for Argon2idHasher {
    async fn hash(&self, password: String) -> Result<String> {
        // Argon2 is cheap to clone (holds params + a ref to default table).
        let argon2 = self.argon2.clone();
        tokio::task::spawn_blocking(move || {
            let salt = SaltString::generate(&mut OsRng);
            argon2
                .hash_password(password.as_bytes(), &salt)
                .map(|h| h.to_string())
                .map_err(|e| IdentityError::Internal(format!("argon2 hash: {e}")))
        })
        .await
        .map_err(|e| IdentityError::Internal(format!("join: {e}")))?
    }

    async fn verify(&self, password: String, phc: String) -> Result<bool> {
        let argon2 = self.argon2.clone();
        tokio::task::spawn_blocking(move || {
            if Self::is_bcrypt(&phc) {
                // Legacy bcrypt hash — verify with the bcrypt crate.
                return Ok(bcrypt::verify(&password, &phc).unwrap_or(false));
            }
            match PasswordHash::new(&phc) {
                Ok(parsed) => Ok(argon2.verify_password(password.as_bytes(), &parsed).is_ok()),
                // Unparseable stored hash → treat as non-match, not a 500, so a
                // corrupt row can't lock everyone out with server errors.
                Err(_) => Ok(false),
            }
        })
        .await
        .map_err(|e| IdentityError::Internal(format!("join: {e}")))?
    }

    fn needs_rehash(&self, phc: &str) -> bool {
        // Any bcrypt hash should migrate; an argon2 hash from a different major
        // param set could also be flagged here in future.
        Self::is_bcrypt(phc)
    }
}

// ─── Password strength policy (H4) ──────────────────────────────────────────

/// Complexity policy enforced at registration / password change. Defaults align
/// with NIST 800-63B's spirit: a meaningful minimum length and an upper bound
/// (a hard cap guards the memory-hard hash from a CPU-DoS via megabyte passwords).
#[derive(Debug, Clone)]
pub struct PasswordPolicy {
    pub min_len: usize,
    pub max_len: usize,
    pub require_upper: bool,
    pub require_lower: bool,
    pub require_digit: bool,
    pub require_symbol: bool,
}

impl Default for PasswordPolicy {
    fn default() -> Self {
        // Length-first (NIST): 12+ chars, no forced composition, generous cap.
        Self {
            min_len: 12,
            max_len: 4096,
            require_upper: false,
            require_lower: false,
            require_digit: false,
            require_symbol: false,
        }
    }
}

impl PasswordPolicy {
    /// Validate a candidate password. Returns [`IdentityError::PasswordRejected`]
    /// (safe to surface — this is registration, not login) on failure.
    pub fn validate(&self, password: &str) -> Result<()> {
        let len = password.chars().count();
        if len < self.min_len {
            return Err(reject(format!(
                "must be at least {} characters",
                self.min_len
            )));
        }
        if len > self.max_len {
            return Err(reject(format!(
                "must be at most {} characters",
                self.max_len
            )));
        }
        if self.require_upper && !password.chars().any(|c| c.is_ascii_uppercase()) {
            return Err(reject("must contain an uppercase letter"));
        }
        if self.require_lower && !password.chars().any(|c| c.is_ascii_lowercase()) {
            return Err(reject("must contain a lowercase letter"));
        }
        if self.require_digit && !password.chars().any(|c| c.is_ascii_digit()) {
            return Err(reject("must contain a digit"));
        }
        if self.require_symbol && password.chars().all(|c| c.is_alphanumeric()) {
            return Err(reject("must contain a symbol"));
        }
        Ok(())
    }
}

fn reject(msg: impl Into<String>) -> IdentityError {
    IdentityError::PasswordRejected(msg.into())
}

/// Checks a password against a breach corpus (e.g. HaveIBeenPwned's k-anonymity
/// range API). The app supplies the backend — the crate makes no network call
/// and never sends a full password/hash off-box (HIBP uses a 5-char SHA-1
/// prefix). Return `Ok(true)` when the password is known-breached.
#[async_trait::async_trait]
pub trait BreachChecker: Send + Sync + 'static {
    async fn is_breached(&self, password: &str) -> Result<bool>;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn policy_enforces_length_and_composition() {
        let p = PasswordPolicy::default();
        assert!(p.validate("short").is_err());
        assert!(p.validate("a-sufficiently-long-passphrase").is_ok());

        let strict = PasswordPolicy {
            min_len: 8,
            require_upper: true,
            require_digit: true,
            require_symbol: true,
            ..PasswordPolicy::default()
        };
        assert!(strict.validate("alllowercase").is_err());
        assert!(strict.validate("Str0ng!pass").is_ok());
    }

    #[tokio::test]
    async fn argon2_roundtrip() {
        let h = Argon2idHasher::new();
        let phc = h.hash("s3cr3t".into()).await.unwrap();
        assert!(h.verify("s3cr3t".into(), phc.clone()).await.unwrap());
        assert!(!h.verify("wrong".into(), phc.clone()).await.unwrap());
        assert!(!h.needs_rehash(&phc));
    }

    #[tokio::test]
    async fn bcrypt_verifies_and_flags_rehash() {
        let h = Argon2idHasher::new();
        let bcrypt_hash = bcrypt::hash("legacy", 4).unwrap();
        assert!(h
            .verify("legacy".into(), bcrypt_hash.clone())
            .await
            .unwrap());
        assert!(h.needs_rehash(&bcrypt_hash));
    }
}