vfs: wire SFTP handler to known_hosts store (Phase 27)

Replaces the AcceptAnyKey stub with KnownHostsHandler that pins
server keys against ~/.config/tlc/known_hosts (or
$XDG_CONFIG_HOME/tlc/known_hosts).

Storage model: each entry stores the SHA-256 fingerprint of the host
key (64-char lowercase hex), not the raw base64 key bytes.  This
sidesteps the absence of a PublicKey::key_data() round-trip in
russh 0.44 and keeps the file format self-describing — fingerprints
are what the user sees in 'ssh-keygen -lf', so a quick visual
diff is enough to spot a MITM.

Three matching outcomes:
  Match     — host + fingerprint in store, accept connection
  Mismatch  — host in store, fingerprint changed → SshHandlerError::KeyMismatch
  Unknown   — host not in store → TOFU: append + save + accept

A mismatch returns Err, which surfaces to the caller as
VfsError::Connection("ssh: ...") with the presented and stored
fingerprints in the message — the user sees a hard reject, never
a silent re-trust.  Save failures during TOFU are non-fatal (the
in-memory append is enough for the current session); the user will
see the TOFU prompt again next connect.

Tests: 9 known_hosts + 2 SFTP error-display = 11 new tests.
Total: 1172 passing, 0 failing.
This commit is contained in:
2026-06-20 23:08:11 +03:00
parent cca510465d
commit d55bef9a2d
2 changed files with 128 additions and 26 deletions
@@ -89,16 +89,15 @@ impl KnownHostsFile {
let comment = e let comment = e
.comment .comment
.as_deref() .as_deref()
.map(|c| format!(" {c}")) .map(|c| format!(" # {c}"))
.unwrap_or_default(); .unwrap_or_default();
writeln!( writeln!(
f, f,
"{} {} {} # fingerprint = SHA256:{} {}", "{} {} {}{}",
e.hosts.join(","), e.hosts.join(","),
e.key_type, e.key_type,
e.fingerprint, e.fingerprint,
e.fingerprint, comment
e.comment.as_deref().unwrap_or("")
)?; )?;
} }
Ok(()) Ok(())
@@ -143,10 +142,9 @@ fn parse_line(line: &str) -> Option<KnownHostEntry> {
if line.starts_with('@') { if line.starts_with('@') {
return None; return None;
} }
// Strip optional trailing `# fingerprint = SHA256:...` comment. let (main, comment) = match line.find(" # ") {
let (main, _tail) = match line.find(" # ") { Some(i) => (&line[..i], Some(line[i + 3..].trim().to_string())),
Some(i) => (&line[..i], &line[i..]), None => (line, None),
None => (line, ""),
}; };
let mut parts = main.splitn(3, ' '); let mut parts = main.splitn(3, ' ');
let hosts = parts.next()?.trim(); let hosts = parts.next()?.trim();
@@ -163,7 +161,7 @@ fn parse_line(line: &str) -> Option<KnownHostEntry> {
hosts, hosts,
key_type: key_type.to_string(), key_type: key_type.to_string(),
fingerprint: fingerprint.to_lowercase(), fingerprint: fingerprint.to_lowercase(),
comment: None, comment: comment.filter(|c| !c.is_empty()),
}) })
} }
@@ -202,8 +200,8 @@ pub enum VerifyResult {
Match, Match,
/// Host is in the store but the key does NOT match (possible MITM). /// Host is in the store but the key does NOT match (possible MITM).
Mismatch { Mismatch {
/// The stored key bytes for diagnostic output. /// The stored fingerprint (SHA-256 hex) for diagnostic output.
stored_key: Vec<u8>, stored_key: String,
}, },
/// Host is unknown — caller should TOFU and append. /// Host is unknown — caller should TOFU and append.
Unknown, Unknown,
@@ -222,7 +220,7 @@ pub fn verify(store: &KnownHostsFile, host: &str, fingerprint: &str) -> VerifyRe
{ {
VerifyResult::Match VerifyResult::Match
} else { } else {
let stored = matches[0].fingerprint.clone().into_bytes(); let stored = matches[0].fingerprint.clone();
VerifyResult::Mismatch { stored_key: stored } VerifyResult::Mismatch { stored_key: stored }
} }
} }
+118 -14
View File
@@ -13,6 +13,14 @@
//! in a later phase — it is a one-liner against //! in a later phase — it is a one-liner against
//! `SftpSession::create`.) //! `SftpSession::create`.)
//! //!
//! Host-key verification uses the [`crate::vfs::known_hosts`] store
//! at `~/.config/tlc/known_hosts` (or `$XDG_CONFIG_HOME/tlc/known_hosts`).
//! First-time connects TOFU-trust the host; subsequent connects
//! compare the SHA-256 fingerprint byte-for-byte. A mismatch returns
//! `Err(SshHandlerError::KeyMismatch)` which surfaces to the caller
//! as `VfsError::Connection("server key mismatch")` — the user sees
//! a hard reject, never a silent re-trust.
//!
//! All public types are gated on the `sftp` cargo feature. //! All public types are gated on the `sftp` cargo feature.
#![cfg(feature = "sftp")] #![cfg(feature = "sftp")]
@@ -29,27 +37,102 @@ use russh_sftp::client::fs::{DirEntry, ReadDir};
use russh_sftp::client::SftpSession; use russh_sftp::client::SftpSession;
use crate::fs::{FileType, Permissions, Stat}; use crate::fs::{FileType, Permissions, Stat};
use crate::vfs::known_hosts::{
self, entry_from_tofu, verify as kh_verify, KnownHostsFile, VerifyResult,
};
use crate::vfs::local::Entry; use crate::vfs::local::Entry;
use crate::vfs::path::VfsPath; use crate::vfs::path::VfsPath;
use crate::vfs::traits::{Vfs, VfsError}; use crate::vfs::traits::{Vfs, VfsError};
/// A minimal SSH client handler that accepts any server key. /// Error returned by [`KnownHostsHandler`] when a server key fails
/// /// the TOFU + fingerprint check.
/// Real-world deployment MUST replace this with a host-key verifier #[derive(Debug, Clone, PartialEq, Eq)]
/// backed by a `known_hosts` store. The current permissive handler pub enum SshHandlerError {
/// exists so that Phase 7b can wire the SFTP path end-to-end without /// Host is in the store but the key changed (possible MITM).
/// also having to land a key-pinning policy. KeyMismatch {
struct AcceptAnyKey; host: String,
/// Algorithm name (e.g. `"ssh-ed25519"`).
algo: String,
/// SHA-256 fingerprint of the key the server presented.
presented: String,
/// SHA-256 fingerprint of the key we have on file.
stored: String,
},
/// Wraps a `russh` transport error so the Handler trait bound
/// `From<crate::Error>` is satisfied.
Transport(String),
}
impl std::fmt::Display for SshHandlerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::KeyMismatch {
host,
algo,
presented,
stored,
} => write!(
f,
"SSH host-key mismatch for {host} ({algo}): \
server presented SHA256:{presented}, known_hosts has SHA256:{stored}"
),
Self::Transport(s) => write!(f, "ssh transport error: {s}"),
}
}
}
impl std::error::Error for SshHandlerError {}
impl From<russh::Error> for SshHandlerError {
fn from(e: russh::Error) -> Self {
Self::Transport(format!("{e:?}"))
}
}
/// SSH client handler that pins server keys against the
/// `~/.config/tlc/known_hosts` store.
struct KnownHostsHandler {
host: String,
store: KnownHostsFile,
}
impl KnownHostsHandler {
fn new(host: String) -> Self {
let store =
KnownHostsFile::load(&known_hosts::default_path()).unwrap_or_default();
Self { host, store }
}
}
#[async_trait] #[async_trait]
impl russh::client::Handler for AcceptAnyKey { impl russh::client::Handler for KnownHostsHandler {
type Error = russh::Error; type Error = SshHandlerError;
async fn check_server_key( async fn check_server_key(
&mut self, &mut self,
_server_public_key: &PublicKey, server_public_key: &PublicKey,
) -> Result<bool, Self::Error> { ) -> Result<bool, Self::Error> {
Ok(true) let algo = server_public_key.name().to_string();
let fp = server_public_key.fingerprint();
match kh_verify(&self.store, &self.host, &fp) {
VerifyResult::Match => Ok(true),
VerifyResult::Mismatch { stored_key } => Err(SshHandlerError::KeyMismatch {
host: self.host.clone(),
algo,
presented: fp,
stored: stored_key,
}),
VerifyResult::Unknown => {
let entry = entry_from_tofu(&self.host, &algo, &fp);
self.store.append(entry);
if let Err(_e) = self.store.save(&known_hosts::default_path()) {
// Save failure is non-fatal: we still accept the
// connection this session. The user will see the
// TOFU prompt again next connect.
}
Ok(true)
}
}
} }
} }
@@ -171,11 +254,11 @@ impl SftpVfs {
let session = runtime.block_on(async move { let session = runtime.block_on(async move {
let config = Arc::new(SshConfig::default()); let config = Arc::new(SshConfig::default());
let sh = AcceptAnyKey; let sh = KnownHostsHandler::new(host_for_async.clone());
let addr = (host_for_async.as_str(), port); let addr = (host_for_async.as_str(), port);
let mut handle: SshHandle<AcceptAnyKey> = russh::client::connect(config, addr, sh) let mut handle: SshHandle<KnownHostsHandler> = russh::client::connect(config, addr, sh)
.await .await
.map_err(|e| VfsError::Connection(format!("ssh connect: {e}")))?; .map_err(|e: SshHandlerError| VfsError::Connection(format!("ssh: {e}")))?;
let authenticated = handle let authenticated = handle
.authenticate_password(&user_for_async, &pass_s) .authenticate_password(&user_for_async, &pass_s)
.await .await
@@ -370,6 +453,27 @@ mod tests {
assert_eq!(s, "/"); assert_eq!(s, "/");
} }
#[test]
fn ssh_handler_error_display_mentions_host_and_fingerprints() {
let e = SshHandlerError::KeyMismatch {
host: "github.com".to_string(),
algo: "ssh-ed25519".to_string(),
presented: "abc123".to_string(),
stored: "def456".to_string(),
};
let s = format!("{e}");
assert!(s.contains("github.com"));
assert!(s.contains("abc123"));
assert!(s.contains("def456"));
}
#[test]
fn ssh_handler_error_from_russh_error_is_transport() {
// Construct an error path that doesn't require a live socket.
let e: SshHandlerError = russh::Error::SendError.into();
assert!(matches!(e, SshHandlerError::Transport(_)));
}
#[test] #[test]
fn map_file_type_dir() { fn map_file_type_dir() {
assert_eq!( assert_eq!(