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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,28 +37,103 @@ 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> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The remote path of an SFTP VfsPath, decoded from its
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user