Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions linux/crates/app/src/ui/error_text/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ pub fn ssh_failure_message(failure: &SshFailure) -> String {
"The host key of {host} was not trusted, so the connection was not made.",
&[("host", host)],
),
// ssh refuses under StrictHostKeyChecking yes before it shows the
// key, so there is no fingerprint and nothing to review.
SshFailure::HostKeyUnknown {
host,
trust_allowed: false,
..
} => crate::i18n::gettext_f(
"The SSH configuration for {host} accepts only keys already in known_hosts. Add the server's key to known_hosts outside TablePro, then retry.",
&[("host", host)],
),
SshFailure::HostKeyUnknown {
host,
sha256_fingerprint,
..
} if sha256_fingerprint.is_empty() => crate::i18n::gettext_f(
"{host} is not in known_hosts. Connect once to review and trust its key.",
&[("host", host)],
),
SshFailure::HostKeyUnknown {
host,
sha256_fingerprint,
Expand Down
29 changes: 29 additions & 0 deletions linux/crates/app/src/ui/error_text/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ fn ssh_failures_say_what_to_change() {
sha256_fingerprint: "SHA256:new".to_owned(),
known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"),
line: 7,
trust_allowed: true,
},
)));
assert!(changed.contains("known_hosts"), "{changed}");
Expand All @@ -285,6 +286,34 @@ fn ssh_failures_say_what_to_change() {
assert!(declined.contains("not trusted"), "{declined}");
}

/// Under StrictHostKeyChecking yes ssh refuses before it shows the key,
/// so there is no fingerprint to print and no review the user can do.
#[test]
fn an_unknown_key_says_what_the_configuration_allows() {
let unknown = |sha256_fingerprint: &str, trust_allowed: bool| {
ssh_failure_message(&SshFailure::HostKeyUnknown {
host: "[bastion]:2222".to_owned(),
algorithm: "ED25519".to_owned(),
sha256_fingerprint: sha256_fingerprint.to_owned(),
trust_allowed,
declined: false,
})
};
let strict = unknown("", false);
let unseen = unknown("", true);
let reviewable = unknown("SHA256:abc", true);

for message in [&strict, &unseen] {
assert!(message.contains("[bastion]:2222"), "{message}");
assert!(!message.contains("key )") && !message.contains("(key"), "{message}");
}
assert!(strict.contains("known_hosts outside TablePro"), "{strict}");
assert!(!strict.contains("review"), "{strict}");
assert!(unseen.contains("review"), "{unseen}");
assert!(reviewable.contains("(key SHA256:abc)"), "{reviewable}");
assert!(reviewable.contains("review"), "{reviewable}");
}

