use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use async_trait::async_trait;
use russh::keys::ssh_key::PublicKey;
use super::provider::AuthProvider;
use crate::shared::auth_types::{AuthResult, UserInfo};
use crate::shared::validation::validate_username;
#[derive(Debug, Clone)]
pub struct PublicKeyAuthConfig {
pub authorized_keys_dir: Option<PathBuf>,
pub authorized_keys_pattern: Option<String>,
}
impl PublicKeyAuthConfig {
pub fn with_directory(dir: impl Into<PathBuf>) -> Self {
Self {
authorized_keys_dir: Some(dir.into()),
authorized_keys_pattern: None,
}
}
pub fn with_pattern(pattern: impl Into<String>) -> Self {
Self {
authorized_keys_dir: None,
authorized_keys_pattern: Some(pattern.into()),
}
}
fn get_authorized_keys_path(&self, username: &str) -> PathBuf {
if let Some(ref pattern) = self.authorized_keys_pattern {
PathBuf::from(pattern.replace("{user}", username))
} else if let Some(ref dir) = self.authorized_keys_dir {
dir.join(username).join("authorized_keys")
} else {
PathBuf::from(format!(
"{}/.ssh/authorized_keys",
default_home_dir(username)
))
}
}
}
#[cfg(target_os = "macos")]
fn default_home_dir(username: &str) -> String {
format!("/Users/{username}")
}
#[cfg(target_os = "linux")]
fn default_home_dir(username: &str) -> String {
format!("/home/{username}")
}
#[cfg(target_os = "windows")]
fn default_home_dir(username: &str) -> String {
format!("C:\\Users\\{username}")
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn default_home_dir(username: &str) -> String {
format!("/home/{username}")
}
#[cfg(target_os = "macos")]
fn default_authorized_keys_pattern() -> String {
"/Users/{user}/.ssh/authorized_keys".to_string()
}
#[cfg(target_os = "linux")]
fn default_authorized_keys_pattern() -> String {
"/home/{user}/.ssh/authorized_keys".to_string()
}
#[cfg(target_os = "windows")]
fn default_authorized_keys_pattern() -> String {
"C:\\Users\\{user}\\.ssh\\authorized_keys".to_string()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn default_authorized_keys_pattern() -> String {
"/home/{user}/.ssh/authorized_keys".to_string()
}
impl Default for PublicKeyAuthConfig {
fn default() -> Self {
Self {
authorized_keys_dir: None,
authorized_keys_pattern: Some(default_authorized_keys_pattern()),
}
}
}
#[derive(Debug, Clone, 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,
pub no_agent_forwarding: bool,
pub no_x11_forwarding: bool,
}
#[derive(Debug)]
pub struct AuthorizedKey {
pub key: PublicKey,
pub comment: Option<String>,
pub options: AuthKeyOptions,
}
pub struct PublicKeyVerifier {
config: PublicKeyAuthConfig,
}
impl PublicKeyVerifier {
pub fn new(config: PublicKeyAuthConfig) -> Self {
Self { config }
}
pub async fn verify(&self, username: &str, key: &PublicKey) -> Result<bool> {
let username = validate_username(username).context("Invalid username")?;
let authorized_keys = self.load_authorized_keys(&username).await?;
for authorized_key in &authorized_keys {
if self.keys_match(key, authorized_key) {
tracing::info!(
user = %username,
key_type = %key.algorithm(),
"Public key authentication successful"
);
return Ok(true);
}
}
tracing::debug!(
user = %username,
key_type = %key.algorithm(),
authorized_keys_count = %authorized_keys.len(),
"No matching authorized key found"
);
Ok(false)
}
async fn load_authorized_keys(&self, username: &str) -> Result<Vec<AuthorizedKey>> {
let path = self.config.get_authorized_keys_path(username);
#[cfg(unix)]
self.check_file_permissions(&path)?;
let content = match tokio::fs::read_to_string(&path).await {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!(
user = %username,
path = %path.display(),
"No authorized_keys file found"
);
return Ok(Vec::new());
}
Err(e) => {
return Err(e).with_context(|| {
format!("Failed to read authorized_keys file: {}", path.display())
});
}
};
self.parse_authorized_keys(&content)
}
#[cfg(unix)]
fn check_file_permissions(&self, path: &Path) -> Result<()> {
use std::os::unix::fs::MetadataExt;
let metadata = match std::fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(());
}
Err(e) => {
return Err(e)
.with_context(|| format!("Failed to get metadata for {}", path.display()));
}
};
if metadata.is_symlink() {
anyhow::bail!(
"authorized_keys file {} is a symbolic link. Symlinks are not allowed for security reasons.",
path.display()
);
}
let mode = metadata.mode();
if mode & 0o002 != 0 {
anyhow::bail!(
"authorized_keys file {} is world-writable (mode {:o})",
path.display(),
mode & 0o777
);
}
if mode & 0o020 != 0 {
anyhow::bail!(
"authorized_keys file {} is group-writable (mode {:o}). This is a security risk.",
path.display(),
mode & 0o777
);
}
if let Some(parent) = path.parent()
&& let Ok(parent_metadata) = std::fs::symlink_metadata(parent)
{
let parent_mode = parent_metadata.mode();
if parent_mode & 0o002 != 0 {
anyhow::bail!(
"Parent directory {} of authorized_keys is world-writable (mode {:o})",
parent.display(),
parent_mode & 0o777
);
}
if parent_mode & 0o020 != 0 {
tracing::warn!(
"Parent directory {} of authorized_keys is group-writable (mode {:o}). This is a potential security risk.",
parent.display(),
parent_mode & 0o777
);
}
let file_uid = metadata.uid();
let parent_uid = parent_metadata.uid();
if file_uid != parent_uid {
tracing::warn!(
"authorized_keys file {} (uid: {}) and parent directory {} (uid: {}) have different owners",
path.display(),
file_uid,
parent.display(),
parent_uid
);
}
}
Ok(())
}
fn parse_authorized_keys(&self, content: &str) -> Result<Vec<AuthorizedKey>> {
let mut keys = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
match self.parse_authorized_key_line(line) {
Ok(key) => keys.push(key),
Err(e) => {
tracing::warn!(
line = %line_num + 1,
error = %e,
"Failed to parse authorized_keys line"
);
}
}
}
Ok(keys)
}
fn parse_authorized_key_line(&self, line: &str) -> Result<AuthorizedKey> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
anyhow::bail!("Empty line");
}
let (options, key_type_idx) = if is_key_type(parts[0]) {
(AuthKeyOptions::default(), 0)
} else {
let opts = parse_key_options(parts[0])?;
(opts, 1)
};
if parts.len() <= key_type_idx + 1 {
anyhow::bail!("Missing key data");
}
let key_type = parts[key_type_idx];
let key_data = parts[key_type_idx + 1];
let comment = if parts.len() > key_type_idx + 2 {
Some(parts[key_type_idx + 2..].join(" "))
} else {
None
};
let key_str = format!("{key_type} {key_data}");
let key = parse_public_key(&key_str)
.with_context(|| format!("Failed to parse public key of type {key_type}"))?;
Ok(AuthorizedKey {
key,
comment,
options,
})
}
fn keys_match(&self, client_key: &PublicKey, authorized: &AuthorizedKey) -> bool {
client_key == &authorized.key
}
}
#[async_trait]
impl AuthProvider for PublicKeyVerifier {
async fn verify_publickey(&self, username: &str, key: &PublicKey) -> Result<AuthResult> {
match self.verify(username, key).await {
Ok(true) => Ok(AuthResult::Accept),
Ok(false) => Ok(AuthResult::Reject),
Err(e) => {
tracing::error!(
user = %username,
error = %e,
"Error during public key verification"
);
Ok(AuthResult::Reject)
}
}
}
async fn verify_password(&self, _username: &str, _password: &str) -> Result<AuthResult> {
Ok(AuthResult::Reject)
}
async fn get_user_info(&self, username: &str) -> Result<Option<UserInfo>> {
let username = validate_username(username).context("Invalid username")?;
let path = self.config.get_authorized_keys_path(&username);
match std::fs::symlink_metadata(&path) {
Ok(metadata) if metadata.is_file() => Ok(Some(UserInfo::new(username))),
Ok(_) => Ok(None), Err(_) => Ok(None), }
}
async fn user_exists(&self, username: &str) -> Result<bool> {
let username_result = validate_username(username);
let path = self
.config
.get_authorized_keys_path(username_result.as_deref().unwrap_or("_invalid_"));
let file_exists = std::fs::symlink_metadata(&path)
.map(|metadata| metadata.is_file())
.unwrap_or(false);
Ok(username_result.is_ok() && file_exists)
}
}
fn is_key_type(s: &str) -> bool {
matches!(
s,
"ssh-rsa"
| "ssh-dss"
| "ssh-ed25519"
| "ssh-ed448"
| "ecdsa-sha2-nistp256"
| "ecdsa-sha2-nistp384"
| "ecdsa-sha2-nistp521"
| "sk-ssh-ed25519@openssh.com"
| "sk-ecdsa-sha2-nistp256@openssh.com"
)
}
fn parse_key_options(options_str: &str) -> Result<AuthKeyOptions> {
let mut options = AuthKeyOptions::default();
for option in options_str.split(',') {
let option = option.trim();
if option.is_empty() {
continue;
}
if let Some((key, value)) = option.split_once('=') {
let value = value.trim_matches('"');
match key {
"command" => options.command = Some(value.to_string()),
"environment" => options.environment.push(value.to_string()),
"from" => {
for addr in value.split(',') {
options.from.push(addr.trim().to_string());
}
}
_ => {
tracing::debug!(option = %key, "Unknown authorized_keys option");
}
}
} else {
match option {
"no-pty" => options.no_pty = true,
"no-port-forwarding" => options.no_port_forwarding = true,
"no-agent-forwarding" => options.no_agent_forwarding = true,
"no-X11-forwarding" => options.no_x11_forwarding = true,
_ => {
tracing::debug!(option = %option, "Unknown authorized_keys option");
}
}
}
}
Ok(options)
}
fn parse_public_key(key_str: &str) -> Result<PublicKey> {
let parts: Vec<&str> = key_str.split_whitespace().collect();
if parts.len() < 2 {
anyhow::bail!("Invalid key format: expected 'type base64data'");
}
let key_data = parts[1];
russh::keys::parse_public_key_base64(key_data)
.map_err(|e| anyhow::anyhow!("Failed to parse public key: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_key_type() {
assert!(is_key_type("ssh-ed25519"));
assert!(is_key_type("ssh-rsa"));
assert!(is_key_type("ecdsa-sha2-nistp256"));
assert!(!is_key_type("no-pty"));
assert!(!is_key_type("command=\"/bin/date\""));
}
#[test]
fn test_parse_key_options_empty() {
let options = parse_key_options("").unwrap();
assert!(options.command.is_none());
assert!(!options.no_pty);
}
#[test]
fn test_parse_key_options_no_pty() {
let options = parse_key_options("no-pty").unwrap();
assert!(options.no_pty);
}
#[test]
fn test_parse_key_options_multiple() {
let options = parse_key_options("no-pty,no-port-forwarding").unwrap();
assert!(options.no_pty);
assert!(options.no_port_forwarding);
}
#[test]
fn test_parse_key_options_command() {
let options = parse_key_options("command=\"/bin/date\"").unwrap();
assert_eq!(options.command, Some("/bin/date".to_string()));
}
#[test]
fn test_config_with_directory() {
let config = PublicKeyAuthConfig::with_directory("/etc/bssh/keys");
let path = config.get_authorized_keys_path("testuser");
assert_eq!(
path,
PathBuf::from("/etc/bssh/keys/testuser/authorized_keys")
);
}
#[test]
fn test_config_with_pattern() {
let config = PublicKeyAuthConfig::with_pattern("/home/{user}/.ssh/authorized_keys");
let path = config.get_authorized_keys_path("testuser");
assert_eq!(path, PathBuf::from("/home/testuser/.ssh/authorized_keys"));
}
#[test]
fn test_config_default() {
let config = PublicKeyAuthConfig::default();
let path = config.get_authorized_keys_path("testuser");
let expected = PathBuf::from(format!(
"{}/.ssh/authorized_keys",
default_home_dir("testuser")
));
assert_eq!(path, expected);
}
#[test]
fn test_parse_authorized_keys_comments() {
let verifier = PublicKeyVerifier::new(PublicKeyAuthConfig::default());
let content = "# This is a comment\n\n# Another comment\n";
let keys = verifier.parse_authorized_keys(content).unwrap();
assert!(keys.is_empty());
}
#[test]
fn test_parse_authorized_key_line_ed25519() {
let verifier = PublicKeyVerifier::new(PublicKeyAuthConfig::default());
let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl test@example";
let result = verifier.parse_authorized_key_line(line);
assert!(result.is_ok());
let key = result.unwrap();
assert_eq!(key.comment, Some("test@example".to_string()));
}
#[test]
fn test_parse_authorized_key_line_with_options() {
let verifier = PublicKeyVerifier::new(PublicKeyAuthConfig::default());
let line = "no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl test@example";
let result = verifier.parse_authorized_key_line(line);
assert!(result.is_ok());
let key = result.unwrap();
assert!(key.options.no_pty);
}
#[test]
fn test_parse_authorized_key_line_invalid() {
let verifier = PublicKeyVerifier::new(PublicKeyAuthConfig::default());
let line = "ssh-ed25519 notbase64!@#$";
let result = verifier.parse_authorized_key_line(line);
assert!(result.is_err());
}
#[tokio::test]
async fn test_user_exists_invalid_username() {
let verifier = PublicKeyVerifier::new(PublicKeyAuthConfig::default());
let exists = verifier.user_exists("../etc/passwd").await.unwrap();
assert!(!exists);
let exists = verifier.user_exists("").await.unwrap();
assert!(!exists);
}
#[tokio::test]
async fn test_verify_invalid_username() {
let verifier = PublicKeyVerifier::new(PublicKeyAuthConfig::default());
let key_str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl";
let key = parse_public_key(key_str).unwrap();
let result = verifier.verify("../etc/passwd", &key).await;
assert!(result.is_err());
}
}