Skip to main content

ssh_cli/ssh/
known_hosts.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! TOFU persistence of host-key fingerprints under XDG.
3//!
4//! Line-oriented format (0o600):
5//! `host:port <fingerprint_sha256>`
6
7use crate::erros::{SshCliError, SshCliResult};
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11/// Map of host:port → fingerprint.
12#[derive(Debug, Default, Clone)]
13pub struct KnownHosts {
14    entries: BTreeMap<String, String>,
15    path: PathBuf,
16}
17
18impl KnownHosts {
19    /// Canonical key `host:port`.
20    #[must_use]
21    pub fn key(host: &str, port: u16) -> String {
22        format!("{host}:{port}")
23    }
24
25    /// Loads the file (empty if missing).
26    pub fn load(path: PathBuf) -> SshCliResult<Self> {
27        let mut entries = BTreeMap::new();
28        if path.exists() {
29            let text = std::fs::read_to_string(&path)?;
30            for line in text.lines() {
31                let line = line.trim();
32                if line.is_empty() || line.starts_with('#') {
33                    continue;
34                }
35                let mut parts = line.split_whitespace();
36                if let (Some(k), Some(fp)) = (parts.next(), parts.next()) {
37                    entries.insert(k.to_string(), fp.to_string());
38                }
39            }
40        }
41        Ok(Self { entries, path })
42    }
43
44    /// Default path `config_dir/known_hosts` next to `config.toml`.
45    #[must_use]
46    pub fn path_beside_config(config_toml: &Path) -> PathBuf {
47        config_toml
48            .parent()
49            .map(|p| p.join("known_hosts"))
50            .unwrap_or_else(|| PathBuf::from("known_hosts"))
51    }
52
53    /// Looks up a stored fingerprint.
54    #[must_use]
55    pub fn get(&self, host: &str, port: u16) -> Option<&str> {
56        self.entries
57            .get(&Self::key(host, port))
58            .map(String::as_str)
59    }
60
61    /// Inserts or updates and persists atomically.
62    pub fn store(&mut self, host: &str, port: u16, fingerprint: &str) -> SshCliResult<()> {
63        self.entries
64            .insert(Self::key(host, port), fingerprint.to_string());
65        self.persist()
66    }
67
68    fn persist(&self) -> SshCliResult<()> {
69        if let Some(parent_dir) = self.path.parent() {
70            std::fs::create_dir_all(parent_dir)?;
71        }
72        let mut body = String::new();
73        body.push_str("# ssh-cli known_hosts (TOFU)\n");
74        for (k, v) in &self.entries {
75            body.push_str(k);
76            body.push(' ');
77            body.push_str(v);
78            body.push('\n');
79        }
80
81        let parent_dir = self
82            .path
83            .parent()
84            .map(Path::to_path_buf)
85            .unwrap_or_else(|| PathBuf::from("."));
86        let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir).map_err(SshCliError::Io)?;
87        use std::io::Write;
88        tmp.write_all(body.as_bytes())?;
89        tmp.as_file().sync_data()?;
90        tmp.persist(&self.path)
91            .map_err(|e| SshCliError::Io(e.error))?;
92
93        #[cfg(unix)]
94        {
95            use std::os::unix::fs::PermissionsExt;
96            let mut perms = std::fs::metadata(&self.path)?.permissions();
97            perms.set_mode(0o600);
98            std::fs::set_permissions(&self.path, perms)?;
99        }
100        Ok(())
101    }
102}
103
104/// Verifica fingerprint TOFU.
105///
106/// - Sem entrada: aceita e grava (TOFU).
107/// - Com entrada igual: aceita.
108/// - Com entrada diferente: recusa, a menos que `replace` seja true.
109///
110/// # Errors
111/// Returns an error if the host key changed and replacement was not allowed, or if persistence fails.
112pub fn verify_tofu(
113    kh: &mut KnownHosts,
114    host: &str,
115    port: u16,
116    fingerprint: &str,
117    replace: bool,
118) -> SshCliResult<bool> {
119    match kh.get(host, port) {
120        None => {
121            kh.store(host, port, fingerprint)?;
122            Ok(true)
123        }
124        Some(existing) if existing == fingerprint => Ok(true),
125        Some(existing) if replace => {
126            tracing::warn!(
127                host,
128                port,
129                old = %existing,
130                novo = %fingerprint,
131                "replacing host key (--replace-host-key)"
132            );
133            kh.store(host, port, fingerprint)?;
134            Ok(true)
135        }
136        Some(existing) => Err(SshCliError::HostKeyChanged {
137            host: host.to_string(),
138            port,
139            expected: existing.to_string(),
140            obtained: fingerprint.to_string(),
141        }),
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use tempfile::TempDir;
149
150    #[test]
151    fn tofu_stores_and_accepts_same() {
152        let tmp = TempDir::new().unwrap();
153        let path = tmp.path().join("known_hosts");
154        let mut kh = KnownHosts::load(path).unwrap();
155        assert!(verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap());
156        assert!(verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap());
157    }
158
159    #[test]
160    fn tofu_rejects_change() {
161        let tmp = TempDir::new().unwrap();
162        let path = tmp.path().join("known_hosts");
163        let mut kh = KnownHosts::load(path).unwrap();
164        verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap();
165        let err = verify_tofu(&mut kh, "h", 22, "fp2", false).unwrap_err();
166        assert!(matches!(err, SshCliError::HostKeyChanged { .. }));
167    }
168
169    #[test]
170    fn tofu_replaces_with_flag() {
171        let tmp = TempDir::new().unwrap();
172        let path = tmp.path().join("known_hosts");
173        let mut kh = KnownHosts::load(path).unwrap();
174        verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap();
175        assert!(verify_tofu(&mut kh, "h", 22, "fp2", true).unwrap());
176        assert_eq!(kh.get("h", 22), Some("fp2"));
177    }
178}