put: surface known_hosts problems instead of silently degrading

An unreadable or unparsable known_hosts file was swallowed with
'let _', silently downgrading to prompt-and-accept without telling the
user why their configuration was ignored: warn naming the file, then
continue. And when the pinned Launchpad fingerprint matches, a
DIFFERENT key recorded for that host in known_hosts was silently
bypassed: warn about the stale entry (diagnostic only — the published
fingerprint stays authoritative).
This commit is contained in:
2026-09-18 10:28:34 +02:00
parent 1aa0ca3d2f
commit 231c478d0b
+172 -12
View File
@@ -25,7 +25,7 @@ use lazy_static::lazy_static;
use log::debug; use log::debug;
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, Session}; use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, KnownHosts, Session};
use crate::ui::prompt; use crate::ui::prompt;
@@ -346,7 +346,9 @@ pub fn connect(
/// Check the server's host key, in decreasing order of trust: /// Check the server's host key, in decreasing order of trust:
/// ///
/// 1. matching one of the host's pinned (published, `host_keys.yml`) /// 1. matching one of the host's pinned (published, `host_keys.yml`)
/// fingerprints — accepted silently; /// fingerprints — accepted (with a diagnostic warning when the user's
/// known_hosts records a *different* key for the host, so a stale
/// entry gets cleaned up; the pin stays authoritative);
/// 2. matching `~/.ssh/known_hosts` — accepted; /// 2. matching `~/.ssh/known_hosts` — accepted;
/// 3. known-and-different — refused loudly (possible man-in-the-middle); /// 3. known-and-different — refused loudly (possible man-in-the-middle);
/// 4. unknown — fingerprint shown, explicit confirmation required, and on /// 4. unknown — fingerprint shown, explicit confirmation required, and on
@@ -362,21 +364,16 @@ fn verify_host_key(
if host_key_is_pinned(host, &fingerprint) { if host_key_is_pinned(host, &fingerprint) {
debug!("{host} host key matches the published fingerprint"); debug!("{host} host key matches the published fingerprint");
// Purely diagnostic — the pin already decided — but a known_hosts
// entry disagreeing with the published key is worth surfacing
log_known_hosts_discrepancy(host, port, key);
return Ok(()); return Ok(());
} }
let mut known_hosts = Session::new()?.known_hosts()?; let mut known_hosts = Session::new()?.known_hosts()?;
for file in known_hosts_files() { load_known_hosts(&mut known_hosts, &known_hosts_files());
// Unreadable/unknown-format lines are skipped by libssh2; a missing
// file is fine
let _ = known_hosts.read_file(&file, KnownHostFileKind::OpenSSH);
}
let check = if port == 22 { let check = check_known_hosts(&known_hosts, host, port, key);
known_hosts.check(host, key)
} else {
known_hosts.check_port(host, port, key)
};
match check { match check {
CheckResult::Match => Ok(()), CheckResult::Match => Ok(()),
@@ -414,6 +411,76 @@ fn verify_host_key(
} }
} }
/// Read `files` into `known_hosts`: silently skips missing files (the
/// normal first-use case — there is nothing to consult), warns about a
/// file that exists but cannot be read or parsed, naming the file and the
/// error — the user should know why their maintained entries are not being
/// consulted — and skips it. Unreadable/unknown-format *lines* are skipped
/// by libssh2 itself. Returns the emitted warnings, so the decision is
/// testable without capturing log output.
fn load_known_hosts(known_hosts: &mut KnownHosts, files: &[PathBuf]) -> Vec<String> {
let mut warnings = Vec::new();
for file in files {
if !file.exists() {
continue;
}
if let Err(e) = known_hosts.read_file(file, KnownHostFileKind::OpenSSH) {
let warning = format!(
"cannot read the known hosts file {}: {e} — its entries are ignored",
file.display()
);
log::warn!("{warning}");
warnings.push(warning);
}
}
warnings
}
/// Look up `host`'s `key` in the loaded entries, using the `[host]:port`
/// spelling for non-standard ports (the known_hosts encoding)
fn check_known_hosts(known_hosts: &KnownHosts, host: &str, port: u16, key: &[u8]) -> CheckResult {
if port == 22 {
known_hosts.check(host, key)
} else {
known_hosts.check_port(host, port, key)
}
}
/// What to tell the user when the server's key matched a pinned (published)
/// fingerprint while their known_hosts records a different key for the same
/// host — a stale entry, or worse an old compromise artifact they should
/// clean up; `None` for every other check result, since the pin is
/// authoritative and needs no corroboration. Pure so the decision and the
/// wording are testable without a server or a filesystem.
fn known_hosts_discrepancy(host: &str, check: CheckResult) -> Option<String> {
match check {
CheckResult::Mismatch => Some(format!(
"the host key of {host} matches Launchpad's published \
fingerprint, but your known_hosts file(s) record a DIFFERENT \
key for it — proceeding on the published fingerprint; consider \
removing the stale '{host}' entry from your known_hosts"
)),
CheckResult::Match | CheckResult::NotFound | CheckResult::Failure => None,
}
}
/// Diagnostic-only companion to the pinned-fingerprint acceptance: load the
/// user's known hosts files and warn when they record a different key for
/// `host` than the pinned one about to be accepted. The pin stays
/// authoritative: any failure here is tolerated and the connection
/// proceeds regardless.
fn log_known_hosts_discrepancy(host: &str, port: u16, key: &[u8]) {
let Ok(mut known_hosts) = Session::new().and_then(|session| session.known_hosts()) else {
return;
};
load_known_hosts(&mut known_hosts, &known_hosts_files());
let check = check_known_hosts(&known_hosts, host, port, key);
if let Some(warning) = known_hosts_discrepancy(host, check) {
log::warn!("{warning}");
}
}
/// Known hosts files to consult, user file first (so that new keys are /// Known hosts files to consult, user file first (so that new keys are
/// accepted because of the user file, mirroring ssh's own ordering) /// accepted because of the user file, mirroring ssh's own ordering)
fn known_hosts_files() -> Vec<PathBuf> { fn known_hosts_files() -> Vec<PathBuf> {
@@ -686,6 +753,99 @@ mod tests {
)); ));
} }
/// A pinned-key host whose known_hosts entries record a DIFFERENT key
/// is flagged with a message naming the host; every other check result
/// stays silent — the published fingerprint remains authoritative.
#[test]
fn known_hosts_discrepancy_flags_mismatch_only() {
let message = known_hosts_discrepancy("ppa.launchpad.net", CheckResult::Mismatch)
.expect("a mismatch under a matching pin must be flagged");
assert!(
message.contains("ppa.launchpad.net"),
"unexpected: {message}"
);
assert!(message.contains("DIFFERENT"), "unexpected: {message}");
assert!(
message.contains("published fingerprint"),
"unexpected: {message}"
);
assert!(
message.contains("stale"),
"the user must be told to clean the entry up: {message}"
);
for quiet in [
CheckResult::Match,
CheckResult::NotFound,
CheckResult::Failure,
] {
assert!(
known_hosts_discrepancy("ppa.launchpad.net", quiet).is_none(),
"{quiet:?} must not be flagged"
);
}
}
/// Known hosts loading decisions: a missing file is the normal
/// first-use case and stays silent, a readable file is loaded and its
/// entries consulted, and an existing but unreadable file yields a
/// warning naming it — the user must learn why their maintained
/// entries are not being consulted. Offline: a bare session suffices
/// for the known-hosts store.
#[test]
fn load_known_hosts_warns_only_on_unreadable_files() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let mut known_hosts = Session::new().unwrap().known_hosts().unwrap();
// A missing file is silent...
let missing = dir.path().join("known_hosts");
assert!(load_known_hosts(&mut known_hosts, &[missing]).is_empty());
// ...a readable one is loaded and consulted...
let key = b"a test host key";
let loaded = dir.path().join("loaded_known_hosts");
fs::write(
&loaded,
format!("host.example.com ssh-ed25519 {}\n", base64_nopad(key)),
)
.unwrap();
assert!(
load_known_hosts(&mut known_hosts, &[loaded]).is_empty(),
"a readable file must not warn"
);
assert!(
matches!(
check_known_hosts(&known_hosts, "host.example.com", 22, key),
CheckResult::Match
),
"the loaded entry must be consulted"
);
// ...and an existing but unreadable one is reported, naming the
// file. Environments that read through the mode bits (root) cannot
// exercise the unreadable case and are skipped.
let locked = dir.path().join("locked_known_hosts");
fs::write(&locked, b"stale.example.com ssh-ed25519 c3RhbGU=").unwrap();
let mut permissions = fs::metadata(&locked).unwrap().permissions();
permissions.set_mode(0o000);
fs::set_permissions(&locked, permissions).unwrap();
if fs::read(&locked).is_ok() {
return;
}
let warnings = load_known_hosts(&mut known_hosts, &[locked]);
assert_eq!(warnings.len(), 1, "unexpected: {warnings:?}");
assert!(
warnings[0].contains("locked_known_hosts"),
"the warning must name the file: {warnings:?}"
);
assert!(
warnings[0].contains("ignored"),
"the warning must say the entries are not consulted: {warnings:?}"
);
}
/// Negated `Host` patterns exclude the host from the whole block (the /// Negated `Host` patterns exclude the host from the whole block (the
/// OpenSSH rule): a block applies iff at least one positive pattern /// OpenSSH rule): a block applies iff at least one positive pattern
/// matches AND no negated pattern matches. /// matches AND no negated pattern matches.