ssh_commander_core/ssh/
host_keys.rs1use anyhow::{Context, Result};
2use russh::keys::{HashAlg, PublicKeyBase64};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use tokio::sync::Mutex;
8
9#[derive(Debug, Clone)]
11pub enum Verdict {
12 Known,
14 Unknown,
16 Mismatch {
18 expected_fingerprint: String,
19 got_fingerprint: String,
20 },
21}
22
23#[derive(Debug, Clone)]
25pub struct HostKeyMismatch {
26 pub host: String,
27 pub port: u16,
28 pub expected_fingerprint: String,
29 pub got_fingerprint: String,
30 pub store_path: PathBuf,
31}
32
33#[derive(Debug, Clone)]
36pub struct HostKeyStoreAccessError {
37 pub host: String,
38 pub port: u16,
39 pub store_path: PathBuf,
40 pub operation: &'static str,
41 pub source: String,
42}
43
44#[derive(Debug, Clone)]
46pub enum HostKeyVerificationFailure {
47 Mismatch(HostKeyMismatch),
48 StoreAccess(HostKeyStoreAccessError),
49}
50
51pub type VerificationFailureSlot = Arc<std::sync::Mutex<Option<HostKeyVerificationFailure>>>;
55
56pub struct HostKeyStore {
61 path: PathBuf,
62 state: Mutex<Option<HashMap<String, String>>>,
63}
64
65impl HostKeyStore {
66 pub fn new(path: PathBuf) -> Self {
67 Self {
68 path,
69 state: Mutex::new(None),
70 }
71 }
72
73 pub fn default_path() -> PathBuf {
76 dirs::config_dir()
77 .unwrap_or_else(std::env::temp_dir)
78 .join("r-shell")
79 .join("known_hosts")
80 }
81
82 pub fn path(&self) -> &Path {
83 &self.path
84 }
85
86 pub async fn verify(
89 &self,
90 host: &str,
91 port: u16,
92 key: &russh::keys::PublicKey,
93 ) -> Result<Verdict> {
94 let offered = key.public_key_base64();
95 let offered_fp = key.fingerprint(HashAlg::Sha256).to_string();
98 let key_id = Self::make_key(host, port);
99
100 let mut guard = self.state.lock().await;
101 if guard.is_none() {
102 *guard = Some(Self::load_from_disk(&self.path).await?);
103 }
104 let entries = guard.as_ref().expect("state initialised above");
105
106 let verdict = match entries.get(&key_id) {
107 Some(stored) if stored == &offered => Verdict::Known,
108 Some(stored) => Verdict::Mismatch {
109 expected_fingerprint: fingerprint_from_stored(stored),
110 got_fingerprint: offered_fp,
111 },
112 None => Verdict::Unknown,
113 };
114
115 Ok(verdict)
116 }
117
118 pub async fn trust(&self, host: &str, port: u16, key: &russh::keys::PublicKey) -> Result<()> {
121 let offered = key.public_key_base64();
122 let key_id = Self::make_key(host, port);
123
124 let mut guard = self.state.lock().await;
125 if guard.is_none() {
126 *guard = Some(Self::load_from_disk(&self.path).await?);
127 }
128
129 let mut snapshot = guard.as_ref().cloned().unwrap_or_default();
130 snapshot.insert(key_id, offered);
131
132 self.write_to_disk(&snapshot).await?;
133 *guard = Some(snapshot);
134 Ok(())
135 }
136
137 pub async fn forget(&self, host: &str, port: u16) -> Result<bool> {
143 let key_id = Self::make_key(host, port);
144
145 let mut guard = self.state.lock().await;
146 if guard.is_none() {
147 *guard = Some(Self::load_from_disk(&self.path).await?);
148 }
149
150 let mut snapshot = guard.as_ref().cloned().unwrap_or_default();
151 let removed = snapshot.remove(&key_id).is_some();
152 if removed {
153 self.write_to_disk(&snapshot).await?;
154 *guard = Some(snapshot);
155 }
156 Ok(removed)
157 }
158
159 fn make_key(host: &str, port: u16) -> String {
162 if port == 22 {
163 host.to_string()
164 } else {
165 format!("[{}]:{}", host, port)
166 }
167 }
168
169 async fn load_from_disk(path: &Path) -> Result<HashMap<String, String>> {
170 let mut map = HashMap::new();
171 let content = match tokio::fs::read_to_string(path).await {
172 Ok(s) => s,
173 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(map),
174 Err(e) => {
175 return Err(e)
176 .with_context(|| format!("failed to read known_hosts at {}", path.display()));
177 }
178 };
179
180 for line in content.lines() {
181 let trimmed = line.trim();
182 if trimmed.is_empty() || trimmed.starts_with('#') {
183 continue;
184 }
185 let mut parts = trimmed.splitn(2, char::is_whitespace);
186 if let (Some(host_id), Some(key_blob)) = (parts.next(), parts.next()) {
187 map.insert(host_id.to_string(), key_blob.trim().to_string());
188 }
189 }
190 Ok(map)
191 }
192
193 async fn write_to_disk(&self, entries: &HashMap<String, String>) -> Result<()> {
194 if let Some(parent) = self.path.parent() {
195 tokio::fs::create_dir_all(parent)
196 .await
197 .with_context(|| format!("failed to create {}", parent.display()))?;
198 }
199
200 let mut content =
201 String::from("# r-shell known hosts — auto-managed, one entry per host\n");
202 let mut keys: Vec<&String> = entries.keys().collect();
203 keys.sort();
204 for k in keys {
205 if let Some(v) = entries.get(k) {
206 content.push_str(k);
207 content.push(' ');
208 content.push_str(v);
209 content.push('\n');
210 }
211 }
212 tokio::fs::write(&self.path, content)
213 .await
214 .with_context(|| format!("failed to write {}", self.path.display()))?;
215 Ok(())
216 }
217}
218
219fn fingerprint_from_stored(blob_b64: &str) -> String {
226 match russh::keys::parse_public_key_base64(blob_b64) {
227 Ok(key) => key.fingerprint(HashAlg::Sha256).to_string(),
228 Err(_) => String::from("<unparseable stored key>"),
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use tempfile::TempDir;
236
237 fn temp_store() -> (TempDir, HostKeyStore) {
238 let dir = TempDir::new().expect("tmpdir");
239 let path = dir.path().join("known_hosts");
240 (dir, HostKeyStore::new(path))
241 }
242
243 #[test]
244 fn make_key_uses_bracket_form_for_non_default_port() {
245 assert_eq!(HostKeyStore::make_key("host", 22), "host");
246 assert_eq!(HostKeyStore::make_key("host", 2222), "[host]:2222");
247 }
248
249 #[tokio::test]
250 async fn unknown_host_yields_unknown_verdict() {
251 let (_dir, store) = temp_store();
252 let mut guard = store.state.lock().await;
254 *guard = Some(HostKeyStore::load_from_disk(store.path()).await.unwrap());
255 assert!(guard.as_ref().unwrap().is_empty());
256 }
257
258 fn test_public_key() -> russh::keys::PublicKey {
265 russh::keys::PrivateKey::random(
266 &mut russh::keys::key::safe_rng(),
267 russh::keys::Algorithm::Ed25519,
268 )
269 .expect("generate ed25519 key")
270 .public_key()
271 .clone()
272 }
273
274 #[tokio::test]
275 async fn verify_propagates_store_read_errors() {
276 let dir = TempDir::new().expect("tmpdir");
277 let store = HostKeyStore::new(dir.path().to_path_buf());
278
279 let err = store
280 .verify("host", 22, &test_public_key())
281 .await
282 .expect_err("directory path must not be treated as an empty store");
283
284 assert!(err.to_string().contains("failed to read known_hosts"));
285 }
286
287 #[tokio::test]
288 async fn trust_does_not_cache_keys_when_write_fails() {
289 let dir = TempDir::new().expect("tmpdir");
290 let file_parent = dir.path().join("not-a-dir");
291 std::fs::write(&file_parent, "regular file").expect("write blocker file");
292 let store = HostKeyStore::new(file_parent.join("known_hosts"));
293 let key = test_public_key();
294
295 store
296 .trust("host", 22, &key)
297 .await
298 .expect_err("write should fail when parent is not a directory");
299
300 let guard = store.state.lock().await;
301 assert!(
302 guard.as_ref().is_none_or(HashMap::is_empty),
303 "failed trust must not mark the key as known in memory",
304 );
305 }
306}