use std::borrow::Cow;
use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use russh::client;
use russh::keys::{HashAlg, PrivateKeyWithHashAlg};
use russh::{Disconnect, Preferred, cipher, kex};
use crate::config::GitwayConfig;
use crate::error::{GitwayError, GitwayErrorKind};
use crate::hostkey;
use crate::relay;
struct GitwayHandler {
fingerprints: Vec<String>,
skip_check: bool,
auth_banner: Arc<Mutex<Option<String>>>,
verified_fingerprint: Arc<Mutex<Option<String>>>,
}
impl fmt::Debug for GitwayHandler {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GitwayHandler")
.field("fingerprints", &self.fingerprints)
.field("skip_check", &self.skip_check)
.field("auth_banner", &self.auth_banner)
.field("verified_fingerprint", &self.verified_fingerprint)
.finish()
}
}
impl client::Handler for GitwayHandler {
type Error = GitwayError;
async fn check_server_key(
&mut self,
server_public_key: &russh::keys::ssh_key::PublicKey,
) -> Result<bool, Self::Error> {
if self.skip_check {
log::warn!("host-key verification skipped (--insecure-skip-host-check)");
return Ok(true);
}
let fp = server_public_key
.fingerprint(HashAlg::Sha256)
.to_string();
log::debug!("session: checking server host key {fp}");
if self.fingerprints.iter().any(|f| f == &fp) {
log::debug!("session: host key verified: {fp}");
if let Ok(mut guard) = self.verified_fingerprint.lock() {
*guard = Some(fp);
}
Ok(true)
} else {
Err(GitwayError::host_key_mismatch(fp))
}
}
async fn auth_banner(
&mut self,
banner: &str,
_session: &mut client::Session,
) -> Result<(), Self::Error> {
let trimmed = banner.trim().to_owned();
log::info!("server banner: {banner}");
if let Ok(mut guard) = self.auth_banner.lock() {
*guard = Some(trimmed);
}
Ok(())
}
}
pub struct GitwaySession {
handle: client::Handle<GitwayHandler>,
auth_banner: Arc<Mutex<Option<String>>>,
verified_fingerprint: Arc<Mutex<Option<String>>>,
}
impl fmt::Debug for GitwaySession {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GitwaySession").finish_non_exhaustive()
}
}
impl GitwaySession {
pub async fn connect(config: &GitwayConfig) -> Result<Self, GitwayError> {
let russh_cfg = Arc::new(build_russh_config(config.inactivity_timeout));
let fingerprints =
hostkey::fingerprints_for_host(&config.host, &config.custom_known_hosts)?;
let auth_banner = Arc::new(Mutex::new(None));
let verified_fingerprint = Arc::new(Mutex::new(None));
let handler = GitwayHandler {
fingerprints,
skip_check: config.skip_host_check,
auth_banner: Arc::clone(&auth_banner),
verified_fingerprint: Arc::clone(&verified_fingerprint),
};
log::debug!("session: connecting to {}:{}", config.host, config.port);
let handle = client::connect(
russh_cfg,
(config.host.as_str(), config.port),
handler,
)
.await?;
log::debug!("session: SSH handshake complete with {}", config.host);
Ok(Self { handle, auth_banner, verified_fingerprint })
}
pub async fn authenticate(
&mut self,
username: &str,
key: PrivateKeyWithHashAlg,
) -> Result<(), GitwayError> {
log::debug!("session: authenticating as {username}");
let result = self.handle.authenticate_publickey(username, key).await?;
if result.success() {
log::debug!("session: authentication succeeded for {username}");
Ok(())
} else {
Err(GitwayError::authentication_failed())
}
}
pub async fn authenticate_with_cert(
&mut self,
username: &str,
key: russh::keys::PrivateKey,
cert: russh::keys::Certificate,
) -> Result<(), GitwayError> {
log::debug!("session: authenticating as {username} with OpenSSH certificate");
let result = self
.handle
.authenticate_openssh_cert(username, Arc::new(key), cert)
.await?;
if result.success() {
log::debug!("session: certificate authentication succeeded for {username}");
Ok(())
} else {
Err(GitwayError::authentication_failed())
}
}
pub async fn authenticate_best(&mut self, config: &GitwayConfig) -> Result<(), GitwayError> {
use crate::auth::{IdentityResolution, find_identity, wrap_key};
let resolution = find_identity(config)?;
match resolution {
IdentityResolution::Found { key, .. } => {
return self.auth_key_or_cert(config, key).await;
}
IdentityResolution::Encrypted { path } => {
log::debug!(
"session: key at {} is passphrase-protected; trying SSH agent first",
path.display()
);
#[cfg(unix)]
{
use crate::auth::connect_agent;
if let Some(conn) = connect_agent().await? {
match self.authenticate_with_agent(&config.username, conn).await {
Ok(()) => return Ok(()),
Err(e) if e.is_authentication_failed() => {
log::debug!(
"session: agent could not authenticate; \
will request passphrase for {}",
path.display()
);
}
Err(e) => return Err(e),
}
}
}
return Err(GitwayError::new(GitwayErrorKind::Keys(
russh::keys::Error::KeyIsEncrypted,
)));
}
IdentityResolution::NotFound => {
}
}
#[cfg(unix)]
{
use crate::auth::connect_agent;
if let Some(conn) = connect_agent().await? {
return self.authenticate_with_agent(&config.username, conn).await;
}
}
let _ = wrap_key; Err(GitwayError::no_key_found())
}
pub async fn authenticate_with_passphrase(
&mut self,
config: &GitwayConfig,
path: &std::path::Path,
passphrase: &str,
) -> Result<(), GitwayError> {
use crate::auth::load_encrypted_key;
let key = load_encrypted_key(path, passphrase)?;
self.auth_key_or_cert(config, key).await
}
#[cfg(unix)]
pub async fn authenticate_with_agent(
&mut self,
username: &str,
mut conn: crate::auth::AgentConnection,
) -> Result<(), GitwayError> {
use russh::keys::agent::AgentIdentity;
for identity in conn.identities.clone() {
let result = match &identity {
AgentIdentity::PublicKey { key, .. } => {
let hash_alg = if key.algorithm().is_rsa() {
self.handle
.best_supported_rsa_hash()
.await?
.flatten()
.or(Some(HashAlg::Sha256))
} else {
None
};
self.handle
.authenticate_publickey_with(
username,
key.clone(),
hash_alg,
&mut conn.client,
)
.await
.map_err(GitwayError::from)
}
AgentIdentity::Certificate { certificate, .. } => {
self.handle
.authenticate_certificate_with(
username,
certificate.clone(),
None,
&mut conn.client,
)
.await
.map_err(GitwayError::from)
}
};
match result? {
r if r.success() => {
log::debug!("session: agent authentication succeeded");
return Ok(());
}
_ => {
log::debug!("session: agent identity rejected; trying next");
}
}
}
Err(GitwayError::no_key_found())
}
pub async fn exec(&mut self, command: &str) -> Result<u32, GitwayError> {
log::debug!("session: opening exec channel for '{command}'");
let channel = self.handle.channel_open_session().await?;
channel.exec(true, command).await?;
let exit_code = relay::relay_channel(channel).await?;
log::debug!("session: command '{command}' exited with code {exit_code}");
Ok(exit_code)
}
pub async fn close(self) -> Result<(), GitwayError> {
self.handle
.disconnect(Disconnect::ByApplication, "", "English")
.await?;
Ok(())
}
#[must_use]
pub fn auth_banner(&self) -> Option<String> {
self.auth_banner
.lock()
.expect("auth_banner lock is not poisoned")
.clone()
}
#[must_use]
pub fn verified_fingerprint(&self) -> Option<String> {
self.verified_fingerprint
.lock()
.expect("verified_fingerprint lock is not poisoned")
.clone()
}
async fn auth_key_or_cert(
&mut self,
config: &GitwayConfig,
key: russh::keys::PrivateKey,
) -> Result<(), GitwayError> {
use crate::auth::{load_cert, wrap_key};
if let Some(ref cert_path) = config.cert_file {
let cert = load_cert(cert_path)?;
return self
.authenticate_with_cert(&config.username, key, cert)
.await;
}
let rsa_hash = if key.algorithm().is_rsa() {
self.handle
.best_supported_rsa_hash()
.await?
.flatten()
.or(Some(HashAlg::Sha256))
} else {
None
};
let wrapped = wrap_key(key, rsa_hash);
self.authenticate(&config.username, wrapped).await
}
}
fn build_russh_config(inactivity_timeout: Duration) -> client::Config {
client::Config {
inactivity_timeout: Some(inactivity_timeout),
preferred: Preferred {
kex: Cow::Owned(vec![
kex::CURVE25519, kex::CURVE25519_PRE_RFC_8731, kex::EXTENSION_SUPPORT_AS_CLIENT, ]),
cipher: Cow::Owned(vec![
cipher::CHACHA20_POLY1305, ]),
..Default::default()
},
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_cipher_excludes_3des() {
let config = build_russh_config(Duration::from_secs(60));
let found = config.preferred.cipher.iter().any(|c| c.as_ref() == "3des-cbc");
assert!(!found, "3DES-CBC must not appear in the cipher list (NFR-6)");
}
#[test]
fn config_key_algorithms_exclude_dsa() {
use russh::keys::Algorithm;
let config = build_russh_config(Duration::from_secs(60));
assert!(
!config.preferred.key.contains(&Algorithm::Dsa),
"DSA must not appear in the key-algorithm list (NFR-6)"
);
}
#[test]
fn config_kex_includes_curve25519() {
let config = build_russh_config(Duration::from_secs(60));
let found = config.preferred.kex.iter().any(|k| k.as_ref() == "curve25519-sha256");
assert!(found, "curve25519-sha256 must be in the kex list (FR-2)");
}
#[test]
fn config_cipher_includes_chacha20_poly1305() {
let config = build_russh_config(Duration::from_secs(60));
let found = config
.preferred
.cipher
.iter()
.any(|c| c.as_ref() == "chacha20-poly1305@openssh.com");
assert!(found, "chacha20-poly1305@openssh.com must be in the cipher list (FR-3)");
}
}