diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 20dfe4ca..fa952747 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -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, }, 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()); diff --git a/crates/tty7-core/src/daemon/ssh/connect.rs b/crates/tty7-core/src/daemon/ssh/connect.rs index b0957fd7..cc1e56ac 100644 --- a/crates/tty7-core/src/daemon/ssh/connect.rs +++ b/crates/tty7-core/src/daemon/ssh/connect.rs @@ -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 { + // 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 { 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 = 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 = 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 = 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 = p.key.iter().map(|a| a.to_string()).collect(); + let mut want: Vec = 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) + }, + ] + ); + } } diff --git a/crates/tty7-core/src/daemon/ssh/handler.rs b/crates/tty7-core/src/daemon/ssh/handler.rs index 56a2063f..170b336a 100644 --- a/crates/tty7-core/src/daemon/ssh/handler.rs +++ b/crates/tty7-core/src/daemon/ssh/handler.rs @@ -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)) diff --git a/crates/tty7-core/src/daemon/ssh/known_hosts.rs b/crates/tty7-core/src/daemon/ssh/known_hosts.rs index 0eb7c066..641186a4 100644 --- a/crates/tty7-core/src/daemon/ssh/known_hosts.rs +++ b/crates/tty7-core/src/daemon/ssh/known_hosts.rs @@ -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 = None; - let mut changed_other_alg: Option = 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 { + 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 { + 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 { + let token = host_token(host, port); + let mut out: Vec = 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 { + 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!["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"")); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 6914debd..66f1f775 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -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", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index a8b2cccb..a4316705 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -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 ではありません", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 489d29f7..d97932f6 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -669,6 +669,7 @@ l10n_keys! { FileDropFailedMany, SshPromptNewKey, SshPromptOldKey, + SshPromptHostKeyNewAlgorithm, EditorCantOpen, EditorCantRead, EditorNotUtf8, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index ec546971..0bbd80c1 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -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", diff --git a/src/ui/ssh_prompt.rs b/src/ui/ssh_prompt.rs index 52346bd9..26c9b50d 100644 --- a/src/ui/ssh_prompt.rs +++ b/src/ui/ssh_prompt.rs @@ -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, }, 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`