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