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