| 1 | //! Known hosts management for TOFU (Trust On First Use) |
| 2 | //! |
| 3 | //! Stores trusted certificate fingerprints for peer machines. |
| 4 | |
| 5 | use std::collections::HashMap; |
| 6 | use std::fs; |
| 7 | use std::path::{Path, PathBuf}; |
| 8 | |
| 9 | use serde::{Deserialize, Serialize}; |
| 10 | |
| 11 | use super::tls::Fingerprint; |
| 12 | |
| 13 | /// Known hosts storage |
| 14 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] |
| 15 | pub struct KnownHosts { |
| 16 | /// Map of machine name to trusted fingerprint |
| 17 | #[serde(default)] |
| 18 | pub hosts: HashMap<String, KnownHost>, |
| 19 | } |
| 20 | |
| 21 | /// A known host entry |
| 22 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 23 | pub struct KnownHost { |
| 24 | /// Certificate fingerprint (SHA-256, hex encoded) |
| 25 | pub fingerprint: String, |
| 26 | /// When this host was first seen |
| 27 | #[serde(default = "default_timestamp")] |
| 28 | pub first_seen: String, |
| 29 | /// When this host was last seen |
| 30 | #[serde(default = "default_timestamp")] |
| 31 | pub last_seen: String, |
| 32 | /// Optional notes |
| 33 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 34 | pub notes: Option<String>, |
| 35 | } |
| 36 | |
| 37 | fn default_timestamp() -> String { |
| 38 | chrono_lite_now() |
| 39 | } |
| 40 | |
| 41 | /// Simple timestamp without chrono dependency |
| 42 | fn chrono_lite_now() -> String { |
| 43 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 44 | let duration = SystemTime::now() |
| 45 | .duration_since(UNIX_EPOCH) |
| 46 | .unwrap_or_default(); |
| 47 | format!("{}", duration.as_secs()) |
| 48 | } |
| 49 | |
| 50 | impl KnownHosts { |
| 51 | /// Get the default known hosts file path |
| 52 | pub fn default_path() -> PathBuf { |
| 53 | dirs::config_dir() |
| 54 | .map(|d| d.join("hyprkvm").join("known_hosts.toml")) |
| 55 | .unwrap_or_else(|| PathBuf::from("known_hosts.toml")) |
| 56 | } |
| 57 | |
| 58 | /// Load known hosts from file, or create empty if not exists |
| 59 | pub fn load(path: &Path) -> Result<Self, KnownHostsError> { |
| 60 | if !path.exists() { |
| 61 | tracing::debug!("Known hosts file not found, starting fresh"); |
| 62 | return Ok(Self::default()); |
| 63 | } |
| 64 | |
| 65 | let content = fs::read_to_string(path) |
| 66 | .map_err(|e| KnownHostsError::Io(format!("Failed to read {:?}: {}", path, e)))?; |
| 67 | |
| 68 | toml::from_str(&content) |
| 69 | .map_err(|e| KnownHostsError::Parse(format!("Failed to parse {:?}: {}", path, e))) |
| 70 | } |
| 71 | |
| 72 | /// Save known hosts to file |
| 73 | pub fn save(&self, path: &Path) -> Result<(), KnownHostsError> { |
| 74 | // Ensure parent directory exists |
| 75 | if let Some(parent) = path.parent() { |
| 76 | fs::create_dir_all(parent) |
| 77 | .map_err(|e| KnownHostsError::Io(format!("Failed to create directory: {}", e)))?; |
| 78 | } |
| 79 | |
| 80 | let content = toml::to_string_pretty(self) |
| 81 | .map_err(|e| KnownHostsError::Serialize(e.to_string()))?; |
| 82 | |
| 83 | fs::write(path, content) |
| 84 | .map_err(|e| KnownHostsError::Io(format!("Failed to write {:?}: {}", path, e)))?; |
| 85 | |
| 86 | tracing::debug!("Saved known hosts to {:?}", path); |
| 87 | Ok(()) |
| 88 | } |
| 89 | |
| 90 | /// Check if a host is known with the given fingerprint |
| 91 | pub fn is_trusted(&self, machine_name: &str, fingerprint: &Fingerprint) -> TrustStatus { |
| 92 | match self.hosts.get(machine_name) { |
| 93 | None => TrustStatus::Unknown, |
| 94 | Some(host) => { |
| 95 | if host.fingerprint == fingerprint.to_hex() { |
| 96 | TrustStatus::Trusted |
| 97 | } else { |
| 98 | TrustStatus::Changed { |
| 99 | old_fingerprint: host.fingerprint.clone(), |
| 100 | new_fingerprint: fingerprint.to_hex(), |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// Add or update a trusted host |
| 108 | pub fn trust_host(&mut self, machine_name: &str, fingerprint: &Fingerprint) { |
| 109 | let now = chrono_lite_now(); |
| 110 | let fp_hex = fingerprint.to_hex(); |
| 111 | |
| 112 | if let Some(existing) = self.hosts.get_mut(machine_name) { |
| 113 | existing.fingerprint = fp_hex; |
| 114 | existing.last_seen = now; |
| 115 | tracing::info!("Updated known host: {}", machine_name); |
| 116 | } else { |
| 117 | self.hosts.insert( |
| 118 | machine_name.to_string(), |
| 119 | KnownHost { |
| 120 | fingerprint: fp_hex, |
| 121 | first_seen: now.clone(), |
| 122 | last_seen: now, |
| 123 | notes: None, |
| 124 | }, |
| 125 | ); |
| 126 | tracing::info!("Added new known host: {}", machine_name); |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /// Remove a host from known hosts |
| 131 | pub fn remove_host(&mut self, machine_name: &str) -> bool { |
| 132 | self.hosts.remove(machine_name).is_some() |
| 133 | } |
| 134 | |
| 135 | /// Get fingerprint for a known host |
| 136 | pub fn get_fingerprint(&self, machine_name: &str) -> Option<Fingerprint> { |
| 137 | self.hosts |
| 138 | .get(machine_name) |
| 139 | .and_then(|h| Fingerprint::from_hex(&h.fingerprint).ok()) |
| 140 | } |
| 141 | |
| 142 | /// Update last_seen timestamp for a host |
| 143 | pub fn touch(&mut self, machine_name: &str) { |
| 144 | if let Some(host) = self.hosts.get_mut(machine_name) { |
| 145 | host.last_seen = chrono_lite_now(); |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | /// Result of checking trust status |
| 151 | #[derive(Debug, Clone, PartialEq)] |
| 152 | pub enum TrustStatus { |
| 153 | /// Host is not in known_hosts |
| 154 | Unknown, |
| 155 | /// Host is known and fingerprint matches |
| 156 | Trusted, |
| 157 | /// Host is known but fingerprint changed (potential MITM!) |
| 158 | Changed { |
| 159 | old_fingerprint: String, |
| 160 | new_fingerprint: String, |
| 161 | }, |
| 162 | } |
| 163 | |
| 164 | impl TrustStatus { |
| 165 | pub fn is_trusted(&self) -> bool { |
| 166 | matches!(self, TrustStatus::Trusted) |
| 167 | } |
| 168 | |
| 169 | pub fn is_unknown(&self) -> bool { |
| 170 | matches!(self, TrustStatus::Unknown) |
| 171 | } |
| 172 | |
| 173 | pub fn is_changed(&self) -> bool { |
| 174 | matches!(self, TrustStatus::Changed { .. }) |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | #[derive(Debug, thiserror::Error)] |
| 179 | pub enum KnownHostsError { |
| 180 | #[error("IO error: {0}")] |
| 181 | Io(String), |
| 182 | |
| 183 | #[error("Parse error: {0}")] |
| 184 | Parse(String), |
| 185 | |
| 186 | #[error("Serialize error: {0}")] |
| 187 | Serialize(String), |
| 188 | } |
| 189 | |
| 190 | #[cfg(test)] |
| 191 | mod tests { |
| 192 | use super::*; |
| 193 | use tempfile::tempdir; |
| 194 | |
| 195 | #[test] |
| 196 | fn test_trust_workflow() { |
| 197 | let mut known_hosts = KnownHosts::default(); |
| 198 | let fp = Fingerprint([0xAB; 32]); |
| 199 | |
| 200 | // Initially unknown |
| 201 | assert!(known_hosts.is_trusted("test-machine", &fp).is_unknown()); |
| 202 | |
| 203 | // Trust it |
| 204 | known_hosts.trust_host("test-machine", &fp); |
| 205 | assert!(known_hosts.is_trusted("test-machine", &fp).is_trusted()); |
| 206 | |
| 207 | // Different fingerprint should be detected |
| 208 | let fp2 = Fingerprint([0xCD; 32]); |
| 209 | assert!(known_hosts.is_trusted("test-machine", &fp2).is_changed()); |
| 210 | } |
| 211 | |
| 212 | #[test] |
| 213 | fn test_save_load() { |
| 214 | let dir = tempdir().unwrap(); |
| 215 | let path = dir.path().join("known_hosts.toml"); |
| 216 | |
| 217 | let mut known_hosts = KnownHosts::default(); |
| 218 | let fp = Fingerprint([0xAB; 32]); |
| 219 | known_hosts.trust_host("test-machine", &fp); |
| 220 | |
| 221 | known_hosts.save(&path).unwrap(); |
| 222 | |
| 223 | let loaded = KnownHosts::load(&path).unwrap(); |
| 224 | assert!(loaded.is_trusted("test-machine", &fp).is_trusted()); |
| 225 | } |
| 226 | } |
| 227 |