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