use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use russh::keys::ssh_key;
use crate::network::ssh_client::{HostKeyVerifier, SshClientError};
use crate::network::ssh_transport::StrictHostKey;
pub struct PermissiveVerifier;
#[async_trait]
impl HostKeyVerifier for PermissiveVerifier {
async fn verify(
&self,
host: &str,
port: u16,
_key: &ssh_key::PublicKey,
) -> Result<bool, SshClientError> {
tracing::warn!(
host = host,
port = port,
"ssh-strict-host-key=no: accepting any host key without verification"
);
Ok(true)
}
}
pub struct StrictVerifier {
pub known_hosts_path: PathBuf,
}
#[async_trait]
impl HostKeyVerifier for StrictVerifier {
async fn verify(
&self,
host: &str,
port: u16,
key: &ssh_key::PublicKey,
) -> Result<bool, SshClientError> {
let known = match known_hosts_lookup(&self.known_hosts_path, host, port) {
Ok(k) => k,
Err(e) => {
tracing::warn!(
host = host,
port = port,
error = %e,
"strict host-key verifier could not read known_hosts"
);
return Ok(false);
}
};
Ok(known.iter().any(|k| k == key))
}
}
pub struct AcceptNewVerifier {
pub known_hosts_path: PathBuf,
memory_cache: Mutex<HashSet<MemoryCacheKey>>,
}
type MemoryCacheKey = (String, u16, String);
impl AcceptNewVerifier {
pub fn new(known_hosts_path: PathBuf) -> Self {
Self {
known_hosts_path,
memory_cache: Mutex::new(HashSet::new()),
}
}
fn memory_cache_key(host: &str, port: u16, key: &ssh_key::PublicKey) -> MemoryCacheKey {
(
host.to_string(),
port,
key.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
)
}
fn memory_cache_contains(&self, host: &str, port: u16, key: &ssh_key::PublicKey) -> bool {
let needle = Self::memory_cache_key(host, port, key);
match self.memory_cache.lock() {
Ok(guard) => guard.contains(&needle),
Err(poisoned) => poisoned.into_inner().contains(&needle),
}
}
fn memory_cache_any_for(&self, host: &str, port: u16) -> bool {
match self.memory_cache.lock() {
Ok(guard) => guard.iter().any(|(h, p, _)| h == host && *p == port),
Err(poisoned) => poisoned
.into_inner()
.iter()
.any(|(h, p, _)| h == host && *p == port),
}
}
fn memory_cache_insert(&self, host: &str, port: u16, key: &ssh_key::PublicKey) {
let entry = Self::memory_cache_key(host, port, key);
match self.memory_cache.lock() {
Ok(mut guard) => {
guard.insert(entry);
}
Err(poisoned) => {
poisoned.into_inner().insert(entry);
}
}
}
}
#[async_trait]
impl HostKeyVerifier for AcceptNewVerifier {
async fn verify(
&self,
host: &str,
port: u16,
key: &ssh_key::PublicKey,
) -> Result<bool, SshClientError> {
match known_hosts_lookup(&self.known_hosts_path, host, port) {
Ok(keys) => {
if keys.iter().any(|k| k == key) {
return Ok(true);
}
if !keys.is_empty() {
return Ok(false);
}
if self.memory_cache_any_for(host, port) {
return Ok(self.memory_cache_contains(host, port, key));
}
match known_hosts_append(&self.known_hosts_path, host, port, key) {
Ok(()) => {}
Err(e) => {
tracing::error!(
host = host,
port = port,
path = %self.known_hosts_path.display(),
error = %e,
"accept-new: could not persist host key; caching in memory for this process only"
);
self.memory_cache_insert(host, port, key);
}
}
Ok(true)
}
Err(e) => {
tracing::warn!(
host = host,
error = %e,
"accept-new: could not read known_hosts, refusing"
);
Ok(false)
}
}
}
}
pub(crate) fn known_hosts_lookup(
path: &Path,
host: &str,
port: u16,
) -> Result<Vec<ssh_key::PublicKey>, std::io::Error> {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut out = Vec::new();
let needle_bracket = format!("[{host}]:{port}");
for raw in content.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (host_field, rest) = match line.split_once(char::is_whitespace) {
Some(p) => p,
None => continue,
};
if host_field.starts_with("|1|") {
continue;
}
let mut matched = false;
for token in host_field.split(',') {
let token = token.trim();
if token.is_empty() {
continue;
}
let hit = if port == 22 {
token == host || token == needle_bracket
} else {
token == needle_bracket
};
if hit {
matched = true;
break;
}
}
if !matched {
continue;
}
let trimmed = rest.trim();
if let Ok(key) = ssh_key::PublicKey::from_openssh(trimmed) {
out.push(key);
}
}
Ok(out)
}
fn known_hosts_append(
path: &Path,
host: &str,
port: u16,
key: &ssh_key::PublicKey,
) -> Result<(), std::io::Error> {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if path.exists() {
let md = std::fs::symlink_metadata(path)?;
if md.file_type().is_symlink() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"refusing to append to symlinked known_hosts at {}",
path.display()
),
));
}
}
let host_field = if port == 22 {
host.to_string()
} else {
format!("[{host}]:{port}")
};
let key_str = key.to_openssh().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("could not serialise host key: {e}"),
)
})?;
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let mut file = opts.open(path)?;
writeln!(file, "{host_field} {key_str}")?;
Ok(())
}
pub fn build_verifier(
policy: StrictHostKey,
known_hosts: Option<PathBuf>,
) -> Arc<dyn HostKeyVerifier> {
let known_hosts_path = known_hosts.unwrap_or_else(default_known_hosts);
match policy {
StrictHostKey::No => Arc::new(PermissiveVerifier),
StrictHostKey::Yes => Arc::new(StrictVerifier { known_hosts_path }),
StrictHostKey::AcceptNew => Arc::new(AcceptNewVerifier::new(known_hosts_path)),
}
}
fn default_known_hosts() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".ssh").join("known_hosts")
} else {
PathBuf::from("known_hosts")
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_KEY_A: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbRhbtx7s0p+e18aTwbGaHN+8UqaBcSRNCE+GU5v6Q7 all-smi-test-a";
const TEST_KEY_B: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO+UHj3N+jyzN/51w3elCnDai2okb8wc+d4JCKQGd23o all-smi-test-b";
fn test_public_key() -> ssh_key::PublicKey {
ssh_key::PublicKey::from_openssh(TEST_KEY_A).expect("fixture key A must parse")
}
fn test_public_key_pair() -> (ssh_key::PublicKey, ssh_key::PublicKey) {
(
ssh_key::PublicKey::from_openssh(TEST_KEY_A).expect("fixture key A must parse"),
ssh_key::PublicKey::from_openssh(TEST_KEY_B).expect("fixture key B must parse"),
)
}
#[test]
fn known_hosts_lookup_missing_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent");
let keys = known_hosts_lookup(&path, "host", 22).unwrap();
assert!(keys.is_empty());
}
#[test]
fn known_hosts_lookup_skips_comments() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("kh");
std::fs::write(&path, "# a comment\n\n").unwrap();
assert!(known_hosts_lookup(&path, "host", 22).unwrap().is_empty());
}
#[test]
fn known_hosts_lookup_matches_multi_host_line() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("kh");
let key = test_public_key();
let key_str = key.to_openssh().unwrap();
std::fs::write(&path, format!("host-a,host-b,host-c {key_str}\n")).unwrap();
let keys = known_hosts_lookup(&path, "host-b", 22).unwrap();
assert_eq!(keys.len(), 1, "multi-host entry must match middle token");
assert_eq!(keys[0], key);
let keys = known_hosts_lookup(&path, "host-c", 22).unwrap();
assert_eq!(keys.len(), 1, "multi-host entry must match last token");
let keys = known_hosts_lookup(&path, "not-in-list", 22).unwrap();
assert!(keys.is_empty(), "hostname absent from list must not match");
}
#[test]
fn known_hosts_lookup_skips_hashed_hostnames() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("kh");
let key = test_public_key();
let key_str = key.to_openssh().unwrap();
let hashed_line = "|1|AAAA|BBBB ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGarbage";
std::fs::write(&path, format!("{hashed_line}\nplain-host {key_str}\n")).unwrap();
let keys = known_hosts_lookup(&path, "plain-host", 22).unwrap();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0], key);
}
#[test]
fn build_verifier_picks_correct_type() {
let _ = build_verifier(StrictHostKey::No, None);
let _ = build_verifier(StrictHostKey::Yes, None);
let _ = build_verifier(
StrictHostKey::AcceptNew,
Some(PathBuf::from("/nonexistent-test-path")),
);
}
#[cfg(unix)]
#[test]
fn known_hosts_append_refuses_symlink_target() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let decoy = dir.path().join("decoy.txt");
std::fs::write(&decoy, b"original-contents\n").unwrap();
let kh_path = dir.path().join("kh");
symlink(&decoy, &kh_path).expect("creating symlink");
let key = test_public_key();
let err = known_hosts_append(&kh_path, "attacker-host", 22, &key)
.expect_err("append through symlink must fail");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
let after = std::fs::read_to_string(&decoy).unwrap();
assert_eq!(
after, "original-contents\n",
"symlink target must not be appended to"
);
}
#[cfg(unix)]
#[test]
fn known_hosts_append_creates_with_restrictive_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let kh_path = dir.path().join("kh");
let key = test_public_key();
known_hosts_append(&kh_path, "host", 22, &key).expect("clean append must succeed");
let md = std::fs::metadata(&kh_path).unwrap();
let mode = md.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "fresh known_hosts must be mode 0o600");
}
#[tokio::test]
async fn accept_new_falls_back_to_memory_on_persist_failure() {
let dir = tempfile::tempdir().unwrap();
let ro_dir = dir.path().join("ro");
std::fs::create_dir(&ro_dir).unwrap();
let kh_path = ro_dir.join("known_hosts");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = std::fs::metadata(&ro_dir).unwrap().permissions();
perm.set_mode(0o500);
std::fs::set_permissions(&ro_dir, perm).unwrap();
}
let verifier = AcceptNewVerifier::new(kh_path);
let (key_a, key_b) = test_public_key_pair();
let first = verifier.verify("host.example", 22, &key_a).await.unwrap();
assert!(first, "first contact must be accepted (TOFU)");
let second = verifier.verify("host.example", 22, &key_a).await.unwrap();
assert!(
second,
"repeat with same key must be accepted from memory cache"
);
let rejected = verifier.verify("host.example", 22, &key_b).await.unwrap();
assert!(
!rejected,
"key change after persist-failure must still be rejected"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = std::fs::metadata(&ro_dir).unwrap().permissions();
perm.set_mode(0o700);
let _ = std::fs::set_permissions(&ro_dir, perm);
}
}
}