Skip to content

Implement public key authentication for server #126

@inureyes

Description

@inureyes

Summary

Implement public key authentication verification for bssh-server. This allows users to authenticate using their SSH key pairs, matching OpenSSH behavior.

Parent Epic

Implementation Details

1. Create Auth Provider Infrastructure

// src/server/auth/mod.rs
pub mod password;
pub mod publickey;
pub mod provider;

pub use provider::{AuthProvider, AuthResult};
// src/server/auth/provider.rs
use async_trait::async_trait;
use russh::keys::ssh_key::PublicKey;

/// Result of an authentication attempt
#[derive(Debug, Clone)]
pub enum AuthResult {
    /// Authentication successful
    Accept,
    /// Authentication failed
    Reject,
    /// Partial success (multi-factor auth)
    Partial { remaining_methods: Vec<String> },
}

/// User information returned after successful auth
#[derive(Debug, Clone)]
pub struct UserInfo {
    pub username: String,
    pub home_dir: PathBuf,
    pub shell: PathBuf,
    pub uid: Option<u32>,
    pub gid: Option<u32>,
    pub env: HashMap<String, String>,
}

/// Trait for authentication providers (extensible for future methods)
#[async_trait]
pub trait AuthProvider: Send + Sync {
    /// Verify public key authentication
    async fn verify_publickey(
        &self,
        username: &str,
        key: &PublicKey,
    ) -> Result<AuthResult>;

    /// Verify password authentication
    async fn verify_password(
        &self,
        username: &str,
        password: &str,
    ) -> Result<AuthResult>;

    /// Get user information after successful authentication
    async fn get_user_info(&self, username: &str) -> Result<Option<UserInfo>>;

    /// Check if a user exists
    async fn user_exists(&self, username: &str) -> Result<bool>;
}

2. Implement Public Key Verifier

// src/server/auth/publickey.rs
use russh::keys::ssh_key::PublicKey;
use std::path::{Path, PathBuf};

/// Configuration for public key authentication
pub struct PublicKeyAuthConfig {
    /// Directory containing authorized_keys files
    /// Structure: {dir}/{username}/authorized_keys
    pub authorized_keys_dir: PathBuf,
    /// Alternative: single file path template with {user} placeholder
    pub authorized_keys_pattern: Option<String>,
}

/// Public key authentication verifier
pub struct PublicKeyVerifier {
    config: PublicKeyAuthConfig,
}

impl PublicKeyVerifier {
    pub fn new(config: PublicKeyAuthConfig) -> Self {
        Self { config }
    }

    /// Verify if the given public key is authorized for the user
    pub async fn verify(&self, username: &str, key: &PublicKey) -> Result<bool> {
        // Validate username to prevent path traversal
        let username = crate::shared::validation::validate_username(username)?;

        // Load authorized keys for user
        let authorized_keys = self.load_authorized_keys(&username).await?;

        // Check if key matches any authorized key
        for authorized_key in authorized_keys {
            if self.keys_match(key, &authorized_key) {
                tracing::info!("Public key authentication successful for {}", username);
                return Ok(true);
            }
        }

        tracing::debug!("No matching authorized key found for {}", username);
        Ok(false)
    }

    /// Load authorized keys for a user
    async fn load_authorized_keys(&self, username: &str) -> Result<Vec<AuthorizedKey>> {
        let path = self.get_authorized_keys_path(username);
        
        if !path.exists() {
            tracing::debug!("No authorized_keys file for user {}: {:?}", username, path);
            return Ok(Vec::new());
        }

        let content = tokio::fs::read_to_string(&path).await
            .context("Failed to read authorized_keys file")?;

        self.parse_authorized_keys(&content)
    }

    /// Get path to authorized_keys file for user
    fn get_authorized_keys_path(&self, username: &str) -> PathBuf {
        if let Some(pattern) = &self.config.authorized_keys_pattern {
            PathBuf::from(pattern.replace("{user}", username))
        } else {
            self.config.authorized_keys_dir
                .join(username)
                .join("authorized_keys")
        }
    }

    /// Parse authorized_keys file content
    fn parse_authorized_keys(&self, content: &str) -> Result<Vec<AuthorizedKey>> {
        let mut keys = Vec::new();

        for line in content.lines() {
            let line = line.trim();
            
            // Skip empty lines and comments
            if line.is_empty() || line.starts_with('#') {
                continue;
            }

            // Parse authorized_keys format:
            // [options] key-type base64-key [comment]
            match self.parse_authorized_key_line(line) {
                Ok(key) => keys.push(key),
                Err(e) => {
                    tracing::warn!("Failed to parse authorized_keys line: {}", e);
                    continue;
                }
            }
        }

        Ok(keys)
    }

