From 33b17223aa1951bd315919f17d8c5834c6359fbc Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 12 Oct 2026 08:19:41 +0100 Subject: [PATCH] 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). --- rust/app/src/app.rs | 8 +- rust/app/src/chrome.rs | 72 ++++++++++++++---- rust/core/src/sessions.rs | 142 ++++++++++++++++++++++++++++-------- rust/core/src/storage.rs | 6 ++ rust/core/tests/sessions.rs | 4 +- 5 files changed, 181 insertions(+), 51 deletions(-) diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index a5fea03..e3664b4 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -10,7 +10,7 @@ use crate::{chrome, forwards, profile_editor, quick_connect, recordings, setting use egui::{Key, KeyboardShortcut, Modifiers, Ui}; use nexus_core::models::{Profile, Protocol, SessionInfo, SessionState}; use nexus_core::service::Service; -use nexus_core::sessions::Event; +use nexus_core::sessions::{Event, HostKeyDecision}; use std::collections::VecDeque; use std::path::PathBuf; 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. pub enum Prompt { Secret { session: String, title: String, message: String, value: String, reply: mpsc::Sender }, - HostKey { host: String, key_type: String, fingerprint: String, reply: mpsc::Sender }, + HostKey { host: String, key_type: String, fingerprint: String, stored: Option, reply: mpsc::Sender }, } /// Modal dialogs owned by the main window. @@ -495,8 +495,8 @@ impl App { Event::SecretPrompt { session, title, message, reply } => { self.prompts.push_back(Prompt::Secret { session, title, message, value: String::new(), reply }); } - Event::HostKeyVerify { host, key_type, fingerprint, reply, .. } => { - self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, reply }); + Event::HostKeyVerify { host, key_type, fingerprint, stored, reply, .. } => { + self.prompts.push_back(Prompt::HostKey { host, key_type, fingerprint, stored, reply }); } Event::VncReady { session } => { if let Some(View::Vnc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) { diff --git a/rust/app/src/chrome.rs b/rust/app/src/chrome.rs index 7d38bb3..a052f2c 100644 --- a/rust/app/src/chrome.rs +++ b/rust/app/src/chrome.rs @@ -9,6 +9,7 @@ use crate::widgets; use egui::{Align2, Color32, Key, Rect, RichText, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; use egui_phosphor::regular as icon; use nexus_core::models::{Protocol, SessionState}; +use nexus_core::sessions::HostKeyDecision; 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 } => { - modal(ctx, "hostkey-prompt", 460.0, |ui| { - ui.label(RichText::new(format!("{} Verify host key", icon::SHIELD_WARNING)).font(theme::semibold(15.0))); - 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() { - let _ = reply.send(true); - done = true; + Prompt::HostKey { host, key_type, fingerprint, stored, reply } => { + modal(ctx, "hostkey-prompt", 480.0, |ui| { + let mut decide = |d: HostKeyDecision| { + let _ = reply.send(d); + done = true; + }; + match stored { + Some(old) => { + ui.label(RichText::new(format!("{} Host key changed", icon::SHIELD_WARNING)).font(theme::semibold(15.0)).color(t.danger)); + ui.add( + egui::Label::new(format!( + "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 { - let _ = reply.send(false); - done = true; + None => { + ui.label(RichText::new(format!("{} Verify host key", icon::SHIELD_WARNING)).font(theme::semibold(15.0))); + 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); + } + }); } - }); + } }); } } diff --git a/rust/core/src/sessions.rs b/rust/core/src/sessions.rs index 8e63c2f..59a2b96 100644 --- a/rust/core/src/sessions.rs +++ b/rust/core/src/sessions.rs @@ -91,12 +91,15 @@ pub enum Event { message: String, reply: mpsc::Sender, }, + /// A host key needs the user's decision: unknown (`stored` is None) or changed (`stored` is + /// the fingerprint on record). HostKeyVerify { session: String, host: String, key_type: String, fingerprint: String, - reply: mpsc::Sender, + stored: Option, + reply: mpsc::Sender, }, VncReady { 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)] pub struct Retry { pub attempt: i64, @@ -463,7 +476,9 @@ impl Manager { 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, session: &str, policy: HostKeyPolicy) -> HostKeyCheck { let (me, sid) = (self.clone(), session.to_string()); Arc::new(move |k: &HostKeyInfo| { @@ -484,41 +499,64 @@ impl Manager { }) .map_err(|e| e.to_string()) }; - let verify = |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 - )); - } + let touch = |mut s: KnownHost| { s.last_seen = now(); let _ = me.store.save_known_host(&s); 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 { + 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) { (HostKeyPolicy::Insecure, None) | (HostKeyPolicy::AcceptNew, None) => save(), - (HostKeyPolicy::Insecure, Some(mut s)) => { - s.last_seen = now(); - let _ = me.store.save_known_host(&s); - Ok(()) - } + (HostKeyPolicy::Insecure, Some(s)) => touch(s), (HostKeyPolicy::Strict, None) => Err(format!("host key for {}:{} is not known (policy: strict)", k.host, k.port)), - (HostKeyPolicy::Ask, None) => { - 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(), - reply: tx, - }); - match rx.recv_timeout(Duration::from_secs(60)) { - Ok(true) => save(), - Ok(false) | Err(mpsc::RecvTimeoutError::Disconnected) => Err(format!("host key for {}:{} rejected by user", k.host, k.port)), - Err(_) => Err("host key prompt failed: host key prompt timed out waiting for user response".into()), + (HostKeyPolicy::Ask, None) => match ask(None)? { + HostKeyDecision::AcceptAndSave => save(), + HostKeyDecision::AcceptOnce => Ok(()), + HostKeyDecision::Reject => Err(format!("host key for {}:{} rejected by user", k.host, k.port)), + }, + (_, Some(s)) if s.fingerprint == k.fingerprint => touch(s), + (HostKeyPolicy::Ask, Some(s)) => match ask(Some(&s))? { + HostKeyDecision::AcceptAndSave => { + me.store.delete_known_host(&s.id).map_err(|e| e.to_string())?; + save()?; + me.store.add_audit( + "hostkey.update", + "", + &sid, + &format!("Replaced host key for {}:{} ({} -> {})", k.host, k.port, s.fingerprint, k.fingerprint), + ); + Ok(()) } - } - (_, Some(s)) => verify(s), + HostKeyDecision::AcceptOnce => { + 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(); 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>> = Arc::default(); + let prompts: Arc>>> = 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); + } } diff --git a/rust/core/src/storage.rs b/rust/core/src/storage.rs index 21a37f5..6616544 100644 --- a/rust/core/src/storage.rs +++ b/rust/core/src/storage.rs @@ -457,6 +457,12 @@ mod tests { 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.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.preferences().terminal_theme, "dracula"); // Migration 007 lets serial profiles in. diff --git a/rust/core/tests/sessions.rs b/rust/core/tests/sessions.rs index 14a5a16..8f05529 100644 --- a/rust/core/tests/sessions.rs +++ b/rust/core/tests/sessions.rs @@ -2,7 +2,7 @@ use nexus_core::models::{Profile, Protocol, SessionState}; 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 std::io::{Read, Write}; use std::net::TcpListener; @@ -34,7 +34,7 @@ fn wait(rx: &mpsc::Receiver, mut f: impl FnMut(&Event) -> Option) - return t; } if let Event::HostKeyVerify { reply, .. } = e { - let _ = reply.send(true); + let _ = reply.send(HostKeyDecision::AcceptAndSave); } } }