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", "AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk="),
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 AAAAB3NzaC1yc2EAAAADAQABAAABgQC5MzWIpZwpkpDjyCNiTIEVFhSA9OUUQvjFo7CgZBGCAj/cqeUIgiLsgtfmtBsfWIkAECQpM7ePP7NLZFGJcHvoyg5jXJiIX5s0eKo9IlcuTLLrMkW5MkHXE7bNklVbW1WdCfF2+y7Ao25B4L8FFRokMh0yp/H6+8xZ7PdVwL3FRPEg8ftZ5R0kuups6xiMHPRX+f/07vfJzA47YDPmXfhkn+JK8kL0JYw8iy8BtNBfRQL99d9iXJzWXnNce5NHMuKD5rOonD3aQHLDlwK+KhrFRrdaxQEM8ZWxNti0ux8yT4Dl5jJY0CrIu3Xl6+qroVgTqJGNkTbhs5DGWdFh6BLPTTH15rN4buisg7uMyLyHqx06ckborqD33gWu+Jig7O+PV6KJmL5mp1O1HXvZqkpBdTiT6GiDKG3oECCIXkUk0BSU9VG9VQcrMxxvgiHlyoXUAfYQoXv/lnxkTnm+Sr36kutsVOs7n5B43ZKAeuaxyQ11huJZpxamc0RA1HM641s= eric@host
698        Example.net ssh-dss AAAAB3NzaC1kc3MAAACBAK2Ek3jVxisXmz5UcZ7W65BAj/nDJCCVvSe0Aytndn4PH6k7sVesut5OoY6PdksZ9tEfuFjjS9HR5SJb8j1GW0GxtaSHHbf+rNc36PeU75bffzyIWwpA8uZFONt5swUAXJXcsHOoapNbUFuhHsRhB2hXxz9QGNiiwIwRJeSHixKRAAAAFQChKfxO1z9H2/757697xP5nJ/Z5dwAAAIEAoc+HIWas+4WowtB/KtAp6XE0B9oHI+55wKtdcGwwb7zHKK9scWNXwxIcMhSvyB3Oe2I7dQQlvyIWxsdZlzOkX0wdsTHjIAnBAP68MyvMv4kq3+I5GAVcFsqoLZfZvh0dlcgUq1/YNYZwKlt89tnzk8Fp4KLWmuw8Bd8IShYVa78AAACAL3qd8kNTY7CthgsQ8iWdjbkGSF/1KCeFyt8UjurInp9wvPDjqagwakbyLOzN7y3/ItTPCaGuX+RjFP0zZTf8i9bsAVyjFJiJ7vzRXcWytuFWANrpzLTn1qzPfh63iK92Aw8AVBYvEA/4bxo+XReAvhNBB/m78G6OedTeu6ZoTsI= eric@host
699        [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
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}