Skip to main content

sequel_mcp/sql/
known_hosts.rs

1//! SSH known_hosts parsing, matching and verification (`sshHostKey.ts`
2//! port): plain, wildcard, `[host]:port`, hashed `|1|` entries,
3//! `@cert-authority`/`@revoked` markers, SHA-256 fingerprints.
4
5use 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>, // "@cert-authority" | "@revoked"
16    pub hashed: Option<(Vec<u8>, Vec<u8>)>, // (salt, expected hmac)
17}
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        // Shell-style glob patterns are ASCII; use a manual match to avoid
118        // pulling a regex dependency.
119        glob_match(&re[1..re.len() - 1], host)
120    } else {
121        false
122    }
123}
124
125/// Tiny glob matcher for the wildcard translation (`.*` / `.`).
126fn 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        // OpenSSH hashed known_hosts format: HMAC-SHA1(salt, hostname).
166        // Retained deliberately — it is the file format's definition, used
167        // only for hostname matching (not secret protection).
168        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        // Length mismatch resisted without an early-content oracle.
207        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
258/// Fail-closed loader: an EXPLICITLY configured known_hosts file that
259/// cannot be read, or that contains no parseable entry at all, is a
260/// deny (in every policy mode) rather than a silently-empty host set.
261/// An absent DEFAULT path (~/.ssh/known_hosts) stays an empty set —
262/// strict then rejects unknown hosts anyway, lenient TOFU-accepts.
263pub 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            // Expand a leading `~` for explicit paths too (validation
270            // accepts absolute or `~`-prefixed; relative paths are
271            // rejected at config time — they would resolve against a
272            // client-controllable CWD).
273            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/// Decision for `check_server_key` in the SSH client handler.
306#[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    // lenient (migration compatibility ONLY, warned loudly). A MISMATCH
349    // means the server identity changed or is being impersonated — that
350    // is rejected in EVERY mode. Only a genuinely unknown host may be
351    // accepted under lenient, with a high-visibility warning.
352    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
368/// HMAC-SHA1 over `data` with key `salt`, used ONLY to match OpenSSH's
369/// hashed known_hosts entry format (`|1|salt|hash`). SHA-1 here is a
370/// file-format compatibility requirement, not a chosen cryptographic
371/// primitive — it is never used for signatures, password hashing, or any
372/// secret-protection purpose.
373fn 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        // malformed + comment + blank lines skipped
416        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); // plain, alias list, revoked
430        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        // matched known host
448        let r = verify_host_key(PUB, "host-a.example.invalid", 22, &h);
449        assert!(r.matched && r.had_matching_host_entry);
450        // changed key on known host (MitM signal)
451        let r = verify_host_key(OTHER, "host-a.example.invalid", 22, &h);
452        assert!(!r.matched && r.had_matching_host_entry);
453        // unknown host
454        let r = verify_host_key(PUB, "nope.example.invalid", 22, &h);
455        assert!(!r.had_matching_host_entry);
456        // revoked key
457        let r = verify_host_key(PUB, "host-a.example.invalid", 22, &h);
458        // revoked entry also matches the same key
459        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        // strict: unknown host rejected
476        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        // strict: matching host accepted
488        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        // strict: mismatch rejected
502        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        // lenient: unknown accepted (warned)
514        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        // lenient: MISMATCH REJECTED (server identity change is never
526        // acceptable, even in migration-compatibility mode)
527        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        // revoked rejected even lenient
539        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        // Build a hashed entry like OpenSSH would, then match it.
556        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        // RFC 2202 test vectors.
577        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        // Two entries for one host with different key types: either key
597        // is acceptable; a third, unseen key is a mismatch.
598        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        // Explicit path that does not exist: deny.
644        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        // Non-empty file with zero parseable entries: deny. (A 3-part
649        // garbage line still parses, mirroring OpenSSH's tolerant
650        // reader — such lines simply never match; only lines with no
651        // key field at all are structurally unparseable.)
652        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        // Empty file is an empty set (not an error).
658        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    /// Differential check against the legacy fixtures.
664    #[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        // Untracked corpus generated from the legacy checkout; skip on
671        // fresh CI checkouts where it is absent.
672        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        // Fixture key material is synthesized there as b64 strings; verify
681        // our fingerprint matches the legacy-computed one for the same bytes.
682        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        // Hashed-entry differential.
720        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}