diff options
Diffstat (limited to 'src/cargo/sources/git/known_hosts.rs')
-rw-r--r-- | src/cargo/sources/git/known_hosts.rs | 923 |
1 files changed, 923 insertions, 0 deletions
diff --git a/src/cargo/sources/git/known_hosts.rs b/src/cargo/sources/git/known_hosts.rs new file mode 100644 index 0000000..0702379 --- /dev/null +++ b/src/cargo/sources/git/known_hosts.rs @@ -0,0 +1,923 @@ +//! SSH host key validation support. +//! +//! A primary goal with this implementation is to provide user-friendly error +//! messages, guiding them to understand the issue and how to resolve it. +//! +//! Note that there are a lot of limitations here. This reads OpenSSH +//! known_hosts files from well-known locations, but it does not read OpenSSH +//! config files. The config file can change the behavior of how OpenSSH +//! handles known_hosts files. For example, some things we don't handle: +//! +//! - `GlobalKnownHostsFile` — Changes the location of the global host file. +//! - `UserKnownHostsFile` — Changes the location of the user's host file. +//! - `KnownHostsCommand` — A command to fetch known hosts. +//! - `CheckHostIP` — DNS spoofing checks. +//! - `VisualHostKey` — Shows a visual ascii-art key. +//! - `VerifyHostKeyDNS` — Uses SSHFP DNS records to fetch a host key. +//! +//! There's also a number of things that aren't supported but could be easily +//! added (it just adds a little complexity). For example, hostname patterns, +//! and revoked markers. See "FIXME" comments littered in this file. + +use crate::util::config::{Config, Definition, Value}; +use git2::cert::{Cert, SshHostKeyType}; +use git2::CertificateCheckStatus; +use hmac::Mac; +use std::collections::HashSet; +use std::fmt::{Display, Write}; +use std::path::{Path, PathBuf}; + +/// These are host keys that are hard-coded in cargo to provide convenience. +/// +/// If GitHub ever publishes new keys, the user can add them to their own +/// configuration file to use those instead. +/// +/// The GitHub keys are sourced from <https://api.github.com/meta> or +/// <https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints>. +/// +/// These will be ignored if the user adds their own entries for `github.com`, +/// which can be useful if GitHub ever revokes their old keys. +static BUNDLED_KEYS: &[(&str, &str, &str)] = &[ + ("github.com", "ssh-ed25519", "AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"), + ("github.com", "ecdsa-sha2-nistp256", "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg="), + ("github.com", "ssh-rsa", "AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk="), +]; + +/// List of keys that public hosts have rotated away from. +/// +/// We explicitly distrust these keys as users with the old key in their +/// local configuration will otherwise be vulnerable to MITM attacks if the +/// attacker has access to the old key. As there is no other way to distribute +/// revocations of ssh host keys, we need to bundle them with the client. +/// +/// Unlike [`BUNDLED_KEYS`], these revocations will not be ignored if the user +/// has their own entries: we *know* that these keys are bad. +static BUNDLED_REVOCATIONS: &[(&str, &str, &str)] = &[ + // Used until March 24, 2023: https://github.blog/2023-03-23-we-updated-our-rsa-ssh-host-key/ + ("github.com", "ssh-rsa", "AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ=="), +]; + +enum KnownHostError { + /// Some general error happened while validating the known hosts. + CheckError(anyhow::Error), + /// The host key was not found. + HostKeyNotFound { + hostname: String, + key_type: SshHostKeyType, + remote_host_key: String, + remote_fingerprint: String, + other_hosts: Vec<KnownHost>, + }, + /// The host key was found, but does not match the remote's key. + HostKeyHasChanged { + hostname: String, + key_type: SshHostKeyType, + old_known_host: KnownHost, + remote_host_key: String, + remote_fingerprint: String, + }, + /// The host key was found with a @revoked marker, it must not be accepted. + HostKeyRevoked { + hostname: String, + key_type: SshHostKeyType, + remote_host_key: String, + location: KnownHostLocation, + }, + /// The host key was not found, but there was a matching known host with a + /// @cert-authority marker (which Cargo doesn't yet support). + HostHasOnlyCertAuthority { + hostname: String, + location: KnownHostLocation, + }, +} + +impl From<anyhow::Error> for KnownHostError { + fn from(err: anyhow::Error) -> KnownHostError { + KnownHostError::CheckError(err.into()) + } +} + +/// The location where a host key was located. +#[derive(Clone)] +enum KnownHostLocation { + /// Loaded from a file from disk. + File { path: PathBuf, lineno: u32 }, + /// Loaded from cargo's config system. + Config { definition: Definition }, + /// Part of the hard-coded bundled keys in Cargo. + Bundled, +} + +impl Display for KnownHostLocation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let loc = match self { + KnownHostLocation::File { path, lineno } => { + format!("{} line {lineno}", path.display()) + } + KnownHostLocation::Config { definition } => { + format!("config value from {definition}") + } + KnownHostLocation::Bundled => format!("bundled with cargo"), + }; + f.write_str(&loc) + } +} + +/// The git2 callback used to validate a certificate (only ssh known hosts are validated). +pub fn certificate_check( + config: &Config, + cert: &Cert<'_>, + host: &str, + port: Option<u16>, + config_known_hosts: Option<&Vec<Value<String>>>, + diagnostic_home_config: &str, +) -> Result<CertificateCheckStatus, git2::Error> { + let Some(host_key) = cert.as_hostkey() else { + // Return passthrough for TLS X509 certificates to use whatever validation + // was done in git2. + return Ok(CertificateCheckStatus::CertificatePassthrough) + }; + // If a nonstandard port is in use, check for that first. + // The fallback to check without a port is handled in the HostKeyNotFound handler. + let host_maybe_port = match port { + Some(port) if port != 22 => format!("[{host}]:{port}"), + _ => host.to_string(), + }; + // The error message must be constructed as a string to pass through the libgit2 C API. + let err_msg = match check_ssh_known_hosts( + config, + host_key, + &host_maybe_port, + config_known_hosts, + ) { + Ok(()) => { + return Ok(CertificateCheckStatus::CertificateOk); + } + Err(KnownHostError::CheckError(e)) => { + format!("error: failed to validate host key:\n{:#}", e) + } + Err(KnownHostError::HostKeyNotFound { + hostname, + key_type, + remote_host_key, + remote_fingerprint, + other_hosts, + }) => { + // Try checking without the port. + if port.is_some() + && !matches!(port, Some(22)) + && check_ssh_known_hosts(config, host_key, host, config_known_hosts).is_ok() + { + return Ok(CertificateCheckStatus::CertificateOk); + } + let key_type_short_name = key_type.short_name(); + let key_type_name = key_type.name(); + let known_hosts_location = user_known_host_location_to_add(diagnostic_home_config); + let other_hosts_message = if other_hosts.is_empty() { + String::new() + } else { + let mut msg = String::from( + "Note: This host key was found, \ + but is associated with a different host:\n", + ); + for known_host in other_hosts { + write!( + msg, + " {loc}: {patterns}\n", + loc = known_host.location, + patterns = known_host.patterns + ) + .unwrap(); + } + msg + }; + format!("error: unknown SSH host key\n\ + The SSH host key for `{hostname}` is not known and cannot be validated.\n\ + \n\ + To resolve this issue, add the host key to {known_hosts_location}\n\ + \n\ + The key to add is:\n\ + \n\ + {hostname} {key_type_name} {remote_host_key}\n\ + \n\ + The {key_type_short_name} key fingerprint is: SHA256:{remote_fingerprint}\n\ + This fingerprint should be validated with the server administrator that it is correct.\n\ + {other_hosts_message}\n\ + See https://doc.rust-lang.org/stable/cargo/appendix/git-authentication.html#ssh-known-hosts \ + for more information.\n\ + ") + } + Err(KnownHostError::HostKeyHasChanged { + hostname, + key_type, + old_known_host, + remote_host_key, + remote_fingerprint, + }) => { + let key_type_short_name = key_type.short_name(); + let key_type_name = key_type.name(); + let known_hosts_location = user_known_host_location_to_add(diagnostic_home_config); + let old_key_resolution = match old_known_host.location { + KnownHostLocation::File { path, lineno } => { + let old_key_location = path.display(); + format!( + "removing the old {key_type_name} key for `{hostname}` \ + located at {old_key_location} line {lineno}, \ + and adding the new key to {known_hosts_location}", + ) + } + KnownHostLocation::Config { definition } => { + format!( + "removing the old {key_type_name} key for `{hostname}` \ + loaded from Cargo's config at {definition}, \ + and adding the new key to {known_hosts_location}" + ) + } + KnownHostLocation::Bundled => { + format!( + "adding the new key to {known_hosts_location}\n\ + The current host key is bundled as part of Cargo." + ) + } + }; + format!("error: SSH host key has changed for `{hostname}`\n\ + *********************************\n\ + * WARNING: HOST KEY HAS CHANGED *\n\ + *********************************\n\ + This may be caused by a man-in-the-middle attack, or the \ + server may have changed its host key.\n\ + \n\ + The {key_type_short_name} fingerprint for the key from the remote host is:\n\ + SHA256:{remote_fingerprint}\n\ + \n\ + You are strongly encouraged to contact the server \ + administrator for `{hostname}` to verify that this new key is \ + correct.\n\ + \n\ + If you can verify that the server has a new key, you can \ + resolve this error by {old_key_resolution}\n\ + \n\ + The key provided by the remote host is:\n\ + \n\ + {hostname} {key_type_name} {remote_host_key}\n\ + \n\ + See https://doc.rust-lang.org/stable/cargo/appendix/git-authentication.html#ssh-known-hosts \ + for more information.\n\ + ") + } + Err(KnownHostError::HostKeyRevoked { + hostname, + key_type, + remote_host_key, + location, + }) => { + let key_type_short_name = key_type.short_name(); + format!( + "error: Key has been revoked for `{hostname}`\n\ + **************************************\n\ + * WARNING: REVOKED HOST KEY DETECTED *\n\ + **************************************\n\ + This may indicate that the key provided by this host has been\n\ + compromised and should not be accepted. + \n\ + The host key {key_type_short_name} {remote_host_key} is revoked\n\ + in {location} and has been rejected.\n\ + " + ) + } + Err(KnownHostError::HostHasOnlyCertAuthority { hostname, location }) => { + format!("error: Found a `@cert-authority` marker for `{hostname}`\n\ + \n\ + Cargo doesn't support certificate authorities for host key verification. It is\n\ + recommended that the command line Git client is used instead. This can be achieved\n\ + by setting `net.git-fetch-with-cli` to `true` in the Cargo config.\n\ + \n + The `@cert-authority` line was found in {location}.\n\ + \n\ + See https://doc.rust-lang.org/stable/cargo/appendix/git-authentication.html#ssh-known-hosts \ + for more information.\n\ + ") + } + }; + Err(git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Callback, + err_msg, + )) +} + +/// Checks if the given host/host key pair is known. +fn check_ssh_known_hosts( + config: &Config, + cert_host_key: &git2::cert::CertHostkey<'_>, + host: &str, + config_known_hosts: Option<&Vec<Value<String>>>, +) -> Result<(), KnownHostError> { + let Some(remote_host_key) = cert_host_key.hostkey() else { + return Err(anyhow::format_err!("remote host key is not available").into()); + }; + let remote_key_type = cert_host_key.hostkey_type().unwrap(); + + // Collect all the known host entries from disk. + let mut known_hosts = Vec::new(); + for path in known_host_files(config) { + if !path.exists() { + continue; + } + let hosts = load_hostfile(&path)?; + known_hosts.extend(hosts); + } + if let Some(config_known_hosts) = config_known_hosts { + // Format errors aren't an error in case the format needs to change in + // the future, to retain forwards compatibility. + for line_value in config_known_hosts { + let location = KnownHostLocation::Config { + definition: line_value.definition.clone(), + }; + match parse_known_hosts_line(&line_value.val, location) { + Some(known_host) => known_hosts.push(known_host), + None => log::warn!( + "failed to parse known host {} from {}", + line_value.val, + line_value.definition + ), + } + } + } + // Load the bundled keys. Don't add keys for hosts that the user has + // configured, which gives them the option to override them. This could be + // useful if the keys are ever revoked. + let configured_hosts: HashSet<_> = known_hosts + .iter() + .flat_map(|known_host| { + known_host + .patterns + .split(',') + .map(|pattern| pattern.to_lowercase()) + }) + .collect(); + for (patterns, key_type, key) in BUNDLED_KEYS { + if !configured_hosts.contains(*patterns) { + let key = base64::decode(key).unwrap(); + known_hosts.push(KnownHost { + location: KnownHostLocation::Bundled, + patterns: patterns.to_string(), + key_type: key_type.to_string(), + key, + line_type: KnownHostLineType::Key, + }); + } + } + for (patterns, key_type, key) in BUNDLED_REVOCATIONS { + let key = base64::decode(key).unwrap(); + known_hosts.push(KnownHost { + location: KnownHostLocation::Bundled, + patterns: patterns.to_string(), + key_type: key_type.to_string(), + key, + line_type: KnownHostLineType::Revoked, + }); + } + check_ssh_known_hosts_loaded(&known_hosts, host, remote_key_type, remote_host_key) +} + +/// Checks a host key against a loaded set of known hosts. +fn check_ssh_known_hosts_loaded( + known_hosts: &[KnownHost], + host: &str, + remote_key_type: SshHostKeyType, + remote_host_key: &[u8], +) -> Result<(), KnownHostError> { + // `latent_error` keeps track of a potential error that will be returned + // in case a matching host key isn't found. + let mut latent_errors: Vec<KnownHostError> = Vec::new(); + + // `other_hosts` keeps track of any entries that have an identical key, + // but a different hostname. + let mut other_hosts = Vec::new(); + + // `accepted_known_host_found` keeps track of whether we've found a matching + // line in the `known_hosts` file that we would accept. We can't return that + // immediately, because there may be a subsequent @revoked key. + let mut accepted_known_host_found = false; + + // Older versions of OpenSSH (before 6.8, March 2015) showed MD5 + // fingerprints (see FingerprintHash ssh config option). Here we only + // support SHA256. + let mut remote_fingerprint = cargo_util::Sha256::new(); + remote_fingerprint.update(remote_host_key.clone()); + let remote_fingerprint = + base64::encode_config(remote_fingerprint.finish(), base64::STANDARD_NO_PAD); + let remote_host_key_encoded = base64::encode(remote_host_key); + + for known_host in known_hosts { + // The key type from libgit2 needs to match the key type from the host file. + if known_host.key_type != remote_key_type.name() { + continue; + } + let key_matches = known_host.key == remote_host_key; + if !known_host.host_matches(host) { + if key_matches { + other_hosts.push(known_host.clone()); + } + continue; + } + match known_host.line_type { + KnownHostLineType::Key => { + if key_matches { + accepted_known_host_found = true; + } else { + // The host and key type matched, but the key itself did not. + // This indicates the key has changed. + // This is only reported as an error if no subsequent lines have a + // correct key. + latent_errors.push(KnownHostError::HostKeyHasChanged { + hostname: host.to_string(), + key_type: remote_key_type, + old_known_host: known_host.clone(), + remote_host_key: remote_host_key_encoded.clone(), + remote_fingerprint: remote_fingerprint.clone(), + }); + } + } + KnownHostLineType::Revoked => { + if key_matches { + return Err(KnownHostError::HostKeyRevoked { + hostname: host.to_string(), + key_type: remote_key_type, + remote_host_key: remote_host_key_encoded, + location: known_host.location.clone(), + }); + } + } + KnownHostLineType::CertAuthority => { + // The host matches a @cert-authority line, which is unsupported. + latent_errors.push(KnownHostError::HostHasOnlyCertAuthority { + hostname: host.to_string(), + location: known_host.location.clone(), + }); + } + } + } + + // We have an accepted host key and it hasn't been revoked. + if accepted_known_host_found { + return Ok(()); + } + + if latent_errors.is_empty() { + // FIXME: Ideally the error message should include the IP address of the + // remote host (to help the user validate that they are connecting to the + // host they were expecting to). However, I don't see a way to obtain that + // information from libgit2. + Err(KnownHostError::HostKeyNotFound { + hostname: host.to_string(), + key_type: remote_key_type, + remote_host_key: remote_host_key_encoded, + remote_fingerprint, + other_hosts, + }) + } else { + // We're going to take the first HostKeyHasChanged error if + // we find one, otherwise we'll take the first error (which + // we expect to be a CertAuthority error). + if let Some(index) = latent_errors + .iter() + .position(|e| matches!(e, KnownHostError::HostKeyHasChanged { .. })) + { + return Err(latent_errors.remove(index)); + } else { + // Otherwise, we take the first error (which we expect to be + // a CertAuthority error). + Err(latent_errors.pop().unwrap()) + } + } +} + +/// Returns a list of files to try loading OpenSSH-formatted known hosts. +fn known_host_files(config: &Config) -> Vec<PathBuf> { + let mut result = Vec::new(); + if config + .get_env_os("__CARGO_TEST_DISABLE_GLOBAL_KNOWN_HOST") + .is_some() + { + } else if cfg!(unix) { + result.push(PathBuf::from("/etc/ssh/ssh_known_hosts")); + } else if cfg!(windows) { + // The msys/cygwin version of OpenSSH uses `/etc` from the posix root + // filesystem there (such as `C:\msys64\etc\ssh\ssh_known_hosts`). + // However, I do not know of a way to obtain that location from + // Windows-land. The ProgramData version here is what the PowerShell + // port of OpenSSH does. + if let Some(progdata) = config.get_env_os("ProgramData") { + let mut progdata = PathBuf::from(progdata); + progdata.push("ssh"); + progdata.push("ssh_known_hosts"); + result.push(progdata) + } + } + result.extend(user_known_host_location()); + result +} + +/// The location of the user's known_hosts file. +fn user_known_host_location() -> Option<PathBuf> { + // NOTE: This is a potentially inaccurate prediction of what the user + // actually wants. The actual location depends on several factors: + // + // - Windows OpenSSH Powershell version: I believe this looks up the home + // directory via ProfileImagePath in the registry, falling back to + // `GetWindowsDirectoryW` if that fails. + // - OpenSSH Portable (under msys): This is very complicated. I got lost + // after following it through some ldap/active directory stuff. + // - OpenSSH (most unix platforms): Uses `pw->pw_dir` from `getpwuid()`. + // + // This doesn't do anything close to that. home_dir's behavior is: + // - Windows: $USERPROFILE, or SHGetFolderPathW() + // - Unix: $HOME, or getpwuid_r() + // + // Since there is a mismatch here, the location returned here might be + // different than what the user's `ssh` CLI command uses. We may want to + // consider trying to align it better. + home::home_dir().map(|mut home| { + home.push(".ssh"); + home.push("known_hosts"); + home + }) +} + +/// The location to display in an error message instructing the user where to +/// add the new key. +fn user_known_host_location_to_add(diagnostic_home_config: &str) -> String { + // Note that we don't bother with the legacy known_hosts2 files. + let user = user_known_host_location(); + let openssh_loc = match &user { + Some(path) => path.to_str().expect("utf-8 home"), + None => "~/.ssh/known_hosts", + }; + format!( + "the `net.ssh.known-hosts` array in your Cargo configuration \ + (such as {diagnostic_home_config}) \ + or in your OpenSSH known_hosts file at {openssh_loc}" + ) +} + +const HASH_HOSTNAME_PREFIX: &str = "|1|"; + +#[derive(Clone)] +enum KnownHostLineType { + Key, + CertAuthority, + Revoked, +} + +/// A single known host entry. +#[derive(Clone)] +struct KnownHost { + location: KnownHostLocation, + /// The hostname. May be comma separated to match multiple hosts. + patterns: String, + key_type: String, + key: Vec<u8>, + line_type: KnownHostLineType, +} + +impl KnownHost { + /// Returns whether or not the given host matches this known host entry. + fn host_matches(&self, host: &str) -> bool { + let mut match_found = false; + let host = host.to_lowercase(); + if let Some(hashed) = self.patterns.strip_prefix(HASH_HOSTNAME_PREFIX) { + return hashed_hostname_matches(&host, hashed); + } + for pattern in self.patterns.split(',') { + let pattern = pattern.to_lowercase(); + // FIXME: support * and ? wildcards + if let Some(pattern) = pattern.strip_prefix('!') { + if pattern == host { + return false; + } + } else { + match_found |= pattern == host; + } + } + match_found + } +} + +fn hashed_hostname_matches(host: &str, hashed: &str) -> bool { + let Some((b64_salt, b64_host)) = hashed.split_once('|') else { return false; }; + let Ok(salt) = base64::decode(b64_salt) else { return false; }; + let Ok(hashed_host) = base64::decode(b64_host) else { return false; }; + let Ok(mut mac) = hmac::Hmac::<sha1::Sha1>::new_from_slice(&salt) else { return false; }; + mac.update(host.as_bytes()); + let result = mac.finalize().into_bytes(); + hashed_host == &result[..] +} + +/// Loads an OpenSSH known_hosts file. +fn load_hostfile(path: &Path) -> Result<Vec<KnownHost>, anyhow::Error> { + let contents = cargo_util::paths::read(path)?; + Ok(load_hostfile_contents(path, &contents)) +} + +fn load_hostfile_contents(path: &Path, contents: &str) -> Vec<KnownHost> { + let entries = contents + .lines() + .enumerate() + .filter_map(|(lineno, line)| { + let location = KnownHostLocation::File { + path: path.to_path_buf(), + lineno: lineno as u32 + 1, + }; + parse_known_hosts_line(line, location) + }) + .collect(); + entries +} + +fn parse_known_hosts_line(line: &str, location: KnownHostLocation) -> Option<KnownHost> { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let mut parts = line.split([' ', '\t']).filter(|s| !s.is_empty()); + + let line_type = if line.starts_with("@") { + let line_type = parts.next()?; + + if line_type == "@cert-authority" { + KnownHostLineType::CertAuthority + } else if line_type == "@revoked" { + KnownHostLineType::Revoked + } else { + // No other markers are defined + return None; + } + } else { + KnownHostLineType::Key + }; + + let patterns = parts.next()?; + let key_type = parts.next()?; + let key = parts.next().map(base64::decode)?.ok()?; + Some(KnownHost { + line_type, + location, + patterns: patterns.to_string(), + key_type: key_type.to_string(), + key, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + static COMMON_CONTENTS: &str = r#" + # Comments allowed at start of line + + example.com,rust-lang.org ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC5MzWIpZwpkpDjyCNiTIEVFhSA9OUUQvjFo7CgZBGCAj/cqeUIgiLsgtfmtBsfWIkAECQpM7ePP7NLZFGJcHvoyg5jXJiIX5s0eKo9IlcuTLLrMkW5MkHXE7bNklVbW1WdCfF2+y7Ao25B4L8FFRokMh0yp/H6+8xZ7PdVwL3FRPEg8ftZ5R0kuups6xiMHPRX+f/07vfJzA47YDPmXfhkn+JK8kL0JYw8iy8BtNBfRQL99d9iXJzWXnNce5NHMuKD5rOonD3aQHLDlwK+KhrFRrdaxQEM8ZWxNti0ux8yT4Dl5jJY0CrIu3Xl6+qroVgTqJGNkTbhs5DGWdFh6BLPTTH15rN4buisg7uMyLyHqx06ckborqD33gWu+Jig7O+PV6KJmL5mp1O1HXvZqkpBdTiT6GiDKG3oECCIXkUk0BSU9VG9VQcrMxxvgiHlyoXUAfYQoXv/lnxkTnm+Sr36kutsVOs7n5B43ZKAeuaxyQ11huJZpxamc0RA1HM641s= eric@host + Example.net ssh-dss AAAAB3NzaC1kc3MAAACBAK2Ek3jVxisXmz5UcZ7W65BAj/nDJCCVvSe0Aytndn4PH6k7sVesut5OoY6PdksZ9tEfuFjjS9HR5SJb8j1GW0GxtaSHHbf+rNc36PeU75bffzyIWwpA8uZFONt5swUAXJXcsHOoapNbUFuhHsRhB2hXxz9QGNiiwIwRJeSHixKRAAAAFQChKfxO1z9H2/757697xP5nJ/Z5dwAAAIEAoc+HIWas+4WowtB/KtAp6XE0B9oHI+55wKtdcGwwb7zHKK9scWNXwxIcMhSvyB3Oe2I7dQQlvyIWxsdZlzOkX0wdsTHjIAnBAP68MyvMv4kq3+I5GAVcFsqoLZfZvh0dlcgUq1/YNYZwKlt89tnzk8Fp4KLWmuw8Bd8IShYVa78AAACAL3qd8kNTY7CthgsQ8iWdjbkGSF/1KCeFyt8UjurInp9wvPDjqagwakbyLOzN7y3/ItTPCaGuX+RjFP0zZTf8i9bsAVyjFJiJ7vzRXcWytuFWANrpzLTn1qzPfh63iK92Aw8AVBYvEA/4bxo+XReAvhNBB/m78G6OedTeu6ZoTsI= eric@host + [example.net]:2222 ssh-dss AAAAB3NzaC1kc3MAAACBAJJN5kLZEpOJpXWyMT4KwYvLAj+b9ErNtglxOi86C6Kw7oZeYdDMCfD3lc3PJyX64udQcWGfO4abSESMiYdY43yFAZH279QGH5Q/B5CklVvTqYpfAUR+1r9TQxy3OVQHk7FB2wOi4xNQ3myO0vaYlBOB9il+P223aERbXx4JTWdvAAAAFQCTHWTcXxLK5Z6ZVPmfdSDyHzkF2wAAAIEAhp41/mTnM0Y0EWSyCXuETMW1QSpKGF8sqoZKp6wdzyhLXu0i32gLdXj4p24em/jObYh93hr+MwgxqWq+FHgD+D80Qg5f6vj4yEl4Uu5hqtTpCBFWUQoyEckbUkPf8uZ4/XzAne+tUSjZm09xATCmK9U2IGqZE+D+90eBkf1Svc8AAACAeKhi4EtfwenFYqKz60ZoEEhIsE1yI2jH73akHnfHpcW84w+fk3YlwjcfDfyYso+D0jZBdJeK5qIdkbUWhAX8wDjJVO0WL6r/YPr4yu/CgEyW1H59tAbujGJ4NR0JDqioulzYqNHnxpiw1RJukZnPBfSFKzRElvPOCq/NkQM/Mwk= eric@host + nistp256.example.org ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJ4iYGCcJrUIfrHfzlsv8e8kaF36qpcUpe3VNAKVCZX/BDptIdlEe8u8vKNRTPgUO9jqS0+tjTcPiQd8/8I9qng= eric@host + nistp384.example.org ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBNuGT3TqMz2rcwOt2ZqkiNqq7dvWPE66W2qPCoZsh0pQhVU3BnhKIc6nEr6+Wts0Z3jdF3QWwxbbTjbVTVhdr8fMCFhDCWiQFm9xLerYPKnu9qHvx9K87/fjc5+0pu4hLA== eric@host + nistp521.example.org ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAD35HH6OsK4DN75BrKipVj/GvZaUzjPNa1F8wMjUdPB1JlVcUfgzJjWSxrhmaNN3u0soiZw8WNRFINsGPCw5E7DywF1689WcIj2Ye2rcy99je15FknScTzBBD04JgIyOI50mCUaPCBoF14vFlN6BmO00cFo+yzy5N8GuQ2sx9kr21xmFQ== eric@host + # Revoked is supported, but without Cert-Authority support, it will only negate some other fixed key. + @revoked revoked.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKtQsi+KPYispwm2rkMidQf30fG1Niy8XNkvASfePoca eric@host + # Cert-Authority is not supported (below key should not be valid anyway) + @cert-authority ca.example.com ssh-rsa AABBB5Wm + example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAWkjI6XT2SZh3xNk5NhisA3o3sGzWR+VAKMSqHtI0aY eric@host + 192.168.42.12 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host + |1|QxzZoTXIWLhUsuHAXjuDMIV3FjQ=|M6NCOIkjiWdCWqkh5+Q+/uFLGjs= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIHgN3O21U4LWtP5OzjTzPnUnSDmCNDvyvlaj6Hi65JC eric@host + # Negation isn't terribly useful without globs. + neg.example.com,!neg.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOXfUnaAHTlo1Qi//rNk26OcmHikmkns1Z6WW/UuuS3K eric@host + "#; + + #[test] + fn known_hosts_parse() { + let kh_path = Path::new("/home/abc/.known_hosts"); + let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS); + assert_eq!(khs.len(), 12); + match &khs[0].location { + KnownHostLocation::File { path, lineno } => { + assert_eq!(path, kh_path); + assert_eq!(*lineno, 4); + } + _ => panic!("unexpected"), + } + assert_eq!(khs[0].patterns, "example.com,rust-lang.org"); + assert_eq!(khs[0].key_type, "ssh-rsa"); + assert_eq!(khs[0].key.len(), 407); + assert_eq!(&khs[0].key[..30], b"\x00\x00\x00\x07ssh-rsa\x00\x00\x00\x03\x01\x00\x01\x00\x00\x01\x81\x00\xb935\x88\xa5\x9c)"); + match &khs[1].location { + KnownHostLocation::File { path, lineno } => { + assert_eq!(path, kh_path); + assert_eq!(*lineno, 5); + } + _ => panic!("unexpected"), + } + assert_eq!(khs[2].patterns, "[example.net]:2222"); + assert_eq!(khs[3].patterns, "nistp256.example.org"); + assert_eq!(khs[9].patterns, "192.168.42.12"); + } + + #[test] + fn host_matches() { + let kh_path = Path::new("/home/abc/.known_hosts"); + let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS); + assert!(khs[0].host_matches("example.com")); + assert!(khs[0].host_matches("rust-lang.org")); + assert!(khs[0].host_matches("EXAMPLE.COM")); + assert!(khs[1].host_matches("example.net")); + assert!(!khs[0].host_matches("example.net")); + assert!(khs[2].host_matches("[example.net]:2222")); + assert!(!khs[2].host_matches("example.net")); + assert!(khs[10].host_matches("hashed.example.com")); + assert!(!khs[10].host_matches("example.com")); + assert!(!khs[11].host_matches("neg.example.com")); + } + + #[test] + fn check_match() { + let kh_path = Path::new("/home/abc/.known_hosts"); + let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS); + + assert!(check_ssh_known_hosts_loaded( + &khs, + "example.com", + SshHostKeyType::Rsa, + &khs[0].key + ) + .is_ok()); + + match check_ssh_known_hosts_loaded(&khs, "example.com", SshHostKeyType::Dss, &khs[0].key) { + Err(KnownHostError::HostKeyNotFound { + hostname, + remote_fingerprint, + other_hosts, + .. + }) => { + assert_eq!( + remote_fingerprint, + "yn+pONDn0EcgdOCVptgB4RZd/wqmsVKrPnQMLtrvhw8" + ); + assert_eq!(hostname, "example.com"); + assert_eq!(other_hosts.len(), 0); + } + _ => panic!("unexpected"), + } + + match check_ssh_known_hosts_loaded( + &khs, + "foo.example.com", + SshHostKeyType::Rsa, + &khs[0].key, + ) { + Err(KnownHostError::HostKeyNotFound { other_hosts, .. }) => { + assert_eq!(other_hosts.len(), 1); + assert_eq!(other_hosts[0].patterns, "example.com,rust-lang.org"); + } + _ => panic!("unexpected"), + } + + let mut modified_key = khs[0].key.clone(); + modified_key[0] = 1; + match check_ssh_known_hosts_loaded(&khs, "example.com", SshHostKeyType::Rsa, &modified_key) + { + Err(KnownHostError::HostKeyHasChanged { old_known_host, .. }) => { + assert!(matches!( + old_known_host.location, + KnownHostLocation::File { lineno: 4, .. } + )); + } + _ => panic!("unexpected"), + } + } + + #[test] + fn revoked() { + let kh_path = Path::new("/home/abc/.known_hosts"); + let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS); + + match check_ssh_known_hosts_loaded( + &khs, + "revoked.example.com", + SshHostKeyType::Ed255219, + &khs[6].key, + ) { + Err(KnownHostError::HostKeyRevoked { + hostname, location, .. + }) => { + assert_eq!("revoked.example.com", hostname); + assert!(matches!( + location, + KnownHostLocation::File { lineno: 11, .. } + )); + } + _ => panic!("Expected key to be revoked for revoked.example.com."), + } + } + + #[test] + fn cert_authority() { + let kh_path = Path::new("/home/abc/.known_hosts"); + let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS); + + match check_ssh_known_hosts_loaded( + &khs, + "ca.example.com", + SshHostKeyType::Rsa, + &khs[0].key, // The key should not matter + ) { + Err(KnownHostError::HostHasOnlyCertAuthority { + hostname, location, .. + }) => { + assert_eq!("ca.example.com", hostname); + assert!(matches!( + location, + KnownHostLocation::File { lineno: 13, .. } + )); + } + Err(KnownHostError::HostKeyNotFound { hostname, .. }) => { + panic!("host key not found... {}", hostname); + } + _ => panic!("Expected host to only have @cert-authority line (which is unsupported)."), + } + } + + #[test] + fn multiple_errors() { + let contents = r#" + not-used.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAWkjI6XT2SZh3xNk5NhisA3o3sGzWR+VAKMSqHtI0aY eric@host + # Cert-authority and changed key for the same host - changed key error should prevail + @cert-authority example.com ssh-ed25519 AABBB5Wm + example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host + "#; + + let kh_path = Path::new("/home/abc/.known_hosts"); + let khs = load_hostfile_contents(kh_path, contents); + + match check_ssh_known_hosts_loaded( + &khs, + "example.com", + SshHostKeyType::Ed255219, + &khs[0].key, + ) { + Err(KnownHostError::HostKeyHasChanged { + hostname, + old_known_host, + remote_host_key, + .. + }) => { + assert_eq!("example.com", hostname); + assert_eq!( + "AAAAC3NzaC1lZDI1NTE5AAAAIAWkjI6XT2SZh3xNk5NhisA3o3sGzWR+VAKMSqHtI0aY", + remote_host_key + ); + assert!(matches!( + old_known_host.location, + KnownHostLocation::File { lineno: 5, .. } + )); + } + _ => panic!("Expected error to be of type HostKeyHasChanged."), + } + } + + #[test] + fn known_host_and_revoked() { + let contents = r#" + example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host + # Later in the file the same host key is revoked + @revoked example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host + "#; + + let kh_path = Path::new("/home/abc/.known_hosts"); + let khs = load_hostfile_contents(kh_path, contents); + + match check_ssh_known_hosts_loaded( + &khs, + "example.com", + SshHostKeyType::Ed255219, + &khs[0].key, + ) { + Err(KnownHostError::HostKeyRevoked { + hostname, + remote_host_key, + location, + .. + }) => { + assert_eq!("example.com", hostname); + assert_eq!( + "AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR", + remote_host_key + ); + assert!(matches!( + location, + KnownHostLocation::File { lineno: 4, .. } + )); + } + _ => panic!("Expected host key to be reject with error HostKeyRevoked."), + } + } +} |