//! Default SSH handler using russh.
//!
//! Provides a real SSH transport backed by the `russh` crate.
//! Used automatically when no custom [`SshHandler`] is set.
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use base64::Engine;
use super::config::TrustedHostKey;
use super::handler::{SshHandler, SshOutput, SshTarget};
/// Shell-escape a string for safe interpolation into a remote command.
/// Wraps in single quotes and escapes embedded single quotes.
fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
/// SSH client handler with host key verification.
///
/// THREAT[TM-SSH-006]: When strict host key checking is enabled (default),
/// connections are rejected unless the server key matches a trusted key.
struct ClientHandler {
/// Target host for this connection (used to look up trusted keys).
host: String,
/// Whether to reject unknown host keys.
strict: bool,
/// Trusted host keys to verify against.
trusted_keys: Vec<TrustedHostKey>,
}
impl russh::client::Handler for ClientHandler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
server_public_key: &russh::keys::PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
if !self.strict {
// THREAT[TM-SSH-006]: Warn when accepting unverified host keys.
eprintln!(
"WARNING: ssh: accepting unverified host key for '{}' \
(strict_host_key_checking is disabled — vulnerable to MITM)",
self.host
);
return Ok(true);
}
// THREAT[TM-SSH-006]: russh 0.63 widened this callback to also deliver
// CA-signed host *certificates*, not just raw host keys. We have no CA
// trust store, so a certificate can never be verified here. Reject it
// rather than fall back to matching the key embedded in the
// certificate: that would extend trust on the strength of a signature
// chain we never validated, and would silently ignore the validity
// window, principals and critical options the certificate carries.
// Configuring the host key directly stays the supported path.
let server_public_key = match server_public_key {
russh::keys::PublicKeyOrCertificate::PublicKey { key, .. } => key,
russh::keys::PublicKeyOrCertificate::Certificate(_) => {
eprintln!(
"WARNING: ssh: rejecting host certificate for '{}' \
(certificate host keys are not supported; \
configure the host's public key as a trusted key instead)",
self.host
);
return Ok(false);
}
};
// Serialize the server key for comparison.
let server_key_str = server_public_key.to_string();
for trusted in &self.trusted_keys {
if trusted.host != self.host && trusted.host != "*" {
continue;
}
// Compare the key type+data portion.
if keys_match(&server_key_str, &trusted.public_key) {
return Ok(true);
}
}
eprintln!(
"WARNING: ssh: rejecting unknown host key for '{}' \
(no matching trusted key configured)",
self.host
);
Ok(false)
}
}
/// Compare two SSH public key strings, ignoring trailing comments.
/// Accepts formats like "ssh-ed25519 AAAA..." or "ssh-ed25519 AAAA... comment".
fn keys_match(server_key: &str, trusted_key: &str) -> bool {
fn normalize(s: &str) -> (&str, &str) {
let parts: Vec<&str> = s.trim().splitn(3, ' ').collect();
if parts.len() >= 2 {
(parts[0], parts[1])
} else {
(s.trim(), "")
}
}
let (s_type, s_data) = normalize(server_key);
let (t_type, t_data) = normalize(trusted_key);
s_type == t_type && s_data == t_data
}
/// Default SSH transport using russh.
///
/// Supports password and private key authentication.
/// SCP/SFTP are implemented via remote commands (`cat`, `base64`).
pub struct RusshHandler {
timeout: Duration,
/// THREAT[TM-SSH-004]: Streaming size limit to prevent OOM from malicious servers.
max_response_bytes: usize,
/// THREAT[TM-SSH-006]: Whether to verify host keys.
strict_host_key_checking: bool,
/// Trusted host keys for verification.
trusted_host_keys: Vec<TrustedHostKey>,
}
impl RusshHandler {
pub fn new(
timeout: Duration,
max_response_bytes: usize,
strict_host_key_checking: bool,
trusted_host_keys: Vec<TrustedHostKey>,
) -> Self {
Self {
timeout,
max_response_bytes,
strict_host_key_checking,
trusted_host_keys,
}
}
/// Connect and authenticate to a remote host.
async fn connect(
&self,
target: &SshTarget,
) -> std::result::Result<russh::client::Handle<ClientHandler>, String> {
let config = russh::client::Config {
inactivity_timeout: Some(self.timeout),
..<_>::default()
};
let handler = ClientHandler {
host: target.host.clone(),
strict: self.strict_host_key_checking,
trusted_keys: self.trusted_host_keys.clone(),
};
let addr = (target.host.as_str(), target.port);
let mut session = russh::client::connect(Arc::new(config), addr, handler)
.await
.map_err(|e| format!("connection failed: {e}"))?;
// Authenticate: try "none" first so the server can succeed without
// ever seeing the configured password/key (TM-SSH secrets-exposure,
// issue #1574). Only fall back to credentials if the server rejects
// none-auth.
let none_auth = session
.authenticate_none(&target.user)
.await
.map_err(|e| format!("auth failed: {e}"))?;
if none_auth.success() {
return Ok(session);
}
if let Some(ref key_pem) = target.private_key {
let key_pair = russh::keys::PrivateKey::from_openssh(key_pem.as_bytes())
.map_err(|e| format!("invalid private key: {e}"))?;
let auth = session
.authenticate_publickey(
&target.user,
russh::keys::PrivateKeyWithHashAlg::new(
Arc::new(key_pair),
session
.best_supported_rsa_hash()
.await
.ok()
.flatten()
.flatten(),
),
)
.await
.map_err(|e| format!("publickey auth failed: {e}"))?;
if !auth.success() {
return Err("publickey authentication rejected".to_string());
}
} else if let Some(ref password) = target.password {
let auth = session
.authenticate_password(&target.user, password)
.await
.map_err(|e| format!("password auth failed: {e}"))?;
if !auth.success() {
return Err("password authentication rejected".to_string());
}
} else {
return Err("ssh: authentication failed (server requires credentials)".to_string());
}
Ok(session)
}
}
#[async_trait]
impl SshHandler for RusshHandler {
async fn exec(
&self,
target: &SshTarget,
command: &str,
) -> std::result::Result<SshOutput, String> {
let session = self.connect(target).await?;
let mut channel = session
.channel_open_session()
.await
.map_err(|e| format!("channel open failed: {e}"))?;
channel
.exec(true, command)
.await
.map_err(|e| format!("exec failed: {e}"))?;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_code: Option<u32> = None;
loop {
let Some(msg) = channel.wait().await else {
break;
};
match msg {
russh::ChannelMsg::Data { ref data } => {
stdout.extend_from_slice(data);
}
russh::ChannelMsg::ExtendedData { ref data, ext: 1 } => {
// stderr
stderr.extend_from_slice(data);
}
russh::ChannelMsg::ExtendedData { .. } => {}
russh::ChannelMsg::ExitStatus { exit_status } => {
exit_code = Some(exit_status);
}
_ => {}
}
// THREAT[TM-SSH-004]: Enforce streaming size limit to prevent OOM
if stdout.len() + stderr.len() > self.max_response_bytes {
let _ = channel.close().await;
let _ = session
.disconnect(russh::Disconnect::ByApplication, "", "")
.await;
return Err(format!(
"ssh: response too large (streaming limit exceeded, max {} bytes)",
self.max_response_bytes
));
}
}
let _ = session
.disconnect(russh::Disconnect::ByApplication, "", "")
.await;
Ok(SshOutput {
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
exit_code: exit_code.unwrap_or(0) as i32,
})
}
async fn shell(&self, target: &SshTarget) -> std::result::Result<SshOutput, String> {
let session = self.connect(target).await?;
let mut channel = session
.channel_open_session()
.await
.map_err(|e| format!("channel open failed: {e}"))?;
// Request a PTY so the remote TUI sends output
channel
.request_pty(false, "xterm", 80, 24, 0, 0, &[])
.await
.map_err(|e| format!("pty request failed: {e}"))?;
channel
.request_shell(true)
.await
.map_err(|e| format!("shell request failed: {e}"))?;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_code: Option<u32> = None;
loop {
let Some(msg) = channel.wait().await else {
break;
};
match msg {
russh::ChannelMsg::Data { ref data } => {
stdout.extend_from_slice(data);
}
russh::ChannelMsg::ExtendedData { ref data, ext: 1 } => {
stderr.extend_from_slice(data);
}
russh::ChannelMsg::ExtendedData { .. } => {}
russh::ChannelMsg::ExitStatus { exit_status } => {
exit_code = Some(exit_status);
}
_ => {}
}
// THREAT[TM-SSH-004]: Enforce streaming size limit to prevent OOM
if stdout.len() + stderr.len() > self.max_response_bytes {
let _ = channel.close().await;
let _ = session
.disconnect(russh::Disconnect::ByApplication, "", "")
.await;
return Err(format!(
"ssh: response too large (streaming limit exceeded, max {} bytes)",
self.max_response_bytes
));
}
}
let _ = session
.disconnect(russh::Disconnect::ByApplication, "", "")
.await;
Ok(SshOutput {
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
exit_code: exit_code.unwrap_or(0) as i32,
})
}
async fn upload(
&self,
target: &SshTarget,
remote_path: &str,
content: &[u8],
mode: u32,
) -> std::result::Result<(), String> {
// THREAT[TM-SSH-008]: Shell-escape remote path to prevent injection
let b64 = base64::engine::general_purpose::STANDARD.encode(content);
let escaped_path = shell_escape(remote_path);
let cmd = format!(
"echo '{}' | base64 -d > {} && chmod {:o} {}",
b64, escaped_path, mode, escaped_path
);
let result = self.exec(target, &cmd).await?;
if result.exit_code != 0 {
return Err(format!(
"upload failed (exit {}): {}",
result.exit_code, result.stderr
));
}
Ok(())
}
async fn download(
&self,
target: &SshTarget,
remote_path: &str,
) -> std::result::Result<Vec<u8>, String> {
// THREAT[TM-SSH-008]: Shell-escape remote path to prevent injection
let cmd = format!("base64 < {}", shell_escape(remote_path));
let result = self.exec(target, &cmd).await?;
if result.exit_code != 0 {
return Err(format!(
"download failed (exit {}): {}",
result.exit_code, result.stderr
));
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(result.stdout.trim())
.map_err(|e| format!("base64 decode failed: {e}"))?;
Ok(decoded)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_russh_handler_stores_max_response_bytes() {
let handler = RusshHandler::new(Duration::from_secs(30), 1024, true, vec![]);
assert_eq!(handler.max_response_bytes, 1024);
}
#[test]
fn test_russh_handler_default_max_response_bytes() {
use super::super::config::DEFAULT_MAX_RESPONSE_BYTES;
let handler = RusshHandler::new(
Duration::from_secs(30),
DEFAULT_MAX_RESPONSE_BYTES,
true,
vec![],
);
assert_eq!(handler.max_response_bytes, 10_000_000);
}
#[test]
fn test_shell_escape() {
assert_eq!(shell_escape("hello"), "'hello'");
assert_eq!(shell_escape("it's"), "'it'\\''s'");
assert_eq!(shell_escape(""), "''");
}
/// Verify the streaming limit is wired through from SshConfig to RusshHandler.
/// The actual streaming enforcement is tested via the mock handler in client.rs tests;
/// here we verify construction and field propagation.
#[test]
fn test_streaming_limit_propagation() {
use super::super::client::SshClient;
use super::super::config::SshConfig;
let config = SshConfig::new().max_response_bytes(512);
let client = SshClient::new(config);
assert_eq!(client.config().max_response_bytes, 512);
}
/// Regression: issue #1574. `authenticate_none` must be attempted before
/// any credential so a server that accepts none-auth never sees the
/// configured default password or key. We assert this by inspecting
/// `connect()` in the source — a unit-level test of the ordering would
/// require a real SSH server.
#[test]
fn test_auth_order_none_first() {
let src = include_str!("russh_handler.rs");
// Locate the `connect` function body.
let connect_start = src
.find("async fn connect(")
.expect("connect fn must exist");
let body = &src[connect_start..];
// Bound the search to the function: stop at the next `}\n}\n` (end of impl).
let none_pos = body
.find(".authenticate_none(")
.expect("authenticate_none must be called in connect");
let key_pos = body
.find(".authenticate_publickey(")
.expect("authenticate_publickey must be called in connect");
let pass_pos = body
.find(".authenticate_password(")
.expect("authenticate_password must be called in connect");
assert!(
none_pos < key_pos,
"authenticate_none must precede authenticate_publickey (#1574)"
);
assert!(
none_pos < pass_pos,
"authenticate_none must precede authenticate_password (#1574)"
);
}
#[test]
fn test_strict_host_key_checking_propagation() {
use super::super::client::SshClient;
use super::super::config::SshConfig;
let config = SshConfig::new().strict_host_key_checking(true);
let client = SshClient::new(config);
assert!(client.config().strict_host_key_checking);
let config = SshConfig::new().strict_host_key_checking(false);
let client = SshClient::new(config);
assert!(!client.config().strict_host_key_checking);
}
#[test]
fn test_keys_match_same_key() {
let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKtQ";
assert!(keys_match(key, key));
}
#[test]
fn test_keys_match_ignores_comment() {
let server = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKtQ";
let trusted = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKtQ user@host";
assert!(keys_match(server, trusted));
}
#[test]
fn test_keys_match_different_key() {
let server = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKtQ";
let trusted = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDiff";
assert!(!keys_match(server, trusted));
}
#[test]
fn test_keys_match_different_type() {
let server = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKtQ";
let trusted = "ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAIKtQ";
assert!(!keys_match(server, trusted));
}
/// An ed25519 host certificate and the public key it certifies. Lifted from
/// the `ssh-key` crate's test vectors so the blob is a real, parseable
/// OpenSSH certificate rather than a hand-rolled one.
const TEST_CERT: &str = "ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIAYkJPGaYen7NK8MwZwWmNAyRaFNsc86AU9NObU2cM2uAAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqtiAAAAAAAAAAAAAAACAAAAB2VkMjU1MTkAAAAUAAAAEGhvc3QuZXhhbXBsZS5jb20AAAAAYkx3NwAAAAB8DuY3AAAAAAAAAAAAAAAAAAAAMwAAAAtzc2gtZWQyNTUxOQAAACCzPq7zfqLffKoBDe/eo04kH2XxtSmk9D7RQyf1xUqrYgAAAFMAAAALc3NoLWVkMjU1MTkAAABApVXBNiYPlPoa1BYH5G4NP9XtjTMZlm7HO5GdbLSvvAw5Vdob7Ka+23hB7isJKHYtzFGGSKXAqxp/Zi8REbCaAw== user@example.com";
const TEST_CERT_INNER_KEY: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti";
/// A second, different key. Only ever used as a *configured trusted key*,
/// which `keys_match` compares as text, so it is never parsed.
const OTHER_KEY: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF3W9vLTBqRDvJmUZeTxJ7pQPqiVLXAiDVTHnDgQqDLm";
fn handler_trusting(keys: &[&str], strict: bool) -> ClientHandler {
ClientHandler {
host: "host.example.com".to_string(),
strict,
trusted_keys: keys
.iter()
.map(|k| TrustedHostKey {
host: "host.example.com".to_string(),
public_key: (*k).to_string(),
})
.collect(),
}
}
fn as_public_key(openssh: &str) -> russh::keys::PublicKeyOrCertificate {
russh::keys::PublicKeyOrCertificate::PublicKey {
key: russh::keys::PublicKey::from_openssh(openssh).expect("valid test key"),
hash_alg: None,
}
}
fn as_certificate() -> russh::keys::PublicKeyOrCertificate {
russh::keys::PublicKeyOrCertificate::Certificate(
russh::keys::Certificate::from_openssh(TEST_CERT).expect("valid test certificate"),
)
}
/// THREAT[TM-SSH-006]: a plain host key matching a trusted entry is accepted.
#[tokio::test]
async fn test_strict_accepts_matching_public_key() {
use russh::client::Handler;
let mut h = handler_trusting(&[TEST_CERT_INNER_KEY], true);
assert!(
h.check_server_key(&as_public_key(TEST_CERT_INNER_KEY))
.await
.unwrap()
);
}
/// THREAT[TM-SSH-006]: a plain host key with no trusted entry is rejected.
#[tokio::test]
async fn test_strict_rejects_unmatched_public_key() {
use russh::client::Handler;
let mut h = handler_trusting(&[OTHER_KEY], true);
assert!(
!h.check_server_key(&as_public_key(TEST_CERT_INNER_KEY))
.await
.unwrap()
);
}
/// THREAT[TM-SSH-006]: russh 0.63 can deliver a CA-signed host certificate
/// here. We have no CA trust store, so it must be rejected in strict mode —
/// *even when the key the certificate wraps is itself trusted*. Matching the
/// embedded key would grant trust on the strength of a signature chain that
/// was never validated, ignoring the certificate's validity window,
/// principals and critical options.
#[tokio::test]
async fn test_strict_rejects_certificate_even_when_inner_key_is_trusted() {
use russh::client::Handler;
let mut h = handler_trusting(&[TEST_CERT_INNER_KEY], true);
assert!(!h.check_server_key(&as_certificate()).await.unwrap());
}
/// A wildcard trusted entry must not open the certificate path either.
#[tokio::test]
async fn test_strict_rejects_certificate_with_wildcard_host() {
use russh::client::Handler;
let mut h = ClientHandler {
host: "host.example.com".to_string(),
strict: true,
trusted_keys: vec![TrustedHostKey {
host: "*".to_string(),
public_key: TEST_CERT_INNER_KEY.to_string(),
}],
};
assert!(!h.check_server_key(&as_certificate()).await.unwrap());
}
/// Non-strict mode is documented as accepting anything, certificates included.
#[tokio::test]
async fn test_non_strict_accepts_certificate() {
use russh::client::Handler;
let mut h = handler_trusting(&[], false);
assert!(h.check_server_key(&as_certificate()).await.unwrap());
}
/// THREAT[TM-SSH-006]: Default strict mode rejects connections with unknown keys.
#[tokio::test]
async fn test_strict_mode_rejects_unknown_key() {
let config = super::super::config::SshConfig::new()
.allow_all()
.strict_host_key_checking(true);
let client = super::super::client::SshClient::new(config);
let target = super::super::handler::SshTarget {
host: "localhost".to_string(),
port: 22,
user: "test".to_string(),
private_key: None,
password: None,
};
// Connection will fail — either because no server is listening,
// or because the host key is unknown. Either way, strict mode
// ensures we don't silently accept keys.
let result = client.exec(&target, "echo hi").await;
assert!(result.is_err());
}
}