entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Credential types for local username/password authentication.
//!
//! [`Credentials`] pairs a validated [`Username`] with a password that is
//! automatically zeroized on drop. [`Username`] enforces the same validation
//! rules as [`is_valid_username`].

use std::fmt;

use crate::crypto::zeroize::Zeroizing;
use crate::util::validation::is_valid_username;

// ---------------------------------------------------------------------------
// Error Type
// ---------------------------------------------------------------------------

/// Error returned when username validation fails.
///
/// The error message includes the validation rules but never includes
/// the rejected input value (it may contain sensitive information).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsernameError {
    _private: (),
}

impl UsernameError {
    /// Creates a new `UsernameError`.
    const fn new() -> Self {
        Self { _private: () }
    }
}

impl fmt::Display for UsernameError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "username: {}", crate::util::validation::USERNAME_RULES)
    }
}

impl std::error::Error for UsernameError {}

// ---------------------------------------------------------------------------
// Username
// ---------------------------------------------------------------------------

/// A validated username string.
///
/// Constructed via [`Username::new`], which enforces the rules from
/// [`is_valid_username`]. Invalid usernames are rejected at construction
/// time so that downstream code can assume the value is well-formed.
#[derive(Clone, PartialEq, Eq)]
pub struct Username(String);

impl Username {
    /// Creates a new validated username.
    ///
    /// # Errors
    ///
    /// Returns [`UsernameError`] if the username fails validation.
    pub fn new(s: &str) -> Result<Self, UsernameError> {
        if is_valid_username(s) {
            Ok(Self(s.to_owned()))
        } else {
            Err(UsernameError::new())
        }
    }

    /// Returns the username as a string slice.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Debug for Username {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_tuple("Username").field(&self.0).finish()
    }
}

impl fmt::Display for Username {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

// ---------------------------------------------------------------------------
// Credentials
// ---------------------------------------------------------------------------

/// A username/password pair with automatic zeroization of the password.
///
/// The password is stored in a [`Zeroizing<Vec<u8>>`] wrapper that clears
/// the password bytes from memory when the `Credentials` value is dropped.
pub struct Credentials {
    username: Username,
    // SECURITY: Password bytes are zeroized on drop to prevent them from
    // lingering in process memory after authentication completes.
    password: Zeroizing<Vec<u8>>,
}

impl Credentials {
    /// Creates a new credentials pair.
    ///
    /// The username is validated via [`Username::new`]. Returns an error if
    /// the username is invalid.
    ///
    /// # Errors
    ///
    /// Returns [`UsernameError`] if the username fails validation.
    pub fn new(username: &str, password: Vec<u8>) -> Result<Self, UsernameError> {
        let username = Username::new(username)?;
        Ok(Self {
            username,
            password: Zeroizing::new(password),
        })
    }

    /// Returns the validated username.
    #[must_use]
    pub fn username(&self) -> &Username {
        &self.username
    }

    /// Returns the password bytes.
    #[must_use]
    pub fn password(&self) -> &[u8] {
        &self.password
    }
}

// SECURITY: Debug output redacts the password to prevent accidental logging.
impl fmt::Debug for Credentials {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Credentials")
            .field("username", &self.username)
            .field("password", &"[REDACTED]")
            .finish()
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    // --- Username Validation ---

    #[test]
    fn username_valid() {
        let u = Username::new("alice").unwrap();
        assert_eq!(u.as_str(), "alice");
    }

    #[test]
    fn username_with_at_sign() {
        let u = Username::new("alice@example.com").unwrap();
        assert_eq!(u.as_str(), "alice@example.com");
    }

    #[test]
    fn username_rejects_empty() {
        assert!(Username::new("").is_err());
    }

    #[test]
    fn username_rejects_leading_special() {
        assert!(Username::new("-alice").is_err());
        assert!(Username::new(".alice").is_err());
        assert!(Username::new("@alice").is_err());
    }

    #[test]
    fn username_rejects_consecutive_specials() {
        assert!(Username::new("alice..bob").is_err());
    }

    #[test]
    fn username_display() {
        let u = Username::new("alice").unwrap();
        assert_eq!(format!("{u}"), "alice");
    }

    #[test]
    fn username_debug() {
        let u = Username::new("alice").unwrap();
        let debug = format!("{u:?}");
        assert!(
            debug.contains("alice"),
            "debug should show username: {debug}"
        );
    }

    // --- Credentials ---

    #[test]
    fn credentials_creation() {
        let creds = Credentials::new("alice", b"secret".to_vec()).unwrap();
        assert_eq!(creds.username().as_str(), "alice");
        assert_eq!(creds.password(), b"secret");
    }

    #[test]
    fn credentials_rejects_invalid_username() {
        assert!(Credentials::new("", b"secret".to_vec()).is_err());
    }

    #[test]
    fn credentials_debug_redacts_password() {
        let creds = Credentials::new("alice", b"secret-password".to_vec()).unwrap();
        let debug = format!("{creds:?}");
        assert!(
            debug.contains("[REDACTED]"),
            "debug should redact password: {debug}"
        );
        assert!(
            !debug.contains("secret-password"),
            "debug must not contain password: {debug}",
        );
    }

    #[test]
    fn credentials_zeroize_on_drop() {
        // Verify that the Zeroizing wrapper is used. We cannot directly
        // inspect memory after drop, but we can verify the type structure
        // by checking that password() returns the expected bytes before drop.
        let creds = Credentials::new("alice", vec![0xFFu8; 16]).unwrap();
        assert!(creds.password().iter().all(|&b| b == 0xFF));
        // When `creds` is dropped here, Zeroizing<Vec<u8>> clears the bytes.
    }

    // --- UsernameError ---

    #[test]
    fn username_error_display() {
        let err = Username::new("").unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.starts_with("username:"),
            "error should be prefixed: {msg}"
        );
    }

    #[test]
    fn username_error_implements_std_error() {
        let err: Box<dyn std::error::Error> = Box::new(Username::new("").unwrap_err());
        let _ = err.to_string();
    }
}