From 32c2382c654c8ea004305e08fa91d8fec1e2248e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C5=A0pa=C4=8Dek?= Date: Sat, 1 Oct 2022 17:16:12 +0200 Subject: [PATCH] Add support for known_hosts to the example client --- Cargo.toml | 1 + examples/client.rs | 113 +++++++++++++++++++++++++++++++++++++-------- src/host_file.rs | 69 ++++++++++++++++++--------- 3 files changed, 142 insertions(+), 41 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5995b5e..8215ed8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ colored = "2.0" enclose = "1.1" env_logger = "0.9" futures = "0.3" +home = "0.5" regex = "1.5" rustix = {version = "0.35", features = ["termios"]} tokio = {version = "1", features = ["full"]} diff --git a/examples/client.rs b/examples/client.rs index 1923597..cc70daf 100644 --- a/examples/client.rs +++ b/examples/client.rs @@ -61,6 +61,10 @@ fn run_main() -> Result { .takes_value(true) .action(clap::ArgAction::Append) .value_name("[remote-host:]remote-port:local-host:local-port")) + .arg(clap::Arg::new("known-hosts").short('k') + .takes_value(true) + .value_hint(clap::ValueHint::FilePath) + .value_name("host-file")) .get_matches(); let mut destination = Destination::default(); @@ -87,7 +91,13 @@ fn run_main() -> Result { .map(|spec| parse_tunnel_spec(&spec)) .collect::>>()?; - let opts = Opts { destination, keys, command, want_tty, local_tunnels, remote_tunnels }; + let host_file_path = matches.get_one::("known-hosts").cloned(); + let host_file = read_host_file(host_file_path)?; + + let opts = Opts { + destination, keys, command, want_tty, + local_tunnels, remote_tunnels, host_file, + }; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all().build()?; @@ -104,6 +114,7 @@ struct Opts { want_tty: bool, local_tunnels: Vec, remote_tunnels: Vec, + host_file: Option, } #[derive(Debug, Default)] @@ -178,6 +189,22 @@ fn parse_tunnel_spec(spec: &str) -> Result { Ok(TunnelSpec { bind_host, bind_port, connect_host, connect_port }) } +#[derive(Debug)] +struct HostFile { + path: PathBuf, + file: makiko::host_file::File, +} + +fn read_host_file(path: Option) -> Result> { + let default_path = home::home_dir().map(|dir| dir.join(".ssh/known_hosts")); + guard!{let Some(path) = path.or(default_path) else { return Ok(None) }}; + + let file_data = fs::read(&path) + .context(format!("could not read known_hosts file {}", path.display()))?; + let file = makiko::host_file::File::decode(file_data.into()); + Ok(Some(HostFile { path, file })) +} + async fn run_client(opts: Opts) -> Result { let host = opts.destination.host .context("please specify the host to connect to")?; @@ -185,6 +212,7 @@ async fn run_client(opts: Opts) -> Result { .context("please specify the username to login with")?; let port = opts.destination.port.unwrap_or(22); let config = makiko::ClientConfig::default_compatible_less_secure(); + let hostname = makiko::host_file::host_port_to_hostname(&host, port); log::info!("connecting to host {:?}, port {}", host, port); let socket = tokio::net::TcpStream::connect((host, port)).await @@ -203,7 +231,7 @@ async fn run_client(opts: Opts) -> Result { let client_task = TaskHandle(tokio::task::spawn(client_fut)); let event_task = TaskHandle(tokio::task::spawn( - run_events(client.clone(), client_rx, remote_tunnel_addrs.clone()) + run_events(client.clone(), client_rx, remote_tunnel_addrs.clone(), hostname, opts.host_file) )); let interact_task = TaskHandle(tokio::task::spawn(enclose!{(client) async move { @@ -257,6 +285,8 @@ async fn run_events( client: makiko::Client, mut client_rx: makiko::ClientReceiver, remote_tunnel_addrs: HashMap<(String, u16), (String, u16)>, + hostname: String, + mut host_file: Option, ) -> Result<()> { let mut pubkey_task = Fuse::terminated(); let mut tunnel_tasks = FuturesUnordered::new(); @@ -264,9 +294,21 @@ async fn run_events( tokio::select!{ event = client_rx.recv() => match event? { Some(makiko::ClientEvent::ServerPubkey(pubkey, accept_tx)) => { - pubkey_task = TaskHandle(tokio::task::spawn( - verify_pubkey(client.clone(), pubkey, accept_tx) - )).fuse(); + let client = client.clone(); + let hostname = hostname.clone(); + let host_file = host_file.take(); + pubkey_task = TaskHandle(tokio::task::spawn(async move { + if verify_pubkey(pubkey, hostname, host_file).await? { + accept_tx.accept(); + } else { + client.disconnect(makiko::DisconnectError { + reason_code: makiko::codes::disconnect::HOST_KEY_NOT_VERIFIABLE, + description: "user did not accept the host public key".into(), + description_lang: "".into(), + })?; + } + Result::<()>::Ok(()) + })).fuse(); }, Some(makiko::ClientEvent::Tunnel(accept)) => { let connect_addr = remote_tunnel_addrs.get(&accept.connected_addr); @@ -286,23 +328,58 @@ async fn run_events( } async fn verify_pubkey( - client: makiko::Client, pubkey: makiko::Pubkey, - accept_tx: makiko::AcceptPubkey, -) -> Result<()> { - log::info!("verifying server pubkey: {}", pubkey); - let prompt = format!("ssh: server pubkey fingerprint {}\nssh: do you want to connect?", - pubkey.fingerprint()); + hostname: String, + mut host_file: Option, +) -> Result { + log::info!("verifying pubkey for server {:?}: {}", hostname, pubkey); + + if let Some(host_file) = host_file.as_ref() { + use makiko::host_file::KeyMatch; + match host_file.file.match_hostname_key(&hostname, &pubkey) { + KeyMatch::Accepted(entries) => { + log::info!("server pubkey found in {}:", host_file.path.display()); + for entry in entries.iter() { + log::info!(" at line {}", entry.line()); + } + return Ok(true) + }, + KeyMatch::Revoked(entry) => { + println!("ssh: server pubkey was revoked in {} at line {}!!!", + host_file.path.display(), entry.line()); + return Ok(false) + }, + KeyMatch::OtherKeys(entries) => { + println!("ssh: found other pubkeys in {}!!!", host_file.path.display()); + for entry in entries.iter() { + println!("ssh: at line {}: {}, fingerprint {}", + entry.line(), entry.pubkey(), entry.pubkey().fingerprint()); + } + return Ok(false) + }, + KeyMatch::NotFound => { + log::info!("server was not found in {}", host_file.path.display()); + }, + } + } + + let prompt = format!( + "ssh: server {:?} has pubkey with fingerprint {}\nssh: do you want to connect?", + hostname, pubkey.fingerprint(), + ); + if ask_yes_no(&prompt).await? { - accept_tx.accept(); + if let Some(host_file) = host_file.as_mut() { + host_file.file.append_entry(makiko::host_file::File::entry_builder() + .hostname(&hostname) + .key(pubkey)); + fs::write(&host_file.path, &host_file.file.encode()) + .context(format!("could not write to {}", host_file.path.display()))?; + } + Ok(true) } else { - client.disconnect(makiko::DisconnectError { - reason_code: makiko::codes::disconnect::HOST_KEY_NOT_VERIFIABLE, - description: "user did not accept the host public key".into(), - description_lang: "".into(), - })?; + Ok(false) } - Ok(()) } async fn run_remote_tunnel(accept: makiko::AcceptTunnel, connect_addr: (String, u16)) -> Result<()> { diff --git a/src/host_file.rs b/src/host_file.rs index fbc2376..22a9971 100644 --- a/src/host_file.rs +++ b/src/host_file.rs @@ -108,6 +108,11 @@ pub enum KeyMatch<'e> { /// The `Entry` is the first revoked entry in the file that matches the hostname and the key. Revoked(&'e Entry), + /// We found other keys for this hostname. + /// + /// The `Vec` list all non-revoked entries that match the hostname, it is always non-empty. + OtherKeys(Vec<&'e Entry>), + /// The combination of key and host was not found. NotFound, } @@ -137,21 +142,23 @@ impl File { /// Finds the match for the given hostname and key in this file. /// - /// See [`host_port_to_hostname()`][Self::host_port_to_hostname()] for the format of the - /// `hostname`; you can use [`match_host_port_key()`][Self::match_host_port_key()] to match a - /// `(host, port)` pair. + /// See [`host_port_to_hostname()`] for the format of the `hostname`; you can use + /// [`match_host_port_key()`][Self::match_host_port_key()] to match a `(host, port)` pair. /// /// If you want more advanced processing, you can use [`entries()`][Self::entries()] to list /// all entries and the [`Entry::matches_hostname()`] and [`Entry::pubkey()`] methods to match /// them to a hostname and key. pub fn match_hostname_key(&self, hostname: &str, pubkey: &Pubkey) -> KeyMatch<'_> { let mut accepted = Vec::new(); + let mut other_keys = Vec::new(); + for entry in self.entries() { if !entry.matches_hostname(hostname) { continue } if entry.pubkey() != pubkey { + other_keys.push(entry); continue } @@ -164,6 +171,8 @@ impl File { if !accepted.is_empty() { KeyMatch::Accepted(accepted) + } else if !other_keys.is_empty() { + KeyMatch::OtherKeys(other_keys) } else { KeyMatch::NotFound } @@ -172,21 +181,9 @@ impl File { /// Finds the match for the given host and port in this file. /// /// Same as [`match_hostname_key()`][Self::match_hostname_key()], but formats the host and port - /// using [`host_port_to_hostname()`][Self::host_port_to_hostname()]. + /// using [`host_port_to_hostname()`]. pub fn match_host_port_key(&self, host: &str, port: u16, pubkey: &Pubkey) -> KeyMatch<'_> { - self.match_hostname_key(&Self::host_port_to_hostname(host, port), pubkey) - } - - /// Converts a host and port to an OpenSSH-compatible hostname. - /// - /// If the port is not 22, it returns `[host]:port`, otherwise the `host` is returned as-is. - /// `host` can be either a domain name or an IP address. - pub fn host_port_to_hostname(host: &str, port: u16) -> String { - if port == 22 { - host.into() - } else { - format!("[{}]:{}", host, port) - } + self.match_hostname_key(&host_port_to_hostname(host, port), pubkey) } /// Creates an [`EntryBuilder`], which can be used to add an entry (or a set of entries) to the @@ -227,6 +224,18 @@ impl Default for File { } } +/// Converts a host and port to an OpenSSH-compatible hostname. +/// +/// If the port is not 22, it returns `[host]:port`, otherwise the `host` is returned as-is. +/// `host` can be either a domain name or an IP address. +pub fn host_port_to_hostname(host: &str, port: u16) -> String { + if port == 22 { + host.into() + } else { + format!("[{}]:{}", host, port) + } +} + impl Entry { /// The line number of this entry in the [`File`]. /// @@ -278,7 +287,7 @@ impl EntryBuilder { /// Adds a given hostname in plaintext. /// - /// See [`File::host_port_to_hostname()`] for the format of the `hostname`; you can use + /// See [`host_port_to_hostname()`] for the format of the `hostname`; you can use /// [`plaintext_host_port()`][Self::plaintext_host_port()] to add a `(host, port)` pair. /// /// The hostname will be added in plaintext, so anybody who has access to `known_hosts` can see @@ -294,12 +303,12 @@ impl EntryBuilder { /// can see which hostnames you connected to. See [`hostname()`][Self::hostname()] if you want /// to hide the hostname. pub fn plaintext_host_port(&mut self, host: &str, port: u16) -> &mut Self { - self.plaintext_hostnames.push(File::host_port_to_hostname(host, port)); self + self.plaintext_hostnames.push(host_port_to_hostname(host, port)); self } /// Adds a given hostname in a hashed form. /// - /// See [`File::host_port_to_hostname()`] for the format of the `hostname`; you can use + /// See [`host_port_to_hostname()`] for the format of the `hostname`; you can use /// [`plaintext_host_port()`][Self::plaintext_host_port()] to add a `(host, port)` pair. /// /// The hostname will be stored in the file as a HMAC-SHA1 hash with a random salt. This hides @@ -313,7 +322,7 @@ impl EntryBuilder { /// The host and port will be stored in the file as a HMAC-SHA1 hash with a random salt. This /// hides the host and port if the file is disclosed. pub fn host_port(&mut self, host: &str, port: u16) -> &mut Self { - self.hashed_hostnames.push(File::host_port_to_hostname(host, port)); self + self.hashed_hostnames.push(host_port_to_hostname(host, port)); self } /// Adds a public key. @@ -851,6 +860,18 @@ mod tests { } } + fn check_other_keys(file: &File, host: &str, port: u16, pubkey: &Pubkey, checks: Vec) { + match file.match_host_port_key(host, port, pubkey) { + KeyMatch::OtherKeys(entries) => { + assert_eq!(entries.len(), checks.len()); + for (entry, check) in entries.iter().zip(checks.iter().copied()) { + check(entry); + } + }, + res => panic!("expected OtherKeys, got: {:?}", res), + } + } + fn check_not_found(file: &File, host: &str, port: u16, pubkey: &Pubkey) { match file.match_host_port_key(host, port, pubkey) { KeyMatch::NotFound => (), @@ -900,13 +921,15 @@ mod tests { assert_eq!(e.key_comment(), Some("edward")); }, ]); - check_not_found(&file, "prefix.example.com", 22, &edward); check_not_found(&file, "example.com", 42, &edward); + check_not_found(&file, "prefix.example.com", 22, &edward); check_accepted(&file, "github.com", 22, &ruth, vec![ |e| assert_eq!(e.line(), 4), ]); - check_not_found(&file, "github.com", 22, &edward); + check_other_keys(&file, "github.com", 22, &edward, vec![ + |e| assert_eq!(e.line(), 4), + ]); check_accepted(&file, "secure.gitlab.org", 22, &alice, vec![ |e| assert_eq!(e.line(), 5),