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
.comment
.as_deref()
.map(|c| format!(" {c}"))
.map(|c| format!(" # {c}"))
.unwrap_or_default();
writeln!(
f,
"{} {} {} # fingerprint = SHA256:{} {}",
"{} {} {}{}",
e.hosts.join(","),
e.key_type,
e.fingerprint,
e.fingerprint,
e.comment.as_deref().unwrap_or("")
comment
)?;
}
Ok(())
@@ -143,10 +142,9 @@ fn parse_line(line: &str) -> Option<KnownHostEntry> {
if line.starts_with('@') {
return None;
}
// Strip optional trailing `# fingerprint = SHA256:...` comment.
let (main, _tail) = match line.find(" # ") {
Some(i) => (&line[..i], &line[i..]),
None => (line, ""),
let (main, comment) = match line.find(" # ") {
Some(i) => (&line[..i], Some(line[i + 3..].trim().to_string())),
None => (line, None),
};
let mut parts = main.splitn(3, ' ');
let hosts = parts.next()?.trim();
@@ -163,7 +161,7 @@ fn parse_line(line: &str) -> Option<KnownHostEntry> {
hosts,
key_type: key_type.to_string(),
fingerprint: fingerprint.to_lowercase(),
comment: None,
comment: comment.filter(|c| !c.is_empty()),
})
}
@@ -202,8 +200,8 @@ pub enum VerifyResult {
Match,
/// Host is in the store but the key does NOT match (possible MITM).
Mismatch {
/// The stored key bytes for diagnostic output.
stored_key: Vec<u8>,
/// The stored fingerprint (SHA-256 hex) for diagnostic output.
stored_key: String,
},
/// Host is unknown — caller should TOFU and append.
Unknown,
@@ -222,7 +220,7 @@ pub fn verify(store: &KnownHostsFile, host: &str, fingerprint: &str) -> VerifyRe
{
VerifyResult::Match
} else {
let stored = matches[0].fingerprint.clone().into_bytes();
let stored = matches[0].fingerprint.clone();
VerifyResult::Mismatch { stored_key: stored }
}
}
+118 -14
View File
@@ -13,6 +13,14 @@
//! in a later phase — it is a one-liner against
//! `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.
#![cfg(feature = "sftp")]
@@ -29,27 +37,102 @@ use russh_sftp::client::fs::{DirEntry, ReadDir};
use russh_sftp::client::SftpSession;
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::path::VfsPath;
use crate::vfs::traits::{Vfs, VfsError};
/// A minimal SSH client handler that accepts any server key.
///
/// Real-world deployment MUST replace this with a host-key verifier
/// backed by a `known_hosts` store. The current permissive handler
/// exists so that Phase 7b can wire the SFTP path end-to-end without
/// also having to land a key-pinning policy.
struct AcceptAnyKey;
/// Error returned by [`KnownHostsHandler`] when a server key fails
/// the TOFU + fingerprint check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SshHandlerError {
/// Host is in the store but the key changed (possible MITM).
KeyMismatch {
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]
impl russh::client::Handler for AcceptAnyKey {
type Error = russh::Error;
impl russh::client::Handler for KnownHostsHandler {
type Error = SshHandlerError;
async fn check_server_key(
&mut self,
_server_public_key: &PublicKey,
server_public_key: &PublicKey,
) -> 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 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 mut handle: SshHandle<AcceptAnyKey> = russh::client::connect(config, addr, sh)
let mut handle: SshHandle<KnownHostsHandler> = russh::client::connect(config, addr, sh)
.await
.map_err(|e| VfsError::Connection(format!("ssh connect: {e}")))?;
.map_err(|e: SshHandlerError| VfsError::Connection(format!("ssh: {e}")))?;
let authenticated = handle
.authenticate_password(&user_for_async, &pass_s)
.await
@@ -370,6 +453,27 @@ mod tests {
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]
fn map_file_type_dir() {
assert_eq!(