Rust · 6901 bytes Raw Blame History
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