1use base64::Engine;
6use hmac;
7use sha1;
8use sha2::{Digest, Sha256};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct KnownHostEntry {
12 pub host_patterns: Vec<String>,
13 pub key_type: String,
14 pub key_base64: String,
15 pub marker: Option<&'static str>, pub hashed: Option<(Vec<u8>, Vec<u8>)>, }
18
19#[derive(Debug, Clone, PartialEq, Eq, Default)]
20pub struct VerifyResult {
21 pub fingerprint: String,
22 pub matched: bool,
23 pub had_matching_host_entry: bool,
24 pub revoked: bool,
25}
26
27pub fn parse_known_hosts(content: &str) -> Vec<KnownHostEntry> {
28 let mut entries = Vec::new();
29 for raw_line in content.split('\n') {
30 let line = raw_line.trim_end_matches('\r').trim();
31 if line.is_empty() || line.starts_with('#') {
32 continue;
33 }
34 let parts: Vec<&str> = line.split_whitespace().collect();
35 let mut cursor = 0usize;
36 let mut marker = None;
37 if parts[0] == "@cert-authority" || parts[0] == "@revoked" {
38 marker = Some(match parts[0] {
39 "@cert-authority" => "@cert-authority",
40 _ => "@revoked",
41 });
42 cursor = 1;
43 }
44 if parts.len() < cursor + 3 {
45 continue;
46 }
47 let host_field = parts[cursor];
48 let key_type = parts[cursor + 1].to_string();
49 let key_base64 = parts[cursor + 2].to_string();
50
51 let mut hashed = None;
52 let mut host_patterns = Vec::new();
53 if let Some(rest) = host_field.strip_prefix("|1|") {
54 let segments: Vec<&str> = rest.split('|').collect();
55 if segments.len() >= 2 {
56 let salt = b64_decode(segments[0]);
57 let hash = b64_decode(segments[1]);
58 if let (Some(salt), Some(hash)) = (salt, hash) {
59 hashed = Some((salt, hash));
60 }
61 }
62 } else {
63 host_patterns = host_field
64 .split(',')
65 .map(str::trim)
66 .filter(|s| !s.is_empty())
67 .map(str::to_string)
68 .collect();
69 }
70 entries.push(KnownHostEntry {
71 host_patterns,
72 key_type,
73 key_base64,
74 marker,
75 hashed,
76 });
77 }
78 entries
79}
80
81fn b64_decode(s: &str) -> Option<Vec<u8>> {
82 base64::engine::general_purpose::STANDARD.decode(s).ok()
83}
84
85fn matches_pattern(pattern: &str, host: &str, port: u16) -> bool {
86 let mut p = pattern.to_string();
87 let mut port_match = true;
88 if p.starts_with('[') {
89 if let Some(close) = p.find("]:") {
90 let port_part = p[close + 2..].to_string();
91 p = p[1..close].to_string();
92 port_match = port_part == port.to_string();
93 }
94 } else if port != 22 {
95 port_match = false;
96 }
97 if !port_match {
98 return false;
99 }
100 if p == host {
101 return true;
102 }
103 if p.contains('*') || p.contains('?') {
104 let mut re = String::from("^");
105 for c in p.chars() {
106 match c {
107 '*' => re.push_str(".*"),
108 '?' => re.push('.'),
109 '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '\\' => {
110 re.push('\\');
111 re.push(c);
112 }
113 other => re.push(other),
114 }
115 }
116 re.push('$');
117 glob_match(&re[1..re.len() - 1], host)
120 } else {
121 false
122 }
123}
124
125fn glob_match(pattern: &str, text: &str) -> bool {
127 let p: Vec<char> = pattern.chars().collect();
128 let t: Vec<char> = text.chars().collect();
129 fn rec(p: &[char], t: &[char]) -> bool {
130 if p.is_empty() {
131 return t.is_empty();
132 }
133 if p[0] == '.' && p.len() > 1 && p[1] == '*' {
134 for i in 0..=t.len() {
135 if rec(&p[2..], &t[i..]) {
136 return true;
137 }
138 }
139 return false;
140 }
141 if t.is_empty() {
142 return false;
143 }
144 if p[0] == '.' && p.len() > 1 && p[1] == '\\' {
145 return p.len() > 2 && t[0] == p[2] && rec(&p[2..], &t[1..]);
146 }
147 if p[0] == '\\' && p.len() > 1 {
148 return t[0] == p[1] && rec(&p[2..], &t[1..]);
149 }
150 (p[0] == t[0]) && rec(&p[1..], &t[1..])
151 }
152 rec(&p, &t)
153}
154
155fn hashed_host_matches(entry: &KnownHostEntry, host: &str, port: u16) -> bool {
156 let Some((salt, expected)) = &entry.hashed else {
157 return false;
158 };
159 let probes: Vec<String> = if port == 22 {
160 vec![host.to_string()]
161 } else {
162 vec![format!("[{host}]:{port}")]
163 };
164 for probe in probes {
165 let tag = hmac_sha1(salt, probe.as_bytes());
169 if tag.as_slice() == expected.as_slice() {
170 return true;
171 }
172 }
173 false
174}
175
176pub fn match_host<'a>(
177 entries: &'a [KnownHostEntry],
178 host: &str,
179 port: u16,
180) -> Vec<&'a KnownHostEntry> {
181 entries
182 .iter()
183 .filter(|e| {
184 if e.hashed.is_some() {
185 hashed_host_matches(e, host, port)
186 } else {
187 e.host_patterns
188 .iter()
189 .any(|p| matches_pattern(p, host, port))
190 }
191 })
192 .collect()
193}
194
195pub fn fingerprint_sha256(raw_key: &[u8]) -> String {
196 let digest = Sha256::digest(raw_key);
197 let b64 = base64::engine::general_purpose::STANDARD.encode(digest);
198 format!("SHA256:{}", b64.trim_end_matches('='))
199}
200
201pub fn key_matches_entry(raw_key: &[u8], entry: &KnownHostEntry) -> bool {
202 let Some(entry_key) = b64_decode(&entry.key_base64) else {
203 return false;
204 };
205 if entry_key.len() != raw_key.len() {
206 let mut diff: u8 = 1;
208 for (a, b) in entry_key.iter().zip(raw_key.iter()) {
209 diff |= a ^ b;
210 }
211 let _ = diff;
212 return false;
213 }
214 let mut diff: u8 = 0;
215 for (a, b) in entry_key.iter().zip(raw_key.iter()) {
216 diff |= a ^ b;
217 }
218 diff == 0
219}
220
221pub fn verify_host_key(
222 raw_key: &[u8],
223 host: &str,
224 port: u16,
225 entries: &[KnownHostEntry],
226) -> VerifyResult {
227 let fingerprint = fingerprint_sha256(raw_key);
228 let candidates = match_host(entries, host, port);
229 let mut matched = false;
230 let mut revoked = false;
231 for e in &candidates {
232 if key_matches_entry(raw_key, e) {
233 if e.marker == Some("@revoked") {
234 revoked = true;
235 } else {
236 matched = true;
237 }
238 }
239 }
240 VerifyResult {
241 fingerprint,
242 matched,
243 had_matching_host_entry: !candidates.is_empty(),
244 revoked,
245 }
246}
247
248pub fn load_known_hosts(file_path: Option<&std::path::Path>) -> Vec<KnownHostEntry> {
249 let target = file_path
250 .map(std::path::PathBuf::from)
251 .unwrap_or_else(|| crate::app::paths::expand_tilde("~/.ssh/known_hosts"));
252 match std::fs::read_to_string(target) {
253 Ok(content) => parse_known_hosts(&content),
254 Err(_) => Vec::new(),
255 }
256}
257
258pub fn load_known_hosts_checked(
264 file_path: Option<&std::path::Path>,
265) -> Result<Vec<KnownHostEntry>, String> {
266 let explicit = file_path.is_some();
267 let target = match file_path {
268 Some(p) => {
269 let s = p.to_string_lossy();
274 if s.starts_with('~') {
275 crate::app::paths::expand_tilde(&s)
276 } else {
277 p.to_path_buf()
278 }
279 }
280 None => crate::app::paths::expand_tilde("~/.ssh/known_hosts"),
281 };
282 match std::fs::read_to_string(&target) {
283 Err(e) if explicit => Err(format!(
284 "known_hosts file {} is unreadable: {e}",
285 target.display()
286 )),
287 Err(_) => Ok(Vec::new()),
288 Ok(content) => {
289 if content.trim().is_empty() {
290 return Ok(Vec::new());
291 }
292 let entries = parse_known_hosts(&content);
293 if entries.is_empty() {
294 Err(format!(
295 "known_hosts file {} is malformed: no parseable entries",
296 target.display()
297 ))
298 } else {
299 Ok(entries)
300 }
301 }
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum HostKeyDecision {
308 Accept,
309 Reject,
310}
311
312pub fn decide_host_key(
313 policy: crate::config::SshHostKeyPolicy,
314 host: &str,
315 port: u16,
316 entries: &[KnownHostEntry],
317 raw_key: &[u8],
318 log: &mut impl FnMut(String),
319) -> HostKeyDecision {
320 let r = verify_host_key(raw_key, host, port, entries);
321 log(format!(
322 "SSH host {host}:{port} fingerprint={}",
323 r.fingerprint
324 ));
325 if r.revoked {
326 log(format!(
327 "SSH host key REVOKED for {host}:{port} — rejecting"
328 ));
329 return HostKeyDecision::Reject;
330 }
331 if policy == crate::config::SshHostKeyPolicy::Strict {
332 if !r.had_matching_host_entry {
333 log(format!(
334 "SSH host {host}:{port} not in known_hosts (strict mode — rejecting). Fingerprint={}",
335 r.fingerprint
336 ));
337 return HostKeyDecision::Reject;
338 }
339 if !r.matched {
340 log(format!(
341 "SSH host key MISMATCH for {host}:{port} (strict mode — rejecting). Got={}",
342 r.fingerprint
343 ));
344 return HostKeyDecision::Reject;
345 }
346 return HostKeyDecision::Accept;
347 }
348 if r.had_matching_host_entry && !r.matched {
353 log(format!(
354 "SSH host key MISMATCH for {host}:{port} (lenient mode — REJECTING). Got={}",
355 r.fingerprint
356 ));
357 return HostKeyDecision::Reject;
358 }
359 if !r.had_matching_host_entry {
360 log(format!(
361 "SSH host {host}:{port} not in known_hosts (lenient mode — accepting, migration compatibility only). Add {} to enable strict mode.",
362 r.fingerprint
363 ));
364 }
365 HostKeyDecision::Accept
366}
367
368fn hmac_sha1(key: &[u8], data: &[u8]) -> [u8; 20] {
374 use hmac::Mac;
375 type HmacSha1 = hmac::Hmac<sha1::Sha1>;
376 let mut mac = HmacSha1::new_from_slice(key).expect("HMAC accepts any key length");
377 mac.update(data);
378 mac.finalize().into_bytes().into()
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 const PUB: &[u8] = b"AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyMaterial0123456789abcdefghijklm";
386 const OTHER: &[u8] = b"AAAAC3NzaC1lZDI1NTE5AAAAIDifferentKeyMaterial9876543210mlkjihgfe";
387
388 fn hosts() -> Vec<KnownHostEntry> {
389 let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
390 parse_known_hosts(&format!(
391 "# comment\n\
392 \n\
393 host-a.example.invalid ssh-ed25519 {}\n\
394 host-a.example.invalid,alias ssh-ed25519 {}\n\
395 [host-a.example.invalid]:2222 ssh-ed25519 {}\n\
396 *.wild.example.invalid ssh-ed25519 {}\n\
397 @revoked host-a.example.invalid ssh-ed25519 {}\n\
398 @cert-authority *.ca.example.invalid ssh-ed25519 {}\n\
399 host-b.example.invalid ssh-ed25519 {}\n\
400 garbage-line\n\
401 host-c.example.invalid\n",
402 b64(PUB),
403 b64(PUB),
404 b64(PUB),
405 b64(PUB),
406 b64(PUB),
407 b64(PUB),
408 b64(OTHER),
409 ))
410 }
411
412 #[test]
413 fn parses_entries() {
414 let h = hosts();
415 assert_eq!(h.len(), 7);
417 assert_eq!(
418 h[0].host_patterns,
419 vec!["host-a.example.invalid".to_string()]
420 );
421 assert_eq!(h[1].host_patterns.len(), 2);
422 assert_eq!(h[4].marker, Some("@revoked"));
423 assert_eq!(h[5].marker, Some("@cert-authority"));
424 }
425
426 #[test]
427 fn matches_hostnames() {
428 let h = hosts();
429 assert_eq!(match_host(&h, "host-a.example.invalid", 22).len(), 3); assert_eq!(match_host(&h, "host-a.example.invalid", 2222).len(), 1);
431 assert_eq!(match_host(&h, "anything.wild.example.invalid", 22).len(), 1);
432 assert_eq!(match_host(&h, "nope.example.invalid", 22).len(), 0);
433 assert_eq!(match_host(&h, "sub.ca.example.invalid", 22).len(), 1);
434 }
435
436 #[test]
437 fn fingerprints() {
438 let f = fingerprint_sha256(PUB);
439 assert!(f.starts_with("SHA256:"));
440 assert!(!f.ends_with('='));
441 assert_ne!(f, fingerprint_sha256(OTHER));
442 }
443
444 #[test]
445 fn verify_outcomes() {
446 let h = hosts();
447 let r = verify_host_key(PUB, "host-a.example.invalid", 22, &h);
449 assert!(r.matched && r.had_matching_host_entry);
450 let r = verify_host_key(OTHER, "host-a.example.invalid", 22, &h);
452 assert!(!r.matched && r.had_matching_host_entry);
453 let r = verify_host_key(PUB, "nope.example.invalid", 22, &h);
455 assert!(!r.had_matching_host_entry);
456 let r = verify_host_key(PUB, "host-a.example.invalid", 22, &h);
458 assert!(r.revoked);
460 }
461
462 #[test]
463 fn length_mismatch_resists_oracle() {
464 let h = hosts();
465 assert!(!key_matches_entry(b"short", &h[0]));
466 }
467
468 #[test]
469 fn strict_and_lenient_decisions() {
470 use crate::config::SshHostKeyPolicy;
471 let h = hosts();
472 let mut logs = Vec::new();
473 let mut log = |m: String| logs.push(m);
474
475 assert_eq!(
477 decide_host_key(
478 SshHostKeyPolicy::Strict,
479 "nope.example.invalid",
480 22,
481 &h,
482 PUB,
483 &mut log
484 ),
485 HostKeyDecision::Reject
486 );
487 let h_no_revoke: Vec<KnownHostEntry> =
489 h.iter().filter(|e| e.marker.is_none()).cloned().collect();
490 assert_eq!(
491 decide_host_key(
492 SshHostKeyPolicy::Strict,
493 "host-a.example.invalid",
494 22,
495 &h_no_revoke,
496 PUB,
497 &mut log
498 ),
499 HostKeyDecision::Accept
500 );
501 assert_eq!(
503 decide_host_key(
504 SshHostKeyPolicy::Strict,
505 "host-a.example.invalid",
506 22,
507 &h_no_revoke,
508 OTHER,
509 &mut log
510 ),
511 HostKeyDecision::Reject
512 );
513 assert_eq!(
515 decide_host_key(
516 SshHostKeyPolicy::Lenient,
517 "nope.example.invalid",
518 22,
519 &h,
520 PUB,
521 &mut log
522 ),
523 HostKeyDecision::Accept
524 );
525 assert_eq!(
528 decide_host_key(
529 SshHostKeyPolicy::Lenient,
530 "host-a.example.invalid",
531 22,
532 &h_no_revoke,
533 OTHER,
534 &mut log
535 ),
536 HostKeyDecision::Reject
537 );
538 assert_eq!(
540 decide_host_key(
541 SshHostKeyPolicy::Lenient,
542 "host-a.example.invalid",
543 22,
544 &h,
545 PUB,
546 &mut log
547 ),
548 HostKeyDecision::Reject
549 );
550 assert!(!logs.is_empty());
551 }
552
553 #[test]
554 fn hashed_entries_self_consistent() {
555 let salt = vec![0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef];
557 let probe = "hashed.example.invalid";
558 let tag = hmac_sha1(&salt, probe.as_bytes());
559 let b64 = base64::engine::general_purpose::STANDARD;
560 let line = format!(
561 "|1|{}|{} ssh-ed25519 {}",
562 b64.encode(&salt),
563 b64.encode(tag),
564 b64.encode(PUB)
565 );
566 let entries = parse_known_hosts(&line);
567 assert_eq!(entries.len(), 1);
568 assert!(entries[0].hashed.is_some());
569 assert_eq!(match_host(&entries, probe, 22).len(), 1);
570 assert_eq!(match_host(&entries, "other.example.invalid", 22).len(), 0);
571 assert!(key_matches_entry(PUB, &entries[0]));
572 }
573
574 #[test]
575 fn hmac_sha1_matches_reference_vectors() {
576 assert_eq!(
578 hmac_sha1(&[0x0b; 20], b"Hi There").to_vec(),
579 hex_decode("b617318655057264e28bc0b6fb378c8ef146be00")
580 );
581 assert_eq!(
582 hmac_sha1(b"Jefe", b"what do ya want for nothing?").to_vec(),
583 hex_decode("effcdf6ae5eb2fa2d27416d5f184df9c259a7c79")
584 );
585 }
586
587 fn hex_decode(s: &str) -> Vec<u8> {
588 (0..s.len())
589 .step_by(2)
590 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
591 .collect()
592 }
593
594 #[test]
595 fn multiple_algorithms_for_same_host() {
596 let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
599 let entries = parse_known_hosts(&format!(
600 "multi.example.invalid ssh-ed25519 {}\n multi.example.invalid ssh-rsa {}",
601 b64(PUB),
602 b64(OTHER)
603 ));
604 assert_eq!(entries.len(), 2);
605 let mut log = |_: String| {};
606 assert_eq!(
607 decide_host_key(
608 crate::config::SshHostKeyPolicy::Strict,
609 "multi.example.invalid",
610 22,
611 &entries,
612 OTHER,
613 &mut log
614 ),
615 HostKeyDecision::Accept
616 );
617 let third = b"AAAAC3NzaC1lZDI1NTE5AAAAIThirdKeyNeverSeenBefore0000000000000";
618 assert_eq!(
619 decide_host_key(
620 crate::config::SshHostKeyPolicy::Strict,
621 "multi.example.invalid",
622 22,
623 &entries,
624 third,
625 &mut log
626 ),
627 HostKeyDecision::Reject
628 );
629 }
630
631 #[test]
632 fn checked_loader_fail_closed() {
633 let dir = tempfile::TempDir::new().unwrap();
634 let good = dir.path().join("good");
635 std::fs::write(
636 &good,
637 "h.example.invalid ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture
638",
639 )
640 .unwrap();
641 assert!(load_known_hosts_checked(Some(&good)).is_ok());
642
643 let missing = dir.path().join("missing");
645 let err = load_known_hosts_checked(Some(&missing)).unwrap_err();
646 assert!(err.contains("unreadable"), "{err}");
647
648 let malformed = dir.path().join("garbage");
653 std::fs::write(&malformed, "two-parts-only\n???\nno-key-here either-way\n").unwrap();
654 let err = load_known_hosts_checked(Some(&malformed)).unwrap_err();
655 assert!(err.contains("malformed"), "{err}");
656
657 let empty = dir.path().join("empty");
659 std::fs::write(&empty, "\n").unwrap();
660 assert!(load_known_hosts_checked(Some(&empty)).unwrap().is_empty());
661 }
662
663 #[test]
665 fn matches_legacy_known_hosts_fixtures() {
666 let path = concat!(
667 env!("CARGO_MANIFEST_DIR"),
668 "/tests/fixtures/legacy/known-hosts.json"
669 );
670 let Ok(raw) = std::fs::read_to_string(path) else {
673 eprintln!("skipping: legacy fixture corpus not present ({path})");
674 return;
675 };
676 let data: serde_json::Value = serde_json::from_str(&raw).unwrap();
677 let parsed = parse_known_hosts(data["content"].as_str().unwrap());
678 assert_eq!(parsed.len(), data["parsedCount"].as_u64().unwrap() as usize);
679
680 let b64 = base64::engine::general_purpose::STANDARD;
683 let pub_bytes = b64
684 .decode(
685 parsed
686 .iter()
687 .find(|e| {
688 e.host_patterns
689 .first()
690 .map(|p| p == "host-a.example.invalid")
691 .unwrap_or(false)
692 })
693 .map(|e| e.key_base64.clone())
694 .unwrap(),
695 )
696 .unwrap();
697 assert_eq!(
698 fingerprint_sha256(&pub_bytes),
699 data["fingerprint"].as_str().unwrap()
700 );
701
702 for m in data["matches"].as_array().unwrap() {
703 let host = m["host"].as_str().unwrap();
704 let port = m["port"].as_u64().unwrap() as u16;
705 assert_eq!(
706 match_host(&parsed, host, port).len() as u64,
707 m["matchedHosts"].as_u64().unwrap(),
708 "match count for {host}:{port}"
709 );
710 let verify = verify_host_key(&pub_bytes, host, port, &parsed);
711 assert_eq!(verify.matched, m["verifyPub"]["matched"].as_bool().unwrap());
712 assert_eq!(
713 verify.had_matching_host_entry,
714 m["verifyPub"]["hadMatchingHostEntry"].as_bool().unwrap()
715 );
716 assert_eq!(verify.revoked, m["verifyPub"]["revoked"].as_bool().unwrap());
717 }
718
719 let h = parse_known_hosts(data["hashedEntry"]["line"].as_str().unwrap());
721 assert_eq!(
722 h.len() as u64,
723 data["hashedEntry"]["parsedCount"].as_u64().unwrap()
724 );
725 assert_eq!(
726 match_host(&h, "hashed.example.invalid", 22).len() as u64,
727 data["hashedEntry"]["matchHost_22"].as_u64().unwrap()
728 );
729 assert_eq!(
730 match_host(&h, "other.example.invalid", 22).len() as u64,
731 data["hashedEntry"]["matchHost_other"].as_u64().unwrap()
732 );
733 }
734}