Skip to main content

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