Skip to main content

ssh_commander_core/ssh/
host_keys.rs

1use anyhow::{Context, Result};
2// In russh 0.61, russh_keys is merged into russh::keys.
3use russh::keys::{HashAlg, PublicKeyBase64};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use tokio::sync::Mutex;
8
9/// Result of verifying a server-offered host key against the local store.
10#[derive(Debug, Clone)]
11pub enum Verdict {
12    /// Host is known and the key matches.
13    Known,
14    /// Host has never been seen — safe to TOFU-trust.
15    Unknown,
16    /// Host is known but the key has changed — refuse the connection.
17    Mismatch {
18        expected_fingerprint: String,
19        got_fingerprint: String,
20    },
21}
22
23/// Details surfaced to the user when `Verdict::Mismatch` caused a rejection.
24#[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/// Details surfaced to the user when host-key verification could not complete
34/// because the trust store was unavailable.
35#[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/// Any verification failure that should be surfaced verbosely to the caller.
45#[derive(Debug, Clone)]
46pub enum HostKeyVerificationFailure {
47    Mismatch(HostKeyMismatch),
48    StoreAccess(HostKeyStoreAccessError),
49}
50
51/// Slot that a `Client` instance writes a host-key verification failure into
52/// during the SSH handshake. The caller of `connect` reads it after the error
53/// to build a descriptive user-facing message.
54pub type VerificationFailureSlot = Arc<std::sync::Mutex<Option<HostKeyVerificationFailure>>>;
55
56/// Persistent store of trusted SSH host keys (analogous to `~/.ssh/known_hosts`).
57///
58/// Internally lazily loaded on first use. Safe to clone `Arc<HostKeyStore>`
59/// across many connections — all access is serialised through an async Mutex.
60pub 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    /// Default location: `$XDG_CONFIG_HOME/r-shell/known_hosts` (or platform
74    /// equivalent via `dirs::config_dir()`).
75    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    /// Check whether the server-offered key matches the stored fingerprint for
87    /// `(host, port)`. Does not mutate the store.
88    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        // fingerprint() now takes a HashAlg in russh 0.61; SHA-256 matches
96        // the "SHA256:…" format shown in user-facing messages.
97        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    /// Persist the server-offered key as trusted for `(host, port)`.
119    /// Creates the parent directory if missing.
120    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    /// Forget a previously-trusted host. Returns `true` if an entry was
138    /// removed, `false` if there was nothing to remove. Used by the UI's
139    /// "Trust new key" flow on a `HostKeyMismatch`: forget the stale
140    /// entry, retry the connect, the next `verify()` falls through to
141    /// `Verdict::Unknown` and the new key is TOFU-trusted.
142    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    /// Normalize host:port into a known_hosts-style key.
160    /// Non-default ports use the `[host]:port` form to match OpenSSH conventions.
161    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
219/// Compute an SHA-256 fingerprint from a stored base64 public-key blob,
220/// matching the format used in `verify` so both sides of a mismatch display
221/// in the same form.
222///
223/// In russh 0.61, `parse_public_key_base64` lives in `russh::keys` and
224/// `PublicKey::fingerprint` requires an explicit `HashAlg`.
225fn 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        // Load without any file present — should be empty, not an error.
253        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    /// In russh 0.61, key generation uses `ssh_key::PrivateKey::random` and
259    /// we extract the public key with `.public_key()`. The old `KeyPair::generate_ed25519`
260    /// / `clone_public_key` API is no longer available.
261    ///
262    /// Use `russh::keys::key::safe_rng()` for a compatible `CryptoRng` — russh
263    /// uses rand 0.10 internally, which is not the same as rand 0.8 in the core crate.
264    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}