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            let is_glob = is_glob_pattern(&pattern);
613
614            if is_glob {
615                match glob::Pattern::new(&pattern) {
616                    Ok(glob) => match_found |= glob.matches(&host),
617                    Err(e) => {
618                        tracing::warn!(
619                            "failed to interpret hostname `{pattern}` as glob pattern: {e}"
620                        )
621                    }
622                }
623            }
624
625            if let Some(pattern) = pattern.strip_prefix('!') {
626                if pattern == host {
627                    return false;
628                }
629            } else {
630                match_found |= pattern == host;
631            }
632        }
633        match_found
634    }
635}
636
637fn hashed_hostname_matches(host: &str, hashed: &str) -> bool {
638    let Some((b64_salt, b64_host)) = hashed.split_once('|') else {
639        return false;
640    };
641    let Ok(salt) = STANDARD.decode(b64_salt) else {
642        return false;
643    };
644    let Ok(hashed_host) = STANDARD.decode(b64_host) else {
645        return false;
646    };
647    let Ok(mut mac) = hmac::Hmac::<sha1::Sha1>::new_from_slice(&salt) else {
648        return false;
649    };
650    mac.update(host.as_bytes());
651    let result = mac.finalize().into_bytes();
652    hashed_host == &result[..]
653}
654
655/// Loads an OpenSSH `known_hosts` file.
656fn load_hostfile(path: &Path) -> Result<Vec<KnownHost>, anyhow::Error> {
657    let contents = cargo_util::paths::read(path)?;
658    Ok(load_hostfile_contents(path, &contents))
659}
660
661fn load_hostfile_contents(path: &Path, contents: &str) -> Vec<KnownHost> {
662    let entries = contents
663        .lines()
664        .enumerate()
665        .filter_map(|(lineno, line)| {
666            let location = KnownHostLocation::File {
667                path: path.to_path_buf(),
668                lineno: lineno as u32 + 1,
669            };
670            parse_known_hosts_line(line, location)
671        })
672        .collect();
673    entries
674}
675
676fn parse_known_hosts_line(line: &str, location: KnownHostLocation) -> Option<KnownHost> {
677    let line = line.trim();
678    if line.is_empty() || line.starts_with('#') {
679        return None;
680    }
681    let mut parts = line.split([' ', '\t']).filter(|s| !s.is_empty());
682
683    let line_type = if line.starts_with("@") {
684        let line_type = parts.next()?;
685
686        if line_type == "@cert-authority" {
687            KnownHostLineType::CertAuthority
688        } else if line_type == "@revoked" {
689            KnownHostLineType::Revoked
690        } else {
691            // No other markers are defined
692            return None;
693        }
694    } else {
695        KnownHostLineType::Key
696    };
697
698    let patterns = parts.next()?;
699    let key_type = parts.next()?;
700    let key = parts.next().map(|p| STANDARD.decode(p))?.ok()?;
701    Some(KnownHost {
702        line_type,
703        location,
704        patterns: patterns.to_string(),
705        key_type: key_type.to_string(),
706        key,
707    })
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    static COMMON_CONTENTS: &str = r#"
715        # Comments allowed at start of line
716
717        example.com,rust-lang.org ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC5MzWIpZwpkpDjyCNiTIEVFhSA9OUUQvjFo7CgZBGCAj/cqeUIgiLsgtfmtBsfWIkAECQpM7ePP7NLZFGJcHvoyg5jXJiIX5s0eKo9IlcuTLLrMkW5MkHXE7bNklVbW1WdCfF2+y7Ao25B4L8FFRokMh0yp/H6+8xZ7PdVwL3FRPEg8ftZ5R0kuups6xiMHPRX+f/07vfJzA47YDPmXfhkn+JK8kL0JYw8iy8BtNBfRQL99d9iXJzWXnNce5NHMuKD5rOonD3aQHLDlwK+KhrFRrdaxQEM8ZWxNti0ux8yT4Dl5jJY0CrIu3Xl6+qroVgTqJGNkTbhs5DGWdFh6BLPTTH15rN4buisg7uMyLyHqx06ckborqD33gWu+Jig7O+PV6KJmL5mp1O1HXvZqkpBdTiT6GiDKG3oECCIXkUk0BSU9VG9VQcrMxxvgiHlyoXUAfYQoXv/lnxkTnm+Sr36kutsVOs7n5B43ZKAeuaxyQ11huJZpxamc0RA1HM641s= eric@host
718        Example.net ssh-dss AAAAB3NzaC1kc3MAAACBAK2Ek3jVxisXmz5UcZ7W65BAj/nDJCCVvSe0Aytndn4PH6k7sVesut5OoY6PdksZ9tEfuFjjS9HR5SJb8j1GW0GxtaSHHbf+rNc36PeU75bffzyIWwpA8uZFONt5swUAXJXcsHOoapNbUFuhHsRhB2hXxz9QGNiiwIwRJeSHixKRAAAAFQChKfxO1z9H2/757697xP5nJ/Z5dwAAAIEAoc+HIWas+4WowtB/KtAp6XE0B9oHI+55wKtdcGwwb7zHKK9scWNXwxIcMhSvyB3Oe2I7dQQlvyIWxsdZlzOkX0wdsTHjIAnBAP68MyvMv4kq3+I5GAVcFsqoLZfZvh0dlcgUq1/YNYZwKlt89tnzk8Fp4KLWmuw8Bd8IShYVa78AAACAL3qd8kNTY7CthgsQ8iWdjbkGSF/1KCeFyt8UjurInp9wvPDjqagwakbyLOzN7y3/ItTPCaGuX+RjFP0zZTf8i9bsAVyjFJiJ7vzRXcWytuFWANrpzLTn1qzPfh63iK92Aw8AVBYvEA/4bxo+XReAvhNBB/m78G6OedTeu6ZoTsI= eric@host
719        [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
720        nistp256.example.org ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJ4iYGCcJrUIfrHfzlsv8e8kaF36qpcUpe3VNAKVCZX/BDptIdlEe8u8vKNRTPgUO9jqS0+tjTcPiQd8/8I9qng= eric@host
721        nistp384.example.org ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBNuGT3TqMz2rcwOt2ZqkiNqq7dvWPE66W2qPCoZsh0pQhVU3BnhKIc6nEr6+Wts0Z3jdF3QWwxbbTjbVTVhdr8fMCFhDCWiQFm9xLerYPKnu9qHvx9K87/fjc5+0pu4hLA== eric@host
722        nistp521.example.org ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAD35HH6OsK4DN75BrKipVj/GvZaUzjPNa1F8wMjUdPB1JlVcUfgzJjWSxrhmaNN3u0soiZw8WNRFINsGPCw5E7DywF1689WcIj2Ye2rcy99je15FknScTzBBD04JgIyOI50mCUaPCBoF14vFlN6BmO00cFo+yzy5N8GuQ2sx9kr21xmFQ== eric@host
723        # Revoked is supported, but without Cert-Authority support, it will only negate some other fixed key.
724        @revoked revoked.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKtQsi+KPYispwm2rkMidQf30fG1Niy8XNkvASfePoca eric@host
725        # Cert-Authority is not supported (below key should not be valid anyway)
726        @cert-authority ca.example.com ssh-rsa AABBB5Wm
727        example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAWkjI6XT2SZh3xNk5NhisA3o3sGzWR+VAKMSqHtI0aY eric@host
728        192.168.42.12 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host
729        |1|QxzZoTXIWLhUsuHAXjuDMIV3FjQ=|M6NCOIkjiWdCWqkh5+Q+/uFLGjs= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIHgN3O21U4LWtP5OzjTzPnUnSDmCNDvyvlaj6Hi65JC eric@host
730        # Negation isn't terribly useful without globs.
731        neg.example.com,!neg.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOXfUnaAHTlo1Qi//rNk26OcmHikmkns1Z6WW/UuuS3K eric@host
732        # Glob patterns
733        *.asterisk.glob.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO6/wm8Z5aVL2cDyALY6zE7KVW0s64utWTUmbAvvSKlI eric@host
734        test?.question.glob.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKceiey2vuK/WB/kLsiGa85xw897JzvGGaHmkAZbVHf3 eric@host
735    "#;
736
737    #[test]
738    fn known_hosts_parse() {
739        let kh_path = Path::new("/home/abc/.known_hosts");
740        let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS);
741        assert_eq!(khs.len(), 14);
742        match &khs[0].location {
743            KnownHostLocation::File { path, lineno } => {
744                assert_eq!(path, kh_path);
745                assert_eq!(*lineno, 4);
746            }
747            _ => panic!("unexpected"),
748        }
749        assert_eq!(khs[0].patterns, "example.com,rust-lang.org");
750        assert_eq!(khs[0].key_type, "ssh-rsa");
751        assert_eq!(khs[0].key.len(), 407);
752        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)");
753        match &khs[1].location {
754            KnownHostLocation::File { path, lineno } => {
755                assert_eq!(path, kh_path);
756                assert_eq!(*lineno, 5);
757            }
758            _ => panic!("unexpected"),
759        }
760        assert_eq!(khs[2].patterns, "[example.net]:2222");
761        assert_eq!(khs[3].patterns, "nistp256.example.org");
762        assert_eq!(khs[9].patterns, "192.168.42.12");
763    }
764
765    #[test]
766    fn host_matches() {
767        let kh_path = Path::new("/home/abc/.known_hosts");
768        let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS);
769        assert!(khs[0].host_matches("example.com"));
770        assert!(khs[0].host_matches("rust-lang.org"));
771        assert!(khs[0].host_matches("EXAMPLE.COM"));
772        assert!(khs[1].host_matches("example.net"));
773        assert!(!khs[0].host_matches("example.net"));
774        assert!(khs[2].host_matches("[example.net]:2222"));
775        assert!(!khs[2].host_matches("example.net"));
776        assert!(khs[10].host_matches("hashed.example.com"));
777        assert!(!khs[10].host_matches("example.com"));
778        assert!(!khs[11].host_matches("neg.example.com"));
779
780        // Glob patterns
781        assert!(khs[12].host_matches("matches.asterisk.glob.example.com"));
782        assert!(!khs[12].host_matches("matches.not.glob.example.com"));
783        assert!(khs[13].host_matches("test3.question.glob.example.com"));
784        assert!(!khs[13].host_matches("test120.question.glob.example.com"));
785    }
786
787    #[test]
788    fn check_match() {
789        let kh_path = Path::new("/home/abc/.known_hosts");
790        let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS);
791
792        assert!(
793            check_ssh_known_hosts_loaded(&khs, "example.com", SshHostKeyType::Rsa, &khs[0].key)
794                .is_ok()
795        );
796
797        match check_ssh_known_hosts_loaded(&khs, "example.com", SshHostKeyType::Dss, &khs[0].key) {
798            Err(KnownHostError::HostKeyNotFound {
799                hostname,
800                remote_fingerprint,
801                other_hosts,
802                ..
803            }) => {
804                assert_eq!(
805                    remote_fingerprint,
806                    "yn+pONDn0EcgdOCVptgB4RZd/wqmsVKrPnQMLtrvhw8"
807                );
808                assert_eq!(hostname, "example.com");
809                assert_eq!(other_hosts.len(), 0);
810            }
811            _ => panic!("unexpected"),
812        }
813
814        match check_ssh_known_hosts_loaded(
815            &khs,
816            "foo.example.com",
817            SshHostKeyType::Rsa,
818            &khs[0].key,
819        ) {
820            Err(KnownHostError::HostKeyNotFound { other_hosts, .. }) => {
821                assert_eq!(other_hosts.len(), 1);
822                assert_eq!(other_hosts[0].patterns, "example.com,rust-lang.org");
823            }
824            _ => panic!("unexpected"),
825        }
826
827        let mut modified_key = khs[0].key.clone();
828        modified_key[0] = 1;
829        match check_ssh_known_hosts_loaded(&khs, "example.com", SshHostKeyType::Rsa, &modified_key)
830        {
831            Err(KnownHostError::HostKeyHasChanged { old_known_host, .. }) => {
832                assert!(matches!(
833                    old_known_host.location,
834                    KnownHostLocation::File { lineno: 4, .. }
835                ));
836            }
837            _ => panic!("unexpected"),
838        }
839    }
840
841    #[test]
842    fn revoked() {
843        let kh_path = Path::new("/home/abc/.known_hosts");
844        let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS);
845
846        match check_ssh_known_hosts_loaded(
847            &khs,
848            "revoked.example.com",
849            SshHostKeyType::Ed255219,
850            &khs[6].key,
851        ) {
852            Err(KnownHostError::HostKeyRevoked {
853                hostname, location, ..
854            }) => {
855                assert_eq!("revoked.example.com", hostname);
856                assert!(matches!(
857                    location,
858                    KnownHostLocation::File { lineno: 11, .. }
859                ));
860            }
861            _ => panic!("Expected key to be revoked for revoked.example.com."),
862        }
863    }
864
865    #[test]
866    fn cert_authority() {
867        let kh_path = Path::new("/home/abc/.known_hosts");
868        let khs = load_hostfile_contents(kh_path, COMMON_CONTENTS);
869
870        match check_ssh_known_hosts_loaded(
871            &khs,
872            "ca.example.com",
873            SshHostKeyType::Rsa,
874            &khs[0].key, // The key should not matter
875        ) {
876            Err(KnownHostError::HostHasOnlyCertAuthority {
877                hostname, location, ..
878            }) => {
879                assert_eq!("ca.example.com", hostname);
880                assert!(matches!(
881                    location,
882                    KnownHostLocation::File { lineno: 13, .. }
883                ));
884            }
885            Err(KnownHostError::HostKeyNotFound { hostname, .. }) => {
886                panic!("host key not found... {}", hostname);
887            }
888            _ => panic!("Expected host to only have @cert-authority line (which is unsupported)."),
889        }
890    }
891
892    #[test]
893    fn multiple_errors() {
894        let contents = r#"
895        not-used.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAWkjI6XT2SZh3xNk5NhisA3o3sGzWR+VAKMSqHtI0aY eric@host
896        # Cert-authority and changed key for the same host - changed key error should prevail
897        @cert-authority example.com ssh-ed25519 AABBB5Wm
898        example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host
899        "#;
900
901        let kh_path = Path::new("/home/abc/.known_hosts");
902        let khs = load_hostfile_contents(kh_path, contents);
903
904        match check_ssh_known_hosts_loaded(
905            &khs,
906            "example.com",
907            SshHostKeyType::Ed255219,
908            &khs[0].key,
909        ) {
910            Err(KnownHostError::HostKeyHasChanged {
911                hostname,
912                old_known_host,
913                remote_host_key,
914                ..
915            }) => {
916                assert_eq!("example.com", hostname);
917                assert_eq!(
918                    "AAAAC3NzaC1lZDI1NTE5AAAAIAWkjI6XT2SZh3xNk5NhisA3o3sGzWR+VAKMSqHtI0aY",
919                    remote_host_key
920                );
921                assert!(matches!(
922                    old_known_host.location,
923                    KnownHostLocation::File { lineno: 5, .. }
924                ));
925            }
926            _ => panic!("Expected error to be of type HostKeyHasChanged."),
927        }
928    }
929
930    #[test]
931    fn known_host_and_revoked() {
932        let contents = r#"
933        example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host
934        # Later in the file the same host key is revoked
935        @revoked example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR eric@host
936        "#;
937
938        let kh_path = Path::new("/home/abc/.known_hosts");
939        let khs = load_hostfile_contents(kh_path, contents);
940
941        match check_ssh_known_hosts_loaded(
942            &khs,
943            "example.com",
944            SshHostKeyType::Ed255219,
945            &khs[0].key,
946        ) {
947            Err(KnownHostError::HostKeyRevoked {
948                hostname,
949                remote_host_key,
950                location,
951                ..
952            }) => {
953                assert_eq!("example.com", hostname);
954                assert_eq!(
955                    "AAAAC3NzaC1lZDI1NTE5AAAAIKVYJpa0yUGaNk0NXQTPWa0tHjqRpx+7hl2diReH6DtR",
956                    remote_host_key
957                );
958                assert!(matches!(
959                    location,
960                    KnownHostLocation::File { lineno: 4, .. }
961                ));
962            }
963            _ => panic!("Expected host key to be reject with error HostKeyRevoked."),
964        }
965    }
966}