//! OpenSSH known_hosts policy: explicit keys, hashed names, patterns and revocations. use super::ssh_wire::fail; use base64::{Engine, engine::general_purpose::STANDARD}; use hmac::{Hmac, Mac}; use std::io::{self, Write}; fn pattern(pattern: &[u8], name: &[u8]) -> bool { // Linear glob matcher with a single backtracking '*' position, no recursion. let (mut p, mut n, mut star, mut retry) = (0, 0, None, 0); while n < name.len() { if p < pattern.len() && (pattern[p] == b'?' || pattern[p].eq_ignore_ascii_case(&name[n])) { p += 1; n += 1; } else if p < pattern.len() && pattern[p] == b'*' { star = Some(p); p += 1; retry = n; } else if let Some(s) = star { retry += 1; n = retry; p = s + 1; } else { return false; } } while p < pattern.len() && pattern[p] == b'*' { p += 1; } p == pattern.len() } fn matches(names: &str, host: &str) -> bool { if names.starts_with('|') { let parts: Vec<_> = names.split('|').collect(); if parts.len() != 4 || parts[1] != "1" { return false; } let (Ok(salt), Ok(hash)) = (STANDARD.decode(parts[2]), STANDARD.decode(parts[3])) else { return false; }; let Ok(mut mac) = as Mac>::new_from_slice(&salt) else { return false; }; mac.update(host.as_bytes()); return mac.verify_slice(&hash).is_ok(); } let mut found = false; for part in names.split(',') { if let Some(negated) = part.strip_prefix('!') { if pattern(negated.as_bytes(), host.as_bytes()) { return false; } } else if pattern(part.as_bytes(), host.as_bytes()) { found = true; } } found } /// Returns false for an unknown host; mismatched/revoked keys are always errors. pub fn check(text: &str, host: &str, key: &[u8]) -> io::Result { let mut seen = false; let mut matched = false; for line in text.lines() { let mut fields = line.split_whitespace(); let Some(first) = fields.next() else { continue; }; if first.starts_with('#') { continue; } let (marker, names) = if first.starts_with('@') { (Some(first), fields.next().unwrap_or("")) } else { (None, first) }; if !matches(names, host) { continue; } let algorithm = fields .next() .ok_or_else(|| fail("malformed known_hosts entry"))?; let encoded = fields .next() .ok_or_else(|| fail("malformed known_hosts entry"))?; let bytes = STANDARD .decode(encoded) .map_err(|_| fail("malformed known_hosts key"))?; if marker == Some("@cert-authority") { continue; } // certificates not negotiated if marker.is_some() && marker != Some("@revoked") { return Err(fail("unsupported known_hosts marker")); } if marker == Some("@revoked") { if bytes == key { return Err(fail( "Host key verification failed: revoked key in ~/.ssh/known_hosts", )); } continue; } // Other algorithms for this host are not a changed ed25519 key. if algorithm == "ssh-ed25519" { seen = true; if bytes == key { matched = true; } } } if matched { Ok(true) } else if seen { Err(fail( "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! Host key verification failed.", )) } else { Ok(false) } } /// No global lock: network operations in unrelated repositories must stay independent. /// Accept-new uses append mode, never read/modify/replace. Racing checks may append /// identical entries; these are harmless. External trust-file edits must still be /// coordinated by the person (as with any concurrent known_hosts editor). pub fn verify(host: &str, port: u16, key: &[u8], accept_new: bool) -> io::Result<()> { let host = if port == 22 { host.to_owned() } else { format!("[{host}]:{port}") }; let folder = std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_else(|| "/".into())) .join(".ssh"); std::fs::create_dir_all(&folder)?; let path = folder.join("known_hosts"); let text = match std::fs::read_to_string(&path) { Ok(s) => s, Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), Err(_) => return Err(fail("could not read ~/.ssh/known_hosts")), }; if check(&text, &host, key)? { return Ok(()); } if !accept_new { use sha2::Digest; let fingerprint = base64::engine::general_purpose::STANDARD_NO_PAD.encode(sha2::Sha256::digest(key)); return Err(fail(&format!( "No ED25519 host key is known for {host} and you have requested strict checking. Host key verification failed. SHA256:{fingerprint}" ))); } let prefix = if text.is_empty() || text.ends_with('\n') { "" } else { "\n" }; let line = format!("{prefix}{host} ssh-ed25519 {}\n", STANDARD.encode(key)); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&path)?; file.write_all(line.as_bytes())?; file.sync_all() } #[cfg(test)] mod tests { use super::*; #[test] fn hosts_plain_hashed_changed_revoked() { let key = b"synthetic-host-key"; let encoded = STANDARD.encode(key); assert!( check( &format!("*.example,!bad.example ssh-ed25519 {encoded}"), "git.example", key ) .unwrap() ); assert!( !check( &format!("*.example,!bad.example ssh-ed25519 {encoded}"), "bad.example", key ) .unwrap() ); assert!( check( &format!("git.example ssh-ed25519 {encoded}"), "git.example", b"changed" ) .is_err() ); assert!( check( &format!( "git.example ssh-ed25519 {encoded}\n@revoked git.example ssh-ed25519 {encoded}" ), "git.example", key ) .is_err() ); let salt = b"01234567890123456789"; let mut mac = as Mac>::new_from_slice(salt).unwrap(); mac.update(b"[git.example]:2222"); let names = format!( "|1|{}|{}", STANDARD.encode(salt), STANDARD.encode(mac.finalize().into_bytes()) ); assert!( check( &format!("{names} ssh-ed25519 {encoded}"), "[git.example]:2222", key ) .unwrap() ); assert!( !check( &format!("{names} ssh-ed25519 {encoded}"), "git.example", key ) .unwrap() ); } #[test] fn duplicate_appends_preserve_trust_policy() { // Model two accept-new callers that both read an unknown host before appending. let key = b"synthetic-host-key"; let line = format!("git.example ssh-ed25519 {}\n", STANDARD.encode(key)); let duplicated = line.repeat(2); assert!(check(&duplicated, "git.example", key).unwrap()); assert!(check(&duplicated, "git.example", b"changed").is_err()); let revoked = format!("{duplicated}@revoked {line}"); assert!(check(&revoked, "git.example", key).is_err()); } }