    /// Parse a single authorized_keys line
    fn parse_authorized_key_line(&self, line: &str) -> Result<AuthorizedKey> {
        // Use russh-keys to parse the public key
        let public_key = russh::keys::parse_public_key_base64(line)
            .or_else(|_| {
                // Try parsing as full line (with key type prefix)
                let parts: Vec<&str> = line.split_whitespace().collect();
                if parts.len() >= 2 {
                    let key_data = format!("{} {}", parts[0], parts[1]);
                    russh::keys::parse_public_key_base64(&parts[1])
                } else {
                    Err(russh::keys::Error::CouldNotReadKey)
                }
            })?;

        Ok(AuthorizedKey {
            key: public_key,
            comment: None,
            options: AuthKeyOptions::default(),
        })
    }

    /// Check if two public keys match
    fn keys_match(&self, key1: &PublicKey, key2: &AuthorizedKey) -> bool {
        // Compare key fingerprints
        key1 == &key2.key
    }
}

#[derive(Debug)]
pub struct AuthorizedKey {
    pub key: PublicKey,
    pub comment: Option<String>,
    pub options: AuthKeyOptions,
}

#[derive(Debug, Default)]
pub struct AuthKeyOptions {
    pub command: Option<String>,
    pub environment: Vec<String>,
    pub from: Vec<String>,
    pub no_pty: bool,
    pub no_port_forwarding: bool,
}

3. Integrate with SSH Handler

// Update src/server/handler.rs
impl Handler for SshHandler {
    async fn auth_publickey(
        &mut self,
        user: &str,
        public_key: &russh::keys::ssh_key::PublicKey,
    ) -> Result<Auth, Self::Error> {
        // Rate limit check
        if let Some(addr) = &self.peer_addr {
            if self.config.security.rate_limiter.is_rate_limited(&addr.to_string()).await {
                tracing::warn!("Rate limited auth attempt from {}", addr);
                return Ok(Auth::Reject { proceed_with_methods: None });
            }
        }

        // Verify public key
        match self.config.auth_provider.verify_publickey(user, public_key).await? {
            AuthResult::Accept => {
                self.user = Some(user.to_string());
                tracing::info!("Public key auth accepted for user: {}", user);
                Ok(Auth::Accept)
            }
            AuthResult::Reject => {
                tracing::debug!("Public key auth rejected for user: {}", user);
                Ok(Auth::Reject {
                    proceed_with_methods: Some(MethodSet::PASSWORD),
                })
            }
            AuthResult::Partial { remaining_methods } => {
                // For future multi-factor auth support
                Ok(Auth::Partial {
                    name: "".into(),
                    instructions: "".into(),
                    prompts: vec![],
                })
            }
        }
    }
}

4. Configuration Support

# Server config example
auth:
  methods:
    - publickey
    - password
  publickey:
    authorized_keys_dir: /etc/bssh/authorized_keys/
    # OR
    authorized_keys_pattern: "/home/{user}/.ssh/authorized_keys"

Reference Code

Reuse validation from existing client code:

  • src/security/validation.rs:validate_username()
  • src/ssh/auth.rs - Timing attack mitigation patterns

Files to Create/Modify

File Action
src/server/auth/mod.rs Create - Auth module exports
src/server/auth/provider.rs Create - AuthProvider trait
src/server/auth/publickey.rs Create - Public key verifier
src/server/handler.rs Modify - Integrate auth_publickey
src/server/config/types.rs Modify - Add auth config

Testing Requirements

  1. Unit test: Parse authorized_keys file formats
  2. Unit test: Key comparison logic
  3. Unit test: Path traversal prevention in username
  4. Integration test: Authenticate with OpenSSH client using key
# Generate test key
ssh-keygen -t ed25519 -f /tmp/test_key -N ""

# Add to authorized_keys
mkdir -p /etc/bssh/authorized_keys/testuser
cat /tmp/test_key.pub > /etc/bssh/authorized_keys/testuser/authorized_keys

# Test authentication
ssh -i /tmp/test_key -p 2222 testuser@localhost

Acceptance Criteria

  • AuthProvider trait defined with extensible interface
  • PublicKeyVerifier implementation complete
  • authorized_keys file parsing (standard OpenSSH format)
  • Key comparison using russh-keys
  • Username validation to prevent path traversal
  • Timing attack mitigation (constant-time comparison where possible)
  • Rate limiting integration
  • Configuration support for authorized_keys location
  • Logging for auth attempts (success/failure)
  • 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