From e005f3c76293bfd38971df2260d9e87b78e67d3f Mon Sep 17 00:00:00 2001 From: Wykiki Date: Fri, 12 Sep 2025 14:18:26 +0200 Subject: [PATCH 1/7] feat: Add AuthMethod to handle PrivateKey and Password at the same time --- src/client.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/client.rs b/src/client.rs index 68450a5..ff030b9 100644 --- a/src/client.rs +++ b/src/client.rs @@ -36,6 +36,12 @@ pub enum AuthMethod { #[cfg(not(target_os = "windows"))] Agent, KeyboardInteractive(AuthKeyboardInteractive), + PrivateKeyAndPassword { + password: String, + /// entire contents of private key file + key_data: String, + key_pass: Option, + }, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -443,6 +449,23 @@ impl Client { .await?; } } + AuthMethod::PrivateKeyAndPassword { password, key_data, key_pass } => { + let cprivk = russh::keys::decode_secret_key(key_data.as_str(), key_pass.as_deref()) + .map_err(crate::Error::KeyInvalid)?; + handle + .authenticate_publickey( + username, + russh::keys::PrivateKeyWithHashAlg::new( + Arc::new(cprivk), + handle.best_supported_rsa_hash().await?.flatten(), + ), + ) + .await?; + let is_authentificated = handle.authenticate_password(username, password).await?; + if !is_authentificated.success() { + return Err(crate::Error::KeyAuthFailed); + } + } }; Ok(()) } From dc64569b93fa41b5e871722d81dfc21e1b54ae87 Mon Sep 17 00:00:00 2001 From: Wykiki Date: Thu, 18 Dec 2025 18:10:27 +0100 Subject: [PATCH 2/7] fix: Make shell scripts work on distro like NixOS --- tests/generate_test_keys.sh | 2 +- tests/local_unit_tests.sh | 2 +- tests/run_unit_tests.sh | 2 +- tests/sshd_debug.sh | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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_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. From 7fd0969f5decd2dd150aa1834a659b69c857f90c Mon Sep 17 00:00:00 2001 From: Wykiki Date: Thu, 18 Dec 2025 18:20:01 +0100 Subject: [PATCH 3/7] feat: Ability to provide multiple authentication methods feat: Rework authenticate error handling --- .env | 2 + src/client.rs | 644 +++++++++++++++++++++++++------------ src/error.rs | 57 +++- src/lib.rs | 5 +- tests/sshd-test/Dockerfile | 10 + 5 files changed, 490 insertions(+), 228 deletions(-) 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/src/client.rs b/src/client.rs index 6e0058a..b3972cf 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,4 @@ -use russh::client::KeyboardInteractiveAuthResponse; +use russh::client::{AuthResult, KeyboardInteractiveAuthResponse}; use russh::{ Channel, client::{Config, Handle, Handler, Msg}, @@ -13,11 +13,12 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::mpsc; use crate::ToSocketAddrsWithHostname; +use crate::error::AuthenticationError; /// 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 { @@ -38,12 +39,6 @@ pub enum AuthMethod { #[cfg(not(target_os = "windows"))] Agent, KeyboardInteractive(AuthKeyboardInteractive), - PrivateKeyAndPassword { - password: String, - /// entire contents of private key file - key_data: String, - key_pass: Option, - }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -253,10 +248,10 @@ impl Client { pub async fn connect( addr: impl ToSocketAddrsWithHostname, username: &str, - auth: AuthMethod, + auths: Vec, server_check: ServerCheckMethod, ) -> Result { - Self::connect_with_config(addr, username, auth, server_check, Config::default()).await + Self::connect_with_config(addr, username, auths, server_check, Config::default()).await } /// Same as `connect`, but with the option to specify a non default @@ -264,7 +259,7 @@ impl Client { pub async fn connect_with_config( addr: impl ToSocketAddrsWithHostname, username: &str, - auth: AuthMethod, + auths: Vec, server_check: ServerCheckMethod, config: Config, ) -> Result { @@ -295,7 +290,7 @@ impl Client { let (address, mut handle) = connect_res?; let username = username.to_string(); - Self::authenticate(&mut handle, &username, auth).await?; + Self::authenticate(&mut handle, &username, auths).await?; Ok(Self { connection_handle: Arc::new(handle), @@ -308,174 +303,251 @@ impl Client { async fn authenticate( handle: &mut Handle, username: &String, - auth: AuthMethod, + auths: Vec, ) -> 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 auths { + 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); - } - } - #[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); - } + 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, + }), + } + } - 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; - } - } + 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, + }), + } + } - if !auth_success { - return Err(crate::Error::AgentAuthenticationFailed); - } + #[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 == cpubk { + auth_identity = Some(identity.clone()); + 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); - } + } - res = handle - .authenticate_keyboard_interactive_respond(responses) - .await?; + if auth_identity.is_none() { + return Err(AuthenticationError::KeyWithoutIdentity); + } + + 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.clone(), + 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::PrivateKeyAndPassword { password, key_data, key_pass } => { - let cprivk = russh::keys::decode_secret_key(key_data.as_str(), key_pass.as_deref()) - .map_err(crate::Error::KeyInvalid)?; - handle - .authenticate_publickey( - username, - russh::keys::PrivateKeyWithHashAlg::new( - Arc::new(cprivk), - handle.best_supported_rsa_hash().await?.flatten(), - ), - ) - .await?; - let is_authentificated = handle.authenticate_password(username, password).await?; - if !is_authentificated.success() { - return Err(crate::Error::KeyAuthFailed); + } + + 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), + } + } + + 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(()) } @@ -1029,6 +1101,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; @@ -1091,7 +1165,7 @@ mod tests { env("ASYNC_SSH2_TEST_HOST_PORT").parse().unwrap(), ), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), + vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], ServerCheckMethod::NoCheck, ) .await @@ -1316,7 +1390,7 @@ mod tests { let client = Client::connect( &[SocketAddr::from(([127, 0, 0, 1], 23)), test_address()][..], &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), + vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], ServerCheckMethod::NoCheck, ) .await @@ -1330,14 +1404,26 @@ mod tests { let error = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_password("hopefully the wrong password"), + vec![AuthMethod::with_password("hopefully the wrong password")], ServerCheckMethod::NoCheck, ) .await .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"), } } @@ -1347,7 +1433,7 @@ mod tests { let no_client = Client::connect( "this is definitely not an address", &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_password("hopefully the wrong password"), + vec![AuthMethod::with_password("hopefully the wrong password")], ServerCheckMethod::NoCheck, ) .await; @@ -1359,7 +1445,7 @@ mod tests { let no_client = Client::connect( (env("ASYNC_SSH2_TEST_HOST_IP"), 23), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), + vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], ServerCheckMethod::NoCheck, ) .await; @@ -1372,7 +1458,7 @@ mod tests { let no_client = Client::connect( "172.16.0.6:22", "xxx", - AuthMethod::with_password("xxx"), + vec![AuthMethod::with_password("xxx")], ServerCheckMethod::NoCheck, ) .await; @@ -1384,7 +1470,10 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_key_file(env("ASYNC_SSH2_TEST_CLIENT_PRIV"), None), + vec![AuthMethod::with_key_file( + env("ASYNC_SSH2_TEST_CLIENT_PRIV"), + None, + )], ServerCheckMethod::NoCheck, ) .await; @@ -1399,7 +1488,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_agent(), + vec![AuthMethod::with_agent()], ServerCheckMethod::NoCheck, ) .await @@ -1417,16 +1506,28 @@ mod tests { let result = Client::connect( test_address(), "wrong_user_that_does_not_exist", - AuthMethod::with_agent(), + vec![AuthMethod::with_agent()], ServerCheckMethod::NoCheck, ) .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] @@ -1442,7 +1543,7 @@ mod tests { let result = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_agent(), + vec![AuthMethod::with_agent()], ServerCheckMethod::NoCheck, ) .await; @@ -1455,7 +1556,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] @@ -1463,10 +1571,10 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_key_file( + vec![AuthMethod::with_key_file( env("ASYNC_SSH2_TEST_CLIENT_PROT_PRIV"), Some(&env("ASYNC_SSH2_TEST_CLIENT_PROT_PASS")), - ), + )], ServerCheckMethod::NoCheck, ) .await; @@ -1484,7 +1592,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_key(key.as_str(), None), + vec![AuthMethod::with_key(key.as_str(), None)], ServerCheckMethod::NoCheck, ) .await; @@ -1498,7 +1606,10 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_key(key.as_str(), Some(&env("ASYNC_SSH2_TEST_CLIENT_PROT_PASS"))), + vec![AuthMethod::with_key( + key.as_str(), + Some(&env("ASYNC_SSH2_TEST_CLIENT_PROT_PASS")), + )], ServerCheckMethod::NoCheck, ) .await; @@ -1510,9 +1621,11 @@ 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(), + vec![ + AuthKeyboardInteractive::new() + .with_response("Password", env("ASYNC_SSH2_TEST_HOST_PW")) + .into(), + ], ServerCheckMethod::NoCheck, ) .await; @@ -1524,9 +1637,11 @@ 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(), + vec![ + AuthKeyboardInteractive::new() + .with_response_exact("Password: ", env("ASYNC_SSH2_TEST_HOST_PW")) + .into(), + ], ServerCheckMethod::NoCheck, ) .await; @@ -1538,18 +1653,29 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthKeyboardInteractive::new() - .with_response_exact("Password: ", "wrong password") - .into(), + vec![ + AuthKeyboardInteractive::new() + .with_response_exact("Password: ", "wrong password") + .into(), + ], 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."), } } @@ -1558,20 +1684,120 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthKeyboardInteractive::new() - .with_response_exact("Password:", "123") - .into(), + vec![ + AuthKeyboardInteractive::new() + .with_response_exact("Password:", "123") + .into(), + ], ServerCheckMethod::NoCheck, ) .await; match client { - Err(crate::error::Error::KeyboardInteractiveNoResponseForPrompt(prompt)) => { - assert_eq!(prompt, "Password: "); + 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"), + vec![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"), + vec![AuthMethod::with_key(key.as_str(), None)], + 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::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:?}"); } } @@ -1580,7 +1806,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), + vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], ServerCheckMethod::with_public_key_file(&env("ASYNC_SSH2_TEST_SERVER_PUB")), ) .await; @@ -1600,7 +1826,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), + vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], ServerCheckMethod::with_public_key(key), ) .await; @@ -1612,7 +1838,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), + vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], ServerCheckMethod::with_known_hosts_file(&env("ASYNC_SSH2_TEST_KNOWN_HOSTS")), ) .await; @@ -1624,7 +1850,7 @@ mod tests { let client = Client::connect( test_hostname(), &env("ASYNC_SSH2_TEST_HOST_USER"), - AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), + vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], ServerCheckMethod::with_known_hosts_file(&env("ASYNC_SSH2_TEST_KNOWN_HOSTS")), ) .await; 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..89a6321 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,11 +22,12 @@ //! // AuthMethod::with_key(key: &str, passphrase: Option<&str>) //! // if you want to use SSH agent (Unix/Linux only), then use following: //! // AuthMethod::with_agent(); -//! let auth_method = AuthMethod::with_password("root"); +//! // You can provide multiple AuthMethod, they will be executed in order. +//! let auth_methods = vec![AuthMethod::with_password("root")]; //! let mut client = Client::connect( //! ("10.10.10.2", 22), //! "root", -//! auth_method, +//! auth_methods, //! ServerCheckMethod::NoCheck, //! ).await?; //! 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 From 32feaab19103970856d6e50d0e1599f538958bdb Mon Sep 17 00:00:00 2001 From: Wykiki Date: Mon, 22 Dec 2025 09:54:44 +0100 Subject: [PATCH 4/7] fix(ci): Set new variables in Dockerfile --- tests/async-ssh2-tokio/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) 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 From 25cc86b167f9ce0b9cf56a00ad2335ba33364be8 Mon Sep 17 00:00:00 2001 From: Wykiki Date: Thu, 8 Jan 2026 16:09:57 +0100 Subject: [PATCH 5/7] feat: Able to take either AuthMethod or a Vec for connect --- src/client.rs | 106 +++++++++++++++++++++++++------------------------- 1 file changed, 54 insertions(+), 52 deletions(-) diff --git a/src/client.rs b/src/client.rs index b3972cf..a90565e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -15,6 +15,10 @@ use tokio::sync::mpsc; use crate::ToSocketAddrsWithHostname; use crate::error::AuthenticationError; +pub trait AuthMethods { + fn methods(&self) -> Vec; +} + /// An authentification token. /// /// Supports password, private key, public key, SSH agent, and keyboard interactive authentication. @@ -41,6 +45,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), @@ -248,10 +264,10 @@ impl Client { pub async fn connect( addr: impl ToSocketAddrsWithHostname, username: &str, - auths: Vec, + auth: impl AuthMethods, server_check: ServerCheckMethod, ) -> Result { - Self::connect_with_config(addr, username, auths, server_check, Config::default()).await + Self::connect_with_config(addr, username, auth, server_check, Config::default()).await } /// Same as `connect`, but with the option to specify a non default @@ -259,7 +275,7 @@ impl Client { pub async fn connect_with_config( addr: impl ToSocketAddrsWithHostname, username: &str, - auths: Vec, + auth: impl AuthMethods, server_check: ServerCheckMethod, config: Config, ) -> Result { @@ -290,7 +306,7 @@ impl Client { let (address, mut handle) = connect_res?; let username = username.to_string(); - Self::authenticate(&mut handle, &username, auths).await?; + Self::authenticate(&mut handle, &username, auth).await?; Ok(Self { connection_handle: Arc::new(handle), @@ -303,10 +319,10 @@ impl Client { async fn authenticate( handle: &mut Handle, username: &String, - auths: Vec, + auth: impl AuthMethods, ) -> Result<(), crate::Error> { let mut auth_errors: Vec = Vec::new(); - for auth in auths { + for auth in auth.methods() { let auth_error = match auth.clone() { AuthMethod::Password(password) => { Self::auth_password(handle, username, password).await @@ -1165,7 +1181,7 @@ mod tests { env("ASYNC_SSH2_TEST_HOST_PORT").parse().unwrap(), ), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), ServerCheckMethod::NoCheck, ) .await @@ -1390,7 +1406,7 @@ mod tests { let client = Client::connect( &[SocketAddr::from(([127, 0, 0, 1], 23)), test_address()][..], &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), ServerCheckMethod::NoCheck, ) .await @@ -1404,7 +1420,7 @@ mod tests { let error = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_password("hopefully the wrong password")], + AuthMethod::with_password("hopefully the wrong password"), ServerCheckMethod::NoCheck, ) .await @@ -1433,7 +1449,7 @@ mod tests { let no_client = Client::connect( "this is definitely not an address", &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_password("hopefully the wrong password")], + AuthMethod::with_password("hopefully the wrong password"), ServerCheckMethod::NoCheck, ) .await; @@ -1445,7 +1461,7 @@ mod tests { let no_client = Client::connect( (env("ASYNC_SSH2_TEST_HOST_IP"), 23), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), ServerCheckMethod::NoCheck, ) .await; @@ -1458,7 +1474,7 @@ mod tests { let no_client = Client::connect( "172.16.0.6:22", "xxx", - vec![AuthMethod::with_password("xxx")], + AuthMethod::with_password("xxx"), ServerCheckMethod::NoCheck, ) .await; @@ -1470,10 +1486,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_key_file( - env("ASYNC_SSH2_TEST_CLIENT_PRIV"), - None, - )], + AuthMethod::with_key_file(env("ASYNC_SSH2_TEST_CLIENT_PRIV"), None), ServerCheckMethod::NoCheck, ) .await; @@ -1488,7 +1501,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_agent()], + AuthMethod::with_agent(), ServerCheckMethod::NoCheck, ) .await @@ -1506,7 +1519,7 @@ mod tests { let result = Client::connect( test_address(), "wrong_user_that_does_not_exist", - vec![AuthMethod::with_agent()], + AuthMethod::with_agent(), ServerCheckMethod::NoCheck, ) .await; @@ -1543,7 +1556,7 @@ mod tests { let result = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_agent()], + AuthMethod::with_agent(), ServerCheckMethod::NoCheck, ) .await; @@ -1571,10 +1584,10 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_key_file( + AuthMethod::with_key_file( env("ASYNC_SSH2_TEST_CLIENT_PROT_PRIV"), Some(&env("ASYNC_SSH2_TEST_CLIENT_PROT_PASS")), - )], + ), ServerCheckMethod::NoCheck, ) .await; @@ -1592,7 +1605,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_key(key.as_str(), None)], + AuthMethod::with_key(key.as_str(), None), ServerCheckMethod::NoCheck, ) .await; @@ -1606,10 +1619,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_key( - key.as_str(), - Some(&env("ASYNC_SSH2_TEST_CLIENT_PROT_PASS")), - )], + AuthMethod::with_key(key.as_str(), Some(&env("ASYNC_SSH2_TEST_CLIENT_PROT_PASS"))), ServerCheckMethod::NoCheck, ) .await; @@ -1621,11 +1631,10 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![ + AuthMethod::from( AuthKeyboardInteractive::new() - .with_response("Password", env("ASYNC_SSH2_TEST_HOST_PW")) - .into(), - ], + .with_response("Password", env("ASYNC_SSH2_TEST_HOST_PW")), + ), ServerCheckMethod::NoCheck, ) .await; @@ -1637,11 +1646,10 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![ + AuthMethod::from( AuthKeyboardInteractive::new() - .with_response_exact("Password: ", env("ASYNC_SSH2_TEST_HOST_PW")) - .into(), - ], + .with_response_exact("Password: ", env("ASYNC_SSH2_TEST_HOST_PW")), + ), ServerCheckMethod::NoCheck, ) .await; @@ -1653,11 +1661,9 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![ - AuthKeyboardInteractive::new() - .with_response_exact("Password: ", "wrong password") - .into(), - ], + AuthMethod::from( + AuthKeyboardInteractive::new().with_response_exact("Password: ", "wrong password"), + ), ServerCheckMethod::NoCheck, ) .await; @@ -1684,11 +1690,9 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![ - AuthKeyboardInteractive::new() - .with_response_exact("Password:", "123") - .into(), - ], + AuthMethod::from( + AuthKeyboardInteractive::new().with_response_exact("Password:", "123"), + ), ServerCheckMethod::NoCheck, ) .await; @@ -1714,9 +1718,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER_MULTI_AUTH"), - vec![AuthMethod::with_password(&env( - "ASYNC_SSH2_TEST_HOST_PW_MULTI_AUTH", - ))], + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW_MULTI_AUTH")), ServerCheckMethod::NoCheck, ) .await; @@ -1751,7 +1753,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER_MULTI_AUTH"), - vec![AuthMethod::with_key(key.as_str(), None)], + AuthMethod::with_key(key.as_str(), None), ServerCheckMethod::NoCheck, ) .await; @@ -1806,7 +1808,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), ServerCheckMethod::with_public_key_file(&env("ASYNC_SSH2_TEST_SERVER_PUB")), ) .await; @@ -1826,7 +1828,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), ServerCheckMethod::with_public_key(key), ) .await; @@ -1838,7 +1840,7 @@ mod tests { let client = Client::connect( test_address(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), ServerCheckMethod::with_known_hosts_file(&env("ASYNC_SSH2_TEST_KNOWN_HOSTS")), ) .await; @@ -1850,7 +1852,7 @@ mod tests { let client = Client::connect( test_hostname(), &env("ASYNC_SSH2_TEST_HOST_USER"), - vec![AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW"))], + AuthMethod::with_password(&env("ASYNC_SSH2_TEST_HOST_PW")), ServerCheckMethod::with_known_hosts_file(&env("ASYNC_SSH2_TEST_KNOWN_HOSTS")), ) .await; From e368ddb5b55b08c2b3b37beaf0a3d75ef0bbbf33 Mon Sep 17 00:00:00 2001 From: Wykiki Date: Thu, 8 Jan 2026 16:20:03 +0100 Subject: [PATCH 6/7] doc: Set back usage example --- src/lib.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 89a6321..3da6bf1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,12 +22,13 @@ //! // 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")]; +//! // 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), //! "root", -//! auth_methods, +//! auth_method, //! ServerCheckMethod::NoCheck, //! ).await?; //! From 2d684c4b1a61ddf56a7f706e946f9dced0d8761a Mon Sep 17 00:00:00 2001 From: Wykiki Date: Tue, 2 Jun 2026 15:44:40 +0200 Subject: [PATCH 7/7] chore: Upgrade dependencies versions --- Cargo.toml | 6 +++--- src/client.rs | 24 +++++++++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) 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 a90565e..602e6e8 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3,7 +3,10 @@ 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; @@ -446,8 +449,8 @@ impl Client { .await .map_err(AuthenticationError::KeyInvalid)? { - if identity == cpubk { - auth_identity = Some(identity.clone()); + if *identity.public_key() == cpubk { + auth_identity = Some(identity.public_key().into_owned()); break; } } @@ -495,7 +498,7 @@ impl Client { let result = handle .authenticate_publickey_with( username, - identity.clone(), + identity.public_key().into_owned(), handle.best_supported_rsa_hash().await?.flatten(), &mut agent, ) @@ -644,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