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::{KeyInit, Mac};
376 type HmacSha1 = hmac::Hmac<sha1::Sha1>;
377 let mut mac = HmacSha1::new_from_slice(key).expect("HMAC accepts any key length");
378 mac.update(data);
379 mac.finalize().into_bytes().into()
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 const PUB: &[u8] = b"AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyMaterial0123456789abcdefghijklm";
387 const OTHER: &[u8] = b"AAAAC3NzaC1lZDI1NTE5AAAAIDifferentKeyMaterial9876543210mlkjihgfe";
388
389 fn hosts() -> Vec<KnownHostEntry> {
390 let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
391 parse_known_hosts(&format!(
392 "# comment\n\
393 \n\
394 host-a.example.invalid ssh-ed25519 {}\n\
395 host-a.example.invalid,alias ssh-ed25519 {}\n\
396 [host-a.example.invalid]:2222 ssh-ed25519 {}\n\
397 *.wild.example.invalid ssh-ed25519 {}\n\
398 @revoked host-a.example.invalid ssh-ed25519 {}\n\
399 @cert-authority *.ca.example.invalid ssh-ed25519 {}\n\
400 host-b.example.invalid ssh-ed25519 {}\n\
401 garbage-line\n\
402 host-c.example.invalid\n",
403 b64(PUB),
404 b64(PUB),
405 b64(PUB),
406 b64(PUB),
407 b64(PUB),
408 b64(PUB),
409 b64(OTHER),
410 ))
411 }
412
413 #[test]
414 fn parses_entries() {
415 let h = hosts();
416 assert_eq!(h.len(), 7);
418 assert_eq!(
419 h[0].host_patterns,
420 vec!["host-a.example.invalid".to_string()]
421 );
422 assert_eq!(h[1].host_patterns.len(), 2);
423 assert_eq!(h[4].marker, Some("@revoked"));
424 assert_eq!(h[5].marker, Some("@cert-authority"));
425 }
426
427 #[test]
428 fn matches_hostnames() {
429 let h = hosts();
430 assert_eq!(match_host(&h, "host-a.example.invalid", 22).len(), 3); assert_eq!(match_host(&h, "host-a.example.invalid", 2222).len(), 1);
432 assert_eq!(match_host(&h, "anything.wild.example.invalid", 22).len(), 1);
433 assert_eq!(match_host(&h, "nope.example.invalid", 22).len(), 0);
434 assert_eq!(match_host(&h, "sub.ca.example.invalid", 22).len(), 1);
435 }
436
437 #[test]
438 fn fingerprints() {
439 let f = fingerprint_sha256(PUB);
440 assert!(f.starts_with("SHA256:"));
441 assert!(!f.ends_with('='));
442 assert_ne!(f, fingerprint_sha256(OTHER));
443 }
444
445 #[test]
446 fn verify_outcomes() {
447 let h = hosts();
448 let r = verify_host_key(PUB, "host-a.example.invalid", 22, &h);
450 assert!(r.matched && r.had_matching_host_entry);
451 let r = verify_host_key(OTHER, "host-a.example.invalid", 22, &h);
453 assert!(!r.matched && r.had_matching_host_entry);
454 let r = verify_host_key(PUB, "nope.example.invalid", 22, &h);
456 assert!(!r.had_matching_host_entry);
457 let r = verify_host_key(PUB, "host-a.example.invalid", 22, &h);
459 assert!(r.revoked);
461 }
462
463 #[test]
464 fn length_mismatch_resists_oracle() {
465 let h = hosts();
466 assert!(!key_matches_entry(b"short", &h[0]));
467 }
468
469 #[test]
470 fn strict_and_lenient_decisions() {
471 use crate::config::SshHostKeyPolicy;
472 let h = hosts();
473 let mut logs = Vec::new();
474 let mut log = |m: String| logs.push(m);
475
476 assert_eq!(
478 decide_host_key(
479 SshHostKeyPolicy::Strict,
480 "nope.example.invalid",
481 22,
482 &h,
483 PUB,
484 &mut log
485 ),
486 HostKeyDecision::Reject
487 );
488 let h_no_revoke: Vec<KnownHostEntry> =
490 h.iter().filter(|e| e.marker.is_none()).cloned().collect();
491 assert_eq!(
492 decide_host_key(
493 SshHostKeyPolicy::Strict,
494 "host-a.example.invalid",
495 22,
496 &h_no_revoke,
497 PUB,
498 &mut log
499 ),
500 HostKeyDecision::Accept
501 );
502 assert_eq!(
504 decide_host_key(
505 SshHostKeyPolicy::Strict,
506 "host-a.example.invalid",
507 22,
508 &h_no_revoke,
509 OTHER,
510 &mut log
511 ),
512 HostKeyDecision::Reject
513 );
514 assert_eq!(
516 decide_host_key(
517 SshHostKeyPolicy::Lenient,
518 "nope.example.invalid",
519 22,
520 &h,
521 PUB,
522 &mut log
523 ),
524 HostKeyDecision::Accept
525 );
526 assert_eq!(
529 decide_host_key(
530 SshHostKeyPolicy::Lenient,
531 "host-a.example.invalid",
532 22,
533 &h_no_revoke,
534 OTHER,
535 &mut log
536 ),
537 HostKeyDecision::Reject
538 );
539 assert_eq!(
541 decide_host_key(
542 SshHostKeyPolicy::Lenient,
543 "host-a.example.invalid",
544 22,
545 &h,
546 PUB,
547 &mut log
548 ),
549 HostKeyDecision::Reject
550 );
551 assert!(!logs.is_empty());
552 }
553
554 #[test]
555 fn hashed_entries_self_consistent() {
556 let salt = vec![0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef];
558 let probe = "hashed.example.invalid";
559 let tag = hmac_sha1(&salt, probe.as_bytes());
560 let b64 = base64::engine::general_purpose::STANDARD;
561 let line = format!(
562 "|1|{}|{} ssh-ed25519 {}",
563 b64.encode(&salt),
564 b64.encode(tag),
565 b64.encode(PUB)
566 );
567 let entries = parse_known_hosts(&line);
568 assert_eq!(entries.len(), 1);
569 assert!(entries[0].hashed.is_some());
570 assert_eq!(match_host(&entries, probe, 22).len(), 1);
571 assert_eq!(match_host(&entries, "other.example.invalid", 22).len(), 0);
572 assert!(key_matches_entry(PUB, &entries[0]));
573 }
574
575 #[test]
576 fn hmac_sha1_matches_reference_vectors() {
577 assert_eq!(
579 hmac_sha1(&[0x0b; 20], b"Hi There").to_vec(),
580 hex_decode("b617318655057264e28bc0b6fb378c8ef146be00")
581 );
582 assert_eq!(
583 hmac_sha1(b"Jefe", b"what do ya want for nothing?").to_vec(),
584 hex_decode("effcdf6ae5eb2fa2d27416d5f184df9c259a7c79")
585 );
586 }
587
588 fn hex_decode(s: &str) -> Vec<u8> {
589 (0..s.len())
590 .step_by(2)
591 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
592 .collect()
593 }
594
595 #[test]
596 fn multiple_algorithms_for_same_host() {
597 let b64 = |b: &[u8]| base64::engine::general_purpose::STANDARD.encode(b);
600 let entries = parse_known_hosts(&format!(
601 "multi.example.invalid ssh-ed25519 {}\n multi.example.invalid ssh-rsa {}",
602 b64(PUB),
603 b64(OTHER)
604 ));
605 assert_eq!(entries.len(), 2);
606 let mut log = |_: String| {};
607 assert_eq!(
608 decide_host_key(
609 crate::config::SshHostKeyPolicy::Strict,
610 "multi.example.invalid",
611 22,
612 &entries,
613 OTHER,
614 &mut log
615 ),
616 HostKeyDecision::Accept
617 );
618 let third = b"AAAAC3NzaC1lZDI1NTE5AAAAIThirdKeyNeverSeenBefore0000000000000";
619 assert_eq!(
620 decide_host_key(
621 crate::config::SshHostKeyPolicy::Strict,
622 "multi.example.invalid",
623 22,
624 &entries,
625 third,
626 &mut log
627 ),
628 HostKeyDecision::Reject
629 );
630 }
631
632 #[test]
633 fn checked_loader_fail_closed() {
634 let dir = tempfile::TempDir::new().unwrap();
635 let good = dir.path().join("good");
636 std::fs::write(
637 &good,
638 "h.example.invalid ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture
639",
640 )
641 .unwrap();
642 assert!(load_known_hosts_checked(Some(&good)).is_ok());
643
644 let missing = dir.path().join("missing");
646 let err = load_known_hosts_checked(Some(&missing)).unwrap_err();
647 assert!(err.contains("unreadable"), "{err}");
648
649 let malformed = dir.path().join("garbage");
654 std::fs::write(&malformed, "two-parts-only\n???\nno-key-here either-way\n").unwrap();
655 let err = load_known_hosts_checked(Some(&malformed)).unwrap_err();
656 assert!(err.contains("malformed"), "{err}");
657
658 let empty = dir.path().join("empty");
660 std::fs::write(&empty, "\n").unwrap();
661 assert!(load_known_hosts_checked(Some(&empty)).unwrap().is_empty());
662 }
663
664 #[test]
666 fn matches_legacy_known_hosts_fixtures() {
667 let path = concat!(
668 env!("CARGO_MANIFEST_DIR"),
669 "/tests/fixtures/legacy/known-hosts.json"
670 );
671 let Ok(raw) = std::fs::read_to_string(path) else {
674 eprintln!("skipping: legacy fixture corpus not present ({path})");
675 return;
676 };
677 let data: serde_json::Value = serde_json::from_str(&raw).unwrap();
678 let parsed = parse_known_hosts(data["content"].as_str().unwrap());
679 assert_eq!(parsed.len(), data["parsedCount"].as_u64().unwrap() as usize);
680
681 let b64 = base64::engine::general_purpose::STANDARD;
684 let pub_bytes = b64
685 .decode(
686 parsed
687 .iter()
688 .find(|e| {
689 e.host_patterns
690 .first()
691 .map(|p| p == "host-a.example.invalid")
692 .unwrap_or(false)
693 })
694 .map(|e| e.key_base64.clone())
695 .unwrap(),
696 )
697 .unwrap();
698 assert_eq!(
699 fingerprint_sha256(&pub_bytes),
700 data["fingerprint"].as_str().unwrap()
701 );
702
703 for m in data["matches"].as_array().unwrap() {
704 let host = m["host"].as_str().unwrap();
705 let port = m["port"].as_u64().unwrap() as u16;
706 assert_eq!(
707 match_host(&parsed, host, port).len() as u64,
708 m["matchedHosts"].as_u64().unwrap(),
709 "match count for {host}:{port}"
710 );
711 let verify = verify_host_key(&pub_bytes, host, port, &parsed);
712 assert_eq!(verify.matched, m["verifyPub"]["matched"].as_bool().unwrap());
713 assert_eq!(
714 verify.had_matching_host_entry,
715 m["verifyPub"]["hadMatchingHostEntry"].as_bool().unwrap()
716 );
717 assert_eq!(verify.revoked, m["verifyPub"]["revoked"].as_bool().unwrap());
718 }
719
720 let h = parse_known_hosts(data["hashedEntry"]["line"].as_str().unwrap());
722 assert_eq!(
723 h.len() as u64,
724 data["hashedEntry"]["parsedCount"].as_u64().unwrap()
725 );
726 assert_eq!(
727 match_host(&h, "hashed.example.invalid", 22).len() as u64,
728 data["hashedEntry"]["matchHost_22"].as_u64().unwrap()
729 );
730 assert_eq!(
731 match_host(&h, "other.example.invalid", 22).len() as u64,
732 data["hashedEntry"]["matchHost_other"].as_u64().unwrap()
733 );
734 }
735}