feat: changed host keys can be rejected, accepted once, or accepted and updated
Under the 'ask' policy a changed host key used to be a hard error. The prompt now shows the stored and received fingerprints and offers Reject, Accept Once (connect, keep the record) and Accept & Update (replace the record; audited). Unknown keys gain an Accept Once option too. Strict and accept-new still refuse changed keys without prompting. Known-host rows written by the old app are confirmed removable by id (test).
This commit is contained in:
+4
-4
@@ -10,7 +10,7 @@ use crate::{chrome, forwards, profile_editor, quick_connect, recordings, setting
|
|||||||
use egui::{Key, KeyboardShortcut, Modifiers, Ui};
|
use egui::{Key, KeyboardShortcut, Modifiers, Ui};
|
||||||
use nexus_core::models::{Profile, Protocol, SessionInfo, SessionState};
|
use nexus_core::models::{Profile, Protocol, SessionInfo, SessionState};
|
||||||
use nexus_core::service::Service;
|
use nexus_core::service::Service;
|
||||||
use nexus_core::sessions::Event;
|
use nexus_core::sessions::{Event, HostKeyDecision};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex, mpsc};
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
@@ -43,7 +43,7 @@ pub fn post_error(e: String) {
|
|||||||
/// A credential or host-key question from a connecting session.
|
/// A credential or host-key question from a connecting session.
|
||||||
pub enum Prompt {
|
pub enum Prompt {
|
||||||
Secret { session: String, title: String, message: String, value: String, reply: mpsc::Sender<String> },
|
Secret { session: String, title: String, message: String, value: String, reply: mpsc::Sender<String> },
|
||||||
HostKey { host: String, key_type: String, fingerprint: String, reply: mpsc::Sender<bool> },
|
HostKey { host: String, key_type: String, fingerprint: String, stored: Option<String>, reply: mpsc::Sender<HostKeyDecision> },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modal dialogs owned by the main window.
|
/// Modal dialogs owned by the main window.
|
||||||
@@ -495,8 +495,8 @@ impl App {
|
|||||||
Event::SecretPrompt { session, title, message, reply } => {
|
Event::SecretPrompt { session, title, message, reply } => {
|
||||||
self.prompts.push_back(Prompt::Secret { session, title, message, value: String::new(), reply });
|
self.prompts.push_back(Prompt::Secret { session, title, message, value: String::new(), reply });
|
||||||
}
|
}
|
||||||
Event::HostKeyVerify { host, key_type, fingerprint, reply, .. } => {
|
Event::HostKeyVerify { host, key_type, fingerprint, stored, reply, .. } => {
|
||||||
self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, reply });
|
self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, stored, reply });
|
||||||
}
|
}
|
||||||
Event::VncReady { session } => {
|
Event::VncReady { session } => {
|
||||||
if let Some(View::Vnc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
|
if let Some(View::Vnc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
|
||||||
|
|||||||
+56
-16
@@ -9,6 +9,7 @@ use crate::widgets;
|
|||||||
use egui::{Align2, Color32, Key, Rect, RichText, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
|
use egui::{Align2, Color32, Key, Rect, RichText, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
|
||||||
use egui_phosphor::regular as icon;
|
use egui_phosphor::regular as icon;
|
||||||
use nexus_core::models::{Protocol, SessionState};
|
use nexus_core::models::{Protocol, SessionState};
|
||||||
|
use nexus_core::sessions::HostKeyDecision;
|
||||||
|
|
||||||
pub const LOGO_PNG: &[u8] = include_bytes!("../assets/icon/icon-256.png");
|
pub const LOGO_PNG: &[u8] = include_bytes!("../assets/icon/icon-256.png");
|
||||||
|
|
||||||
@@ -708,24 +709,63 @@ pub fn prompts(app: &mut App, ctx: &egui::Context) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Prompt::HostKey { host, key_type, fingerprint, reply } => {
|
Prompt::HostKey { host, key_type, fingerprint, stored, reply } => {
|
||||||
modal(ctx, "hostkey-prompt", 460.0, |ui| {
|
modal(ctx, "hostkey-prompt", 480.0, |ui| {
|
||||||
ui.label(RichText::new(format!("{} Verify host key", icon::SHIELD_WARNING)).font(theme::semibold(15.0)));
|
let mut decide = |d: HostKeyDecision| {
|
||||||
ui.label(format!("The authenticity of {host} can't be established."));
|
let _ = reply.send(d);
|
||||||
ui.add_space(4.0);
|
done = true;
|
||||||
widgets::kv(ui, "hk", &[("Key type", key_type.clone()), ("Fingerprint", fingerprint.clone())]);
|
};
|
||||||
widgets::hint(ui, "Accept only if this matches the fingerprint the server's administrator gave you.");
|
match stored {
|
||||||
ui.add_space(6.0);
|
Some(old) => {
|
||||||
ui.horizontal(|ui| {
|
ui.label(RichText::new(format!("{} Host key changed", icon::SHIELD_WARNING)).font(theme::semibold(15.0)).color(t.danger));
|
||||||
if widgets::primary(ui, icon::CHECK, "Accept & Save").clicked() {
|
ui.add(
|
||||||
let _ = reply.send(true);
|
egui::Label::new(format!(
|
||||||
done = true;
|
"The key {host} presented does not match the one on record. This happens when a server is reinstalled, but it is also what an interception attack looks like. Only continue if you can confirm the new fingerprint with the server's administrator."
|
||||||
|
))
|
||||||
|
.wrap(),
|
||||||
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
widgets::kv(ui, "hk", &[("Key type", key_type.clone()), ("Stored", old.clone()), ("Received", fingerprint.clone())]);
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if widgets::primary(ui, icon::X, "Reject").clicked() || esc {
|
||||||
|
decide(HostKeyDecision::Reject);
|
||||||
|
}
|
||||||
|
if ui.button(format!("{} Accept Once", icon::CHECK)).on_hover_text("Connect this time; keep the stored key unchanged").clicked() {
|
||||||
|
decide(HostKeyDecision::AcceptOnce);
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(RichText::new(format!("{} Accept & Update", icon::ARROWS_CLOCKWISE)).color(t.accent_text))
|
||||||
|
.fill(t.danger),
|
||||||
|
)
|
||||||
|
.on_hover_text("Connect and replace the stored key with this one")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
decide(HostKeyDecision::AcceptAndSave);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if ui.button("Reject").clicked() || esc {
|
None => {
|
||||||
let _ = reply.send(false);
|
ui.label(RichText::new(format!("{} Verify host key", icon::SHIELD_WARNING)).font(theme::semibold(15.0)));
|
||||||
done = true;
|
ui.label(format!("The authenticity of {host} can't be established."));
|
||||||
|
ui.add_space(4.0);
|
||||||
|
widgets::kv(ui, "hk", &[("Key type", key_type.clone()), ("Fingerprint", fingerprint.clone())]);
|
||||||
|
widgets::hint(ui, "Accept only if this matches the fingerprint the server's administrator gave you.");
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if widgets::primary(ui, icon::CHECK, "Accept & Save").clicked() {
|
||||||
|
decide(HostKeyDecision::AcceptAndSave);
|
||||||
|
}
|
||||||
|
if ui.button(format!("{} Accept Once", icon::CHECK)).on_hover_text("Connect without recording the key").clicked() {
|
||||||
|
decide(HostKeyDecision::AcceptOnce);
|
||||||
|
}
|
||||||
|
if ui.button("Reject").clicked() || esc {
|
||||||
|
decide(HostKeyDecision::Reject);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-29
@@ -91,12 +91,15 @@ pub enum Event {
|
|||||||
message: String,
|
message: String,
|
||||||
reply: mpsc::Sender<String>,
|
reply: mpsc::Sender<String>,
|
||||||
},
|
},
|
||||||
|
/// A host key needs the user's decision: unknown (`stored` is None) or changed (`stored` is
|
||||||
|
/// the fingerprint on record).
|
||||||
HostKeyVerify {
|
HostKeyVerify {
|
||||||
session: String,
|
session: String,
|
||||||
host: String,
|
host: String,
|
||||||
key_type: String,
|
key_type: String,
|
||||||
fingerprint: String,
|
fingerprint: String,
|
||||||
reply: mpsc::Sender<bool>,
|
stored: Option<String>,
|
||||||
|
reply: mpsc::Sender<HostKeyDecision>,
|
||||||
},
|
},
|
||||||
VncReady {
|
VncReady {
|
||||||
session: String,
|
session: String,
|
||||||
@@ -111,6 +114,16 @@ pub enum Event {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's answer to a host-key prompt.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum HostKeyDecision {
|
||||||
|
Reject,
|
||||||
|
/// Connect, leaving the known-hosts record as it is.
|
||||||
|
AcceptOnce,
|
||||||
|
/// Connect and record this key (replacing a changed one).
|
||||||
|
AcceptAndSave,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct Retry {
|
pub struct Retry {
|
||||||
pub attempt: i64,
|
pub attempt: i64,
|
||||||
@@ -463,7 +476,9 @@ impl Manager {
|
|||||||
Arc::new(move |msg| me.prompt(&sid, &title, msg, timeout))
|
Arc::new(move |msg| me.prompt(&sid, &title, msg, timeout))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host-key policy check against the known-hosts table (Go's HostKeyVerifier).
|
/// Host-key policy check against the known-hosts table (Go's HostKeyVerifier). Under the
|
||||||
|
/// `ask` policy both unknown and changed keys go to the user, who can reject, accept once
|
||||||
|
/// (connect without touching the record) or accept and save/update the record.
|
||||||
pub fn host_key_check(self: &Arc<Self>, session: &str, policy: HostKeyPolicy) -> HostKeyCheck {
|
pub fn host_key_check(self: &Arc<Self>, session: &str, policy: HostKeyPolicy) -> HostKeyCheck {
|
||||||
let (me, sid) = (self.clone(), session.to_string());
|
let (me, sid) = (self.clone(), session.to_string());
|
||||||
Arc::new(move |k: &HostKeyInfo| {
|
Arc::new(move |k: &HostKeyInfo| {
|
||||||
@@ -484,41 +499,64 @@ impl Manager {
|
|||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
};
|
};
|
||||||
let verify = |mut s: KnownHost| {
|
let touch = |mut s: KnownHost| {
|
||||||
if s.fingerprint != k.fingerprint {
|
|
||||||
return Err(format!(
|
|
||||||
"host key for {} has changed! stored fingerprint: {}, received: {} — possible MITM attack",
|
|
||||||
k.host, s.fingerprint, k.fingerprint
|
|
||||||
));
|
|
||||||
}
|
|
||||||
s.last_seen = now();
|
s.last_seen = now();
|
||||||
let _ = me.store.save_known_host(&s);
|
let _ = me.store.save_known_host(&s);
|
||||||
Ok(())
|
Ok(())
|
||||||
};
|
};
|
||||||
|
let changed = |s: &KnownHost| {
|
||||||
|
format!("host key for {} has changed! stored fingerprint: {}, received: {} — possible MITM attack", k.host, s.fingerprint, k.fingerprint)
|
||||||
|
};
|
||||||
|
let ask = |stored: Option<&KnownHost>| -> std::result::Result<HostKeyDecision, String> {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
me.emit(Event::HostKeyVerify {
|
||||||
|
session: sid.clone(),
|
||||||
|
host: k.host.clone(),
|
||||||
|
key_type: k.key_type.clone(),
|
||||||
|
fingerprint: k.fingerprint.clone(),
|
||||||
|
stored: stored.map(|s| s.fingerprint.clone()),
|
||||||
|
reply: tx,
|
||||||
|
});
|
||||||
|
match rx.recv_timeout(Duration::from_secs(60)) {
|
||||||
|
Ok(d) => Ok(d),
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => Ok(HostKeyDecision::Reject),
|
||||||
|
Err(_) => Err("host key prompt failed: host key prompt timed out waiting for user response".into()),
|
||||||
|
}
|
||||||
|
};
|
||||||
match (policy, stored) {
|
match (policy, stored) {
|
||||||
(HostKeyPolicy::Insecure, None) | (HostKeyPolicy::AcceptNew, None) => save(),
|
(HostKeyPolicy::Insecure, None) | (HostKeyPolicy::AcceptNew, None) => save(),
|
||||||
(HostKeyPolicy::Insecure, Some(mut s)) => {
|
(HostKeyPolicy::Insecure, Some(s)) => touch(s),
|
||||||
s.last_seen = now();
|
|
||||||
let _ = me.store.save_known_host(&s);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
(HostKeyPolicy::Strict, None) => Err(format!("host key for {}:{} is not known (policy: strict)", k.host, k.port)),
|
(HostKeyPolicy::Strict, None) => Err(format!("host key for {}:{} is not known (policy: strict)", k.host, k.port)),
|
||||||
(HostKeyPolicy::Ask, None) => {
|
(HostKeyPolicy::Ask, None) => match ask(None)? {
|
||||||
let (tx, rx) = mpsc::channel();
|
HostKeyDecision::AcceptAndSave => save(),
|
||||||
me.emit(Event::HostKeyVerify {
|
HostKeyDecision::AcceptOnce => Ok(()),
|
||||||
session: sid.clone(),
|
HostKeyDecision::Reject => Err(format!("host key for {}:{} rejected by user", k.host, k.port)),
|
||||||
host: k.host.clone(),
|
},
|
||||||
key_type: k.key_type.clone(),
|
(_, Some(s)) if s.fingerprint == k.fingerprint => touch(s),
|
||||||
fingerprint: k.fingerprint.clone(),
|
(HostKeyPolicy::Ask, Some(s)) => match ask(Some(&s))? {
|
||||||
reply: tx,
|
HostKeyDecision::AcceptAndSave => {
|
||||||
});
|
me.store.delete_known_host(&s.id).map_err(|e| e.to_string())?;
|
||||||
match rx.recv_timeout(Duration::from_secs(60)) {
|
save()?;
|
||||||
Ok(true) => save(),
|
me.store.add_audit(
|
||||||
Ok(false) | Err(mpsc::RecvTimeoutError::Disconnected) => Err(format!("host key for {}:{} rejected by user", k.host, k.port)),
|
"hostkey.update",
|
||||||
Err(_) => Err("host key prompt failed: host key prompt timed out waiting for user response".into()),
|
"",
|
||||||
|
&sid,
|
||||||
|
&format!("Replaced host key for {}:{} ({} -> {})", k.host, k.port, s.fingerprint, k.fingerprint),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
HostKeyDecision::AcceptOnce => {
|
||||||
(_, Some(s)) => verify(s),
|
me.store.add_audit(
|
||||||
|
"hostkey.accept-once",
|
||||||
|
"",
|
||||||
|
&sid,
|
||||||
|
&format!("Accepted changed host key for {}:{} once ({})", k.host, k.port, k.fingerprint),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
HostKeyDecision::Reject => Err(format!("{} (rejected by user)", changed(&s))),
|
||||||
|
},
|
||||||
|
(_, Some(s)) => Err(changed(&s)),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1399,4 +1437,50 @@ mod tests {
|
|||||||
m.close_all();
|
m.close_all();
|
||||||
let _ = std::fs::remove_dir_all(dir);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changed_host_keys_can_be_rejected_accepted_once_or_updated() {
|
||||||
|
use crate::clients::ssh::HostKeyInfo;
|
||||||
|
let (m, dir) = manager("hostkey");
|
||||||
|
let answers: Arc<Mutex<Vec<HostKeyDecision>>> = Arc::default();
|
||||||
|
let prompts: Arc<Mutex<Vec<Option<String>>>> = Arc::default();
|
||||||
|
let (a, pr) = (answers.clone(), prompts.clone());
|
||||||
|
m.set_sink(Arc::new(move |e| {
|
||||||
|
if let Event::HostKeyVerify { stored, reply, .. } = e {
|
||||||
|
pr.lock().unwrap().push(stored);
|
||||||
|
let _ = reply.send(a.lock().unwrap().remove(0));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let info = |fp: &str| HostKeyInfo { host: "h".into(), port: 22, key_type: "ssh-ed25519".into(), fingerprint: fp.into(), public_key_b64: "AAAA".into() };
|
||||||
|
let stored = |m: &Manager| m.store.get_known_host("h", 22).unwrap().map(|k| k.fingerprint);
|
||||||
|
// Unknown key: accept once records nothing; accept & save records it.
|
||||||
|
let check = m.host_key_check("s", HostKeyPolicy::Ask);
|
||||||
|
answers.lock().unwrap().push(HostKeyDecision::AcceptOnce);
|
||||||
|
check(&info("A")).unwrap();
|
||||||
|
assert_eq!(stored(&m), None);
|
||||||
|
answers.lock().unwrap().push(HostKeyDecision::AcceptAndSave);
|
||||||
|
check(&info("A")).unwrap();
|
||||||
|
assert_eq!(stored(&m).as_deref(), Some("A"));
|
||||||
|
assert_eq!(prompts.lock().unwrap().as_slice(), [None, None]);
|
||||||
|
// Same key again: no prompt.
|
||||||
|
check(&info("A")).unwrap();
|
||||||
|
assert_eq!(prompts.lock().unwrap().len(), 2);
|
||||||
|
// Changed key: reject, accept once (record untouched), accept & update (record replaced).
|
||||||
|
answers.lock().unwrap().push(HostKeyDecision::Reject);
|
||||||
|
assert!(check(&info("B")).unwrap_err().contains("has changed"));
|
||||||
|
answers.lock().unwrap().push(HostKeyDecision::AcceptOnce);
|
||||||
|
check(&info("B")).unwrap();
|
||||||
|
assert_eq!(stored(&m).as_deref(), Some("A"));
|
||||||
|
answers.lock().unwrap().push(HostKeyDecision::AcceptAndSave);
|
||||||
|
check(&info("B")).unwrap();
|
||||||
|
assert_eq!(stored(&m).as_deref(), Some("B"));
|
||||||
|
assert_eq!(prompts.lock().unwrap()[2..], [Some("A".to_string()), Some("A".into()), Some("A".into())]);
|
||||||
|
assert_eq!(m.store.list_known_hosts().unwrap().len(), 1);
|
||||||
|
// Strict and accept-new never prompt on a changed key.
|
||||||
|
for policy in [HostKeyPolicy::Strict, HostKeyPolicy::AcceptNew] {
|
||||||
|
assert!(m.host_key_check("s", policy)(&info("C")).unwrap_err().contains("has changed"));
|
||||||
|
}
|
||||||
|
assert_eq!(prompts.lock().unwrap().len(), 5);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,12 @@ mod tests {
|
|||||||
let irc = ps.iter().find(|p| p.protocol == Protocol::Irc).unwrap();
|
let irc = ps.iter().find(|p| p.protocol == Protocol::Irc).unwrap();
|
||||||
assert_eq!(s.irc_last_rooms(&irc.id).unwrap(), ["#rust", "#nexus"]);
|
assert_eq!(s.irc_last_rooms(&irc.id).unwrap(), ["#rust", "#nexus"]);
|
||||||
assert_eq!(s.list_known_hosts().unwrap()[0].fingerprint, "SHA256:abc");
|
assert_eq!(s.list_known_hosts().unwrap()[0].fingerprint, "SHA256:abc");
|
||||||
|
// Known hosts written by the Go app can be removed by id; unknown ids are an error.
|
||||||
|
let kh = s.list_known_hosts().unwrap().remove(0);
|
||||||
|
s.delete_known_host(&kh.id).unwrap();
|
||||||
|
assert!(s.list_known_hosts().unwrap().is_empty());
|
||||||
|
assert!(s.delete_known_host(&kh.id).is_err());
|
||||||
|
s.save_known_host(&kh).unwrap();
|
||||||
assert_eq!(s.list_snippets().unwrap()[0].command, "uptime");
|
assert_eq!(s.list_snippets().unwrap()[0].command, "uptime");
|
||||||
assert_eq!(s.preferences().terminal_theme, "dracula");
|
assert_eq!(s.preferences().terminal_theme, "dracula");
|
||||||
// Migration 007 lets serial profiles in.
|
// Migration 007 lets serial profiles in.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use nexus_core::models::{Profile, Protocol, SessionState};
|
use nexus_core::models::{Profile, Protocol, SessionState};
|
||||||
use nexus_core::secrets::{Keychain, Secrets, Vault};
|
use nexus_core::secrets::{Keychain, Secrets, Vault};
|
||||||
use nexus_core::sessions::{Event, Manager};
|
use nexus_core::sessions::{Event, HostKeyDecision, Manager};
|
||||||
use nexus_core::storage::Store;
|
use nexus_core::storage::Store;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
@@ -34,7 +34,7 @@ fn wait<T>(rx: &mpsc::Receiver<Event>, mut f: impl FnMut(&Event) -> Option<T>) -
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
if let Event::HostKeyVerify { reply, .. } = e {
|
if let Event::HostKeyVerify { reply, .. } = e {
|
||||||
let _ = reply.send(true);
|
let _ = reply.send(HostKeyDecision::AcceptAndSave);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user