Skip to content

Implement password authentication for server #127

@inureyes

Description

@inureyes

Summary

Implement password authentication verification for bssh-server. This provides an alternative authentication method when public key auth is not available.

Parent Epic

Security Considerations

  • Passwords must NEVER be stored in plain text
  • Use secure password hashing (Argon2id recommended, bcrypt acceptable)
  • Implement timing attack mitigation
  • Rate limit authentication attempts
  • Use zeroize crate for password memory cleanup

Implementation Details

1. Password Verifier

// src/server/auth/password.rs
use argon2::{Argon2, PasswordHash, PasswordVerifier as Argon2Verifier};
use zeroize::Zeroizing;

/// Configuration for password authentication
pub struct PasswordAuthConfig {
    /// Path to users file (YAML format)
    pub users_file: Option<PathBuf>,
    /// Inline user definitions (for container environments)
    pub users: Vec<UserDefinition>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct UserDefinition {
    pub name: String,
    /// Password hash (Argon2id or bcrypt format)
    pub password_hash: String,
    pub shell: Option<PathBuf>,
    pub home: Option<PathBuf>,
    pub env: Option<HashMap<String, String>>,
}

pub struct PasswordVerifier {
    config: PasswordAuthConfig,
    users: RwLock<HashMap<String, UserDefinition>>,
}

impl PasswordVerifier {
    pub fn new(config: PasswordAuthConfig) -> Result<Self> {
        let verifier = Self {
            config,
            users: RwLock::new(HashMap::new()),
        };
        // Load users on init
        tokio::task::block_in_place(|| {
            tokio::runtime::Handle::current().block_on(verifier.reload_users())
        })?;
        Ok(verifier)
    }

    /// Reload users from configuration
    pub async fn reload_users(&self) -> Result<()> {
        let mut users = HashMap::new();

        // Load from file if specified
        if let Some(ref path) = self.config.users_file {
            let content = tokio::fs::read_to_string(path).await?;
            let file_users: UsersFile = serde_yaml::from_str(&content)?;
            for user in file_users.users {
                users.insert(user.name.clone(), user);
            }
        }

        // Add inline users (override file users)
        for user in &self.config.users {
            users.insert(user.name.clone(), user.clone());
        }

        *self.users.write().await = users;
        Ok(())
    }

    /// Verify password for user
    pub async fn verify(&self, username: &str, password: &str) -> Result<bool> {
        // Wrap password in Zeroizing for secure cleanup
        let password = Zeroizing::new(password.to_string());

        // Timing attack mitigation: always do work even for non-existent users
        let start = std::time::Instant::now();
        let min_time = Duration::from_millis(100);

        let result = self.verify_internal(username, &password).await;

        // Normalize timing
        let elapsed = start.elapsed();
        if elapsed < min_time {
            tokio::time::sleep(min_time - elapsed).await;
        }

        result
    }

    async fn verify_internal(&self, username: &str, password: &Zeroizing<String>) -> Result<bool> {
        let users = self.users.read().await;

        let user = match users.get(username) {
            Some(u) => u,
            None => {
                // Do dummy hash verification to prevent timing attacks
                let _ = self.verify_dummy_hash(password);
                return Ok(false);
            }
        };

        // Parse and verify password hash
        let hash = PasswordHash::new(&user.password_hash)
            .map_err(|e| anyhow::anyhow!("Invalid password hash format: {}", e))?;

        let argon2 = Argon2::default();
        match argon2.verify_password(password.as_bytes(), &hash) {
            Ok(()) => {
                tracing::info!("Password authentication successful for {}", username);
                Ok(true)
            }
            Err(_) => {
                tracing::debug!("Password authentication failed for {}", username);
                Ok(false)
            }
        }
    }

    /// Dummy hash verification for timing attack mitigation
    fn verify_dummy_hash(&self, password: &Zeroizing<String>) -> bool {
        // Pre-computed dummy hash to verify against
        const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dummy$dummyhash";
        let hash = PasswordHash::new(DUMMY_HASH).unwrap();
        let argon2 = Argon2::default();
        argon2.verify_password(password.as_bytes(), &hash).is_ok()
    }

