Skip to content

Commit

Permalink
Add support for known_hosts to the example client
Browse files Browse the repository at this point in the history
  • Loading branch information
honzasp committed Oct 1, 2022
1 parent 42178d4 commit 32c2382
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 41 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
Expand Down
113 changes: 95 additions & 18 deletions examples/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ fn run_main() -> Result<ExitCode> {
.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();
Expand All @@ -87,7 +91,13 @@ fn run_main() -> Result<ExitCode> {
.map(|spec| parse_tunnel_spec(&spec))
.collect::<Result<Vec<_>>>()?;

let opts = Opts { destination, keys, command, want_tty, local_tunnels, remote_tunnels };
let host_file_path = matches.get_one::<PathBuf>("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()?;
Expand All @@ -104,6 +114,7 @@ struct Opts {
want_tty: bool,
local_tunnels: Vec<TunnelSpec>,
remote_tunnels: Vec<TunnelSpec>,
host_file: Option<HostFile>,
}

#[derive(Debug, Default)]
Expand Down Expand Up @@ -178,13 +189,30 @@ fn parse_tunnel_spec(spec: &str) -> Result<TunnelSpec> {
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<PathBuf>) -> Result<Option<HostFile>> {
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<ExitCode> {
let host = opts.destination.host
.context("please specify the host to connect to")?;
let username = opts.destination.username
.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
Expand All @@ -203,7 +231,7 @@ async fn run_client(opts: Opts) -> Result<ExitCode> {
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 {
Expand Down Expand Up @@ -257,16 +285,30 @@ 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<HostFile>,
) -> Result<()> {
let mut pubkey_task = Fuse::terminated();
let mut tunnel_tasks = FuturesUnordered::new();
loop {
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);
Expand All @@ -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<HostFile>,
) -> Result<bool> {
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<()> {
Expand Down
69 changes: 46 additions & 23 deletions src/host_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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`].
///
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -851,6 +860,18 @@ mod tests {
}
}

fn check_other_keys(file: &File, host: &str, port: u16, pubkey: &Pubkey, checks: Vec<fn(&Entry)>) {
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 => (),
Expand Down Expand Up @@ -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),
Expand Down

0 comments on commit 32c2382

Please sign in to comment.