use std::borrow::Cow;
use std::fmt;
use std::sync::{Arc, Mutex};
use russh::client;
use russh::keys::{HashAlg, PrivateKeyWithHashAlg};
use russh::{cipher, kex, Disconnect, Preferred};
use std::path::PathBuf;
use crate::config::AnvilConfig;
use crate::error::{AnvilError, AnvilErrorKind};
use crate::hostkey;
use crate::relay;
use crate::ssh_config::StrictHostKeyChecking;
struct GitwayHandler {
fingerprints: Vec<String>,
revoked: Vec<String>,
policy: StrictHostKeyChecking,
host: String,
custom_known_hosts: Option<PathBuf>,
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("revoked", &self.revoked)
.field("policy", &self.policy)
.field("host", &self.host)
.field("custom_known_hosts", &self.custom_known_hosts)
.field("auth_banner", &self.auth_banner)
.field("verified_fingerprint", &self.verified_fingerprint)
.finish()
}
}
impl client::Handler for GitwayHandler {
type Error = AnvilError;
async fn check_server_key(
&mut self,
server_public_key: &russh::keys::ssh_key::PublicKey,
) -> Result<bool, Self::Error> {
let fp = server_public_key.fingerprint(HashAlg::Sha256).to_string();
let alg = server_public_key.algorithm().as_str().to_owned();
tracing::trace!(
target: crate::log::CAT_KEX,
host = %self.host,
fp = %fp,
alg = %alg,
"check_server_key entry",
);
log::debug!("session: checking server host key {fp}");
if self.revoked.iter().any(|r| r == &fp) {
tracing::warn!(
target: crate::log::CAT_AUTH,
host = %self.host,
fp = %fp,
verdict = "revoked",
"host key in @revoked list",
);
return Err(AnvilError::host_key_mismatch(fp.clone()).with_hint(format!(
"{fp} is listed in a @revoked entry for {} in the known_hosts \
file (M14, FR-64). Refusing the connection unconditionally — \
the key has been explicitly blocklisted. Remove the @revoked \
line if the revocation was a mistake, or rotate the upstream \
host key.",
self.host,
)));
}
if matches!(self.policy, StrictHostKeyChecking::No) {
tracing::warn!(
target: crate::log::CAT_AUTH,
host = %self.host,
fp = %fp,
verdict = "skipped",
"host-key verification skipped (StrictHostKeyChecking=No)",
);
log::warn!("host-key verification skipped (StrictHostKeyChecking=No)");
if let Ok(mut guard) = self.verified_fingerprint.lock() {
*guard = Some(fp);
}
return Ok(true);
}
if self.fingerprints.iter().any(|f| f == &fp) {
tracing::debug!(
target: crate::log::CAT_AUTH,
host = %self.host,
fp = %fp,
verdict = "verified",
"host key matches pinned fingerprint",
);
log::debug!("session: host key verified: {fp}");
if let Ok(mut guard) = self.verified_fingerprint.lock() {
*guard = Some(fp);
}
return Ok(true);
}
if matches!(self.policy, StrictHostKeyChecking::AcceptNew) && self.fingerprints.is_empty() {
if let Some(path) = &self.custom_known_hosts {
hostkey::append_known_host(path, &self.host, &fp)?;
tracing::info!(
target: crate::log::CAT_AUTH,
host = %self.host,
fp = %fp,
path = %path.display(),
verdict = "accepted_new",
"host-key first-use accepted (AcceptNew)",
);
log::info!(
"host-key first-use accepted: {} -> {} (recorded in {})",
self.host,
fp,
path.display(),
);
if let Ok(mut guard) = self.verified_fingerprint.lock() {
*guard = Some(fp);
}
return Ok(true);
}
log::warn!(
"StrictHostKeyChecking=accept-new requested but no \
custom_known_hosts path is set; downgrading to Yes \
semantics for {}",
self.host,
);
}
tracing::warn!(
target: crate::log::CAT_AUTH,
host = %self.host,
fp = %fp,
verdict = "mismatch",
"host-key fingerprint did not match any pinned entry",
);
Err(AnvilError::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 AnvilSession {
handle: client::Handle<GitwayHandler>,
auth_banner: Arc<Mutex<Option<String>>>,
verified_fingerprint: Arc<Mutex<Option<String>>>,
retry_history: Vec<crate::retry::RetryAttempt>,
}
impl fmt::Debug for AnvilSession {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AnvilSession").finish_non_exhaustive()
}
}
struct HandlerPieces {
russh_cfg: Arc<client::Config>,
handler: GitwayHandler,
auth_banner: Arc<Mutex<Option<String>>>,
verified_fingerprint: Arc<Mutex<Option<String>>>,
}
impl AnvilSession {
fn build_handler_pieces(config: &AnvilConfig) -> Result<HandlerPieces, AnvilError> {
let russh_cfg = Arc::new(build_russh_config(config));
let trust = hostkey::host_key_trust(&config.host, &config.custom_known_hosts)?;
let revoked: Vec<String> = trust.revoked.into_iter().map(|r| r.fingerprint).collect();
let fingerprints = if !trust.fingerprints.is_empty() {
trust.fingerprints
} else if matches!(
config.strict_host_key_checking,
StrictHostKeyChecking::AcceptNew
) && config.custom_known_hosts.is_some()
{
log::info!(
"session: no fingerprints known for {}; \
accept-new will record on first connection",
config.host,
);
Vec::new()
} else {
return Err(AnvilError::invalid_config(format!(
"no fingerprints known for host '{}'",
config.host
))
.with_hint(format!(
"Gitway refuses to connect to hosts whose SSH fingerprint it can't \
verify (no trust-on-first-use). Either you typed the hostname wrong, \
or this is a self-hosted server and you need to pin its fingerprint: \
fetch it from the provider's docs (GitHub, GitLab, Codeberg publish \
them) and append one line to ~/.config/gitway/known_hosts:\n\
\n\
{} SHA256:<base64-fingerprint>\n\
\n\
As a last resort, re-run with --insecure-skip-host-check (not \
recommended — this disables MITM protection).",
config.host,
)));
};
let auth_banner = Arc::new(Mutex::new(None));
let verified_fingerprint = Arc::new(Mutex::new(None));
let handler = GitwayHandler {
fingerprints,
revoked,
policy: config.strict_host_key_checking,
host: config.host.clone(),
custom_known_hosts: config.custom_known_hosts.clone(),
auth_banner: Arc::clone(&auth_banner),
verified_fingerprint: Arc::clone(&verified_fingerprint),
};
Ok(HandlerPieces {
russh_cfg,
handler,
auth_banner,
verified_fingerprint,
})
}
pub async fn connect(config: &AnvilConfig) -> Result<Self, AnvilError> {
let policy = retry_policy_from_config(config);
log::debug!("session: connecting to {}:{}", config.host, config.port);
let ((handle, pieces), retry_history) = crate::retry::run(&policy, || async {
let pieces = Self::build_handler_pieces(config)?;
let connect_fut = client::connect(
pieces.russh_cfg,
(config.host.as_str(), config.port),
pieces.handler,
);
let handle = match policy.connect_timeout {
Some(t) => match tokio::time::timeout(t, connect_fut).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => return Err(e),
Err(_elapsed) => {
return Err(AnvilError::new(crate::error::AnvilErrorKind::Io(
std::io::Error::from(std::io::ErrorKind::TimedOut),
)));
}
},
None => connect_fut.await?,
};
Ok((
handle,
ConnectArtifacts {
auth_banner: pieces.auth_banner,
verified_fingerprint: pieces.verified_fingerprint,
},
))
})
.await?;
log::debug!("session: SSH handshake complete with {}", config.host);
Ok(Self {
handle,
auth_banner: pieces.auth_banner,
verified_fingerprint: pieces.verified_fingerprint,
retry_history,
})
}
#[must_use]
pub fn retry_history(&self) -> &[crate::retry::RetryAttempt] {
&self.retry_history
}
#[allow(
clippy::too_many_lines,
reason = "Single multi-step async chain orchestrator for per-hop connect / auth / direct-tcpip; extracting helpers would just shuffle the same logic across short fns and obscure the read-flow. M15.2 added 12 lines of FR-66 instrumentation — splitting here is a future cleanup, not an M15.2 concern."
)]
pub async fn connect_via_jump_hosts(
config: &AnvilConfig,
jumps: &[crate::proxy::JumpHost],
) -> Result<Self, AnvilError> {
if jumps.is_empty() {
return Err(AnvilError::invalid_config(
"ProxyJump: empty jump-host list; call AnvilSession::connect instead",
));
}
tracing::debug!(
target: crate::log::CAT_CHANNEL,
target_host = %config.host,
target_port = config.port,
hop_count = jumps.len(),
"ProxyJump chain start",
);
log::debug!(
"session: connecting to {}:{} via {} bastion hop(s)",
config.host,
config.port,
jumps.len(),
);
let mut prev_handle: Option<client::Handle<GitwayHandler>> = None;
for (idx, hop) in jumps.iter().enumerate() {
let hop_config = jump_to_config(hop, config);
let pieces = Self::build_handler_pieces(&hop_config)?;
tracing::debug!(
target: crate::log::CAT_CHANNEL,
hop_index = idx + 1,
hop_total = jumps.len(),
hop_host = %hop.host,
hop_port = hop.port,
"ProxyJump hop connecting",
);
log::debug!(
"session: bastion hop {}/{}: connecting to {}:{}",
idx + 1,
jumps.len(),
hop.host,
hop.port,
);
let handle = match prev_handle.take() {
None => {
client::connect(
pieces.russh_cfg,
(hop.host.as_str(), hop.port),
pieces.handler,
)
.await?
}
Some(prev) => {
let channel = prev
.channel_open_direct_tcpip(
hop.host.clone(),
u32::from(hop.port),
"127.0.0.1",
0_u32,
)
.await?;
client::connect_stream(pieces.russh_cfg, channel.into_stream(), pieces.handler)
.await?
}
};
let mut hop_session = Self {
handle,
auth_banner: pieces.auth_banner,
verified_fingerprint: pieces.verified_fingerprint,
retry_history: Vec::new(),
};
hop_session
.authenticate_best(&hop_config)
.await
.map_err(|e| {
e.with_hint(format!(
"ProxyJump: authentication failed at bastion hop {}/{} ({}:{})",
idx + 1,
jumps.len(),
hop.host,
hop.port,
))
})?;
prev_handle = Some(hop_session.handle);
}
let prev = prev_handle
.expect("loop body ran at least once because jumps is non-empty (checked above)");
let target_pieces = Self::build_handler_pieces(config)?;
log::debug!(
"session: connecting to target {}:{} via last bastion",
config.host,
config.port,
);
let channel = prev
.channel_open_direct_tcpip(
config.host.clone(),
u32::from(config.port),
"127.0.0.1",
0_u32,
)
.await?;
let final_handle = client::connect_stream(
target_pieces.russh_cfg,
channel.into_stream(),
target_pieces.handler,
)
.await?;
log::debug!(
"session: SSH handshake complete with {} (via {} bastion hop(s))",
config.host,
jumps.len(),
);
Ok(Self {
handle: final_handle,
auth_banner: target_pieces.auth_banner,
verified_fingerprint: target_pieces.verified_fingerprint,
retry_history: Vec::new(),
})
}
pub async fn connect_via_proxy_command(
config: &AnvilConfig,
proxy_command_template: &str,
alias: &str,
) -> Result<Self, AnvilError> {
if proxy_command_template.eq_ignore_ascii_case("none") {
return Err(AnvilError::invalid_config(
"ProxyCommand=none is the disable sentinel; \
call AnvilSession::connect instead",
));
}
let pieces = Self::build_handler_pieces(config)?;
log::debug!(
"session: connecting to {} via ProxyCommand template `{proxy_command_template}`",
config.host,
);
let stream = crate::proxy::command::spawn_proxy_command(
proxy_command_template,
&config.host,
config.port,
&config.username,
alias,
)?;
let handle = client::connect_stream(pieces.russh_cfg, stream, pieces.handler).await?;
log::debug!(
"session: SSH handshake complete with {} (via ProxyCommand)",
config.host,
);
Ok(Self {
handle,
auth_banner: pieces.auth_banner,
verified_fingerprint: pieces.verified_fingerprint,
retry_history: Vec::new(),
})
}
pub async fn authenticate(
&mut self,
username: &str,
key: PrivateKeyWithHashAlg,
) -> Result<(), AnvilError> {
let alg = key.algorithm().as_str().to_owned();
let fp = key.public_key().fingerprint(HashAlg::Sha256).to_string();
tracing::debug!(
target: crate::log::CAT_AUTH,
user = %username,
alg = %alg,
fp = %fp,
"trying public-key authentication",
);
log::debug!("session: authenticating as {username}");
let result = self.handle.authenticate_publickey(username, key).await?;
if result.success() {
tracing::info!(
target: crate::log::CAT_AUTH,
user = %username,
alg = %alg,
fp = %fp,
verdict = "accepted",
"public-key authentication succeeded",
);
log::debug!("session: authentication succeeded for {username}");
Ok(())
} else {
tracing::warn!(
target: crate::log::CAT_AUTH,
user = %username,
alg = %alg,
fp = %fp,
verdict = "rejected",
"public-key authentication rejected",
);
Err(AnvilError::authentication_failed())
}
}
pub async fn authenticate_with_cert(
&mut self,
username: &str,
key: russh::keys::PrivateKey,
cert: russh::keys::Certificate,
) -> Result<(), AnvilError> {
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(AnvilError::authentication_failed())
}
}
pub async fn authenticate_best(&mut self, config: &AnvilConfig) -> Result<(), AnvilError> {
use crate::auth::{find_identity, wrap_key, IdentityResolution};
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(AnvilError::new(AnvilErrorKind::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(AnvilError::no_key_found())
}
pub async fn authenticate_with_passphrase(
&mut self,
config: &AnvilConfig,
path: &std::path::Path,
passphrase: &str,
) -> Result<(), AnvilError> {
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<(), AnvilError> {
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(AnvilError::from)
}
AgentIdentity::Certificate { certificate, .. } => self
.handle
.authenticate_certificate_with(
username,
certificate.clone(),
None,
&mut conn.client,
)
.await
.map_err(AnvilError::from),
};
match result? {
r if r.success() => {
log::debug!("session: agent authentication succeeded");
return Ok(());
}
_ => {
log::debug!("session: agent identity rejected; trying next");
}
}
}
Err(AnvilError::no_key_found())
}
pub async fn exec(&mut self, command: &str) -> Result<u32, AnvilError> {
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<(), AnvilError> {
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: &AnvilConfig,
key: russh::keys::PrivateKey,
) -> Result<(), AnvilError> {
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
}
}
struct ConnectArtifacts {
auth_banner: Arc<Mutex<Option<String>>>,
verified_fingerprint: Arc<Mutex<Option<String>>>,
}
fn retry_policy_from_config(config: &AnvilConfig) -> crate::retry::RetryPolicy {
let mut policy = crate::retry::RetryPolicy::default();
if let Some(t) = config.connect_timeout {
policy.connect_timeout = Some(t);
}
if let Some(n) = config.connection_attempts {
policy.attempts = n.max(1);
}
if let Some(w) = config.max_retry_window {
policy.max_window = w;
}
policy
}
fn build_russh_config(config: &AnvilConfig) -> client::Config {
let kex_strings = config
.kex_algorithms
.clone()
.unwrap_or_else(crate::algorithms::anvil_default_kex);
let cipher_strings = config
.ciphers
.clone()
.unwrap_or_else(crate::algorithms::anvil_default_ciphers);
let mac_strings = config
.macs
.clone()
.unwrap_or_else(crate::algorithms::anvil_default_macs);
let host_key_strings = config
.host_key_algorithms
.clone()
.unwrap_or_else(crate::algorithms::anvil_default_host_keys);
tracing::trace!(
target: crate::log::CAT_KEX,
kex = ?kex_strings,
cipher = ?cipher_strings,
mac = ?mac_strings,
host_key = ?host_key_strings,
"negotiating with offered algorithm sets",
);
let kex_list: Vec<kex::Name> = kex_strings
.iter()
.filter_map(|s| russh_kex_name(s))
.collect();
let cipher_list: Vec<cipher::Name> = cipher_strings
.iter()
.filter_map(|s| russh_cipher_name(s))
.collect();
let mac_list: Vec<russh::mac::Name> = mac_strings
.iter()
.filter_map(|s| russh_mac_name(s))
.collect();
let host_key_list: Vec<russh::keys::Algorithm> = host_key_strings
.iter()
.filter_map(|s| s.parse::<russh::keys::Algorithm>().ok())
.collect();
client::Config {
inactivity_timeout: Some(config.inactivity_timeout),
preferred: Preferred {
kex: Cow::Owned(kex_list),
cipher: Cow::Owned(cipher_list),
mac: Cow::Owned(mac_list),
key: Cow::Owned(host_key_list),
..Default::default()
},
..Default::default()
}
}
fn russh_kex_name(s: &str) -> Option<kex::Name> {
let s = s.trim();
Some(match s {
"curve25519-sha256" => kex::CURVE25519,
"curve25519-sha256@libssh.org" => kex::CURVE25519_PRE_RFC_8731,
"diffie-hellman-group-exchange-sha256" => kex::DH_GEX_SHA256,
"diffie-hellman-group-exchange-sha1" => kex::DH_GEX_SHA1,
"diffie-hellman-group1-sha1" => kex::DH_G1_SHA1,
"diffie-hellman-group14-sha1" => kex::DH_G14_SHA1,
"diffie-hellman-group14-sha256" => kex::DH_G14_SHA256,
"diffie-hellman-group15-sha512" => kex::DH_G15_SHA512,
"diffie-hellman-group16-sha512" => kex::DH_G16_SHA512,
"diffie-hellman-group17-sha512" => kex::DH_G17_SHA512,
"diffie-hellman-group18-sha512" => kex::DH_G18_SHA512,
"ext-info-c" => kex::EXTENSION_SUPPORT_AS_CLIENT,
_ => return None,
})
}
fn russh_cipher_name(s: &str) -> Option<cipher::Name> {
let s = s.trim();
Some(match s {
"chacha20-poly1305@openssh.com" => cipher::CHACHA20_POLY1305,
"aes128-ctr" => cipher::AES_128_CTR,
"aes192-ctr" => cipher::AES_192_CTR,
"aes256-ctr" => cipher::AES_256_CTR,
"aes128-cbc" => cipher::AES_128_CBC,
"aes192-cbc" => cipher::AES_192_CBC,
"aes256-cbc" => cipher::AES_256_CBC,
"aes128-gcm@openssh.com" => cipher::AES_128_GCM,
"aes256-gcm@openssh.com" => cipher::AES_256_GCM,
_ => return None,
})
}
fn russh_mac_name(s: &str) -> Option<russh::mac::Name> {
let s = s.trim();
Some(match s {
"hmac-sha2-512-etm@openssh.com" => russh::mac::HMAC_SHA512_ETM,
"hmac-sha2-256-etm@openssh.com" => russh::mac::HMAC_SHA256_ETM,
"hmac-sha1-etm@openssh.com" => russh::mac::HMAC_SHA1_ETM,
"hmac-sha2-512" => russh::mac::HMAC_SHA512,
"hmac-sha2-256" => russh::mac::HMAC_SHA256,
"hmac-sha1" => russh::mac::HMAC_SHA1,
_ => return None,
})
}
fn jump_to_config(hop: &crate::proxy::JumpHost, primary: &AnvilConfig) -> AnvilConfig {
let mut builder = AnvilConfig::builder(&hop.host)
.port(hop.port)
.strict_host_key_checking(primary.strict_host_key_checking)
.verbose(primary.verbose);
let username = hop.user.clone().unwrap_or_else(|| primary.username.clone());
builder = builder.username(username);
let identity_files: Vec<_> = if hop.identity_files.is_empty() {
primary.identity_files.clone()
} else {
hop.identity_files.clone()
};
builder = builder.identity_files(identity_files);
if let Some(p) = &primary.custom_known_hosts {
builder = builder.custom_known_hosts(p.clone());
}
builder.build()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_cipher_excludes_3des() {
let anvil_config = AnvilConfig::builder("test.example").build();
let config = build_russh_config(&anvil_config);
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 anvil_config = AnvilConfig::builder("test.example").build();
let config = build_russh_config(&anvil_config);
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 anvil_config = AnvilConfig::builder("test.example").build();
let config = build_russh_config(&anvil_config);
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 anvil_config = AnvilConfig::builder("test.example").build();
let config = build_russh_config(&anvil_config);
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)"
);
}
}