    /// Get user info after successful auth
    pub async fn get_user_info(&self, username: &str) -> Option<UserInfo> {
        let users = self.users.read().await;
        users.get(username).map(|u| UserInfo {
            username: u.name.clone(),
            home_dir: u.home.clone().unwrap_or_else(|| PathBuf::from("/tmp")),
            shell: u.shell.clone().unwrap_or_else(|| PathBuf::from("/bin/sh")),
            uid: None,
            gid: None,
            env: u.env.clone().unwrap_or_default(),
        })
    }
}

#[derive(Deserialize)]
struct UsersFile {
    users: Vec<UserDefinition>,
}

2. Password Hash Generation Utility

// src/server/auth/password.rs (additional)

/// Generate a password hash for configuration files
pub fn hash_password(password: &str) -> Result<String> {
    use argon2::{
        password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
        Argon2,
    };

    let salt = SaltString::generate(&mut OsRng);
    let argon2 = Argon2::default();
    let hash = argon2
        .hash_password(password.as_bytes(), &salt)
        .map_err(|e| anyhow::anyhow!("Failed to hash password: {}", e))?;

    Ok(hash.to_string())
}

/// CLI subcommand for password hashing
pub fn hash_password_cli() -> Result<()> {
    use rpassword::prompt_password;

    let password = prompt_password("Enter password to hash: ")?;
    let confirm = prompt_password("Confirm password: ")?;

    if password != confirm {
        anyhow::bail!("Passwords do not match");
    }

    let hash = hash_password(&password)?;
    println!("{}", hash);
    Ok(())
}

3. Integrate with SSH Handler

// Update src/server/handler.rs
impl Handler for SshHandler {
    async fn auth_password(
        &mut self,
        user: &str,
        password: &str,
    ) -> Result<Auth, Self::Error> {
        // Check if password auth is enabled
        if !self.config.auth.methods.contains(&AuthMethod::Password) {
            return Ok(Auth::Reject { proceed_with_methods: None });
        }

        // Rate limit check
        if let Some(addr) = &self.peer_addr {
            self.config.security.rate_limiter.try_acquire(&addr.to_string()).await
                .map_err(|_| {
                    tracing::warn!("Rate limited password auth attempt from {}", addr);
                })?;
        }

        // Verify password
        match self.config.auth_provider.verify_password(user, password).await? {
            AuthResult::Accept => {
                self.user = Some(user.to_string());
                tracing::info!("Password auth accepted for user: {}", user);
                Ok(Auth::Accept)
            }
            AuthResult::Reject => {
                // Record failed attempt for rate limiting
                if let Some(addr) = &self.peer_addr {
                    self.config.security.record_failed_auth(&addr.to_string()).await;
                }
                tracing::debug!("Password auth rejected for user: {}", user);
                Ok(Auth::Reject { proceed_with_methods: None })
            }
            _ => Ok(Auth::Reject { proceed_with_methods: None }),
        }
    }
}

4. Configuration

# Server config
auth:
  methods:
    - publickey
    - password
  password:
    # Option 1: External file
    users_file: /etc/bssh/users.yaml
    
    # Option 2: Inline users (useful for containers)
    # users:
    #   - name: admin
    #     password_hash: "$argon2id$v=19$m=19456,t=2,p=1$..."
    #     shell: /bin/bash
    #     home: /home/admin
# /etc/bssh/users.yaml
users:
  - name: admin
    password_hash: "$argon2id$v=19$m=19456,t=2,p=1$randomsalt$hashedpassword"
    shell: /bin/bash
    home: /home/admin
    env:
      EDITOR: vim
      
  - name: deploy
    password_hash: "$argon2id$v=19$m=19456,t=2,p=1$randomsalt$hashedpassword"
    shell: /bin/sh
    home: /home/deploy

Dependencies to Add

[dependencies]
argon2 = "0.5"  # Password hashing

Reference Code

  • src/ssh/auth.rs - Timing attack mitigation patterns
  • src/shared/validation.rs - Username validation

Files to Create/Modify

File Action
src/server/auth/password.rs Create - Password verifier
src/server/handler.rs Modify - Integrate auth_password
src/server/config/types.rs Modify - Add password config
Cargo.toml Modify - Add argon2 dependency

Testing Requirements

  1. Unit test: Password hash verification
  2. Unit test: Timing attack mitigation (verify timing consistency)
  3. Unit test: Invalid hash format handling
  4. Unit test: Non-existent user handling (timing-safe)
  5. Integration test: Authenticate with OpenSSH client using password
# Generate password hash
bssh-server hash-password

# Test authentication
sshpass -p 'testpassword' ssh -p 2222 testuser@localhost

Acceptance Criteria

  • PasswordVerifier implementation with Argon2id
  • Timing attack mitigation for all code paths
  • zeroize used for password memory cleanup
  • Users file loading (YAML format)
  • Inline user configuration support
  • Password hashing utility for generating hashes
  • Rate limiting integration with failed attempts
  • Configuration support
  • Tests passing

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions