use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use hmac::{Hmac, Mac};
use russh::keys::{ssh_key::PublicKey, HashAlg};
use sha1::Sha1;
use crate::error::AnvilError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CertAuthority {
pub host_pattern: String,
pub algorithm: String,
pub fingerprint: String,
pub openssh: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RevokedEntry {
pub host_pattern: String,
pub fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectHostKey {
pub host_pattern: String,
pub fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HashedHost {
pub salt: [u8; 20],
pub hash: [u8; 20],
pub fingerprint: String,
}
impl HashedHost {
#[must_use]
pub fn matches(&self, host: &str) -> bool {
let Ok(mut mac) = <Hmac<Sha1>>::new_from_slice(&self.salt) else {
return false;
};
mac.update(host.as_bytes());
mac.verify_slice(&self.hash).is_ok()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KnownHostsFile {
pub direct: Vec<DirectHostKey>,
pub cert_authorities: Vec<CertAuthority>,
pub revoked: Vec<RevokedEntry>,
pub hashed: Vec<HashedHost>,
}
pub fn parse_known_hosts(content: &str) -> Result<KnownHostsFile, AnvilError> {
let mut out = KnownHostsFile::default();
for (idx, raw) in content.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line_no = idx + 1;
if let Some(rest) = strip_marker_ci(line, "@cert-authority") {
parse_cert_authority_line(rest, line_no, &mut out)?;
continue;
}
if let Some(rest) = strip_marker_ci(line, "@revoked") {
parse_revoked_line(rest, line_no, &mut out);
continue;
}
let mut parts = line.splitn(2, char::is_whitespace);
let Some(host_part) = parts.next() else {
continue;
};
let Some(fp_part) = parts.next() else {
continue;
};
let fp = fp_part.trim();
if fp.is_empty() {
continue;
}
for host_token in split_host_patterns(host_part) {
if host_token.starts_with("|1|") {
match parse_hashed_token(&host_token) {
Some((salt, hash)) => {
out.hashed.push(HashedHost {
salt,
hash,
fingerprint: fp.to_owned(),
});
}
None => {
log::warn!(
"known_hosts: line {line_no}: malformed hashed token '{host_token}'; \
skipping (expected '|1|<base64-salt>|<base64-hash>')",
);
}
}
} else {
out.direct.push(DirectHostKey {
host_pattern: host_token,
fingerprint: fp.to_owned(),
});
}
}
}
Ok(out)
}
fn parse_hashed_token(token: &str) -> Option<([u8; 20], [u8; 20])> {
let rest = token.strip_prefix("|1|")?;
let (salt_b64, hash_b64) = rest.split_once('|')?;
let salt_bytes = BASE64.decode(salt_b64.as_bytes()).ok()?;
let hash_bytes = BASE64.decode(hash_b64.as_bytes()).ok()?;
let salt: [u8; 20] = salt_bytes.try_into().ok()?;
let hash: [u8; 20] = hash_bytes.try_into().ok()?;
Some((salt, hash))
}
fn strip_marker_ci<'a>(line: &'a str, marker: &str) -> Option<&'a str> {
if line.len() <= marker.len() {
return None;
}
let head = line.get(..marker.len())?;
if !head.eq_ignore_ascii_case(marker) {
return None;
}
let rest = &line[marker.len()..];
let trimmed = rest.trim_start();
if !rest.starts_with(char::is_whitespace) || trimmed.is_empty() {
return None;
}
Some(trimmed)
}
fn parse_cert_authority_line(
rest: &str,
line_no: usize,
out: &mut KnownHostsFile,
) -> Result<(), AnvilError> {
let mut parts = rest.splitn(2, char::is_whitespace);
let Some(host_part) = parts.next() else {
return Err(AnvilError::invalid_config(format!(
"known_hosts:{line_no}: @cert-authority line missing host pattern",
)));
};
let Some(key_part) = parts.next() else {
return Err(AnvilError::invalid_config(format!(
"known_hosts:{line_no}: @cert-authority line missing pubkey",
)));
};
let key_part = key_part.trim();
let pk = PublicKey::from_openssh(key_part).map_err(|e| {
AnvilError::invalid_config(format!(
"known_hosts:{line_no}: failed to parse @cert-authority pubkey: {e}",
))
})?;
let algorithm = pk.algorithm().as_str().to_owned();
let fingerprint = pk.fingerprint(HashAlg::Sha256).to_string();
for host in split_host_patterns(host_part) {
out.cert_authorities.push(CertAuthority {
host_pattern: host,
algorithm: algorithm.clone(),
fingerprint: fingerprint.clone(),
openssh: key_part.to_owned(),
});
}
Ok(())
}
fn parse_revoked_line(rest: &str, line_no: usize, out: &mut KnownHostsFile) {
let mut parts = rest.splitn(2, char::is_whitespace);
let Some(host_part) = parts.next() else {
log::warn!("known_hosts:{line_no}: @revoked line missing host pattern");
return;
};
let Some(fp_part) = parts.next() else {
log::warn!("known_hosts:{line_no}: @revoked line missing fingerprint");
return;
};
let fp = fp_part.trim();
if fp.is_empty() {
log::warn!("known_hosts:{line_no}: @revoked line has empty fingerprint");
return;
}
for host in split_host_patterns(host_part) {
out.revoked.push(RevokedEntry {
host_pattern: host,
fingerprint: fp.to_owned(),
});
}
}
fn split_host_patterns(column: &str) -> Vec<String> {
column
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_yields_default() {
let parsed = parse_known_hosts("").expect("empty");
assert_eq!(parsed, KnownHostsFile::default());
}
#[test]
fn comments_and_blanks_skipped() {
let parsed = parse_known_hosts(
"# top comment\n\
\n\
# another\n",
)
.expect("parse");
assert_eq!(parsed, KnownHostsFile::default());
}
#[test]
fn direct_fingerprint_line() {
let parsed =
parse_known_hosts("github.com SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s\n")
.expect("parse");
assert_eq!(parsed.direct.len(), 1);
assert_eq!(parsed.direct[0].host_pattern, "github.com");
assert_eq!(
parsed.direct[0].fingerprint,
"SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s",
);
assert!(parsed.cert_authorities.is_empty());
assert!(parsed.revoked.is_empty());
}
#[test]
fn comma_separated_hosts_split_into_multiple_entries() {
let parsed =
parse_known_hosts("github.com,gitlab.com,codeberg.org SHA256:abcd\n").expect("parse");
assert_eq!(parsed.direct.len(), 3);
let hosts: Vec<&str> = parsed
.direct
.iter()
.map(|d| d.host_pattern.as_str())
.collect();
assert_eq!(hosts, vec!["github.com", "gitlab.com", "codeberg.org"]);
}
#[test]
fn cert_authority_line_parsed() {
let parsed = parse_known_hosts(
"@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca-key\n",
)
.expect("parse");
assert_eq!(parsed.cert_authorities.len(), 1);
let ca = &parsed.cert_authorities[0];
assert_eq!(ca.host_pattern, "*.example.com");
assert_eq!(ca.algorithm, "ssh-ed25519");
assert!(
ca.fingerprint.starts_with("SHA256:"),
"expected SHA256 fp, got: {}",
ca.fingerprint,
);
assert!(parsed.direct.is_empty());
assert!(parsed.revoked.is_empty());
}
#[test]
fn cert_authority_marker_case_insensitive() {
let parsed = parse_known_hosts(
"@CERT-AUTHORITY *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti\n",
)
.expect("parse");
assert_eq!(parsed.cert_authorities.len(), 1);
}
#[test]
fn cert_authority_invalid_pubkey_errors() {
let err = parse_known_hosts("@cert-authority *.example.com ssh-ed25519 not-base64-data\n")
.expect_err("malformed pubkey");
let msg = format!("{err}");
assert!(
msg.contains("@cert-authority"),
"expected error to mention @cert-authority, got: {msg}",
);
}
#[test]
fn revoked_line_parsed() {
let parsed =
parse_known_hosts("@revoked example.com SHA256:abcdefghijklmnop\n").expect("parse");
assert_eq!(parsed.revoked.len(), 1);
assert_eq!(parsed.revoked[0].host_pattern, "example.com");
assert_eq!(parsed.revoked[0].fingerprint, "SHA256:abcdefghijklmnop");
assert!(parsed.direct.is_empty());
assert!(parsed.cert_authorities.is_empty());
}
#[test]
fn revoked_marker_case_insensitive() {
let parsed = parse_known_hosts("@REVOKED * SHA256:a\n").expect("parse");
assert_eq!(parsed.revoked.len(), 1);
assert_eq!(parsed.revoked[0].host_pattern, "*");
}
#[test]
fn revoked_with_comma_hosts() {
let parsed =
parse_known_hosts("@revoked a.example.com,b.example.com SHA256:abc\n").expect("parse");
assert_eq!(parsed.revoked.len(), 2);
assert_eq!(parsed.revoked[0].host_pattern, "a.example.com");
assert_eq!(parsed.revoked[1].host_pattern, "b.example.com");
}
#[test]
fn revoked_missing_fingerprint_logged_and_skipped() {
let parsed = parse_known_hosts("@revoked example.com\n").expect("parse");
assert!(parsed.revoked.is_empty());
}
#[test]
fn hashed_entry_skipped_silently() {
let parsed = parse_known_hosts(
"|1|abcdef==|fedcba== ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti\n",
)
.expect("parse");
assert!(parsed.direct.is_empty());
assert!(parsed.cert_authorities.is_empty());
}
#[test]
fn mixed_file_three_classes() {
let parsed = parse_known_hosts(
"# header\n\
github.com SHA256:fp1\n\
@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n\
@revoked github.com SHA256:bad-fp\n\
gitlab.com SHA256:fp2\n",
)
.expect("parse");
assert_eq!(parsed.direct.len(), 2);
assert_eq!(parsed.cert_authorities.len(), 1);
assert_eq!(parsed.revoked.len(), 1);
assert_eq!(parsed.direct[0].host_pattern, "github.com");
assert_eq!(parsed.direct[1].host_pattern, "gitlab.com");
assert_eq!(parsed.cert_authorities[0].host_pattern, "*.example.com");
assert_eq!(parsed.revoked[0].host_pattern, "github.com");
}
#[test]
fn marker_without_trailing_space_not_treated_as_marker() {
let parsed = parse_known_hosts("@cert-authoritynot-a-marker\n").expect("parse");
assert_eq!(parsed, KnownHostsFile::default());
}
#[test]
fn whitespace_around_fields_tolerated() {
let parsed = parse_known_hosts(" github.com\tSHA256:fp\n").expect("parse");
assert_eq!(parsed.direct.len(), 1);
assert_eq!(parsed.direct[0].host_pattern, "github.com");
assert_eq!(parsed.direct[0].fingerprint, "SHA256:fp");
}
}