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:
l0ng-ai
2026-08-11 19:58:43 +08:00
parent 0f5e63701e
commit fd44c13cb9
9 changed files with 495 additions and 17 deletions
+33
View File
@@ -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());
+95 -4
View File
@@ -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)
},
]
);
}
}
+38 -2
View File
@@ -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))
+267 -11
View File
@@ -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""));
+4
View File
@@ -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",
+3
View File
@@ -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 ではありません",
+1
View File
@@ -669,6 +669,7 @@ l10n_keys! {
FileDropFailedMany,
SshPromptNewKey,
SshPromptOldKey,
SshPromptHostKeyNewAlgorithm,
EditorCantOpen,
EditorCantRead,
EditorNotUtf8,
+3
View File
@@ -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",
+51
View File
@@ -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`