Skip to main content

ssh_cli/ssh/
known_hosts.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! TOFU persistence of host-key fingerprints under XDG.
5//!
6//! Line-oriented format (0o600):
7//! `host:port <fingerprint_sha256>`
8//!
9//! # Concurrency (G-PAR-49)
10//!
11//! Multi-host fan-out can run N first-connect TOFU writes against the same file.
12//! Every mutating path takes an exclusive flock on a sibling `*.lock` file,
13//! reloads disk state, merges, then atomic-persists — same pattern as
14//! [`crate::vps::save`] for `config.toml`.
15
16use crate::errors::{SshCliError, SshCliResult};
17use std::collections::BTreeMap;
18use std::fs::File;
19use std::path::{Path, PathBuf};
20
21/// Constant-time equality for fingerprint bytes (G-SEC-05).
22///
23/// Host-key fingerprints are not high-entropy passwords, but TOFU comparison
24/// still prefers data-independent timing so a local co-tenant cannot learn
25/// which prefix mismatched via timing. Length differences return `false`
26/// immediately (fixed-format SHA-256 hex strings share a length in practice).
27#[must_use]
28fn fingerprints_eq(a: &str, b: &str) -> bool {
29    let a = a.as_bytes();
30    let b = b.as_bytes();
31    if a.len() != b.len() {
32        return false;
33    }
34    let mut diff = 0u8;
35    for (x, y) in a.iter().zip(b.iter()) {
36        diff |= x ^ y;
37    }
38    // Prevent the compiler from short-circuit optimizing the loop away.
39    std::hint::black_box(diff) == 0
40}
41
42/// Map of host:port → fingerprint.
43#[derive(Debug, Default, Clone)]
44pub struct KnownHosts {
45    entries: BTreeMap<String, String>,
46    path: PathBuf,
47}
48
49/// RAII exclusive lock on `known_hosts` sibling lock file (G-PAR-49).
50struct HostsFileLock {
51    file: File,
52}
53
54impl HostsFileLock {
55    /// Lock path: `<known_hosts>.lock` (e.g. `known_hosts.lock`).
56    fn acquire(kh_path: &Path) -> SshCliResult<Self> {
57        let lock_path = {
58            let mut os = kh_path.as_os_str().to_owned();
59            os.push(".lock");
60            PathBuf::from(os)
61        };
62        if let Some(parent_dir) = lock_path.parent() {
63            std::fs::create_dir_all(parent_dir)?;
64        }
65        let file = std::fs::OpenOptions::new()
66            .create(true)
67            .truncate(false)
68            .read(true)
69            .write(true)
70            .open(&lock_path)
71            .map_err(SshCliError::Io)?;
72        // Best-effort secret mode on lock file.
73        let _ = crate::fs_perm::set_secret_file_mode(&lock_path);
74        fs2::FileExt::lock_exclusive(&file).map_err(SshCliError::Io)?;
75        Ok(Self { file })
76    }
77}
78
79impl Drop for HostsFileLock {
80    fn drop(&mut self) {
81        let _ = fs2::FileExt::unlock(&self.file);
82    }
83}
84
85impl KnownHosts {
86    /// Canonical key `host:port`.
87    #[must_use]
88    pub fn key(host: &str, port: u16) -> String {
89        format!("{host}:{port}")
90    }
91
92    /// Loads the file (empty if missing). Does **not** take the flock.
93    pub fn load(path: PathBuf) -> SshCliResult<Self> {
94        let mut entries = BTreeMap::new();
95        if path.exists() {
96            let text = crate::paths::read_text_capped(&path, crate::paths::MAX_KNOWN_HOSTS_BYTES)?;
97            for line in text.lines() {
98                let line = line.trim();
99                if line.is_empty() || line.starts_with('#') {
100                    continue;
101                }
102                let mut parts = line.split_whitespace();
103                if let (Some(k), Some(fp)) = (parts.next(), parts.next()) {
104                    entries.insert(k.to_string(), fp.to_string());
105                }
106            }
107        }
108        Ok(Self { entries, path })
109    }
110
111    /// Default path `config_dir/known_hosts` next to `config.toml`.
112    #[must_use]
113    pub fn path_beside_config(config_toml: &Path) -> PathBuf {
114        config_toml
115            .parent()
116            .map(|p| p.join(crate::constants::KNOWN_HOSTS_FILE_NAME))
117            .unwrap_or_else(|| PathBuf::from(crate::constants::KNOWN_HOSTS_FILE_NAME))
118    }
119
120    /// Looks up a stored fingerprint (in-memory only).
121    #[must_use]
122    pub fn get(&self, host: &str, port: u16) -> Option<&str> {
123        self.entries.get(&Self::key(host, port)).map(String::as_str)
124    }
125
126    /// Inserts or updates and persists under exclusive flock (G-PAR-49).
127    ///
128    /// Reloads disk state under the lock so concurrent multi-host first-connect
129    /// writers merge instead of last-write-wins.
130    pub fn store(&mut self, host: &str, port: u16, fingerprint: &str) -> SshCliResult<()> {
131        let _lock = HostsFileLock::acquire(&self.path)?;
132        self.reload_from_disk_unlocked()?;
133        self.entries
134            .insert(Self::key(host, port), fingerprint.to_string());
135        self.persist_unlocked()
136    }
137
138    fn reload_from_disk_unlocked(&mut self) -> SshCliResult<()> {
139        let fresh = Self::load(self.path.clone())?;
140        self.entries = fresh.entries;
141        Ok(())
142    }
143
144    fn persist_unlocked(&self) -> SshCliResult<()> {
145        if let Some(parent_dir) = self.path.parent() {
146            std::fs::create_dir_all(parent_dir)?;
147        }
148        // The final size is known before the first push: one line per entry plus the
149        // header. Growing from empty reallocates and memcpies the whole buffer roughly
150        // log2(n) times, and this runs on every TOFU write — the hot path for a fleet
151        // command that touches many hosts in one process.
152        const HEADER: &str = "# ssh-cli known_hosts (TOFU)\n";
153        let body_len = self
154            .entries
155            .iter()
156            .fold(HEADER.len(), |acc, (k, v)| acc + k.len() + v.len() + 2);
157        let mut body = String::with_capacity(body_len);
158        body.push_str(HEADER);
159        for (k, v) in &self.entries {
160            body.push_str(k);
161            body.push(' ');
162            body.push_str(v);
163            body.push('\n');
164        }
165
166        let parent_dir = self
167            .path
168            .parent()
169            .map(Path::to_path_buf)
170            .unwrap_or_else(|| PathBuf::from("."));
171        let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir).map_err(SshCliError::Io)?;
172        use std::io::Write;
173        tmp.write_all(body.as_bytes())?;
174        tmp.as_file().sync_data()?;
175        tmp.persist(&self.path)
176            .map_err(|e| SshCliError::Io(e.error))?;
177
178        crate::fs_perm::set_secret_file_mode(&self.path)?;
179        Ok(())
180    }
181}
182
183/// Verify TOFU fingerprint under exclusive flock (G-PAR-49 / G-TLS-10).
184///
185/// - No entry: accept and record (TOFU).
186/// - Matching entry: accept.
187/// - Differing entry: refuse, unless `replace` is true.
188///
189/// Reloads from disk under the lock so concurrent multi-host first-connect
190/// sees peers' writes before deciding.
191///
192/// # Errors
193/// Returns an error if the host key changed and replacement was not allowed, or if persistence fails.
194pub fn verify_tofu(
195    kh: &mut KnownHosts,
196    host: &str,
197    port: u16,
198    fingerprint: &str,
199    replace: bool,
200) -> SshCliResult<bool> {
201    let _lock = HostsFileLock::acquire(&kh.path)?;
202    kh.reload_from_disk_unlocked()?;
203    match kh.get(host, port).map(str::to_string) {
204        None => {
205            kh.entries
206                .insert(KnownHosts::key(host, port), fingerprint.to_string());
207            kh.persist_unlocked()?;
208            Ok(true)
209        }
210        Some(existing) if fingerprints_eq(&existing, fingerprint) => Ok(true),
211        Some(existing) if replace => {
212            tracing::warn!(
213                host,
214                port,
215                old = %existing,
216                novo = %fingerprint,
217                "replacing host key (--replace-host-key)"
218            );
219            kh.entries
220                .insert(KnownHosts::key(host, port), fingerprint.to_string());
221            kh.persist_unlocked()?;
222            Ok(true)
223        }
224        Some(existing) => Err(SshCliError::HostKeyChanged {
225            host: host.to_string(),
226            port,
227            expected: existing,
228            obtained: fingerprint.to_string(),
229        }),
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::sync::{Arc, Barrier};
237    use std::thread;
238    use tempfile::TempDir;
239
240    #[test]
241    fn fingerprints_eq_matches_and_rejects() {
242        assert!(fingerprints_eq("abc", "abc"));
243        assert!(!fingerprints_eq("abc", "abd"));
244        assert!(!fingerprints_eq("abc", "ab"));
245        assert!(!fingerprints_eq("ab", "abc"));
246    }
247
248    #[test]
249    fn tofu_stores_and_accepts_same() {
250        let tmp = TempDir::new().unwrap();
251        let path = tmp.path().join("known_hosts");
252        let mut kh = KnownHosts::load(path).unwrap();
253        assert!(verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap());
254        assert!(verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap());
255    }
256
257    #[test]
258    fn tofu_rejects_change() {
259        let tmp = TempDir::new().unwrap();
260        let path = tmp.path().join("known_hosts");
261        let mut kh = KnownHosts::load(path).unwrap();
262        verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap();
263        let err = verify_tofu(&mut kh, "h", 22, "fp2", false).unwrap_err();
264        assert!(matches!(err, SshCliError::HostKeyChanged { .. }));
265    }
266
267    #[test]
268    fn tofu_replaces_with_flag() {
269        let tmp = TempDir::new().unwrap();
270        let path = tmp.path().join("known_hosts");
271        let mut kh = KnownHosts::load(path).unwrap();
272        verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap();
273        assert!(verify_tofu(&mut kh, "h", 22, "fp2", true).unwrap());
274        assert_eq!(kh.get("h", 22), Some("fp2"));
275    }
276
277    /// G-PAR-49 / G-PAR-54: concurrent first-connect TOFU must not drop entries.
278    #[test]
279    fn concurrent_store_merges_both_hosts() {
280        let tmp = TempDir::new().unwrap();
281        let path = Arc::new(tmp.path().join("known_hosts"));
282        let barrier = Arc::new(Barrier::new(2));
283        let p1 = Arc::clone(&path);
284        let p2 = Arc::clone(&path);
285        let b1 = Arc::clone(&barrier);
286        let b2 = Arc::clone(&barrier);
287
288        let t1 = thread::spawn(move || {
289            let mut kh = KnownHosts::load((*p1).clone()).unwrap();
290            b1.wait();
291            verify_tofu(&mut kh, "alpha.example", 22, "fp-alpha", false).unwrap();
292        });
293        let t2 = thread::spawn(move || {
294            let mut kh = KnownHosts::load((*p2).clone()).unwrap();
295            b2.wait();
296            verify_tofu(&mut kh, "beta.example", 22, "fp-beta", false).unwrap();
297        });
298        t1.join().unwrap();
299        t2.join().unwrap();
300
301        let final_kh = KnownHosts::load((*path).clone()).unwrap();
302        assert_eq!(final_kh.get("alpha.example", 22), Some("fp-alpha"));
303        assert_eq!(final_kh.get("beta.example", 22), Some("fp-beta"));
304        assert_eq!(final_kh.entries.len(), 2);
305    }
306}