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