mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
fix(ssh): stop a new host-key algorithm from reading as a compromise
A host that grows an ed25519 key beside the ssh-rsa one it has always had raised the full man-in-the-middle sheet — red border, fingerprint diff, a "type yes" field — because `check_in_str` folded "known by another algorithm" into `HostKeyStatus::Changed`. OpenSSH treats a key of an algorithm the host has no entry for as simply unknown, and saves the alarm for a key that contradicts one on file. `ChangedAlgorithm` splits the two apart, with `Changed` keeping precedence so a same-algorithm mismatch still screams however many other-algorithm lines sit beside it. The dialog was only half of it. Negotiation started from russh's default order, which leads with ed25519, so a host known only by ssh-rsa was *asked about on every single connection* — and an attacker could pick an algorithm the user had no entry for to trade the alarm for the mild confirmation. `build_preferred` now orders the host-key list the way OpenSSH's `order_hostkeyalgs()` does: what is already on file goes first, nothing is dropped, and a pinned `HostKeyAlgorithms` is left alone. It matches on key type, so all three RSA spellings travel together rather than pinning the host to SHA-1 signatures. The prompt reuses `AuthPromptKind::HostKeyUnknown` with an added optional field rather than gaining a variant: the enum is externally tagged and crosses both the daemon/GUI and the GUI/tty7-server boundaries, where a new variant is a hard decode failure on an older peer and a new field is not. Also fixes a defect the issue did not mention: overriding a genuinely changed key appended the new line without removing the old one, and since any same-algorithm match answers `Known`, the superseded — possibly attacker's — key stayed trusted forever, silently. The superseded line is now dropped first, and only lines naming this one host are touched, so a wildcard or `@revoked` entry is never collateral.
This commit is contained in:
@@ -551,6 +551,17 @@ pub enum AuthPromptKind {
|
||||
port: u16,
|
||||
algorithm: String,
|
||||
fingerprint_sha256: String,
|
||||
/// The algorithm this host is already on file under, when the key is
|
||||
/// new only because its algorithm is.
|
||||
///
|
||||
/// A field rather than a variant of its own on purpose. This enum is
|
||||
/// externally tagged and travels both to the GUI and to the remote
|
||||
/// `tty7-server`, either of which may be an older build; an unknown
|
||||
/// variant fails the whole decode, while an unknown field is ignored
|
||||
/// and a missing one defaults. So an old peer shows the plain
|
||||
/// unknown-host confirmation, which is the right prompt either way.
|
||||
#[serde(default)]
|
||||
previously_known_as: Option<String>,
|
||||
},
|
||||
HostKeyChanged {
|
||||
host: String,
|
||||
@@ -1935,6 +1946,28 @@ mod tests {
|
||||
assert_eq!(info.title, "");
|
||||
}
|
||||
|
||||
/// The reason `previously_known_as` is a field and not a variant: this
|
||||
/// prompt is decoded by whatever GUI or `tty7-server` is at the other end,
|
||||
/// and one of them is regularly older than the build that sent it. A
|
||||
/// missing field defaults; an unknown variant would fail the whole frame.
|
||||
#[test]
|
||||
fn an_unknown_host_prompt_from_an_older_peer_still_decodes() {
|
||||
let prompt: AuthPromptKind = serde_json::from_str(
|
||||
r#"{"HostKeyUnknown":{"host":"h","port":22,"algorithm":"ssh-ed25519","fingerprint_sha256":"SHA256:x"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
prompt,
|
||||
AuthPromptKind::HostKeyUnknown {
|
||||
host: "h".into(),
|
||||
port: 22,
|
||||
algorithm: "ssh-ed25519".into(),
|
||||
fingerprint_sha256: "SHA256:x".into(),
|
||||
previously_known_as: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn sample_native_spec() -> NativeSshSpec {
|
||||
let mut passphrases = std::collections::HashMap::new();
|
||||
passphrases.insert("~/.ssh/id_ed25519".to_string(), "topsecret".to_string());
|
||||
|
||||
@@ -9,6 +9,7 @@ use tokio::net::TcpStream;
|
||||
|
||||
use crate::daemon::protocol::{NativeSshSpec, SshAlgorithms, SshProxy};
|
||||
|
||||
use super::known_hosts;
|
||||
use super::session::SshConnection;
|
||||
|
||||
pub enum Transport {
|
||||
@@ -369,8 +370,11 @@ async fn http_connect(
|
||||
}
|
||||
|
||||
pub fn build_config(spec: &NativeSshSpec) -> Arc<russh::client::Config> {
|
||||
// Every hop comes through here — the target and each jump host build their
|
||||
// own config from their own spec — so each asks known_hosts about itself.
|
||||
let known = known_hosts::known_algorithms(&spec.host, spec.port);
|
||||
let mut cfg = russh::client::Config {
|
||||
preferred: build_preferred(&spec.algorithms),
|
||||
preferred: build_preferred(&spec.algorithms, &known),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(iv) = spec.keepalive_interval_s.filter(|v| *v > 0) {
|
||||
@@ -382,7 +386,10 @@ pub fn build_config(spec: &NativeSshSpec) -> Arc<russh::client::Config> {
|
||||
Arc::new(cfg)
|
||||
}
|
||||
|
||||
fn build_preferred(a: &SshAlgorithms) -> russh::Preferred {
|
||||
fn build_preferred(
|
||||
a: &SshAlgorithms,
|
||||
known_host_keys: &[russh::keys::Algorithm],
|
||||
) -> russh::Preferred {
|
||||
let mut p = russh::Preferred::DEFAULT;
|
||||
if !a.kex.is_empty() {
|
||||
let v: Vec<russh::kex::Name> = a
|
||||
@@ -423,6 +430,21 @@ fn build_preferred(a: &SshAlgorithms) -> russh::Preferred {
|
||||
if !v.is_empty() {
|
||||
p.key = Cow::Owned(v);
|
||||
}
|
||||
} else if !known_host_keys.is_empty() {
|
||||
// What OpenSSH's `order_hostkeyalgs()` does: offer the algorithms this
|
||||
// host already has entries for ahead of the rest, so a server that has
|
||||
// both an ssh-rsa key on file and an ed25519 key on offer answers with
|
||||
// the one the user has already accepted. Without it the default order
|
||||
// picks ed25519, and a host known only by ssh-rsa raises a
|
||||
// key-you-have-never-seen prompt on every single connection.
|
||||
//
|
||||
// Reordering only, never filtering — a host whose keys have genuinely
|
||||
// rotated must still be able to negotiate — and skipped entirely when
|
||||
// the user pinned `HostKeyAlgorithms`, which OpenSSH also treats as the
|
||||
// last word.
|
||||
let mut ordered: Vec<russh::keys::Algorithm> = p.key.iter().cloned().collect();
|
||||
ordered.sort_by_key(|alg| !known_host_keys.iter().any(|k| same_key_type(k, alg)));
|
||||
p.key = Cow::Owned(ordered);
|
||||
}
|
||||
if !a.compression.is_empty() {
|
||||
let v: Vec<russh::compression::Name> = a
|
||||
@@ -437,6 +459,21 @@ fn build_preferred(a: &SshAlgorithms) -> russh::Preferred {
|
||||
p
|
||||
}
|
||||
|
||||
/// Whether two host-key algorithms stand for the same *key*.
|
||||
///
|
||||
/// An `ssh-rsa` line in known_hosts parses to `Rsa { hash: None }`, but what
|
||||
/// gets negotiated is one of `rsa-sha2-512`, `rsa-sha2-256` or `ssh-rsa` — three
|
||||
/// signature algorithms over one key. Comparing with `==` would float only the
|
||||
/// SHA-1 spelling to the front of the list, which is a downgrade dressed up as a
|
||||
/// preference; OpenSSH's `order_hostkeyalgs()` matches on the key type too.
|
||||
fn same_key_type(a: &russh::keys::Algorithm, b: &russh::keys::Algorithm) -> bool {
|
||||
use russh::keys::Algorithm;
|
||||
match (a, b) {
|
||||
(Algorithm::Rsa { .. }, Algorithm::Rsa { .. }) => true,
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -473,9 +510,10 @@ mod tests {
|
||||
#[test]
|
||||
fn build_preferred_keeps_defaults_for_empty_lists() {
|
||||
let a = SshAlgorithms::default();
|
||||
let p = build_preferred(&a);
|
||||
let p = build_preferred(&a, &[]);
|
||||
assert_eq!(p.kex, russh::Preferred::DEFAULT.kex);
|
||||
assert_eq!(p.cipher, russh::Preferred::DEFAULT.cipher);
|
||||
assert_eq!(p.key, russh::Preferred::DEFAULT.key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -484,8 +522,61 @@ mod tests {
|
||||
cipher: vec!["totally-not-a-cipher".into(), "aes256-ctr".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let p = build_preferred(&a);
|
||||
let p = build_preferred(&a, &[]);
|
||||
let aes = russh::cipher::Name::try_from("aes256-ctr").unwrap();
|
||||
assert_eq!(p.cipher.as_ref(), &[aes]);
|
||||
}
|
||||
|
||||
/// The default order leads with ed25519, so a host known only by an
|
||||
/// ssh-rsa key was greeted with a key it had never seen on every connect.
|
||||
#[test]
|
||||
fn build_preferred_floats_known_hosts_algorithms_to_the_front() {
|
||||
let rsa = russh::keys::Algorithm::Rsa { hash: None };
|
||||
let p = build_preferred(&SshAlgorithms::default(), &[rsa]);
|
||||
// All three RSA spellings sign for the one key on file, so all three
|
||||
// lead — anything less would pin the host to SHA-1 signatures.
|
||||
assert!(
|
||||
p.key
|
||||
.iter()
|
||||
.take(3)
|
||||
.all(|a| matches!(a, russh::keys::Algorithm::Rsa { .. })),
|
||||
"expected the RSA spellings first, got {:?}",
|
||||
p.key
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_preferred_reordering_never_drops_an_algorithm() {
|
||||
let rsa = russh::keys::Algorithm::Rsa { hash: None };
|
||||
let p = build_preferred(&SshAlgorithms::default(), &[rsa]);
|
||||
let mut got: Vec<String> = p.key.iter().map(|a| a.to_string()).collect();
|
||||
let mut want: Vec<String> = russh::Preferred::DEFAULT
|
||||
.key
|
||||
.iter()
|
||||
.map(|a| a.to_string())
|
||||
.collect();
|
||||
got.sort();
|
||||
want.sort();
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
/// OpenSSH stops reordering once `HostKeyAlgorithms` is explicit, because
|
||||
/// the user has already said what order they want.
|
||||
#[test]
|
||||
fn build_preferred_leaves_a_pinned_host_key_list_alone() {
|
||||
let a = SshAlgorithms {
|
||||
host_key: vec!["ssh-ed25519".into(), "rsa-sha2-512".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let p = build_preferred(&a, &[russh::keys::Algorithm::Rsa { hash: None }]);
|
||||
assert_eq!(
|
||||
p.key.as_ref(),
|
||||
&[
|
||||
russh::keys::Algorithm::Ed25519,
|
||||
russh::keys::Algorithm::Rsa {
|
||||
hash: Some(russh::keys::HashAlg::Sha512)
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,22 @@ impl ClientHandler {
|
||||
remember,
|
||||
} => {
|
||||
if remember {
|
||||
if let Err(e) = known_hosts::append_trusted(&self.host, self.port, key) {
|
||||
log::warn!("failed to record host key in known_hosts: {e}");
|
||||
// The superseded line has to go before the new one lands.
|
||||
// `known_hosts::check` answers `Known` on any
|
||||
// same-algorithm match, so an override that only appended
|
||||
// left the key the user had just rejected trusted for good.
|
||||
// If it cannot be dropped, do not append either: being
|
||||
// asked again next time is the better half of that trade.
|
||||
match known_hosts::forget_superseded(&self.host, self.port, key) {
|
||||
Ok(()) => {
|
||||
if let Err(e) = known_hosts::append_trusted(&self.host, self.port, key)
|
||||
{
|
||||
log::warn!("failed to record host key in known_hosts: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"not recording host key: the superseded known_hosts line could not be removed: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -75,6 +89,28 @@ impl russh::client::Handler for ClientHandler {
|
||||
port: self.port,
|
||||
algorithm,
|
||||
fingerprint_sha256,
|
||||
previously_known_as: None,
|
||||
})
|
||||
.await;
|
||||
Ok(self.apply_decision(resp, server_public_key))
|
||||
}
|
||||
// Deliberately the unknown-host prompt and not a variant of its
|
||||
// own: `AuthPromptKind` crosses to the GUI *and* to whatever
|
||||
// `tty7-server` the far end happens to be running, and a new
|
||||
// externally-tagged variant is a hard decode failure on any peer
|
||||
// that predates it. The extra field is additive in both
|
||||
// directions.
|
||||
HostKeyStatus::ChangedAlgorithm {
|
||||
known_algorithm, ..
|
||||
} => {
|
||||
let resp = self
|
||||
.broker
|
||||
.prompt(AuthPromptKind::HostKeyUnknown {
|
||||
host: self.host.clone(),
|
||||
port: self.port,
|
||||
algorithm,
|
||||
fingerprint_sha256,
|
||||
previously_known_as: Some(known_algorithm),
|
||||
})
|
||||
.await;
|
||||
Ok(self.apply_decision(resp, server_public_key))
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use russh::keys::ssh_key::{HashAlg, PublicKey};
|
||||
use russh::keys::ssh_key::{Algorithm, HashAlg, PublicKey};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HostKeyStatus {
|
||||
Known,
|
||||
Unknown,
|
||||
Changed { old_fingerprint_sha256: String },
|
||||
Changed {
|
||||
old_fingerprint_sha256: String,
|
||||
},
|
||||
/// The host is on file, but only under some *other* key algorithm.
|
||||
///
|
||||
/// A server that grows an ed25519 key beside the ssh-rsa one it has always
|
||||
/// had has not been tampered with, and OpenSSH says so: it treats a key of
|
||||
/// an algorithm the host has no entry for as simply unknown, and saves the
|
||||
/// man-in-the-middle warning for a key that contradicts one on file. The
|
||||
/// algorithm travels with the status so the confirmation can name what the
|
||||
/// host was known by.
|
||||
ChangedAlgorithm {
|
||||
known_fingerprint_sha256: String,
|
||||
known_algorithm: String,
|
||||
},
|
||||
Revoked,
|
||||
}
|
||||
|
||||
@@ -71,7 +85,7 @@ pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> H
|
||||
}
|
||||
|
||||
let mut changed: Option<String> = None;
|
||||
let mut changed_other_alg: Option<String> = None;
|
||||
let mut changed_other_alg: Option<(String, String)> = None;
|
||||
for line in contents.lines() {
|
||||
let Some(entry) = KnownHostsLine::parse(line) else {
|
||||
continue;
|
||||
@@ -84,9 +98,11 @@ pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> H
|
||||
Some(Marker::Revoked) => continue,
|
||||
None => {
|
||||
let Some(stored) = entry.key() else { continue };
|
||||
if stored.algorithm() != our_alg {
|
||||
let stored_alg = stored.algorithm();
|
||||
if stored_alg != our_alg {
|
||||
if changed_other_alg.is_none() {
|
||||
changed_other_alg = Some(fingerprint_sha256(&stored));
|
||||
changed_other_alg =
|
||||
Some((fingerprint_sha256(&stored), stored_alg.as_str().to_string()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -100,14 +116,62 @@ pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> H
|
||||
}
|
||||
}
|
||||
|
||||
match changed.or(changed_other_alg) {
|
||||
Some(old_fingerprint_sha256) => HostKeyStatus::Changed {
|
||||
// A same-algorithm contradiction outranks everything else on file: the host
|
||||
// has an entry that says this key is wrong, and no amount of other-algorithm
|
||||
// company softens that.
|
||||
if let Some(old_fingerprint_sha256) = changed {
|
||||
return HostKeyStatus::Changed {
|
||||
old_fingerprint_sha256,
|
||||
};
|
||||
}
|
||||
match changed_other_alg {
|
||||
Some((known_fingerprint_sha256, known_algorithm)) => HostKeyStatus::ChangedAlgorithm {
|
||||
known_fingerprint_sha256,
|
||||
known_algorithm,
|
||||
},
|
||||
None => HostKeyStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// The host-key algorithms this host already has entries for, in file order.
|
||||
///
|
||||
/// Negotiation reads this so the algorithms already on file are offered first —
|
||||
/// see `connect::build_preferred`. Markers are skipped: a `@cert-authority` line
|
||||
/// names the authority's key rather than the host's, and a `@revoked` one names
|
||||
/// a key that would be refused, so neither predicts what the host will present.
|
||||
pub fn known_algorithms(host: &str, port: u16) -> Vec<Algorithm> {
|
||||
match default_path() {
|
||||
Some(path) => known_algorithms_in_file(&path, host, port),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn known_algorithms_in_file(path: &Path, host: &str, port: u16) -> Vec<Algorithm> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(contents) => known_algorithms_in_str(&contents, host, port),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn known_algorithms_in_str(contents: &str, host: &str, port: u16) -> Vec<Algorithm> {
|
||||
let token = host_token(host, port);
|
||||
let mut out: Vec<Algorithm> = Vec::new();
|
||||
for line in contents.lines() {
|
||||
let Some(entry) = KnownHostsLine::parse(line) else {
|
||||
continue;
|
||||
};
|
||||
if entry.marker.is_some() || !entry.matches_host(&token) {
|
||||
continue;
|
||||
}
|
||||
let Some(stored) = entry.key() else { continue };
|
||||
let alg = stored.algorithm();
|
||||
if !out.contains(&alg) {
|
||||
out.push(alg);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn append_trusted(host: &str, port: u16, key: &PublicKey) -> std::io::Result<()> {
|
||||
let path = default_path().ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "no home dir for known_hosts")
|
||||
@@ -252,6 +316,75 @@ pub fn delete_in_str(contents: &str, id: &KnownHostId) -> (String, bool) {
|
||||
(out, removed)
|
||||
}
|
||||
|
||||
/// Drop the lines `key` replaces before it is appended for `host`.
|
||||
///
|
||||
/// Appending on its own is not enough when the user overrides a *changed* key:
|
||||
/// `check_in_str` answers `Known` on any same-algorithm match, so the line the
|
||||
/// new key contradicts — the one the user just decided was wrong, and which in
|
||||
/// the case the warning exists for is an attacker's — would go on being trusted
|
||||
/// forever, with no warning ever shown again. OpenSSH rewrites the file for the
|
||||
/// same reason.
|
||||
///
|
||||
/// A no-op for a host that was merely unknown, which by definition has no
|
||||
/// same-algorithm line to drop.
|
||||
pub fn forget_superseded(host: &str, port: u16, key: &PublicKey) -> std::io::Result<()> {
|
||||
match default_path() {
|
||||
Some(path) => forget_superseded_in_file(&path, host, port, key),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forget_superseded_in_file(
|
||||
path: &Path,
|
||||
host: &str,
|
||||
port: u16,
|
||||
key: &PublicKey,
|
||||
) -> std::io::Result<()> {
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
for id in superseded_ids_in_str(&contents, host, port, key) {
|
||||
delete_in_file(path, &id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn superseded_ids_in_str(
|
||||
contents: &str,
|
||||
host: &str,
|
||||
port: u16,
|
||||
key: &PublicKey,
|
||||
) -> Vec<KnownHostId> {
|
||||
let token = host_token(host, port);
|
||||
let our_alg = key.algorithm();
|
||||
let mut out = Vec::new();
|
||||
for line in contents.lines() {
|
||||
let Some(entry) = KnownHostsLine::parse(line) else {
|
||||
continue;
|
||||
};
|
||||
// Only a plain line for this one host: a `@revoked` line is a standing
|
||||
// refusal that an override of a different key must not lift, and a glob
|
||||
// or a comma list also speaks for hosts nobody is connecting to — the
|
||||
// rest of `*.example.com` should not be forgotten because one machine
|
||||
// behind it rotated its key.
|
||||
if entry.marker.is_some() || !entry.names_only_host(&token) {
|
||||
continue;
|
||||
}
|
||||
let Some(stored) = entry.key() else { continue };
|
||||
if stored.algorithm() != our_alg || &stored == key {
|
||||
continue;
|
||||
}
|
||||
out.push(KnownHostId {
|
||||
host: entry.hosts.to_string(),
|
||||
key_type: entry.keytype.to_string(),
|
||||
keyblob: entry.keyblob.to_string(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn split_keep_terminators(text: &str) -> Vec<&str> {
|
||||
let mut segments = Vec::new();
|
||||
let mut start = 0;
|
||||
@@ -314,6 +447,29 @@ impl<'a> KnownHostsLine<'a> {
|
||||
PublicKey::from_openssh(&format!("{} {}", self.keytype, self.keyblob)).ok()
|
||||
}
|
||||
|
||||
/// Whether this line's host field names `token` and nothing else.
|
||||
///
|
||||
/// `matches_host` is the right question for "does this entry apply here";
|
||||
/// this is the stricter one to ask before *deleting* a line, because a glob
|
||||
/// or a comma list carries other hosts with it. A hashed pattern names
|
||||
/// exactly one token, so it passes.
|
||||
fn names_only_host(&self, token: &str) -> bool {
|
||||
if self.hosts.contains(',') {
|
||||
return false;
|
||||
}
|
||||
let pattern = self.hosts.trim();
|
||||
match pattern.strip_prefix("|1|") {
|
||||
Some(hashed) => hashed_host_matches(hashed, token),
|
||||
None => {
|
||||
!pattern
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.any(|&b| b == b'*' || b == b'?' || b == b'!')
|
||||
&& pattern.eq_ignore_ascii_case(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_host(&self, token: &str) -> bool {
|
||||
let mut matched = false;
|
||||
for pattern in self.hosts.split(',') {
|
||||
@@ -555,13 +711,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
const KEY_ECDSA: &str = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCdv5xfuuCGyVbYZSTqcFjQWE7YtIsx8fqlXF1+v728j1RUnELLVrmgsC6gZ0zObXAzJ39JEynaQv9tf/v16V58=";
|
||||
|
||||
/// Not `Changed`, which is the man-in-the-middle alarm: a host that has
|
||||
/// grown a second key type has not contradicted anything on file, and
|
||||
/// OpenSSH asks the same mild question it asks about any unseen key.
|
||||
#[test]
|
||||
fn different_key_type_for_a_known_host_reports_changed_not_unknown() {
|
||||
const KEY_ECDSA: &str = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCdv5xfuuCGyVbYZSTqcFjQWE7YtIsx8fqlXF1+v728j1RUnELLVrmgsC6gZ0zObXAzJ39JEynaQv9tf/v16V58=";
|
||||
fn a_key_of_a_new_algorithm_is_flagged_as_the_algorithm_being_new() {
|
||||
let file = format!("example.com {KEY_A}\n");
|
||||
match check_in_str(&file, "example.com", 22, &key(KEY_ECDSA)) {
|
||||
HostKeyStatus::Changed { .. } => {}
|
||||
other => panic!("expected Changed, got {other:?}"),
|
||||
HostKeyStatus::ChangedAlgorithm {
|
||||
known_algorithm, ..
|
||||
} => assert_eq!(known_algorithm, "ssh-ed25519"),
|
||||
other => panic!("expected ChangedAlgorithm, got {other:?}"),
|
||||
}
|
||||
let file = format!("example.com {KEY_A}\nexample.com {KEY_ECDSA}\n");
|
||||
assert_eq!(
|
||||
@@ -570,6 +732,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_key_of_the_same_algorithm_is_still_a_change() {
|
||||
let file = format!("example.com {KEY_A}\n");
|
||||
match check_in_str(&file, "example.com", 22, &key(KEY_B)) {
|
||||
HostKeyStatus::Changed {
|
||||
old_fingerprint_sha256,
|
||||
} => assert_eq!(old_fingerprint_sha256, fingerprint_sha256(&key(KEY_A))),
|
||||
other => panic!("expected Changed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The downgrade this split has to not open: an attacker offering a key of
|
||||
/// an algorithm the host also has an entry for gets the full alarm, however
|
||||
/// many other-algorithm lines sit alongside it.
|
||||
#[test]
|
||||
fn a_same_algorithm_mismatch_outranks_an_other_algorithm_entry() {
|
||||
let file = format!("example.com {KEY_ECDSA}\nexample.com {KEY_A}\n");
|
||||
match check_in_str(&file, "example.com", 22, &key(KEY_B)) {
|
||||
HostKeyStatus::Changed { .. } => {}
|
||||
other => panic!("expected Changed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_algorithms_lists_each_algorithm_once_in_file_order() {
|
||||
let file = format!(
|
||||
"example.com {KEY_ECDSA}\nexample.com {KEY_A}\nexample.com {KEY_B}\nother.com {KEY_A}\n"
|
||||
);
|
||||
let algs = known_algorithms_in_str(&file, "example.com", 22);
|
||||
assert_eq!(
|
||||
algs.iter().map(|a| a.as_str()).collect::<Vec<_>>(),
|
||||
vec!["ecdsa-sha2-nistp256", "ssh-ed25519"]
|
||||
);
|
||||
assert!(known_algorithms_in_str(&file, "nowhere.com", 22).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_algorithms_ignores_revoked_and_cert_authority_lines() {
|
||||
let file = format!("@revoked example.com {KEY_A}\n@cert-authority example.com {KEY_B}\n");
|
||||
assert!(known_algorithms_in_str(&file, "example.com", 22).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_default_port_uses_bracket_syntax() {
|
||||
let ka = key(KEY_A);
|
||||
@@ -771,6 +975,58 @@ mod tests {
|
||||
assert_eq!(after, contents);
|
||||
}
|
||||
|
||||
/// Overriding a changed key used to append and leave the old line in
|
||||
/// place, and `check_in_str` answers `Known` on any same-algorithm match —
|
||||
/// so the key the user had just refused stayed trusted, silently, forever.
|
||||
#[test]
|
||||
fn overriding_a_changed_key_stops_trusting_the_one_it_replaces() {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-kh-supersede-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("known_hosts");
|
||||
std::fs::write(&path, format!("example.com {KEY_A}\n")).unwrap();
|
||||
|
||||
let kb = key(KEY_B);
|
||||
forget_superseded_in_file(&path, "example.com", 22, &kb).unwrap();
|
||||
append_trusted_to(&path, "example.com", 22, &kb).unwrap();
|
||||
|
||||
let contents = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(
|
||||
!contents.contains(KEY_A.split_whitespace().nth(1).unwrap()),
|
||||
"the superseded key is still on file: {contents}"
|
||||
);
|
||||
assert_eq!(
|
||||
check_in_str(&contents, "example.com", 22, &kb),
|
||||
HostKeyStatus::Known
|
||||
);
|
||||
assert!(matches!(
|
||||
check_in_str(&contents, "example.com", 22, &key(KEY_A)),
|
||||
HostKeyStatus::Changed { .. }
|
||||
));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_superseded_by_a_key_of_another_algorithm_or_another_host() {
|
||||
let file = format!("example.com {KEY_A}\nother.com {KEY_B}\n");
|
||||
assert!(superseded_ids_in_str(&file, "example.com", 22, &key(KEY_ECDSA)).is_empty());
|
||||
assert!(superseded_ids_in_str(&file, "example.com", 22, &key(KEY_A)).is_empty());
|
||||
assert_eq!(
|
||||
superseded_ids_in_str(&file, "example.com", 22, &key(KEY_B)).len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// A wildcard or comma-list line speaks for hosts nobody is connecting to,
|
||||
/// and a `@revoked` line is a standing refusal — one host's override must
|
||||
/// not quietly drop either.
|
||||
#[test]
|
||||
fn superseding_never_touches_a_shared_or_revoked_line() {
|
||||
let file = format!(
|
||||
"*.example.com {KEY_A}\nweb.example.com,db.example.com {KEY_A}\n@revoked web.example.com {KEY_A}\n"
|
||||
);
|
||||
assert!(superseded_ids_in_str(&file, "web.example.com", 22, &key(KEY_B)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_matcher_edge_cases() {
|
||||
assert!(glob_match(b"*", b""));
|
||||
|
||||
@@ -869,6 +869,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::FileDropFailedMany => "Could not copy {name}, and {n} more failed",
|
||||
L10nKey::SshPromptNewKey => "new {fingerprint}",
|
||||
L10nKey::SshPromptOldKey => "old {old_fingerprint}",
|
||||
L10nKey::SshPromptHostKeyNewAlgorithm => {
|
||||
"You already know this host by a {previous_algorithm} key. This is a new \
|
||||
{algorithm} key, not a replacement for that one."
|
||||
}
|
||||
L10nKey::EditorCantOpen => "Could not open {path}: {e}",
|
||||
L10nKey::EditorCantRead => "Could not read {path}: {e}",
|
||||
L10nKey::EditorNotUtf8 => "\"{path}\" is not valid UTF-8",
|
||||
|
||||
@@ -911,6 +911,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::FileDropFailedMany => "{name} をコピーできませんでした。他に {n} 件も失敗しました",
|
||||
L10nKey::SshPromptNewKey => "新しいキー {fingerprint}",
|
||||
L10nKey::SshPromptOldKey => "以前のキー {old_fingerprint}",
|
||||
L10nKey::SshPromptHostKeyNewAlgorithm => {
|
||||
"このホストはすでに {previous_algorithm} キーで登録されています。これはそれを置き換えるものではなく、新しい {algorithm} キーです"
|
||||
}
|
||||
L10nKey::EditorCantOpen => "{path} を開けません: {e}",
|
||||
L10nKey::EditorCantRead => "{path} を読み取れません: {e}",
|
||||
L10nKey::EditorNotUtf8 => "「{path}」は有効な UTF-8 ではありません",
|
||||
|
||||
@@ -669,6 +669,7 @@ l10n_keys! {
|
||||
FileDropFailedMany,
|
||||
SshPromptNewKey,
|
||||
SshPromptOldKey,
|
||||
SshPromptHostKeyNewAlgorithm,
|
||||
EditorCantOpen,
|
||||
EditorCantRead,
|
||||
EditorNotUtf8,
|
||||
|
||||
@@ -832,6 +832,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::FileDropFailedMany => "无法复制 {name},另有 {n} 个也失败了",
|
||||
L10nKey::SshPromptNewKey => "新 {fingerprint}",
|
||||
L10nKey::SshPromptOldKey => "旧 {old_fingerprint}",
|
||||
L10nKey::SshPromptHostKeyNewAlgorithm => {
|
||||
"你已经通过一把 {previous_algorithm} 密钥认识这台主机。这是一把新的 {algorithm} 密钥,并不是用来替换那一把的。"
|
||||
}
|
||||
L10nKey::EditorCantOpen => "无法打开 {path}:{e}",
|
||||
L10nKey::EditorCantRead => "无法读取 {path}:{e}",
|
||||
L10nKey::EditorNotUtf8 => "“{path}”不是有效的 UTF-8",
|
||||
|
||||
@@ -41,6 +41,9 @@ pub(crate) enum PromptModel {
|
||||
port: u16,
|
||||
algorithm: String,
|
||||
fingerprint: String,
|
||||
/// Set when the host is already on file under some other algorithm, so
|
||||
/// the sheet can say why a known host is offering an unseen key.
|
||||
previously_known_as: Option<String>,
|
||||
},
|
||||
HostKeyChanged {
|
||||
host: String,
|
||||
@@ -108,11 +111,13 @@ impl PromptModel {
|
||||
port,
|
||||
algorithm,
|
||||
fingerprint_sha256,
|
||||
previously_known_as,
|
||||
} => PromptModel::HostKeyUnknown {
|
||||
host,
|
||||
port,
|
||||
algorithm,
|
||||
fingerprint: fingerprint_sha256,
|
||||
previously_known_as,
|
||||
},
|
||||
AuthPromptKind::HostKeyChanged {
|
||||
host,
|
||||
@@ -730,6 +735,7 @@ impl Tty7App {
|
||||
fingerprint,
|
||||
port,
|
||||
host,
|
||||
previously_known_as,
|
||||
} => card
|
||||
.child(div().text_xs().child(format!("{host}:{port} {algorithm}")))
|
||||
.child(
|
||||
@@ -738,6 +744,21 @@ impl Tty7App {
|
||||
.font_family("monospace")
|
||||
.child(fingerprint.clone()),
|
||||
)
|
||||
// A host that already has an entry under another algorithm is
|
||||
// the ordinary way a server grows an ed25519 key beside its old
|
||||
// ssh-rsa one. Saying so is the difference between "who is
|
||||
// this?" and "this is the host you know, with a second key".
|
||||
.when_some(previously_known_as.as_ref(), |c, previous| {
|
||||
c.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(crate::ui::i18n::t_fmt(
|
||||
crate::ui::i18n::L10nKey::SshPromptHostKeyNewAlgorithm,
|
||||
&[("previous_algorithm", previous), ("algorithm", algorithm)],
|
||||
)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
@@ -1024,6 +1045,35 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The host is known, just not by this algorithm — the mild confirmation,
|
||||
/// carrying the algorithm it *is* known by, and never the danger sheet.
|
||||
#[test]
|
||||
fn a_new_algorithm_raises_the_unknown_host_sheet_not_the_changed_one() {
|
||||
let m = PromptModel::from_prompt(
|
||||
AuthPromptKind::HostKeyUnknown {
|
||||
host: "example.com".into(),
|
||||
port: 22,
|
||||
algorithm: "ssh-ed25519".into(),
|
||||
fingerprint_sha256: "SHA256:new".into(),
|
||||
previously_known_as: Some("ssh-rsa".into()),
|
||||
},
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
m,
|
||||
PromptModel::HostKeyUnknown {
|
||||
host: "example.com".into(),
|
||||
port: 22,
|
||||
algorithm: "ssh-ed25519".into(),
|
||||
fingerprint: "SHA256:new".into(),
|
||||
previously_known_as: Some("ssh-rsa".into()),
|
||||
}
|
||||
);
|
||||
assert_eq!(m.input_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1071,6 +1121,7 @@ mod focus_tests {
|
||||
port: 22,
|
||||
algorithm: "ssh-ed25519".into(),
|
||||
fingerprint: "SHA256:zzz".into(),
|
||||
previously_known_as: None,
|
||||
});
|
||||
window.focus(&app.ssh_prompt.focus_handle, cx);
|
||||
// A headless harness has no live pane, so `focus_active`
|
||||
|
||||
Reference in New Issue
Block a user