240 lines
7.8 KiB
Rust
240 lines
7.8 KiB
Rust
//! 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) = <Hmac<sha1::Sha1> 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<bool> {
|
|
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 = <Hmac<sha1::Sha1> 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());
|
|
}
|
|
}
|