#[test]
fn connect_error_text_covers_every_variant() {
let errors = [
Expand Down
10 changes: 8 additions & 2 deletions linux/crates/app/src/ui/host_key_failure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ pub(crate) enum HostKeyFailureKind {
Unknown {
trust_allowed: bool,
},
/// The old key is at `line` of `known_hosts`.
/// The old key is at `line` of `known_hosts`. `trust_allowed` is as
/// for `Unknown`, for the new key once the old one is removed.
Changed {
known_hosts: PathBuf,
line: usize,
trust_allowed: bool,
},
Revoked {
detail: String,
Expand Down Expand Up @@ -63,13 +65,15 @@ impl HostKeyFailure {
sha256_fingerprint,
known_hosts,
line,
trust_allowed,
} => Some(Self {
host: host.clone(),
algorithm: algorithm.clone(),
fingerprint: sha256_fingerprint.clone(),
kind: HostKeyFailureKind::Changed {
known_hosts: known_hosts.clone(),
line: *line,
trust_allowed: *trust_allowed,
},
}),
SshFailure::HostKeyRevoked { host, detail } => Some(Self {
Expand Down Expand Up @@ -108,6 +112,7 @@ mod tests {
sha256_fingerprint: "SHA256:new".to_owned(),
known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"),
line: 3,
trust_allowed: false,
});
let revoked = ssh(SshFailure::HostKeyRevoked {
host: "bastion".to_owned(),
Expand All @@ -125,7 +130,8 @@ mod tests {
changed.kind,
HostKeyFailureKind::Changed {
known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"),
line: 3
line: 3,
trust_allowed: false,
}
);
assert!(matches!(
Expand Down
62 changes: 60 additions & 2 deletions linux/crates/app/src/ui/host_key_review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,29 @@ pub(crate) fn host_key_dialog_spec(failure: &HostKeyFailure) -> HostKeyDialogSpe
&[("host", host)],
),
),
// ssh would refuse the new key without asking once the old one is
// gone, so a replace would only lose the old key.
HostKeyFailureKind::Changed {
known_hosts,
line,
trust_allowed: false,
} => fixed_elsewhere(
crate::i18n::gettext("SSH Host Key Changed"),
match known_hosts.as_os_str().is_empty() {
true => crate::i18n::gettext_f(
"The key “{host}” sent is not the one trusted in known_hosts. Someone may be intercepting the connection, or the server was reinstalled. The SSH configuration for this host accepts only keys already in known_hosts, so if your server administrator confirms the new fingerprint, replace the key there yourself, then retry.",
&[("host", host)],
),
false => crate::i18n::gettext_f(
"The key “{host}” sent is not the one trusted in {file}, line {line}. Someone may be intercepting the connection, or the server was reinstalled. The SSH configuration for this host accepts only keys already in known_hosts, so if your server administrator confirms the new fingerprint, replace the key in that file yourself, then retry.",
&[
("host", host),
("file", &known_hosts.display().to_string()),
("line", &line.to_string()),
],
),
},
),
// Without the file ssh named, there is no key to replace.
HostKeyFailureKind::Changed { known_hosts, .. } if known_hosts.as_os_str().is_empty() => fixed_elsewhere(
crate::i18n::gettext("SSH Host Key Changed"),
Expand All @@ -109,7 +132,7 @@ pub(crate) fn host_key_dialog_spec(failure: &HostKeyFailure) -> HostKeyDialogSpe
&[("host", host)],
),
),
HostKeyFailureKind::Changed { known_hosts, line } => HostKeyDialogSpec {
HostKeyFailureKind::Changed { known_hosts, line, .. } => HostKeyDialogSpec {
heading: crate::i18n::gettext("SSH Host Key Changed"),
body: crate::i18n::gettext_f(
"The key “{host}” sent is not the one trusted in {file}, line {line}. Someone may be intercepting the connection, or the server was reinstalled. Replace the key only if your server administrator confirms the new fingerprint.",
Expand Down Expand Up @@ -237,6 +260,23 @@ mod tests {
HostKeyFailureKind::Changed {
known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"),
line: 3,
trust_allowed: true,
},
"SHA256:new",
));
let strict_changed = host_key_dialog_spec(&failure(
HostKeyFailureKind::Changed {
known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"),
line: 3,
trust_allowed: false,
},
"SHA256:new",
));
let strict_unplaced = host_key_dialog_spec(&failure(
HostKeyFailureKind::Changed {
known_hosts: PathBuf::new(),
line: 0,
trust_allowed: false,
},
"SHA256:new",
));
Expand All @@ -250,6 +290,7 @@ mod tests {
HostKeyFailureKind::Changed {
known_hosts: PathBuf::new(),
line: 0,
trust_allowed: true,
},
"SHA256:new",
));
Expand All @@ -266,7 +307,24 @@ mod tests {
assert!(revoked.body.contains("/etc/ssh/krl"), "{}", revoked.body);
assert_eq!(ids(&unplaced), [CLOSE, RETRY]);
assert!(!unplaced.body.contains(", line 0"), "{}", unplaced.body);
for spec in [&unknown, &unseen, &strict, &changed, &revoked, &unplaced] {
assert_eq!(ids(&strict_changed), [CLOSE, RETRY]);
assert!(
strict_changed.body.contains("/home/u/.ssh/known_hosts, line 3"),
"{}",
strict_changed.body
);
assert_eq!(ids(&strict_unplaced), [CLOSE, RETRY]);
assert!(!strict_unplaced.body.contains(", line 0"), "{}", strict_unplaced.body);
for spec in [
&unknown,
&unseen,
&strict,
&changed,
&revoked,
&unplaced,
&strict_changed,
&strict_unplaced,
] {
assert!(spec.body.contains("bastion"), "{}", spec.body);
assert_eq!(spec.default, spec.close, "{spec:?}");
let default = spec.responses.iter().find(|response| response.id == spec.default);
Expand Down
53 changes: 48 additions & 5 deletions linux/crates/app/src/ui/workspace/health_banner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ mod tests {
HostKeyFailureKind::Changed {
known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"),
line: 1,
trust_allowed: true,
},
HostKeyFailureKind::Revoked {
detail: "revoked".to_owned(),
Expand Down Expand Up @@ -403,12 +404,19 @@ mod tests {
/// Open the review from the banner and click `response`, the way a
/// user does.
fn review_and_answer(test: &TestWindow, response: &str) {
answer(test, &open_review(test), response);
}

fn open_review(test: &TestWindow) -> adw::AlertDialog {
gtk::prelude::WidgetExt::activate_action(&test.window, &format!("win.{REVIEW_HOST_KEY}"), None)
.expect("the review action");
wait_until(WAIT, || alert(test).is_some()).expect("the review dialog");
let dialog = alert(test).expect("the review dialog");
alert(test).expect("the review dialog")
}

fn answer(test: &TestWindow, dialog: &adw::AlertDialog, response: &str) {
let label = dialog.response_label(response);
descendants(&dialog)
descendants(dialog)
.into_iter()
.filter_map(|widget| widget.downcast::<gtk::Button>().ok())
.find(|button| button.label().as_deref() == Some(label.as_str()))
Expand Down Expand Up @@ -517,17 +525,18 @@ mod tests {
}

fn changed_key(known_hosts: &Path) -> FakeEstablish {
changed_key_of("[bastion]:2222", known_hosts)
changed_key_of("[bastion]:2222", known_hosts, true)
}

fn changed_key_of(host: &str, known_hosts: &Path) -> FakeEstablish {
fn changed_key_of(host: &str, known_hosts: &Path, trust_allowed: bool) -> FakeEstablish {
FakeEstablish::Fail(ConnectError::Driver(DriverError::Transport(TransportError::Ssh(
SshFailure::HostKeyChanged {
host: host.to_owned(),
algorithm: "ED25519".to_owned(),
sha256_fingerprint: FINGERPRINT.to_owned(),
known_hosts: known_hosts.to_owned(),
line: 1,
trust_allowed,
},
))))
}
Expand Down Expand Up @@ -651,7 +660,7 @@ mod tests {
}
let test = parked_with(
name,
vec![changed_key_of(host, &reported)],
vec![changed_key_of(host, &reported, true)],
Some(resolving_ssh(dir.path(), &known_hosts)),
Some(tunnel()),
);
Expand All @@ -666,6 +675,40 @@ mod tests {
}
}

/// Under StrictHostKeyChecking yes ssh would refuse the new key once
/// the old one is gone, so the review offers no replace and the old
/// key stays.
#[gtk4::test]
fn review_offers_no_replace_when_the_configuration_takes_only_known_keys() {
let dir = tempfile::tempdir().unwrap();
let known_hosts = dir.path().join("known_hosts");
let keys = format!("[bastion]:2222 {KEY}\n");
std::fs::write(&known_hosts, &keys).unwrap();
let test = parked_with(
"ReviewStrictChanged",
vec![
changed_key_of("[bastion]:2222", &known_hosts, false),
FakeEstablish::Connect(healthy()),
],
Some(resolving_ssh(dir.path(), &known_hosts)),
Some(tunnel()),
);
wait_for_button(&test, "Review Key…");

let dialog = open_review(&test);

assert!(!dialog.has_response("replace"), "a replace was offered");
assert!(dialog.has_response("retry"));
assert!(
dialog.body().contains(&known_hosts.display().to_string()),
"{}",
dialog.body()
);
answer(&test, &dialog, "retry");
wait_until(WAIT, || health(&test) == SessionHealth::Online).expect("the session back online");
assert_eq!(std::fs::read_to_string(&known_hosts).unwrap(), keys);
}

#[gtk4::test]
fn trust_answers_only_for_the_host_it_was_reviewed_for() {
let test = parked(
Expand Down
5 changes: 5 additions & 0 deletions linux/crates/core/src/ssh_failure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ pub enum SshFailure {
sha256_fingerprint: String,
known_hosts: PathBuf,
line: usize,
/// False when the host's ssh configuration takes only keys already
/// in known_hosts, so ssh would refuse the new key without asking
/// once the old one is removed.
trust_allowed: bool,
},

#[error("the host key for {host} is revoked: {detail}")]
Expand Down Expand Up @@ -155,6 +159,7 @@ mod tests {
sha256_fingerprint: "SHA256:abc".to_owned(),
known_hosts: PathBuf::from("/home/user/.ssh/known_hosts"),
line: 3,
trust_allowed: true,
},
ErrorCategory::Authentication,
),
Expand Down
1 change: 1 addition & 0 deletions linux/crates/session/src/health/probe_outcome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ mod tests {
sha256_fingerprint: "SHA256:abc".to_owned(),
known_hosts: "/home/u/.ssh/known_hosts".into(),
line: 3,
trust_allowed: true,
}));

assert_eq!(ProbeOutcome::classify(Ok(())), ProbeOutcome::Healthy);
Expand Down
1 change: 1 addition & 0 deletions linux/crates/ssh/src/forward.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ impl SshSession {
host: &self.host,
forward_target: Some(&target_text),
declined_host_key: None,
strict_hosts: &[],
};
return Err(classify(
output.status.code(),
Expand Down
1 change: 1 addition & 0 deletions linux/crates/ssh/src/hop_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ mod tests {
JumpHop {
destination: SshDestination::new(host, None, None).unwrap(),
host_keys,
known_hosts_name: Some(host.to_owned()),
}
}

Expand Down
5 changes: 5 additions & 0 deletions linux/crates/ssh/src/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use tokio::process::Command;

use crate::SshDestination;
use crate::host_key_checking::HostKeyChecking;
use crate::known_hosts::known_hosts_lookup;

/// `ssh -G` does no network I/O, but a `Match exec` line can run a
/// command, so each lookup is still bounded.
Expand All @@ -31,6 +32,8 @@ enum Resolved {
pub(crate) struct JumpHop {
pub(crate) destination: SshDestination,
pub(crate) host_keys: Option<HostKeyChecking>,
/// The name ssh looks the hop's key up by in known_hosts.
pub(crate) known_hosts_name: Option<String>,
}

/// The jump hosts between this machine and the destination, as far as
Expand Down Expand Up @@ -75,6 +78,7 @@ pub(crate) async fn trace_route(ssh_program: &Path, config_file: Option<&Path>,
route.hops.push(JumpHop {
destination: chain[index].clone(),
host_keys: HostKeyChecking::from_ssh_g(&resolved),
known_hosts_name: known_hosts_lookup(&resolved),
});
output = resolved;
}
Expand Down Expand Up @@ -280,6 +284,7 @@ esac"#,
[JumpHop {
destination: hop("bastion"),
host_keys: Some(HostKeyChecking::AcceptNew),
known_hosts_name: Some("[192.0.2.10]:2222".to_owned()),
}]
);
}
Expand Down
Loading
Loading