1use 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
38static 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
66static BUNDLED_REVOCATIONS: &[(&str, &str, &str)] = &[
76 (
78 "github.com",
79 "ssh-rsa",
80 "AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==",
81 ),
82];
83
84enum KnownHostError {
85 CheckError(anyhow::Error),
87 HostKeyNotFound {
89 hostname: String,
90 key_type: SshHostKeyType,
91 remote_host_key: String,
92 remote_fingerprint: String,
93 other_hosts: Vec<KnownHost>,
94 },
95 HostKeyHasChanged {
97 hostname: String,
98 key_type: SshHostKeyType,
99 old_known_host: KnownHost,
100 remote_host_key: String,
101 remote_fingerprint: String,
102 },
103 HostKeyRevoked {
105 hostname: String,
106 key_type: SshHostKeyType,
107 remote_host_key: String,
108 location: KnownHostLocation,
109 },
110 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#[derive(Clone)]
126enum KnownHostLocation {
127 File { path: PathBuf, lineno: u32 },
129 Config { definition: Definition },
131 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
150pub 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 Ok(CertificateCheckStatus::CertificatePassthrough);
163 };
164 let host_maybe_port = match port {
167 Some(port) if port != 22 => format!("[{host}]:{port}"),
168 _ => host.to_string(),
169 };
170 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 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
327fn 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 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 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 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
402fn 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 let mut latent_errors: Vec<KnownHostError> = Vec::new();
412
413 let mut other_hosts = Vec::new();
416
417 let mut accepted_known_host_found = false;
421
422 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 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 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 latent_errors.push(KnownHostError::HostHasOnlyCertAuthority {
473 hostname: host.to_string(),
474 location: known_host.location.clone(),
475 });
476 }
477 }
478 }
479
480 if accepted_known_host_found {
482 return Ok(());
483 }
484
485 if latent_errors.is_empty() {
486 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 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 Err(latent_errors.pop().unwrap())
510 }
511 }
512}
513
514fn 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 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
540fn user_known_host_location() -> Option<PathBuf> {
542 home::home_dir().map(|mut home| {
560 home.push(".ssh");
561 home.push("known_hosts");
562 home
563 })
564}
565
566fn user_known_host_location_to_add(diagnostic_home_config: &str) -> String {
569 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#[derive(Clone)]
593struct KnownHost {
594 location: KnownHostLocation,
595 patterns: String,
597 key_type: String,
598 key: Vec<u8>,
599 line_type: KnownHostLineType,
600}
601
602impl KnownHost {
603 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 negated && matches {
634 return false;
635 }
636
637 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
668fn 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 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 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, ) {
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}