#
cargo/sources/git/
known_hosts.rs

    1//! SSH host key validation support.
    2//!
    3//! The only public item in this module is [`certificate_check`],
    4//! which provides a callback to [`git2::RemoteCallbacks::certificate_check`].
    5//!
    6//! A primary goal with this implementation is to provide user-friendly error
    7//! messages, guiding them to understand the issue and how to resolve it.
    8//!
    9//! Note that there are a lot of limitations here. This reads OpenSSH
    10//! `known_hosts` files from well-known locations, but it does not read OpenSSH
    11//! config files. The config file can change the behavior of how OpenSSH
    12//! handles `known_hosts` files. For example, some things we don't handle:
    13//!
    14//! - `GlobalKnownHostsFile` — Changes the location of the global host file.
    15//! - `UserKnownHostsFile` — Changes the location of the user's host file.
    16//! - `KnownHostsCommand` — A command to fetch known hosts.
    17//! - `CheckHostIP` — DNS spoofing checks.
    18//! - `VisualHostKey` — Shows a visual ascii-art key.
    19//! - `VerifyHostKeyDNS` — Uses SSHFP DNS records to fetch a host key.
    20//!
    21//! There's also a number of things that aren't supported but could be easily
    22//! added (it just adds a little complexity). For example, hostname patterns,
    23//! and revoked markers. See "FIXME" comments littered in this file.
    24
    25use crate::util::context::{Definition, GlobalContext, Value};
    26use crate::CargoResult;
    27use base64::engine::general_purpose::STANDARD;
    28use base64::engine::general_purpose::STANDARD_NO_PAD;
    29use base64::Engine as _;
    30use git2::cert::{Cert, SshHostKeyType};
    31use git2::CertificateCheckStatus;
    32use hmac::Mac;
    33use std::collections::HashSet;
    34use std::fmt::{Display, Write};
    35use std::path::{Path, PathBuf};
    36
    37/// These are host keys that are hard-coded in cargo to provide convenience.
    38///
    39/// If GitHub ever publishes new keys, the user can add them to their own
    40/// configuration file to use those instead.
    41///
    42/// The GitHub keys are sourced from <https://api.github.com/meta> or
    43/// <https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints>.
    44///
    45/// These will be ignored if the user adds their own entries for `github.com`,
    46/// which can be useful if GitHub ever revokes their old keys.
    47static BUNDLED_KEYS: &[(&str, &str, &str)] = &[
    48    ("github.com", "ssh-ed25519", "AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"),
    49    ("github.com", "ecdsa-sha2-nistp256", "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg="),
    50    ("github.com", "ssh-rsa", " "),
    51];
    52
    53/// List of keys that public hosts have rotated away from.
    54///
    55/// We explicitly distrust these keys as users with the old key in their
    56/// local configuration will otherwise be vulnerable to MITM attacks if the
    57/// attacker has access to the old key. As there is no other way to distribute
    58/// revocations of ssh host keys, we need to bundle them with the client.
    59///
    60/// Unlike [`BUNDLED_KEYS`], these revocations will not be ignored if the user
    61/// has their own entries: we *know* that these keys are bad.
    62static BUNDLED_REVOCATIONS: &[(&str, &str, &str)] = &[
    63    // Used until March 24, 2023: https://github.blog/2023-03-23-we-updated-our-rsa-ssh-host-key/
    64    ("github.com", "ssh-rsa", "AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ=="),
    65];
    66
    67enum KnownHostError {
    68    /// Some general error happened while validating the known hosts.
    69    CheckError(anyhow::Error),
    70    /// The host key was not found.
    71    HostKeyNotFound {
    72        hostname: String,
    73        key_type: SshHostKeyType,
    74        remote_host_key: String,
    75        remote_fingerprint: String,
    76        other_hosts: Vec<KnownHost>,
    77    },
    78    /// The host key was found, but does not match the remote's key.
    79    HostKeyHasChanged {
    80        hostname: String,
    81        key_type: SshHostKeyType,
    82        old_known_host: KnownHost,
    83        remote_host_key: String,
    84        remote_fingerprint: String,
    85    },
    86    /// The host key was found with a @revoked marker, it must not be accepted.
    87    HostKeyRevoked {
    88        hostname: String,
    89        key_type: SshHostKeyType,
    90        remote_host_key: String,
    91        location: KnownHostLocation,
    92    },
    93    /// The host key was not found, but there was a matching known host with a
    94    /// @cert-authority marker (which Cargo doesn't yet support).
    95    HostHasOnlyCertAuthority {
    96        hostname: String,
    97        location: KnownHostLocation,
    98    },
    99}
    100
    101impl From<anyhow::Error> for KnownHostError {
    102    fn from(err: anyhow::Error) -> KnownHostError {
    103        KnownHostError::CheckError(err)
    104    }
    105}
    106
    107/// The location where a host key was located.
    108#[derive(Clone)]
    109enum KnownHostLocation {
    110    /// Loaded from a file from disk.
    111    File { path: PathBuf, lineno: u32 },
    112    /// Loaded from cargo's config system.
    113    Config { definition: Definition },
    114    /// Part of the hard-coded bundled keys in Cargo.
    115    Bundled,
    116}
    117
    118impl Display for KnownHostLocation {
    119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    120        let loc = match self {
    121            KnownHostLocation::File { path, lineno } => {
    122                format!("{} line {lineno}", path.display())
    123            }
    124            KnownHostLocation::Config { definition } => {
    125                format!("config value from {definition}")
    126            }
    127            KnownHostLocation::Bundled => format!("bundled with cargo"),
    128        };
    129        f.write_str(&loc)
    130    }
    131}
    132
    133/// The git2 callback used to validate a certificate (only ssh known hosts are validated).
    134pub fn certificate_check(
    135    gctx: &GlobalContext,
    136    cert: &Cert<'_>,
    137    host: &str,
    138    port: Option<u16>,
    139    config_known_hosts: Option<&Vec<Value<String>>>,
    140    diagnostic_home_config: &str,
    141) -> CargoResult<CertificateCheckStatus> {
    142    let Some(host_key) = cert.as_hostkey() else {
    143        // Return passthrough for TLS X509 certificates to use whatever validation
    144        // was done in git2.
    145        return Ok(CertificateCheckStatus::CertificatePassthrough);
    146    };
    147    // If a nonstandard port is in use, check for that first.
    148    // The fallback to check without a port is handled in the HostKeyNotFound handler.
    149    let host_maybe_port = match port {
    150        Some(port) if port != 22 => format!("[{host}]:{port}"),
    151        _ => host.to_string(),
    152    };
    153    // The error message must be constructed as a string to pass through the libgit2 C API.
    154    match check_ssh_known_hosts(gctx, host_key, &host_maybe_port, config_known_hosts) {
    155        Ok(()) => {
    156            return Ok(CertificateCheckStatus::CertificateOk);
    157        }
    158        Err(KnownHostError::CheckError(e)) => {
    159            anyhow::bail!("error: failed to validate host key:\n{:#}", e)
    160        }
    161        Err(KnownHostError::HostKeyNotFound {
    162            hostname,
    163            key_type,
    164            remote_host_key,
    165            remote_fingerprint,
    166            other_hosts,
    167        }) => {
    168            // Try checking without the port.
    169            if port.is_some()
    170                && !matches!(port, Some(22))
    171                && check_ssh_known_hosts(gctx, host_key, host, config_known_hosts).is_ok()
    172            {
    173                return Ok(CertificateCheckStatus::CertificateOk);
    174            }
    175            let key_type_short_name = key_type.short_name();
    176            let key_type_name = key_type.name();
    177            let known_hosts_location = user_known_host_location_to_add(diagnostic_home_config);
    178            let other_hosts_message = if other_hosts.is_empty() {
    179                String::new()
    180            } else {
    181                let mut msg = String::from(
    182                    "Note: This host key was found, \
    183                    but is associated with a different host:\n",
    184                );
    185                for known_host in other_hosts {
    186                    write!(
    187                        msg,
    188                        "    {loc}: {patterns}\n",
    189                        loc = known_host.location,
    190                        patterns = known_host.patterns
    191                    )
    192                    .unwrap();
    193                }
    194                msg
    195            };
    196            anyhow::bail!("error: unknown SSH host key\n\
    197                The SSH host key for `{hostname}` is not known and cannot be validated.\n\
    198                \n\
    199                To resolve this issue, add the host key to {known_hosts_location}\n\
    200                \n\
    201                The key to add is:\n\
    202                \n\
    203                {hostname} {key_type_name} {remote_host_key}\n\
    204                \n\
    205                The {key_type_short_name} key fingerprint is: SHA256:{remote_fingerprint}\n\
    206                This fingerprint should be validated with the server administrator that it is correct.\n\
    207                {other_hosts_message}\n\
    208                See https://doc.rust-lang.org/stable/cargo/appendix/git-authentication.html#ssh-known-hosts \
    209                for more information.\n\
    210                ")
    211        }
    212        Err(KnownHostError::HostKeyHasChanged {
    213            hostname,
    214            key_type,
    215            old_known_host,
    216            remote_host_key,
    217            remote_fingerprint,
    218        }) => {
    219            let key_type_short_name = key_type.short_name();
    220            let key_type_name = key_type.name();
    221            let known_hosts_location = user_known_host_location_to_add(diagnostic_home_config);
    222            let old_key_resolution = match old_known_host.location {
    223                KnownHostLocation::File { path, lineno } => {
    224                    let old_key_location = path.display();
    225                    format!(
    226                        "removing the old {key_type_name} key for `{hostname}` \
    227                        located at {old_key_location} line {lineno}, \
    228                        and adding the new key to {known_hosts_location}",
    229                    )
    230                }
    231                KnownHostLocation::Config { definition } => {
    232                    format!(
    233                        "removing the old {key_type_name} key for `{hostname}` \
    234                        loaded from Cargo's config at {definition}, \
    235                        and adding the new key to {known_hosts_location}"
    236                    )
    237                }
    238                KnownHostLocation::Bundled => {
    239                    format!(
    240                        "adding the new key to {known_hosts_location}\n\
    241                        The current host key is bundled as part of Cargo."
    242                    )
    243                }
    244            };
    245            anyhow::bail!("error: SSH host key has changed for `{hostname}`\n\
    246                *********************************\n\
    247                * WARNING: HOST KEY HAS CHANGED *\n\
    248                *********************************\n\
    249                This may be caused by a man-in-the-middle attack, or the \
    250                server may have changed its host key.\n\
    251                \n\
    252                The {key_type_short_name} fingerprint for the key from the remote host is:\n\
    253                    SHA256:{remote_fingerprint}\n\
    254                \n\
    255                You are strongly encouraged to contact the server \
    256                administrator for `{hostname}` to verify that this new key is \
    257                correct.\n\
    258                \n\
    259                If you can verify that the server has a new key, you can \
    260                resolve this error by {old_key_resolution}\n\
    261                \n\
    262                The key provided by the remote host is:\n\
    263                \n\
    264                {hostname} {key_type_name} {remote_host_key}\n\
    265                \n\
    266                See https://doc.rust-lang.org/stable/cargo/appendix/git-authentication.html#ssh-known-hosts \
    267                for more information.\n\
    268                ")
    269        }
    270        Err(KnownHostError::HostKeyRevoked {
    271            hostname,
    272            key_type,
    273            remote_host_key,
    274            location,
    275        }) => {
    276            let key_type_short_name = key_type.short_name();
    277            anyhow::bail!(
    278                "error: Key has been revoked for `{hostname}`\n\
    279                **************************************\n\
    280                * WARNING: REVOKED HOST KEY DETECTED *\n\
    281                **************************************\n\
    282                This may indicate that the key provided by this host has been\n\
    283                compromised and should not be accepted.
    284                \n\
    285                The host key {key_type_short_name} {remote_host_key} is revoked\n\
    286                in {location} and has been rejected.\n\
    287                "
    288            )
    289        }
    290        Err(KnownHostError::HostHasOnlyCertAuthority { hostname, location }) => {
    291            anyhow::bail!("error: Found a `@cert-authority` marker for `{hostname}`\n\
    292                \n\
    293                Cargo doesn't support certificate authorities for host key verification. It is\n\
    294                recommended that the command line Git client is used instead. This can be achieved\n\
    295                by setting `net.git-fetch-with-cli` to `true` in the Cargo config.\n\
    296                \n
    297                The `@cert-authority` line was found in {location}.\n\
    298                \n\
    299                See https://doc.rust-lang.org/stable/cargo/appendix/git-authentication.html#ssh-known-hosts \
    300                for more information.\n\
    301                ")
    302        }
    303    }
    304}
    305
    306/// Checks if the given host/host key pair is known.
    307fn check_ssh_known_hosts(
    308    gctx: &GlobalContext,
    309    cert_host_key: &git2::cert::CertHostkey<'_>,
    310    host: &str,
    311    config_known_hosts: Option<&Vec<Value<String>>>,
    312) -> Result<(), KnownHostError> {
    313    let Some(remote_host_key) = cert_host_key.hostkey() else {
    314        return Err(anyhow::format_err!("remote host key is not available").into());
    315    };
    316    let remote_key_type = cert_host_key.hostkey_type().unwrap();
    317
    318    // Collect all the known host entries from disk.
    319    let mut known_hosts = Vec::new();
    320    for path in known_host_files(gctx) {
    321        if !path.exists() {
    322            continue;
    323        }
    324        let hosts = load_hostfile(&path)?;
    325        known_hosts.extend(hosts);
    326    }
    327    if let Some(config_known_hosts) = config_known_hosts {
    328        // Format errors aren't an error in case the format needs to change in
    329        // the future, to retain forwards compatibility.
    330        for line_value in config_known_hosts {
    331            let location = KnownHostLocation::Config {
    332                definition: line_value.definition.clone(),
    333            };
    334            match parse_known_hosts_line(&line_value.val, location) {
    335                Some(known_host) => known_hosts.push(known_host),
    336                None => tracing::warn!(
    337                    "failed to parse known host {} from {}",
    338                    line_value.val,
    339                    line_value.definition
    340                ),
    341            }
    342        }
    343    }
    344    // Load the bundled keys. Don't add keys for hosts that the user has
    345    // configured, which gives them the option to override them. This could be
    346    // useful if the keys are ever revoked.
    347    let configured_hosts: HashSet<_> = known_hosts
    348        .iter()
    349        .flat_map(|known_host| {
    350            known_host
    351                .patterns
    352                .split(',')
    353                .map(|pattern| pattern.to_lowercase())
    354        })
    355        .collect();
    356    for (patterns, key_type, key) in BUNDLED_KEYS {
    357        if !configured_hosts.contains(*patterns) {
    358            let key = STANDARD.decode(key).unwrap();
    359            known_hosts.push(KnownHost {
    360                location: KnownHostLocation::Bundled,
    361                patterns: patterns.to_string(),
    362                key_type: key_type.to_string(),
    363                key,
    364                line_type: KnownHostLineType::Key,
    365            });
    366        }
    367    }
    368    for (patterns, key_type, key) in BUNDLED_REVOCATIONS {
    369        let key = STANDARD.decode(key).unwrap();
    370        known_hosts.push(KnownHost {
    371            location: KnownHostLocation::Bundled,
    372            patterns: patterns.to_string(),
    373            key_type: key_type.to_string(),
    374            key,
    375            line_type: KnownHostLineType::Revoked,
    376        });
    377    }
    378    check_ssh_known_hosts_loaded(&known_hosts, host, remote_key_type, remote_host_key)
    379}
    380
    381/// Checks a host key against a loaded set of known hosts.
    382fn check_ssh_known_hosts_loaded(
    383    known_hosts: &[KnownHost],
    384    host: &str,
    385    remote_key_type: SshHostKeyType,
    386    remote_host_key: &[u8],
    387) -> Result<(), KnownHostError> {
    388    // `latent_error` keeps track of a potential error that will be returned
    389    // in case a matching host key isn't found.
    390    let mut latent_errors: Vec<KnownHostError> = Vec::new();
    391
    392    // `other_hosts` keeps track of any entries that have an identical key,
    393    // but a different hostname.
    394    let mut other_hosts = Vec::new();
    395
    396    // `accepted_known_host_found` keeps track of whether we've found a matching
    397    // line in the `known_hosts` file that we would accept. We can't return that
    398    // immediately, because there may be a subsequent @revoked key.
    399    let mut accepted_known_host_found = false;
    400
    401    // Older versions of OpenSSH (before 6.8, March 2015) showed MD5
    402    // fingerprints (see FingerprintHash ssh config option). Here we only
    403    // support SHA256.
    404    let mut remote_fingerprint = cargo_util::Sha256::new();
    405    remote_fingerprint.update(remote_host_key);
    406    let remote_fingerprint = STANDARD_NO_PAD.encode(remote_fingerprint.finish());
    407    let remote_host_key_encoded = STANDARD.encode(remote_host_key);
    408
    409    for known_host in known_hosts {
    410        // The key type from libgit2 needs to match the key type from the host file.
    411        if known_host.key_type != remote_key_type.name() {
    412            continue;
    413        }
    414        let key_matches = known_host.key == remote_host_key;
    415        if !known_host.host_matches(host) {
    416            if key_matches {
    417                other_hosts.push(known_host.clone());
    418            }
    419            continue;
    420        }
    421        match known_host.line_type {
    422            KnownHostLineType::Key => {
    423                if key_matches {
    424                    accepted_known_host_found = true;
    425                } else {
    426                    // The host and key type matched, but the key itself did not.
    427                    // This indicates the key has changed.
    428                    // This is only reported as an error if no subsequent lines have a
    429                    // correct key.
    430                    latent_errors.push(KnownHostError::HostKeyHasChanged {
    431                        hostname: host.to_string(),
    432                        key_type: remote_key_type,
    433                        old_known_host: known_host.clone(),
    434                        remote_host_key: remote_host_key_encoded.clone(),
    435                        remote_fingerprint: remote_fingerprint.clone(),
    436                    });
    437                }
    438            }
    439            KnownHostLineType::Revoked => {
    440                if key_matches {
    441                    return Err(KnownHostError::HostKeyRevoked {
    442                        hostname: host.to_string(),
    443                        key_type: remote_key_type,
    444                        remote_host_key: remote_host_key_encoded,
    445                        location: known_host.location.clone(),
    446                    });
    447                }
    448            }
    449            KnownHostLineType::CertAuthority => {
    450                // The host matches a @cert-authority line, which is unsupported.
    451                latent_errors.push(KnownHostError::HostHasOnlyCertAuthority {
    452                    hostname: host.to_string(),
    453                    location: known_host.location.clone(),
    454                });
    455            }
    456        }
    457    }
    458
    459    // We have an accepted host key and it hasn't been revoked.
    460    if accepted_known_host_found {
    461        return Ok(());
    462    }
    463
    464    if latent_errors.is_empty() {
    465        // FIXME: Ideally the error message should include the IP address of the
    466        // remote host (to help the user validate that they are connecting to the
    467        // host they were expecting to). However, I don't see a way to obtain that
    468        // information from libgit2.
    469        Err(KnownHostError::HostKeyNotFound {
    470            hostname: host.to_string(),
    471            key_type: remote_key_type,
    472            remote_host_key: remote_host_key_encoded,
    473            remote_fingerprint,
    474            other_hosts,
    475        })
    476    } else {
    477        // We're going to take the first HostKeyHasChanged error if
    478        // we find one, otherwise we'll take the first error (which
    479        // we expect to be a CertAuthority error).
    480        if let Some(index) = latent_errors
    481            .iter()
    482            .position(|e| matches!(e, KnownHostError::HostKeyHasChanged { .. }))
    483        {
    484            return Err(latent_errors.remove(index));
    485        } else {
    486            // Otherwise, we take the first error (which we expect to be
    487            // a CertAuthority error).
    488            Err(latent_errors.pop().unwrap())
    489        }
    490    }
    491}
    492
    493/// Returns a list of files to try loading OpenSSH-formatted known hosts.
    494fn known_host_files(gctx: &GlobalContext) -> Vec<PathBuf> {
    495    let mut result = Vec::new();
    496    if gctx
    497        .get_env_os("__CARGO_TEST_DISABLE_GLOBAL_KNOWN_HOST")
    498        .is_some()
    499    {
    500    } else if cfg!(unix) {
    501        result.push(PathBuf::from("/etc/ssh/ssh_known_hosts"));
    502    } else if cfg!(windows) {
    503        // The msys/cygwin version of OpenSSH uses `/etc` from the posix root
    504        // filesystem there (such as `C:\msys64\etc\ssh\ssh_known_hosts`).
    505        // However, I do not know of a way to obtain that location from
    506        // Windows-land. The ProgramData version here is what the PowerShell
    507        // port of OpenSSH does.
    508        if let Some(progdata) = gctx.get_env_os("ProgramData") {
    509            let mut progdata = PathBuf::from(progdata);
    510            progdata.push("ssh");
    511            progdata.push("ssh_known_hosts");
    512            result.push(progdata)
    513        }
    514    }
    515    result.extend(user_known_host_location());
    516    result
    517}
    518
    519/// The location of the user's `known_hosts` file.
    520fn user_known_host_location() -> Option<PathBuf> {
    521    // NOTE: This is a potentially inaccurate prediction of what the user
    522    // actually wants. The actual location depends on several factors:
    523    //
    524    // - Windows OpenSSH Powershell version: I believe this looks up the home
    525    //   directory via ProfileImagePath in the registry, falling back to
    526    //   `GetWindowsDirectoryW` if that fails.
    527    // - OpenSSH Portable (under msys): This is very complicated. I got lost
    528    //   after following it through some ldap/active directory stuff.
    529    // - OpenSSH (most unix platforms): Uses `pw->pw_dir` from `getpwuid()`.
    530    //
    531    // This doesn't do anything close to that. home_dir's behavior is:
    532    // - Windows: $USERPROFILE, or SHGetKnownFolderPath()
    533    // - Unix: $HOME, or getpwuid_r()
    534    //
    535    // Since there is a mismatch here, the location returned here might be
    536    // different than what the user's `ssh` CLI command uses. We may want to
    537    // consider trying to align it better.
    538    home::home_dir().map(|mut home| {
    539        home.push(".ssh");
    540        home.push("known_hosts");
    541        home
    542    })
    543}
    544
    545/// The location to display in an error message instructing the user where to
    546/// add the new key.
    547fn user_known_host_location_to_add(diagnostic_home_config: &str) -> String {
    548    // Note that we don't bother with the legacy known_hosts2 files.
    549    let user = user_known_host_location();
    550    let openssh_loc = match &user {
    551        Some(path) => path.to_str().expect("utf-8 home"),
    552        None => "~/.ssh/known_hosts",
    553    };
    554    format!(
    555        "the `net.ssh.known-hosts` array in your Cargo configuration \
    556        (such as {diagnostic_home_config}) \
    557        or in your OpenSSH known_hosts file at {openssh_loc}"
    558    )
    559}
    560
    561const HASH_HOSTNAME_PREFIX: &str = "|1|";
    562
    563#[derive(Clone)]
    564enum KnownHostLineType {
    565    Key,
    566    CertAuthority,
    567    Revoked,
    568}
    569
    570/// A single known host entry.
    571#[derive(Clone)]
    572struct KnownHost {
    573    location: KnownHostLocation,
    574    /// The hostname. May be comma separated to match multiple hosts.
    575    patterns: String,
    576    key_type: String,
    577    key: Vec<u8>,
    578    line_type: KnownHostLineType,
    579}
    580
    581impl KnownHost {
    582    /// Returns whether or not the given host matches this known host entry.
    583    fn host_matches(&self, host: &str) -> bool {
    584        let mut match_found = false;
    585        let host = host.to_lowercase();
    586        if let Some(hashed) = self.patterns.strip_prefix(HASH_HOSTNAME_PREFIX) {
    587            return hashed_hostname_matches(&host, hashed);
    588        }
    589        for pattern in self.patterns.split(',') {
    590            let pattern = pattern.to_lowercase();
    591            // FIXME: support * and ? wildcards
    592            if let Some(pattern) = pattern.strip_prefix('!') {
    593                if pattern == host {
    594                    return false;
    595                }
    596            } else {
    597                match_found |= pattern == host;
    598            }
    599        }
    600        match_found
    601    }
    602}
    603
    604fn hashed_hostname_matches(host: &str, hashed: &str) -> bool {
    605    let Some((b64_salt, b64_host)) = hashed.split_once('|') else {
    606        return false;
    607    };
    608    let Ok(salt) = STANDARD.decode(b64_salt) else {
    609        return false;
    610    };
    611    let Ok(hashed_host) = STANDARD.decode(b64_host) else {
    612        return false;
    613    };
    614    let Ok(mut mac) = hmac::Hmac::<sha1::Sha1>::new_from_slice(&salt) else {
    615        return false;
    616    };
    617    mac.update(host.as_bytes());
    618    let result = mac.finalize().into_bytes();
    619    hashed_host == &result[..]
    620}
    621
    622/// Loads an OpenSSH `known_hosts` file.
    623fn load_hostfile(path: &Path) -> Result<Vec<KnownHost>, anyhow::Error> {
    624    let contents = cargo_util::paths::read(path)?;
    625    Ok(load_hostfile_contents(path, &contents))
    626}
    627
    628fn load_hostfile_contents(path: &Path, contents: &str) -> Vec<KnownHost> {
    629    let entries = contents
    630        .lines()
    631        .enumerate()
    632        .filter_map(|(lineno, line)| {
    633            let location = KnownHostLocation::File {
    634                path: path.to_path_buf(),
    635                lineno: lineno as u32 + 1,
    636            };
    637            parse_known_hosts_line(line, location)
    638        })
    639        .collect();
    640    entries
    641}
    642
    643fn parse_known_hosts_line(line: &str, location: KnownHostLocation) -> Option<KnownHost> {
    644    let line = line.trim();
    645    if line.is_empty() || line.starts_with('#') {
    646        return None;
    647    }
    648    let mut parts = line.split([' ', '\t']).filter(|s| !s.is_empty());
    649
    650    let line_type = if line.starts_with("@") {
    651        let line_type = parts.next()?;
    652
    653        if line_type == "@cert-authority" {
    654            KnownHostLineType::CertAuthority
    655        } else if line_type == "@revoked" {
    656            KnownHostLineType::Revoked
    657        } else {
    658            // No other markers are defined
    659            return None;
    660        }
    661    } else {
    662        KnownHostLineType::Key
    663    };
    664
    665    let patterns = parts.next()?;
    666    let key_type = parts.next()?;
    667    let key = parts.next().map(|p| STANDARD.decode(p))?.ok()?;
    668    Some(KnownHost {
    669        line_type,
    670        location,
    671        patterns: patterns.to_string(),
    672        key_type: key_type.to_string(),
    673        key,
    674    })
    675}
    676
    677#[cfg(test)]
    678mod tests {
    679    use super::*;
    680
    681    static COMMON_CONTENTS: &str = r#"
    682        # Comments allowed at start of line
    683
    684        example.com,rust-lang.org ssh-rsa   eric@host
    685        Example.net ssh-dss   eric@host
    686        [example.net]:2222 ssh-dss   eric@host
    687        nistp256.example.org ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJ4iYGCcJrUIfrHfzlsv8e8kaF36qpcUpe3VNAKVCZX/BDptIdlEe8u8vKNRTPgUO9jqS0+tjTcPiQd8/8I9qng= eric@host
    688        nistp384.example.org ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBNuGT3TqMz2rcwOt2ZqkiNqq7dvWPE66W2qPCoZsh0pQhVU3BnhKIc6nEr6+Wts0Z3jdF3QWwxbbTjbVTVhdr8fMCFhDCWiQFm9xLerYPKnu9qHvx9K87/fjc5+0pu4hLA== eric@host
    689        nistp521.example.org ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAD35HH6OsK4DN75BrKipVj/GvZaUzjPNa1F8wMjUdPB1JlVcUfgzJjWSxrhmaNN3u0soiZw8WNRFINsGPCw5E7DywF1689WcIj2Ye2rcy99je15FknScTzBBD04JgIyOI50mCUaPCBoF14vFlN6BmO00cFo+yzy5N8GuQ2sx9kr21xmFQ== eric@host
    690        # Revoked is supported, but without Cert-Authority support, it will only negate some other fixed key.
    691        @revoked revoked.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKtQsi+KPYispwm2rkMidQf30fG1Niy8XNkvASfePoca eric@host
    692        # Cert-Authority is not supported (below key should not be valid anyway)
    693        @cert-authority ca.example.com ssh-rsa AABBB5Wm
    694        example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAWkjI6XT2SZh3xNk5NhisA3o3sGzWR+VAKMSqHtI0aY eric@host
    695        192.168.42.12 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host
    696        |1|QxzZoTXIWLhUsuHAXjuDMIV3FjQ=|M6NCOIkjiWdCWqkh5+Q+/uFLGjs= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIHgN3O21U4LWtP5OzjTzPnUnSDmCNDvyvlaj6Hi65JC eric@host
    697        # Negation isn't terribly useful without globs.
    698        neg.example.com,!neg.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOXfUnaAHTlo1Qi//rNk26OcmHikmkns1Z6WW/UuuS3K eric@host
    699    "#;
    700
    701    #[test]
    702    fn known_hosts_parse() {
    703        let kh_path = Path::new("/home/abc/.known_hosts");
    704        let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS);
    705        assert_eq!(khs.len(), 12);
    706        match &khs[0].location {
    707            KnownHostLocation::File { path, lineno } => {
    708                assert_eq!(path, kh_path);
    709                assert_eq!(*lineno, 4);
    710            }
    711            _ => panic!("unexpected"),
    712        }
    713        assert_eq!(khs[0].patterns, "example.com,rust-lang.org");
    714        assert_eq!(khs[0].key_type, "ssh-rsa");
    715        assert_eq!(khs[0].key.len(), 407);
    716        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)");
    717        match &khs[1].location {
    718            KnownHostLocation::File { path, lineno } => {
    719                assert_eq!(path, kh_path);
    720                assert_eq!(*lineno, 5);
    721            }
    722            _ => panic!("unexpected"),
    723        }
    724        assert_eq!(khs[2].patterns, "[example.net]:2222");
    725        assert_eq!(khs[3].patterns, "nistp256.example.org");
    726        assert_eq!(khs[9].patterns, "192.168.42.12");
    727    }
    728
    729    #[test]
    730    fn host_matches() {
    731        let kh_path = Path::new("/home/abc/.known_hosts");
    732        let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS);
    733        assert!(khs[0].host_matches("example.com"));
    734        assert!(khs[0].host_matches("rust-lang.org"));
    735        assert!(khs[0].host_matches("EXAMPLE.COM"));
    736        assert!(khs[1].host_matches("example.net"));
    737        assert!(!khs[0].host_matches("example.net"));
    738        assert!(khs[2].host_matches("[example.net]:2222"));
    739        assert!(!khs[2].host_matches("example.net"));
    740        assert!(khs[10].host_matches("hashed.example.com"));
    741        assert!(!khs[10].host_matches("example.com"));
    742        assert!(!khs[11].host_matches("neg.example.com"));
    743    }
    744
    745    #[test]
    746    fn check_match() {
    747        let kh_path = Path::new("/home/abc/.known_hosts");
    748        let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS);
    749
    750        assert!(check_ssh_known_hosts_loaded(
    751            &khs,
    752            "example.com",
    753            SshHostKeyType::Rsa,
    754            &khs[0].key
    755        )
    756        .is_ok());
    757
    758        match check_ssh_known_hosts_loaded(&khs, "example.com", SshHostKeyType::Dss, &khs[0].key) {
    759            Err(KnownHostError::HostKeyNotFound {
    760                hostname,
    761                remote_fingerprint,
    762                other_hosts,
    763                ..
    764            }) => {
    765                assert_eq!(
    766                    remote_fingerprint,
    767                    "yn+pONDn0EcgdOCVptgB4RZd/wqmsVKrPnQMLtrvhw8"
    768                );
    769                assert_eq!(hostname, "example.com");
    770                assert_eq!(other_hosts.len(), 0);
    771            }
    772            _ => panic!("unexpected"),
    773        }
    774
    775        match check_ssh_known_hosts_loaded(
    776            &khs,
    777            "foo.example.com",
    778            SshHostKeyType::Rsa,
    779            &khs[0].key,
    780        ) {
    781            Err(KnownHostError::HostKeyNotFound { other_hosts, .. }) => {
    782                assert_eq!(other_hosts.len(), 1);
    783                assert_eq!(other_hosts[0].patterns, "example.com,rust-lang.org");
    784            }
    785            _ => panic!("unexpected"),
    786        }
    787
    788        let mut modified_key = khs[0].key.clone();
    789        modified_key[0] = 1;
    790        match check_ssh_known_hosts_loaded(&khs, "example.com", SshHostKeyType::Rsa, &modified_key)
    791        {
    792            Err(KnownHostError::HostKeyHasChanged { old_known_host, .. }) => {
    793                assert!(matches!(
    794                    old_known_host.location,
    795                    KnownHostLocation::File { lineno: 4, .. }
    796                ));
    797            }
    798            _ => panic!("unexpected"),
    799        }
    800    }
    801
    802    #[test]
    803    fn revoked() {
    804        let kh_path = Path::new("/home/abc/.known_hosts");
    805        let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS);
    806
    807        match check_ssh_known_hosts_loaded(
    808            &khs,
    809            "revoked.example.com",
    810            SshHostKeyType::Ed255219,
    811            &khs[6].key,
    812        ) {
    813            Err(KnownHostError::HostKeyRevoked {
    814                hostname, location, ..
    815            }) => {
    816                assert_eq!("revoked.example.com", hostname);
    817                assert!(matches!(
    818                    location,
    819                    KnownHostLocation::File { lineno: 11, .. }
    820                ));
    821            }
    822            _ => panic!("Expected key to be revoked for revoked.example.com."),
    823        }
    824    }
    825
    826    #[test]
    827    fn cert_authority() {
    828        let kh_path = Path::new("/home/abc/.known_hosts");
    829        let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS);
    830
    831        match check_ssh_known_hosts_loaded(
    832            &khs,
    833            "ca.example.com",
    834            SshHostKeyType::Rsa,
    835            &khs[0].key, // The key should not matter
    836        ) {
    837            Err(KnownHostError::HostHasOnlyCertAuthority {
    838                hostname, location, ..
    839            }) => {
    840                assert_eq!("ca.example.com", hostname);
    841                assert!(matches!(
    842                    location,
    843                    KnownHostLocation::File { lineno: 13, .. }
    844                ));
    845            }
    846            Err(KnownHostError::HostKeyNotFound { hostname, .. }) => {
    847                panic!("host key not found... {}", hostname);
    848            }
    849            _ => panic!("Expected host to only have @cert-authority line (which is unsupported)."),
    850        }
    851    }
    852
    853    #[test]
    854    fn multiple_errors() {
    855        let contents = r#"
    856        not-used.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAWkjI6XT2SZh3xNk5NhisA3o3sGzWR+VAKMSqHtI0aY eric@host
    857        # Cert-authority and changed key for the same host - changed key error should prevail
    858        @cert-authority example.com ssh-ed25519 AABBB5Wm
    859        example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host
    860        "#;
    861
    862        let kh_path = Path::new("/home/abc/.known_hosts");
    863        let khs = load_hostfile_contents(kh_path, contents);
    864
    865        match check_ssh_known_hosts_loaded(
    866            &khs,
    867            "example.com",
    868            SshHostKeyType::Ed255219,
    869            &khs[0].key,
    870        ) {
    871            Err(KnownHostError::HostKeyHasChanged {
    872                hostname,
    873                old_known_host,
    874                remote_host_key,
    875                ..
    876            }) => {
    877                assert_eq!("example.com", hostname);
    878                assert_eq!(
    879                    "AAAAC3NzaC1lZDI1NTE5AAAAIAWkjI6XT2SZh3xNk5NhisA3o3sGzWR+VAKMSqHtI0aY",
    880                    remote_host_key
    881                );
    882                assert!(matches!(
    883                    old_known_host.location,
    884                    KnownHostLocation::File { lineno: 5, .. }
    885                ));
    886            }
    887            _ => panic!("Expected error to be of type HostKeyHasChanged."),
    888        }
    889    }
    890
    891    #[test]
    892    fn known_host_and_revoked() {
    893        let contents = r#"
    894        example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host
    895        # Later in the file the same host key is revoked
    896        @revoked example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host
    897        "#;
    898
    899        let kh_path = Path::new("/home/abc/.known_hosts");
    900        let khs = load_hostfile_contents(kh_path, contents);
    901
    902        match check_ssh_known_hosts_loaded(
    903            &khs,
    904            "example.com",
    905            SshHostKeyType::Ed255219,
    906            &khs[0].key,
    907        ) {
    908            Err(KnownHostError::HostKeyRevoked {
    909                hostname,
    910                remote_host_key,
    911                location,
    912                ..
    913            }) => {
    914                assert_eq!("example.com", hostname);
    915                assert_eq!(
    916                    "AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR",
    917                    remote_host_key
    918                );
    919                assert!(matches!(
    920                    location,
    921                    KnownHostLocation::File { lineno: 4, .. }
    922                ));
    923            }
    924            _ => panic!("Expected host key to be reject with error HostKeyRevoked."),
    925        }
    926    }
    927}