diff --git a/.env b/.env index ad3ba92..a8eff0d 100644 --- a/.env +++ b/.env @@ -1,6 +1,8 @@ ASYNC_SSH2_TEST_HOST_IP=10.10.10.2 ASYNC_SSH2_TEST_HOST_PW=root +ASYNC_SSH2_TEST_HOST_PW_MULTI_AUTH=multi-auth ASYNC_SSH2_TEST_HOST_USER=root +ASYNC_SSH2_TEST_HOST_USER_MULTI_AUTH=multi-auth ASYNC_SSH2_TEST_KNOWN_HOSTS=./tests/async-ssh2-tokio/known_hosts ASYNC_SSH2_TEST_CLIENT_PRIV=./tests/client.ed25519 ASYNC_SSH2_TEST_CLIENT_PUB=./tests/client.ed25519.pub diff --git a/Cargo.toml b/Cargo.toml index 0e2f2ea..375754e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,11 +16,11 @@ authors = ["Miyoshi-Ryota "] openssl = [] [dependencies] -russh = { version = "0.55.0", default-features = false, features = ["rsa", "flate2", "ring"] } +russh = { version = "0.61.1", default-features = false, features = ["rsa", "flate2", "ring"] } log = "0.4" -russh-sftp = "2.1.1" +russh-sftp = "2.3.0" thiserror = "2.0.17" -tokio = { version = "1.45.1", features = ["fs"] } +tokio = { version = "1.52.3", features = ["fs"] } [dev-dependencies] dotenv = "0.15.0" diff --git a/src/client.rs b/src/client.rs index 197f077..602e6e8 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,9 +1,12 @@ -use russh::client::KeyboardInteractiveAuthResponse; +use russh::client::{AuthResult, KeyboardInteractiveAuthResponse}; use russh::{ Channel, client::{Config, Handle, Handler, Msg}, }; -use russh_sftp::{client::SftpSession, protocol::OpenFlags}; +use russh_sftp::{ + client::{Config as SftpConfig, SftpSession}, + protocol::OpenFlags, +}; use std::net::SocketAddr; use std::sync::Arc; use std::time::Instant; @@ -13,11 +16,16 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::mpsc; use crate::ToSocketAddrsWithHostname; +use crate::error::AuthenticationError; + +pub trait AuthMethods { + fn methods(&self) -> Vec; +} /// An authentification token. /// -/// Used when creating a [`Client`] for authentification. /// Supports password, private key, public key, SSH agent, and keyboard interactive authentication. +/// Used when creating a [`Client`] for authentification. #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum AuthMethod { @@ -40,6 +48,18 @@ pub enum AuthMethod { KeyboardInteractive(AuthKeyboardInteractive), } +impl AuthMethods for AuthMethod { + fn methods(&self) -> Vec { + vec![self.clone()] + } +} + +impl AuthMethods for Vec { + fn methods(&self) -> Vec { + self.clone() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum SteamingOutput { Stdout(Vec), @@ -247,7 +267,7 @@ impl Client { pub async fn connect( addr: impl ToSocketAddrsWithHostname, username: &str, - auth: AuthMethod, + auth: impl AuthMethods, server_check: ServerCheckMethod, ) -> Result { Self::connect_with_config(addr, username, auth, server_check, Config::default()).await @@ -258,7 +278,7 @@ impl Client { pub async fn connect_with_config( addr: impl ToSocketAddrsWithHostname, username: &str, - auth: AuthMethod, + auth: impl AuthMethods, server_check: ServerCheckMethod, config: Config, ) -> Result { @@ -302,157 +322,251 @@ impl Client { async fn authenticate( handle: &mut Handle, username: &String, - auth: AuthMethod, + auth: impl AuthMethods, ) -> Result<(), crate::Error> { - match auth { - AuthMethod::Password(password) => { - let is_authentificated = handle.authenticate_password(username, password).await?; - if !is_authentificated.success() { - return Err(crate::Error::PasswordWrong); + let mut auth_errors: Vec = Vec::new(); + for auth in auth.methods() { + let auth_error = match auth.clone() { + AuthMethod::Password(password) => { + Self::auth_password(handle, username, password).await } - } - AuthMethod::PrivateKey { key_data, key_pass } => { - let cprivk = russh::keys::decode_secret_key(key_data.as_str(), key_pass.as_deref()) - .map_err(crate::Error::KeyInvalid)?; - let is_authentificated = handle - .authenticate_publickey( - username, - russh::keys::PrivateKeyWithHashAlg::new( - Arc::new(cprivk), - handle.best_supported_rsa_hash().await?.flatten(), - ), - ) - .await?; - if !is_authentificated.success() { - return Err(crate::Error::KeyAuthFailed); + AuthMethod::PrivateKey { key_data, key_pass } => { + Self::auth_private_key(handle, username, key_data, key_pass).await } - } - AuthMethod::PrivateKeyFile { - key_file_path, - key_pass, - } => { - let cprivk = russh::keys::load_secret_key(key_file_path, key_pass.as_deref()) - .map_err(crate::Error::KeyInvalid)?; - let is_authentificated = handle - .authenticate_publickey( - username, - russh::keys::PrivateKeyWithHashAlg::new( - Arc::new(cprivk), - handle.best_supported_rsa_hash().await?.flatten(), - ), - ) - .await?; - if !is_authentificated.success() { - return Err(crate::Error::KeyAuthFailed); + AuthMethod::PrivateKeyFile { + key_file_path, + key_pass, + } => Self::auth_private_key_file(handle, username, key_file_path, key_pass).await, + #[cfg(not(target_os = "windows"))] + AuthMethod::PublicKeyFile { key_file_path } => { + Self::auth_public_key_file(handle, username, key_file_path).await } - } - #[cfg(not(target_os = "windows"))] - AuthMethod::PublicKeyFile { key_file_path } => { - let cpubk = russh::keys::load_public_key(key_file_path) - .map_err(crate::Error::KeyInvalid)?; - let mut agent = russh::keys::agent::client::AgentClient::connect_env() - .await - .unwrap(); - let mut auth_identity: Option = None; - for identity in agent - .request_identities() - .await - .map_err(crate::Error::KeyInvalid)? - { - if identity == cpubk { - auth_identity = Some(identity.clone()); - break; - } + #[cfg(not(target_os = "windows"))] + AuthMethod::Agent => Self::auth_agent(handle, username).await, + AuthMethod::KeyboardInteractive(kbd) => { + Self::auth_keyboard_interactive(handle, username, kbd).await } + }; + if let Err(error) = auth_error { + auth_errors.push(error); + } else { + return Ok(()); + } + } + Err(crate::Error::Authentication(auth_errors)) + } - if auth_identity.is_none() { - return Err(crate::Error::KeyAuthFailed); - } + async fn auth_password( + handle: &mut Handle, + username: &String, + password: String, + ) -> Result<(), AuthenticationError> { + let auth_result = handle.authenticate_password(username, password).await?; + match auth_result { + AuthResult::Success => Ok(()), + AuthResult::Failure { + remaining_methods, + partial_success, + } => Err(AuthenticationError::PasswordWrong { + remaining_methods, + partial_success, + }), + } + } - let is_authentificated = handle - .authenticate_publickey_with( - username, - cpubk, - handle.best_supported_rsa_hash().await?.flatten(), - &mut agent, - ) - .await?; - if !is_authentificated.success() { - return Err(crate::Error::KeyAuthFailed); - } + async fn auth_private_key( + handle: &mut Handle, + username: &String, + key_data: String, + key_pass: Option, + ) -> Result<(), AuthenticationError> { + let cprivk = russh::keys::decode_secret_key(key_data.as_str(), key_pass.as_deref()) + .map_err(AuthenticationError::KeyInvalid)?; + let auth_result = handle + .authenticate_publickey( + username, + russh::keys::PrivateKeyWithHashAlg::new( + Arc::new(cprivk), + handle.best_supported_rsa_hash().await?.flatten(), + ), + ) + .await?; + match auth_result { + AuthResult::Success => Ok(()), + AuthResult::Failure { + remaining_methods, + partial_success, + } => Err(AuthenticationError::KeyAuthFailed { + remaining_methods, + partial_success, + }), + } + } + + async fn auth_private_key_file( + handle: &mut Handle, + username: &String, + key_file_path: PathBuf, + key_pass: Option, + ) -> Result<(), AuthenticationError> { + let cprivk = russh::keys::load_secret_key(key_file_path, key_pass.as_deref()) + .map_err(AuthenticationError::KeyInvalid)?; + let auth_result = handle + .authenticate_publickey( + username, + russh::keys::PrivateKeyWithHashAlg::new( + Arc::new(cprivk), + handle.best_supported_rsa_hash().await?.flatten(), + ), + ) + .await?; + match auth_result { + AuthResult::Success => Ok(()), + AuthResult::Failure { + remaining_methods, + partial_success, + } => Err(AuthenticationError::KeyAuthFailed { + remaining_methods, + partial_success, + }), + } + } + + #[cfg(not(target_os = "windows"))] + async fn auth_public_key_file( + handle: &mut Handle, + username: &String, + key_file_path: PathBuf, + ) -> Result<(), AuthenticationError> { + let cpubk = + russh::keys::load_public_key(key_file_path).map_err(AuthenticationError::KeyInvalid)?; + let mut agent = russh::keys::agent::client::AgentClient::connect_env() + .await + .unwrap(); + let mut auth_identity: Option = None; + for identity in agent + .request_identities() + .await + .map_err(AuthenticationError::KeyInvalid)? + { + if *identity.public_key() == cpubk { + auth_identity = Some(identity.public_key().into_owned()); + break; } - #[cfg(not(target_os = "windows"))] - AuthMethod::Agent => { - let mut agent = russh::keys::agent::client::AgentClient::connect_env() - .await - .map_err(|_| crate::Error::AgentConnectionFailed)?; - - let identities = agent - .request_identities() - .await - .map_err(|_| crate::Error::AgentRequestIdentitiesFailed)?; - - if identities.is_empty() { - return Err(crate::Error::AgentNoIdentities); - } + } - let mut auth_success = false; - for identity in identities { - let result = handle - .authenticate_publickey_with( - username, - identity.clone(), - handle.best_supported_rsa_hash().await?.flatten(), - &mut agent, - ) - .await; - - if let Ok(auth_result) = result - && auth_result.success() - { - auth_success = true; - break; - } - } + if auth_identity.is_none() { + return Err(AuthenticationError::KeyWithoutIdentity); + } - if !auth_success { - return Err(crate::Error::AgentAuthenticationFailed); + let auth_result = handle + .authenticate_publickey_with( + username, + cpubk, + handle.best_supported_rsa_hash().await?.flatten(), + &mut agent, + ) + .await?; + match auth_result { + AuthResult::Success => Ok(()), + AuthResult::Failure { + remaining_methods, + partial_success, + } => Err(AuthenticationError::KeyAuthFailed { + remaining_methods, + partial_success, + }), + } + } + + #[cfg(not(target_os = "windows"))] + async fn auth_agent( + handle: &mut Handle, + username: &String, + ) -> Result<(), AuthenticationError> { + let mut agent = russh::keys::agent::client::AgentClient::connect_env() + .await + .map_err(|_| AuthenticationError::AgentConnectionFailed)?; + + let identities = agent + .request_identities() + .await + .map_err(|_| AuthenticationError::AgentRequestIdentitiesFailed)?; + + let mut last_auth = None; + for identity in identities { + let result = handle + .authenticate_publickey_with( + username, + identity.public_key().into_owned(), + handle.best_supported_rsa_hash().await?.flatten(), + &mut agent, + ) + .await; + if let Ok(auth_result) = result { + let is_success = auth_result.success(); + last_auth = Some(auth_result); + if is_success { + break; } } - AuthMethod::KeyboardInteractive(mut kbd) => { - let mut res = handle - .authenticate_keyboard_interactive_start(username, kbd.submethods) - .await?; - loop { - let prompts = match res { - KeyboardInteractiveAuthResponse::Success => break, - KeyboardInteractiveAuthResponse::Failure { .. } => { - return Err(crate::Error::KeyboardInteractiveAuthFailed); - } - KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => prompts, - }; - - let mut responses = vec![]; - for prompt in prompts { - let Some(pos) = kbd - .responses - .iter() - .position(|pr| pr.matches(&prompt.prompt)) - else { - return Err(crate::Error::KeyboardInteractiveNoResponseForPrompt( - prompt.prompt, - )); - }; - let pr = kbd.responses.remove(pos); - responses.push(pr.response); - } + } + + match last_auth { + Some(AuthResult::Success) => Ok(()), + Some(AuthResult::Failure { + remaining_methods, + partial_success, + }) => Err(AuthenticationError::AgentAuthenticationFailed { + remaining_methods, + partial_success, + }), + None => Err(AuthenticationError::AgentNoIdentities), + } + } - res = handle - .authenticate_keyboard_interactive_respond(responses) - .await?; + async fn auth_keyboard_interactive( + handle: &mut Handle, + username: &String, + mut kbd: AuthKeyboardInteractive, + ) -> Result<(), AuthenticationError> { + let mut res = handle + .authenticate_keyboard_interactive_start(username, kbd.submethods) + .await?; + loop { + let prompts = match res { + KeyboardInteractiveAuthResponse::Success => break, + KeyboardInteractiveAuthResponse::Failure { + remaining_methods, + partial_success, + } => { + return Err(AuthenticationError::KeyboardInteractiveAuthFailed { + remaining_methods, + partial_success, + }); } + KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => prompts, + }; + + let mut responses = vec![]; + for prompt in prompts { + let Some(pos) = kbd + .responses + .iter() + .position(|pr| pr.matches(&prompt.prompt)) + else { + return Err(AuthenticationError::KeyboardInteractiveNoResponseForPrompt( + prompt.prompt, + )); + }; + let pr = kbd.responses.remove(pos); + responses.push(pr.response); } - }; + + res = handle + .authenticate_keyboard_interactive_respond(responses) + .await?; + } Ok(()) } @@ -533,7 +647,18 @@ impl Client { // start sftp session let channel = self.get_channel().await?; channel.request_subsystem(true, "sftp").await?; - let sftp = SftpSession::new_opts(channel.into_stream(), timeout_seconds).await?; + let sftp = if let Some(secs) = timeout_seconds { + SftpSession::new_with_config( + channel.into_stream(), + SftpConfig { + request_timeout_secs: secs, + ..SftpConfig::default() + }, + ) + .await? + } else { + SftpSession::new(channel.into_stream()).await? + }; let file_size = tokio::fs::metadata(&src_file_path).await?.len(); // read file contents locally @@ -1006,6 +1131,8 @@ mod tests { use crate::client::*; use core::time; use dotenv::dotenv; + use russh::{MethodKind, MethodSet}; + use std::panic; use std::path::Path; use std::sync::Once; use tokio::io::AsyncReadExt; @@ -1314,7 +1441,19 @@ mod tests { .expect_err("Client connected with wrong password"); match error { - crate::Error::PasswordWrong => {} + crate::Error::Authentication(mut errors) => { + assert_eq!(errors.len(), 1); + let error = errors.pop().expect("Must contain an entry"); + match error { + AuthenticationError::PasswordWrong { + remaining_methods: _, + partial_success, + } => { + assert!(!partial_success); + } + _ => panic!("Wrong AuthenticationError type"), + } + } _ => panic!("Wrong error type"), } } @@ -1400,10 +1539,22 @@ mod tests { .await; // Should fail with authentication error - assert!(matches!( - result, - Err(crate::Error::AgentAuthenticationFailed) - )); + match result { + Err(crate::Error::Authentication(mut errors)) => { + assert_eq!(errors.len(), 1); + let error = errors.pop().expect("Must contain an entry"); + match error { + AuthenticationError::AgentAuthenticationFailed { + remaining_methods: _, + partial_success, + } => { + assert!(!partial_success); + } + _ => panic!("Wrong AuthenticationError type"), + } + } + _ => panic!("Wrong error type"), + } } #[tokio::test] @@ -1432,7 +1583,14 @@ mod tests { } // Should fail with connection error - assert!(matches!(result, Err(crate::Error::AgentConnectionFailed))); + match result { + Err(crate::Error::Authentication(mut errors)) => { + assert_eq!(errors.len(), 1); + let error = errors.pop().expect("Must contain an entry"); + assert!(matches!(error, AuthenticationError::AgentConnectionFailed)); + } + _ => panic!("Wrong error type"), + } } #[tokio::test] @@ -1487,9 +1645,10 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthKeyboardInteractive::new() - .with_response("Password", env("ASYNC_SSH2_TEST_HOST_PW")) - .into(), + AuthMethod::from( + AuthKeyboardInteractive::new() + .with_response("Password", env("ASYNC_SSH2_TEST_HOST_PW")), + ), ServerCheckMethod::NoCheck, ) .await; @@ -1501,9 +1660,10 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthKeyboardInteractive::new() - .with_response_exact("Password: ", env("ASYNC_SSH2_TEST_HOST_PW")) - .into(), + AuthMethod::from( + AuthKeyboardInteractive::new() + .with_response_exact("Password: ", env("ASYNC_SSH2_TEST_HOST_PW")), + ), ServerCheckMethod::NoCheck, ) .await; @@ -1515,18 +1675,27 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthKeyboardInteractive::new() - .with_response_exact("Password: ", "wrong password") - .into(), + AuthMethod::from( + AuthKeyboardInteractive::new().with_response_exact("Password: ", "wrong password"), + ), ServerCheckMethod::NoCheck, ) .await; match client { - Err(crate::error::Error::KeyboardInteractiveAuthFailed) => {} - Err(e) => { - panic!("Expected KeyboardInteractiveAuthFailed error. Got error: {e:?}") + Err(crate::Error::Authentication(mut errors)) => { + assert_eq!(errors.len(), 1); + let error = errors.pop().expect("Must contain an entry"); + match error { + AuthenticationError::KeyboardInteractiveAuthFailed { + remaining_methods: _, + partial_success, + } => { + assert!(!partial_success); + } + _ => panic!("Wrong AuthenticationError type"), + } } - Ok(_) => panic!("Expected KeyboardInteractiveAuthFailed error."), + _ => panic!("Expected Authentication error."), } } @@ -1535,20 +1704,116 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthKeyboardInteractive::new() - .with_response_exact("Password:", "123") - .into(), + AuthMethod::from( + AuthKeyboardInteractive::new().with_response_exact("Password:", "123"), + ), + ServerCheckMethod::NoCheck, + ) + .await; + match client { + Err(crate::Error::Authentication(mut errors)) => { + assert_eq!(errors.len(), 1); + let error = errors.pop().expect("Must contain an entry"); + match error { + AuthenticationError::KeyboardInteractiveNoResponseForPrompt(prompt) => { + assert_eq!(prompt, "Password: "); + } + e => panic!( + "Expected KeyboardInteractiveNoResponseForPrompt error. Got error: {e:?}" + ), + } + } + _ => panic!("Expected KeyboardInteractiveNoResponseForPrompt error."), + } + } + + #[tokio::test] + async fn multi_auth_password_key_without_key() { + let client = Client::connect( + test_address(), + &env("ASYNC_SSH2_TEST_HOST_USER_MULTI_AUTH"), + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW_MULTI_AUTH")), + ServerCheckMethod::NoCheck, + ) + .await; + match client { + Err(crate::error::Error::Authentication(mut errors)) => { + assert_eq!(errors.len(), 1); + let error = errors.pop().unwrap(); + match error { + AuthenticationError::PasswordWrong { + remaining_methods, + partial_success, + } => { + assert!( + !partial_success, + "We must have the PublicKey method first, so partial_success must be false", + ); + let mut expected_method_set = MethodSet::empty(); + expected_method_set.push(MethodKind::PublicKey); + assert_eq!(remaining_methods, expected_method_set); + } + _ => panic!("Wrong AuthenticationError type"), + } + } + _ => panic!("Expected Authentication error."), + } + } + + #[tokio::test] + async fn multi_auth_password_key_without_password() { + let key = std::fs::read_to_string(env("ASYNC_SSH2_TEST_CLIENT_PRIV")).unwrap(); + + let client = Client::connect( + test_address(), + &env("ASYNC_SSH2_TEST_HOST_USER_MULTI_AUTH"), + AuthMethod::with_key(key.as_str(), None), ServerCheckMethod::NoCheck, ) .await; match client { - Err(crate::error::Error::KeyboardInteractiveNoResponseForPrompt(prompt)) => { - assert_eq!(prompt, "Password: "); + Err(crate::error::Error::Authentication(mut errors)) => { + assert_eq!(errors.len(), 1); + let error = errors.pop().unwrap(); + match error { + AuthenticationError::KeyAuthFailed { + remaining_methods, + partial_success, + } => { + assert!( + partial_success, + "We have the PublicKey method first, so partial_success must be true", + ); + let mut expected_method_set = MethodSet::empty(); + expected_method_set.push(MethodKind::Password); + assert_eq!(remaining_methods, expected_method_set); + } + _ => panic!("Wrong AuthenticationError type"), + } } Err(e) => { - panic!("Expected KeyboardInteractiveNoResponseForPrompt error. Got error: {e:?}") + panic!("Expected Authentication error. Got error: {e:?}") } - Ok(_) => panic!("Expected KeyboardInteractiveNoResponseForPrompt error."), + Ok(_) => panic!("Expected Authentication error."), + } + } + + #[tokio::test] + async fn multi_auth_password_key() { + let key = std::fs::read_to_string(env("ASYNC_SSH2_TEST_CLIENT_PRIV")).unwrap(); + + let client = Client::connect( + test_address(), + &env("ASYNC_SSH2_TEST_HOST_USER_MULTI_AUTH"), + vec![ + AuthMethod::with_key(key.as_str(), None), + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW_MULTI_AUTH")), + ], + ServerCheckMethod::NoCheck, + ) + .await; + if let Err(e) = client { + panic!("Unexpected error: {e:?}"); } } diff --git a/src/error.rs b/src/error.rs index 1a189e5..6cceaea 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,6 @@ use std::io; +use russh::MethodSet; use tokio::sync::mpsc; /// This is the `thiserror` error for all crate errors. @@ -9,16 +10,6 @@ use tokio::sync::mpsc; #[derive(thiserror::Error, Debug)] #[non_exhaustive] pub enum Error { - #[error("Keyboard-interactive authentication failed")] - KeyboardInteractiveAuthFailed, - #[error("No keyboard-interactive response for prompt: {0}")] - KeyboardInteractiveNoResponseForPrompt(String), - #[error("Key authentication failed")] - KeyAuthFailed, - #[error("Unable to load key, bad format or passphrase: {0}")] - KeyInvalid(russh::keys::Error), - #[error("Password authentication failed")] - PasswordWrong, #[error("Invalid address was provided: {0}")] AddressInvalid(io::Error), #[error("The executed command didn't send an exit code")] @@ -29,6 +20,39 @@ pub enum Error { SshError(#[from] russh::Error), #[error("Send error")] SendError(#[from] russh::SendError), + #[error("SFTP error occured: {0}")] + SftpError(#[from] russh_sftp::client::error::Error), + #[error("I/O error")] + IoError(#[from] io::Error), + #[error("Channel send error")] + ChannelSendError(#[from] mpsc::error::SendError>), + #[error("Authentication methods exhausted")] + Authentication(Vec), +} + +#[derive(thiserror::Error, Debug)] +pub enum AuthenticationError { + #[error("Keyboard-interactive authentication failed")] + KeyboardInteractiveAuthFailed { + remaining_methods: MethodSet, + partial_success: bool, + }, + #[error("No keyboard-interactive response for prompt: {0}")] + KeyboardInteractiveNoResponseForPrompt(String), + #[error("Key authentication failed")] + KeyAuthFailed { + remaining_methods: MethodSet, + partial_success: bool, + }, + #[error("Key does not match known identity")] + KeyWithoutIdentity, + #[error("Unable to load key, bad format or passphrase: {0}")] + KeyInvalid(russh::keys::Error), + #[error("Password authentication failed")] + PasswordWrong { + remaining_methods: MethodSet, + partial_success: bool, + }, #[error("Agent auth error")] AgentAuthError(#[from] russh::AgentAuthError), #[error("Failed to connect to SSH agent")] @@ -38,11 +62,10 @@ pub enum Error { #[error("SSH agent has no identities")] AgentNoIdentities, #[error("SSH agent authentication failed")] - AgentAuthenticationFailed, - #[error("SFTP error occured: {0}")] - SftpError(#[from] russh_sftp::client::error::Error), - #[error("I/O error")] - IoError(#[from] io::Error), - #[error("Channel send error")] - ChannelSendError(#[from] mpsc::error::SendError>), + AgentAuthenticationFailed { + remaining_methods: MethodSet, + partial_success: bool, + }, + #[error("Ssh error occured: {0}")] + SshError(#[from] russh::Error), } diff --git a/src/lib.rs b/src/lib.rs index 9e1dc9a..3da6bf1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,8 @@ //! // AuthMethod::with_key(key: &str, passphrase: Option<&str>) //! // if you want to use SSH agent (Unix/Linux only), then use following: //! // AuthMethod::with_agent(); +//! // You can provide multiple AuthMethod, they will be executed in order : +//! // let auth_methods = vec![AuthMethod::with_password("root")]; //! let auth_method = AuthMethod::with_password("root"); //! let mut client = Client::connect( //! ("10.10.10.2", 22), diff --git a/tests/async-ssh2-tokio/Dockerfile b/tests/async-ssh2-tokio/Dockerfile index a9f61a2..55055e8 100644 --- a/tests/async-ssh2-tokio/Dockerfile +++ b/tests/async-ssh2-tokio/Dockerfile @@ -2,7 +2,9 @@ FROM rust:1.90.0 ENV ASYNC_SSH2_TEST_HOST_IP=10.10.10.2 ENV ASYNC_SSH2_TEST_HOST_USER=root +ENV ASYNC_SSH2_TEST_HOST_USER_MULTI_AUTH=multi-auth ENV ASYNC_SSH2_TEST_HOST_PW=root +ENV ASYNC_SSH2_TEST_HOST_PW_MULTI_AUTH=multi-auth ENV ASYNC_SSH2_TEST_HTTP_SERVER_IP=10.10.10.4 ENV ASYNC_SSH2_TEST_HTTP_SERVER_PORT=8000 ENV ASYNC_SSH2_TEST_CLIENT_PRIV=/root/.ssh/id_ed25519 diff --git a/tests/generate_test_keys.sh b/tests/generate_test_keys.sh index c7b9ed4..37e0a3b 100755 --- a/tests/generate_test_keys.sh +++ b/tests/generate_test_keys.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # change to script dir cd "${0%/*}" || exit 1 diff --git a/tests/local_unit_tests.sh b/tests/local_unit_tests.sh index ed3817d..4b42eb3 100755 --- a/tests/local_unit_tests.sh +++ b/tests/local_unit_tests.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # This script makes it easy to run the cargo tests locally without dockers # a user "test" with password "test" is required diff --git a/tests/run_unit_tests.sh b/tests/run_unit_tests.sh index d11e4ba..a22bc35 100755 --- a/tests/run_unit_tests.sh +++ b/tests/run_unit_tests.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # change to script dir cd "${0%/*}" || exit 1 diff --git a/tests/sshd-test/Dockerfile b/tests/sshd-test/Dockerfile index 16b16da..704049f 100644 --- a/tests/sshd-test/Dockerfile +++ b/tests/sshd-test/Dockerfile @@ -12,15 +12,25 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* RUN echo 'root:root' |chpasswd +RUN useradd -m -s /bin/bash multi-auth +RUN echo 'multi-auth:multi-auth' |chpasswd RUN sed -ri 's/^#?PermitRootLogin\s+.*/PermitRootLogin yes/g' /etc/ssh/sshd_config RUN sed -ri 's/^#?PasswordAuthentication.*$/PasswordAuthentication yes/g' /etc/ssh/sshd_config RUN sed -ri 's/^#?KbdInteractiveAuthentication.*$/KbdInteractiveAuthentication yes/g' /etc/ssh/sshd_config +RUN <> /etc/ssh/sshd_config +# Below match is used to test multiple auth method +Match Group multi-auth + AuthenticationMethods publickey,password +EOF +EOR RUN mkdir /var/run/sshd COPY ssh_host_ed25519_key ssh_host_ed25519_key.pub /etc/ssh/ COPY authorized_keys /root/.ssh/authorized_keys +COPY authorized_keys /home/multi-auth/.ssh/authorized_keys RUN chmod 600 ~/.ssh/authorized_keys diff --git a/tests/sshd_debug.sh b/tests/sshd_debug.sh index 3d3c449..c7efc03 100755 --- a/tests/sshd_debug.sh +++ b/tests/sshd_debug.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # This script runs another instance of sshd in verbose debug mode on port 2222 # for debugging SSH connections. It does not use the system's host key.