]>
Commit | Line | Data |
---|---|---|
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 | ||
25 | use crate::util::config::{Config, Definition, Value}; | |
26 | use base64::engine::general_purpose::STANDARD; | |
27 | use base64::engine::general_purpose::STANDARD_NO_PAD; | |
28 | use base64::Engine as _; | |
29 | use git2::cert::{Cert, SshHostKeyType}; | |
30 | use git2::CertificateCheckStatus; | |
31 | use hmac::Mac; | |
32 | use std::collections::HashSet; | |
33 | use std::fmt::{Display, Write}; | |
34 | use 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. | |
46 | static 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. | |
61 | static 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 | ||
66 | enum 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 | ||
100 | impl 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)] | |
108 | enum 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 | ||
117 | impl 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). | |
133 | pub 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. | |
316 | fn 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. | |
391 | fn 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. | |
503 | fn 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. | |
529 | fn 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. | |
556 | fn 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 | ||
570 | const HASH_HOSTNAME_PREFIX: &str = "|1|"; | |
571 | ||
572 | #[derive(Clone)] | |
573 | enum KnownHostLineType { | |
574 | Key, | |
575 | CertAuthority, | |
576 | Revoked, | |
577 | } | |
578 | ||
579 | /// A single known host entry. | |
580 | #[derive(Clone)] | |
581 | struct 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 | ||
590 | impl 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 | ||
613 | fn 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. | |
632 | fn 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 | ||
637 | fn 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 | ||
652 | fn 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)] | |
687 | mod 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 | } |