feat: support keyboard-interactive auth and interactive passphrase prompts (resolves #33)

This commit is contained in:
TomZz
2026-06-23 00:16:48 +08:00
parent 59df2b5a9a
commit 73938aef8a
10 changed files with 406 additions and 27 deletions
+1
View File
@@ -179,3 +179,4 @@ sync_downloading: "Downloading and decrypting configuration..."
sync_upload_complete: "Configuration uploaded"
sync_download_complete: "Configuration downloaded"
sync_failed: "Synchronization failed"
keyboard_interactive: "Keyboard Interactive"
+1
View File
@@ -181,3 +181,4 @@ sync_downloading: "正在下载并解密配置..."
sync_upload_complete: "配置上传完成"
sync_download_complete: "配置下载完成"
sync_failed: "同步失败"
keyboard_interactive: "键盘交互"
+124 -10
View File
@@ -1,5 +1,5 @@
use gpui::{
Anchor, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton,
Anchor, AppContext as _, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton,
ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div,
prelude::FluentBuilder as _, px, rems,
};
@@ -8,7 +8,7 @@ use gpui_component::{
button::{Button, ButtonVariants as _},
dialog::Dialog,
h_flex,
input::Input,
input::{Input, InputState},
menu::{DropdownMenu as _, PopupMenuItem},
progress::Progress,
scroll::{Scrollbar, ScrollbarShow},
@@ -62,7 +62,10 @@ impl Ashell {
let key_inline_input = key_inline_input.clone();
let passphrase_input = passphrase_input.clone();
move |content, window, cx| {
let is_password = view.read(cx).ssh_auth_method == AuthMethod::Password;
let method = view.read(cx).ssh_auth_method;
let is_password = method == AuthMethod::Password;
let is_key = method == AuthMethod::Key;
let is_kb = method == AuthMethod::KeyboardInteractive;
let is_editing = view.read(cx).editing_session_id.is_some();
content.child(
v_flex()
@@ -89,13 +92,13 @@ impl Ashell {
AuthMethod::Password,
cx,
)
},
)),
},
)),
)
.child(
Button::new("ssh-auth-key")
.label(t!("key").to_string())
.when(!is_password, |button| button.primary())
.when(is_key, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
@@ -103,8 +106,22 @@ impl Ashell {
AuthMethod::Key,
cx,
)
},
)),
},
)),
)
.child(
Button::new("ssh-auth-kb")
.label(t!("keyboard_interactive").to_string())
.when(is_kb, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_auth_method(
AuthMethod::KeyboardInteractive,
cx,
)
},
)),
),
)
.when(is_password, |this| {
@@ -112,7 +129,7 @@ impl Ashell {
Input::new(&password_input).mask_toggle().tab_index(4),
)
})
.when(!is_password, |this| {
.when(is_key, |this| {
this.child(
h_flex()
.gap_2()
@@ -1761,7 +1778,7 @@ impl Ashell {
)
.child(
div()
.text_size(rems(0.9))
.text_size(rems(0.9))
.text_color(cx.theme().muted_foreground)
.child(t!("about_feedback_hint")),
)
@@ -1782,4 +1799,101 @@ impl Ashell {
})
});
}
pub(crate) fn show_interactive_prompt_dialog(
&mut self,
tab_id: String,
prompt_type: crate::terminal::PromptType,
instruction: String,
prompts: Vec<crate::terminal::PromptInfo>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let view = cx.entity();
// Dynamically instantiate InputState for each prompt
let mut input_states = Vec::new();
for p in &prompts {
let is_masked = !p.echo;
let input_state = cx.new(|cx| {
let mut state = InputState::new(window, cx).placeholder(p.prompt.clone());
if is_masked {
state = state.masked(true);
}
state
});
input_states.push(input_state);
}
let tab_id_clone = tab_id.clone();
let input_states_clone = input_states.clone();
self.active_dialog = Some(crate::app::DialogKind::PromptRequest);
let tab_id_for_close = tab_id.clone();
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
let title = match prompt_type {
crate::terminal::PromptType::KeyboardInteractive => "Keyboard Interactive Authentication",
crate::terminal::PromptType::Passphrase => "Enter Private Key Passphrase",
};
let tab_id_for_ok = tab_id_clone.clone();
let input_states_for_ok = input_states_clone.clone();
let view_for_ok = view.clone();
let view_for_close = view.clone();
let tab_id_for_close = tab_id_for_close.clone();
let instruction_for_content = instruction.clone();
let input_states_for_content = input_states_clone.clone();
dialog
.title(title)
.w(px(500.))
.overlay_closable(false)
.on_close(move |_, _, cx| {
view_for_close.update(cx, |this, cx| {
this.active_dialog = None;
// Send Close command to abort connection if they close the dialog without OK
if let Some(tab) = this.tabs.iter().find(|t| t.id == tab_id_for_close) {
tab.backend.send(crate::terminal::BackendCommand::Close);
}
cx.notify();
});
})
.on_ok(move |_, window, cx| {
view_for_ok.update(cx, |this, cx| {
this.active_dialog = None;
let mut responses = Vec::new();
for state in &input_states_for_ok {
responses.push(state.read(cx).text().to_string());
}
if let Some(tab) = this.tabs.iter().find(|t| t.id == tab_id_for_ok) {
tab.backend.send(crate::terminal::BackendCommand::PromptResponse(responses));
}
cx.notify();
});
window.close_dialog(cx);
true
})
.content(move |content, _window, _cx| {
let mut container = v_flex().gap_3();
if !instruction_for_content.is_empty() {
container = container.child(
div()
.text_sm()
.text_color(gpui::rgba(0x808080ff))
.child(instruction_for_content.clone())
);
}
for input_state in &input_states_for_content {
container = container.child(
Input::new(input_state).w_full()
);
}
content.child(container)
})
});
}
}
+29 -11
View File
@@ -196,6 +196,7 @@ pub(crate) enum DialogKind {
SessionSelector,
Transfers,
NewSsh,
PromptRequest,
}
pub(crate) struct Ashell {
@@ -604,7 +605,7 @@ impl Ashell {
this.apply_theme_preferences(window, cx);
// this.open_local(cx);
this.start_event_pump(cx);
this.start_event_pump(window, cx);
this
}
@@ -654,17 +655,19 @@ impl Ashell {
cx.notify();
}
pub(crate) fn start_event_pump(&self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
pub(crate) fn start_event_pump(&self, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, mut cx| {
let mut idle_frames = 0u32;
loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
if this
.update(cx, |this, cx| {
let changed = this.drain_backend_events();
let system_sampled = this.sample_system_if_due();
let mut changed = false;
let mut system_sampled = false;
let update_res = gpui::AsyncWindowContext::update(&mut cx, |window, cx| {
let _ = this.update(cx, |this, cx| {
changed = this.drain_backend_events(window, cx);
system_sampled = this.sample_system_if_due();
this.sync_theme_if_due(cx);
if changed || system_sampled {
cx.notify();
@@ -676,9 +679,9 @@ impl Ashell {
idle_frames = 0;
}
}
})
.is_err()
{
});
});
if update_res.is_err() {
break;
}
}
@@ -686,7 +689,7 @@ impl Ashell {
.detach();
}
pub(crate) fn drain_backend_events(&mut self) -> bool {
pub(crate) fn drain_backend_events(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let mut changed = false;
let mut transfers_changed = false;
while let Ok(event) = self.events_rx.try_recv() {
@@ -725,6 +728,21 @@ impl Ashell {
self.connection_progress = None;
}
}
BackendEvent::PromptRequest {
tab_id,
prompt_type,
instruction,
prompts,
} => {
self.show_interactive_prompt_dialog(
tab_id,
prompt_type,
instruction,
prompts,
window,
cx,
);
}
BackendEvent::SftpEntries {
tab_id,
path,
+1
View File
@@ -117,6 +117,7 @@ pub fn spawn_local_terminal(
}
BackendCommand::Close => break,
BackendCommand::SampleMetrics => {}
BackendCommand::PromptResponse(_) => {}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Some(status)) = child.try_wait() {
+167 -3
View File
@@ -16,8 +16,23 @@ use tokio::sync::mpsc;
use crate::{
session::config::{AuthMethod, Session},
system::{SystemSnapshot, remote_snapshot_from_kv},
terminal::{BackendCommand, BackendEvent, BackendTx},
terminal::{BackendCommand, BackendEvent, BackendTx, PromptType, PromptInfo},
};
use std::sync::OnceLock;
use std::collections::HashMap;
use tokio::sync::Mutex;
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct CachedCreds {
pub password: Option<String>,
pub passphrase: Option<String>,
pub kb_responses: Option<Vec<String>>,
}
pub static CREDENTIALS_CACHE: OnceLock<std::sync::Mutex<HashMap<String, CachedCreds>>> = OnceLock::new();
pub static PROMPT_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
pub fn spawn_ssh_terminal(
runtime: &tokio::runtime::Handle,
@@ -95,7 +110,7 @@ async fn run_ssh(
});
let handle = Arc::new(tokio::sync::Mutex::new(
connect_and_authenticate(&tab_id, &session, &events).await?,
connect_and_authenticate(&tab_id, &session, &events, &mut commands).await?,
));
let mut channel = handle
@@ -156,6 +171,9 @@ async fn run_ssh(
}
});
}
Some(BackendCommand::PromptResponse(_)) => {
tracing::warn!("[ssh] received unexpected prompt response after authentication");
}
Some(BackendCommand::Close) | None => {
tracing::info!("[ssh] local client closed the session for tab {}", tab_id);
let _ = channel.eof().await;
@@ -213,10 +231,27 @@ async fn run_ssh(
Ok(())
}
async fn load_session_private_key_with_cache(session: &Session) -> Result<PrivateKey> {
let cached_passphrase = {
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).and_then(|c| c.passphrase.clone())
};
if let Some(p) = cached_passphrase {
let mut temp_session = session.clone();
temp_session.passphrase = p;
if let Ok(key) = load_session_private_key(&temp_session) {
return Ok(key);
}
}
load_session_private_key(session)
}
async fn connect_and_authenticate(
tab_id: &str,
session: &Session,
events: &std::sync::mpsc::Sender<BackendEvent>,
commands: &mut mpsc::UnboundedReceiver<BackendCommand>,
) -> Result<russh::client::Handle<ClientHandler>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
@@ -271,7 +306,55 @@ async fn connect_and_authenticate(
tab_id: tab_id.to_string(),
text: format!("connected to {addr}, loading private key from {source}"),
});
let keypair = load_session_private_key(session)?;
let mut keypair = load_session_private_key_with_cache(session).await;
if let Err(e) = &keypair {
let err_str = e.to_string();
if err_str.contains("encrypted") || err_str.contains("passphrase") || err_str.contains("decrypt") {
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
let _guard = prompt_lock.lock().await;
let _ = events.send(BackendEvent::PromptRequest {
tab_id: tab_id.to_string(),
prompt_type: PromptType::Passphrase,
instruction: format!("Enter passphrase for private key (source: {})", source),
prompts: vec![PromptInfo {
prompt: "Passphrase".to_string(),
echo: false,
}],
});
let mut passphrase_res = None;
while let Some(cmd) = commands.recv().await {
match cmd {
BackendCommand::PromptResponse(responses) => {
if let Some(p) = responses.first().cloned() {
passphrase_res = Some(p);
}
break;
}
BackendCommand::Close => {
return Err(anyhow!("Authentication cancelled by user"));
}
_ => {}
}
}
if let Some(p) = passphrase_res {
{
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut cache = cache_lock.lock().unwrap();
cache.entry(session.id.clone()).or_insert_with(|| CachedCreds {
password: None,
passphrase: None,
kb_responses: None,
}).passphrase = Some(p.clone());
}
let mut temp_session = session.clone();
temp_session.passphrase = p;
keypair = load_session_private_key(&temp_session);
} else {
return Err(anyhow!("Passphrase prompt cancelled"));
}
}
}
let keypair = keypair.context("failed to load private key")?;
let algorithm = format!("{:?}", keypair.algorithm());
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
@@ -307,6 +390,83 @@ async fn connect_and_authenticate(
}
success
}
AuthMethod::KeyboardInteractive => {
let cached = {
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).cloned()
};
let mut response = if cached.as_ref().and_then(|c| c.kb_responses.as_ref()).is_some() {
handle.authenticate_keyboard_interactive_start(&session.user, None).await?
} else {
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
let _guard = prompt_lock.lock().await;
handle.authenticate_keyboard_interactive_start(&session.user, None).await?
};
loop {
match response {
russh::client::KeyboardInteractiveAuthResponse::Success => {
break true;
}
russh::client::KeyboardInteractiveAuthResponse::Failure => {
break false;
}
russh::client::KeyboardInteractiveAuthResponse::InfoRequest { name, instructions, prompts } => {
let mut responses = Vec::new();
let mut cache_hit = false;
if let Some(c) = cached.as_ref().and_then(|c| c.kb_responses.as_ref()) {
if c.len() == prompts.len() {
responses = c.clone();
cache_hit = true;
}
}
if !cache_hit {
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
let _guard = prompt_lock.lock().await;
let mut prompt_infos = Vec::new();
for p in &prompts {
prompt_infos.push(PromptInfo {
prompt: p.prompt.clone(),
echo: p.echo,
});
}
let _ = events.send(BackendEvent::PromptRequest {
tab_id: tab_id.to_string(),
prompt_type: PromptType::KeyboardInteractive,
instruction: format!("{}\n{}", name, instructions),
prompts: prompt_infos,
});
let mut prompt_res = None;
while let Some(cmd) = commands.recv().await {
match cmd {
BackendCommand::PromptResponse(res) => {
prompt_res = Some(res);
break;
}
BackendCommand::Close => {
return Err(anyhow!("Authentication cancelled"));
}
_ => {}
}
}
if let Some(res) = prompt_res {
responses = res;
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut cache = cache_lock.lock().unwrap();
cache.entry(session.id.clone()).or_insert_with(|| CachedCreds {
password: None,
passphrase: None,
kb_responses: None,
}).kb_responses = Some(responses.clone());
} else {
return Err(anyhow!("Keyboard-interactive cancelled"));
}
}
response = handle.authenticate_keyboard_interactive_respond(responses).await?;
}
}
}
}
};
if !authed {
@@ -328,6 +488,10 @@ async fn connect_and_authenticate(
session.port,
key_source_label(session)
),
AuthMethod::KeyboardInteractive => format!(
"authentication failed: server rejected keyboard-interactive authentication for {}@{}:{}",
session.user, session.host, session.port
),
}
));
}
+23 -1
View File
@@ -6,10 +6,11 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[serde(rename_all = "kebab-case")]
pub enum AuthMethod {
Password,
Key,
KeyboardInteractive,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -73,6 +74,27 @@ impl Session {
last_used: None,
}
}
pub fn keyboard_interactive(
host: String,
port: u16,
user: String,
) -> Self {
let name = format!("{user}@{host}");
Self {
id: Uuid::new_v4().to_string(),
name,
host,
port,
user,
auth: AuthMethod::KeyboardInteractive,
password: String::new(),
private_key_path: String::new(),
private_key_inline: String::new(),
passphrase: String::new(),
last_used: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+1
View File
@@ -100,6 +100,7 @@ impl Ashell {
}
Session::key(host, port, user, key_path, key_inline, passphrase)
}
AuthMethod::KeyboardInteractive => Session::keyboard_interactive(host, port, user),
};
session.name = name;
if let Some(id) = existing_id {
+40 -2
View File
@@ -868,6 +868,34 @@ async fn connect_and_authenticate(
}
success
}
AuthMethod::KeyboardInteractive => {
let cached_kb_responses = {
let cache_lock = crate::backend::ssh::CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).and_then(|c| c.kb_responses.clone())
};
let responses = cached_kb_responses.unwrap_or_else(|| vec![session.password.clone()]);
let mut response = handle.authenticate_keyboard_interactive_start(&session.user, None).await?;
loop {
match response {
russh::client::KeyboardInteractiveAuthResponse::Success => {
break true;
}
russh::client::KeyboardInteractiveAuthResponse::Failure => {
break false;
}
russh::client::KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => {
let mut resp = responses.clone();
if resp.len() < prompts.len() {
resp.resize(prompts.len(), String::new());
} else if resp.len() > prompts.len() {
resp.truncate(prompts.len());
}
response = handle.authenticate_keyboard_interactive_respond(resp).await?;
}
}
}
}
};
if !authed {
@@ -879,6 +907,7 @@ async fn connect_and_authenticate(
match session.auth {
AuthMethod::Password => "password",
AuthMethod::Key => "public key",
AuthMethod::KeyboardInteractive => "keyboard interactive",
},
session.user,
session.host,
@@ -892,8 +921,17 @@ async fn connect_and_authenticate(
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let inline_key = normalize_inline_private_key(&session.private_key_inline);
let key_path = expand_key_path(session.private_key_path.trim());
let passphrase = session.passphrase.trim();
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
let cached_passphrase = {
let cache_lock = crate::backend::ssh::CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).and_then(|c| c.passphrase.clone())
};
let passphrase = if let Some(ref p) = cached_passphrase {
Some(p.as_str())
} else {
let p = session.passphrase.trim();
(!p.is_empty()).then_some(p)
};
let has_inline = !inline_key.is_empty();
let has_path = key_path.is_some();
+19
View File
@@ -24,16 +24,35 @@ pub enum TabKind {
Ssh,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptType {
KeyboardInteractive,
Passphrase,
}
#[derive(Debug, Clone)]
pub struct PromptInfo {
pub prompt: String,
pub echo: bool,
}
#[derive(Debug)]
pub enum BackendCommand {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
SampleMetrics,
Close,
PromptResponse(Vec<String>),
}
#[derive(Debug, Clone)]
pub enum BackendEvent {
PromptRequest {
tab_id: String,
prompt_type: PromptType,
instruction: String,
prompts: Vec<PromptInfo>,
},
Output {
tab_id: String,
bytes: Vec<u8>,