use std::fs;
use std::io;
use std::path::Path;
use purecrypto::rng::{OsRng, RngCore};
use super::format::{
Entry, HostSpec, Marker, ParsedLine, format_entry, format_host_pattern, parse_line,
patterns_match,
};
use super::hash::{check_hashed, hash_new, parse_hashed};
#[derive(Debug)]
pub enum LookupResult {
Match,
Mismatch {
expected: Vec<(String, Vec<u8>)>,
},
Unknown,
}
pub struct KnownHosts {
lines: Vec<Slot>,
}
enum Slot {
Verbatim(String),
Entry(Entry),
Removed,
}
impl Default for KnownHosts {
fn default() -> Self {
Self::new()
}
}
impl KnownHosts {
pub fn new() -> Self {
Self { lines: Vec::new() }
}
pub fn from_bytes(data: &[u8]) -> Self {
let mut out = Self::new();
let mut parts = data.split(|&b| b == b'\n').peekable();
while let Some(line) = parts.next() {
if parts.peek().is_none() && line.is_empty() {
break;
}
let line = match line.split_last() {
Some((b'\r', rest)) => rest,
_ => line,
};
let raw = match std::str::from_utf8(line) {
Ok(s) => s,
Err(_) => continue,
};
match parse_line(raw) {
ParsedLine::Entry(e) => out.lines.push(Slot::Entry(e)),
ParsedLine::Verbatim(s) => out.lines.push(Slot::Verbatim(s)),
}
}
out
}
pub fn load(path: impl AsRef<Path>) -> io::Result<Self> {
match fs::read(path) {
Ok(bytes) => Ok(Self::from_bytes(&bytes)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::new()),
Err(e) => Err(e),
}
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
for slot in &self.lines {
match slot {
Slot::Verbatim(s) => {
out.extend_from_slice(s.as_bytes());
out.push(b'\n');
}
Slot::Entry(e) => {
out.extend_from_slice(format_entry(e).as_bytes());
out.push(b'\n');
}
Slot::Removed => {}
}
}
out
}
pub fn save(&self, path: impl AsRef<Path>) -> io::Result<()> {
let path = path.as_ref();
let tmp = unique_tmp_path(path);
match write_private_file(&tmp, &self.to_bytes()) {
Ok(()) => {}
Err(e) => {
let _ = fs::remove_file(&tmp);
return Err(e);
}
}
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
#[cfg(unix)]
{
if let Some(parent) = path.parent() {
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
}
}
Ok(())
}
pub fn lookup(&self, host: &str, port: u16, key_type: &str, key_blob: &[u8]) -> LookupResult {
for slot in &self.lines {
if let Slot::Entry(e) = slot
&& e.marker == Some(Marker::Revoked)
&& host_field_matches(&e.host_spec, host, port)
&& e.key_type == key_type
&& e.key_blob == key_blob
{
return LookupResult::Mismatch {
expected: vec![(e.key_type.clone(), e.key_blob.clone())],
};
}
}
let mut host_matched = false;
let mut expected: Vec<(String, Vec<u8>)> = Vec::new();
for slot in &self.lines {
let e = match slot {
Slot::Entry(e) => e,
_ => continue,
};
if !host_field_matches(&e.host_spec, host, port) {
continue;
}
if e.marker == Some(Marker::Revoked) {
continue;
}
host_matched = true;
if e.key_type == key_type && e.key_blob == key_blob {
return LookupResult::Match;
}
expected.push((e.key_type.clone(), e.key_blob.clone()));
}
if host_matched {
LookupResult::Mismatch { expected }
} else {
LookupResult::Unknown
}
}
pub fn verify_host_cert(
&self,
host: &str,
port: u16,
cert: &crate::cert::Certificate,
allowed_ca_algos: &[&str],
now: u64,
) -> crate::Result<()> {
use crate::error::Error;
for slot in &self.lines {
if let Slot::Entry(e) = slot
&& e.marker == Some(Marker::Revoked)
&& host_field_matches(&e.host_spec, host, port)
&& (e.key_blob == cert.signature_key_blob
|| e.key_blob == cert.embedded_pubkey_blob)
{
return Err(Error::HostKeyRejected);
}
}
cert.check_type(crate::cert::CertType::Host)?;
cert.check_validity(now)?;
cert.require_known_critical_options()?;
let mut saw_ca = false;
for slot in &self.lines {
let Slot::Entry(e) = slot else { continue };
if e.marker != Some(Marker::CertAuthority) {
continue;
}
if !host_field_matches(&e.host_spec, host, port) {
continue;
}
saw_ca = true;
if e.key_blob == cert.signature_key_blob {
cert.verify_ca_signature(allowed_ca_algos)?;
cert.check_principal(host)?;
return Ok(());
}
}
let _ = saw_ca;
Err(Error::HostKeyRejected)
}
pub fn add(&mut self, host: &str, port: u16, key_type: &str, key_blob: &[u8], hashed: bool) {
let host_spec = if hashed {
let mut rng = OsRng;
let (_salt, token) = hash_new(&mut rng, host, port);
HostSpec::Hashed(token)
} else {
HostSpec::Patterns(vec![format_host_pattern(host, port)])
};
self.lines.push(Slot::Entry(Entry {
marker: None,
host_spec,
key_type: key_type.to_string(),
key_blob: key_blob.to_vec(),
comment: String::new(),
}));
}
pub fn remove(&mut self, host: &str, port: u16) -> usize {
let mut removed = 0usize;
for slot in self.lines.iter_mut() {
if let Slot::Entry(e) = slot
&& host_field_matches(&e.host_spec, host, port)
{
removed += 1;
*slot = Slot::Removed;
}
}
removed
}
pub fn find(&self, host: &str, port: u16) -> Vec<&Entry> {
self.lines
.iter()
.filter_map(|s| match s {
Slot::Entry(e) if host_field_matches(&e.host_spec, host, port) => Some(e),
_ => None,
})
.collect()
}
pub fn hash_in_place(&mut self) {
let mut rng = OsRng;
let mut new_lines: Vec<Slot> = Vec::with_capacity(self.lines.len());
for slot in self.lines.drain(..) {
match slot {
Slot::Entry(e) => match e.host_spec {
HostSpec::Hashed(_) => new_lines.push(Slot::Entry(e)),
HostSpec::Patterns(pats) => {
let mut hashed: Vec<Entry> = Vec::with_capacity(pats.len());
let mut all_ok = true;
for pat in &pats {
let Some((host, port)) = split_host_port(pat) else {
all_ok = false;
break;
};
let mut salt = [0u8; super::hash::SALT_LEN];
rng.fill_bytes(&mut salt);
let token = super::hash::encode_hashed(
&salt,
&super::hash::format_host(&host, port),
);
hashed.push(Entry {
marker: e.marker,
host_spec: HostSpec::Hashed(token),
key_type: e.key_type.clone(),
key_blob: e.key_blob.clone(),
comment: e.comment.clone(),
});
}
if all_ok {
for entry in hashed {
new_lines.push(Slot::Entry(entry));
}
} else {
new_lines.push(Slot::Entry(Entry {
marker: e.marker,
host_spec: HostSpec::Patterns(pats),
key_type: e.key_type,
key_blob: e.key_blob,
comment: e.comment,
}));
}
}
},
other => new_lines.push(other),
}
}
self.lines = new_lines;
}
}
fn write_private_file(path: &Path, data: &[u8]) -> io::Result<()> {
use std::io::Write as _;
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(data)?;
f.sync_all()?;
Ok(())
}
fn unique_tmp_path(path: &Path) -> std::path::PathBuf {
let mut rng = OsRng;
let mut nonce = [0u8; 8];
rng.fill_bytes(&mut nonce);
let nonce = u64::from_le_bytes(nonce);
let pid = std::process::id();
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("known_hosts");
let tmp_name = format!(".{file_name}.tmp.{pid}.{nonce:016x}");
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.join(tmp_name),
_ => std::path::PathBuf::from(tmp_name),
}
}
fn host_field_matches(spec: &HostSpec, host: &str, port: u16) -> bool {
match spec {
HostSpec::Patterns(pats) => patterns_match(pats, host, port),
HostSpec::Hashed(token) => match parse_hashed(token) {
Some((salt, hash)) => check_hashed(&salt, &hash, host, port),
None => false,
},
}
}
fn split_host_port(pat: &str) -> Option<(String, u16)> {
crate::config::parse_host_port_pattern(pat, 22).ok()
}
#[cfg(test)]
mod store_tests {
use super::*;
#[test]
fn invalid_utf8_line_is_skipped_not_whole_file() {
let mut data: Vec<u8> = Vec::new();
data.extend_from_slice(b"good1.example.com ssh-ed25519 AAAA\n");
data.extend_from_slice(b"bad.example.com ssh-ed25519 ");
data.push(0xFF);
data.push(b'\n');
data.extend_from_slice(b"good2.example.com ssh-ed25519 AAAA\n");
let kh = KnownHosts::from_bytes(&data);
assert!(
matches!(
kh.lookup("good1.example.com", 22, "ssh-ed25519", &[0, 0, 0]),
LookupResult::Match
),
"first valid entry should have loaded"
);
assert!(
matches!(
kh.lookup("good2.example.com", 22, "ssh-ed25519", &[0, 0, 0]),
LookupResult::Match
),
"third valid entry should have loaded despite the bad middle line"
);
assert!(
matches!(
kh.lookup("bad.example.com", 22, "ssh-ed25519", &[0, 0, 0]),
LookupResult::Unknown
),
"the invalid-utf8 line must be skipped, not parsed"
);
let entries = kh
.lines
.iter()
.filter(|s| matches!(s, Slot::Entry(_)))
.count();
assert_eq!(entries, 2, "only the two valid lines should load");
}
#[test]
fn trailing_newline_does_not_add_blank_line() {
let src = b"example.com ssh-ed25519 AAAA\n";
let kh = KnownHosts::from_bytes(src);
let out = kh.to_bytes();
assert_eq!(out, src, "round-trip must not add a trailing blank line");
}
}