Files
nyaterm/src-tauri/src/core/ssh/auth.rs
T

2508 lines
80 KiB
Rust

use super::client::{SshAuth, SshConfig, SshHandler, SshPostLoginConfig};
use crate::error::{AppError, AppResult};
use crate::observability::{self, StructuredLog, StructuredLogLevel};
use russh::client::{self, KeyboardInteractiveAuthResponse};
use russh::{MethodKind, MethodSet};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::{Mutex, oneshot};
/// Manages pending keyboard-interactive auth requests awaiting user input from the frontend.
pub struct PendingAuthManager {
pending: Mutex<HashMap<String, oneshot::Sender<Option<Vec<String>>>>>,
}
impl PendingAuthManager {
pub fn new() -> Self {
Self {
pending: Mutex::new(HashMap::new()),
}
}
pub async fn register(&self, request_id: String) -> oneshot::Receiver<Option<Vec<String>>> {
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(request_id, tx);
rx
}
pub async fn respond(&self, request_id: &str, responses: Option<Vec<String>>) -> bool {
if let Some(tx) = self.pending.lock().await.remove(request_id) {
tx.send(responses).is_ok()
} else {
false
}
}
}
/// Manages pending runtime SSH credential requests awaiting user input.
pub struct PendingSshAuthManager {
pending: Mutex<HashMap<String, oneshot::Sender<Option<SshAuthResponse>>>>,
}
impl PendingSshAuthManager {
pub fn new() -> Self {
Self {
pending: Mutex::new(HashMap::new()),
}
}
pub async fn register(&self, request_id: String) -> oneshot::Receiver<Option<SshAuthResponse>> {
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(request_id, tx);
rx
}
pub async fn respond(&self, request_id: &str, response: Option<SshAuthResponse>) -> bool {
if let Some(tx) = self.pending.lock().await.remove(request_id) {
tx.send(response).is_ok()
} else {
false
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SshAuthResponse {
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub secret: Option<String>,
#[serde(default)]
pub key_id: Option<String>,
#[serde(default)]
pub password_id: Option<String>,
#[serde(default)]
pub save: Option<SshAuthSaveRequest>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SshAuthSaveRequest {
pub kind: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub password_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct OtpPrompt {
prompt: String,
echo: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct OtpRequestPayload {
request_id: String,
connection_name: String,
name: Option<String>,
instructions: Option<String>,
round: u32,
prompts: Vec<OtpPrompt>,
otp_entry_id: Option<String>,
target_window_label: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SshAuthPromptReason {
MissingPassword,
PasswordRejected,
KeyPassphraseRequired,
KeyRejectedPasswordFallback,
PublickeyRejected,
PublickeyRequired,
}
impl SshAuthPromptReason {
fn as_str(self) -> &'static str {
match self {
Self::MissingPassword => "missing_password",
Self::PasswordRejected => "password_rejected",
Self::KeyPassphraseRequired => "key_passphrase_required",
Self::KeyRejectedPasswordFallback => "key_rejected_password_fallback",
Self::PublickeyRejected => "publickey_rejected",
Self::PublickeyRequired => "publickey_required",
}
}
fn prompt_kind(self) -> &'static str {
match self {
Self::KeyPassphraseRequired => "passphrase",
Self::PublickeyRejected | Self::PublickeyRequired => "publickey",
_ => "password",
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SshAuthRequestPayload {
request_id: String,
connection_id: Option<String>,
connection_name: String,
host: String,
port: u16,
username: String,
reason: String,
prompt_kind: String,
available_methods: Vec<String>,
current_auth_mode: String,
attempt: u32,
can_save: bool,
password_id: Option<String>,
target_window_label: Option<String>,
}
#[derive(Debug, Clone)]
struct RuntimeSecret {
value: String,
save: Option<RuntimeSecretSave>,
}
#[derive(Debug, Clone)]
struct RuntimeKeyPassphrase {
key_id: String,
secret: RuntimeSecret,
}
#[derive(Debug, Clone)]
enum RuntimeSecretSave {
ConnectionInline,
SavedPassword { id: Option<String>, name: String },
SavedPasswordReference { id: String },
KeyPassphrase,
}
#[derive(Debug, Clone, Default)]
struct SshRuntimeAuthUpdates {
password: Option<RuntimeSecret>,
key_passphrase: Option<RuntimeKeyPassphrase>,
}
#[derive(Debug, Clone, Default)]
struct RuntimePasswordAuthOutcome {
password: Option<RuntimeSecret>,
key_passphrase: Option<RuntimeKeyPassphrase>,
}
#[derive(Debug, Clone)]
enum RuntimeAuthSelection {
Password(RuntimeSecret),
Key(String),
}
fn build_ids(connection_id: Option<&str>, request_id: Option<&str>) -> Option<Value> {
let mut ids = Map::new();
if let Some(connection_id) = connection_id {
ids.insert(
"connection_id".to_string(),
Value::String(connection_id.to_string()),
);
}
if let Some(request_id) = request_id {
ids.insert(
"request_id".to_string(),
Value::String(request_id.to_string()),
);
}
if ids.is_empty() {
None
} else {
Some(Value::Object(ids))
}
}
fn log_structured(
level: StructuredLogLevel,
domain: &str,
event: &str,
message: &str,
connection_id: Option<&str>,
request_id: Option<&str>,
data: Option<Value>,
error: Option<Value>,
) {
observability::log_event(StructuredLog {
level,
domain: domain.to_string(),
event: event.to_string(),
message: message.to_string(),
ids: build_ids(connection_id, request_id),
data,
error,
client_timestamp: None,
});
}
#[derive(Debug, Clone)]
struct SshAuthFailure {
message: String,
remaining_methods: Vec<String>,
partial_success: bool,
}
impl SshAuthFailure {
fn from_auth_result(
message: impl Into<String>,
remaining_methods: &MethodSet,
partial_success: bool,
) -> Self {
Self {
message: message.into(),
remaining_methods: method_names(remaining_methods),
partial_success,
}
}
fn from_error(message: impl Into<String>, error: AppError) -> Self {
Self {
message: format!("{}: {}", message.into(), error),
remaining_methods: Vec::new(),
partial_success: false,
}
}
fn has_method(&self, method: &str) -> bool {
self.remaining_methods
.iter()
.any(|candidate| candidate == method)
}
fn password_available(&self) -> bool {
self.has_method("password") || self.has_method("keyboard-interactive")
}
fn publickey_available(&self) -> bool {
self.has_method("publickey")
}
}
impl From<SshAuthFailure> for AppError {
fn from(value: SshAuthFailure) -> Self {
if value.remaining_methods.is_empty() {
AppError::Auth(value.message)
} else {
AppError::Auth(format!(
"{} (remaining methods: {}; partial success: {})",
value.message,
value.remaining_methods.join(", "),
value.partial_success
))
}
}
}
fn method_names(methods: &MethodSet) -> Vec<String> {
methods.iter().map(String::from).collect()
}
fn current_auth_mode(auth: &SshAuth) -> &'static str {
match auth {
SshAuth::None => "none",
SshAuth::Password { .. } => "password",
SshAuth::Key { .. } => "key",
}
}
fn default_available_methods(reason: SshAuthPromptReason) -> Vec<String> {
match reason.prompt_kind() {
"passphrase" | "publickey" => vec!["publickey".to_string()],
_ => vec!["password".to_string(), "keyboard-interactive".to_string()],
}
}
fn runtime_prompt_kind(reason: SshAuthPromptReason, available_methods: &[String]) -> &'static str {
if reason == SshAuthPromptReason::KeyPassphraseRequired {
return "passphrase";
}
let password_available = available_methods
.iter()
.any(|method| method == "password" || method == "keyboard-interactive");
let publickey_available = available_methods.iter().any(|method| method == "publickey");
if password_available && publickey_available {
"auth_method"
} else {
reason.prompt_kind()
}
}
pub(crate) fn load_saved_ssh_config(app: &AppHandle, connection_id: &str) -> AppResult<SshConfig> {
let conn = crate::config::load_connection_by_id(app, connection_id)?;
let mut visited = HashSet::new();
visited.insert(connection_id.to_string());
resolve_saved_ssh_config(
app,
&conn,
Some(connection_id.to_string()),
true,
&mut visited,
)
}
fn resolve_saved_ssh_config(
app: &AppHandle,
conn: &crate::config::SavedConnection,
connection_id: Option<String>,
include_proxy_jump: bool,
visited_proxy_jumps: &mut HashSet<String>,
) -> AppResult<SshConfig> {
let proxy = resolve_proxy(app, conn)?;
let (host, port, username) = resolve_ssh_target(conn)?;
let auth = resolve_auth(app, conn)?;
let proxy_jump = if include_proxy_jump {
resolve_proxy_jump(app, conn, visited_proxy_jumps)?
} else {
None
};
let post_login = resolve_post_login(conn);
let x11_forwarding = resolve_x11_forwarding(conn);
let x11_display = crate::config::load_app_settings(app)
.map(|settings| settings.terminal.x11_display)
.unwrap_or_default();
let encoding = crate::config::resolve_connection_encoding(app, conn);
Ok(SshConfig {
connection_id,
owner_window_label: None,
name: conn.name.clone(),
host,
port,
username,
auth,
backspace_mode: resolve_ssh_backspace_mode(conn),
x11_forwarding,
x11_display,
proxy,
proxy_jump,
post_login,
ssh_algorithms: conn.ssh_algorithms.clone(),
sftp: conn.sftp.clone(),
encoding,
})
}
fn resolve_x11_forwarding(conn: &crate::config::SavedConnection) -> bool {
match &conn.config {
crate::config::ConnectionType::Ssh { x11_forwarding, .. } => *x11_forwarding,
_ => false,
}
}
fn resolve_ssh_backspace_mode(conn: &crate::config::SavedConnection) -> String {
match &conn.config {
crate::config::ConnectionType::Ssh { backspace_mode, .. } => backspace_mode.clone(),
_ => "del".to_string(),
}
}
fn resolve_post_login(conn: &crate::config::SavedConnection) -> Option<SshPostLoginConfig> {
let post_login = conn.post_login.as_ref()?;
if !post_login.enabled || post_login.command.trim().is_empty() {
return None;
}
Some(SshPostLoginConfig {
command: post_login.command.clone(),
delay_ms: post_login.delay_ms,
})
}
fn resolve_ssh_target(conn: &crate::config::SavedConnection) -> AppResult<(String, u16, String)> {
match &conn.config {
crate::config::ConnectionType::Ssh {
host,
port,
username,
..
} => Ok((host.clone(), *port, username.clone())),
_ => Err(AppError::Auth(
"Connection is not an SSH connection".to_string(),
)),
}
}
fn resolve_auth(app: &AppHandle, conn: &crate::config::SavedConnection) -> AppResult<SshAuth> {
let Some(conn_auth) = conn.auth.as_ref() else {
return Ok(SshAuth::None);
};
match conn_auth.mode.as_str() {
"none" => Ok(SshAuth::None),
"password" => {
let password = resolve_password_material(Some(app), conn_auth)?;
Ok(SshAuth::Password { password })
}
"key" => {
let Some(key_id) = conn_auth.key_id.as_deref() else {
return Ok(SshAuth::None);
};
let ssh_key = crate::config::load_key_by_id(app, key_id)?;
let key_data = crate::config::decrypt_key_pem(&ssh_key)?
.ok_or_else(|| AppError::Auth("No key data stored".to_string()))?;
let cert_data = crate::config::decrypt_key_cert(&ssh_key)?;
Ok(SshAuth::Key {
key_id: Some(key_id.to_string()),
key_data,
cert_data,
passphrase: ssh_key.passphrase,
})
}
other => Err(AppError::Auth(format!("Unknown auth type: {}", other))),
}
}
fn resolve_password_material(
app: Option<&AppHandle>,
conn_auth: &crate::config::ConnectionAuth,
) -> AppResult<Option<String>> {
if let Some(ref ciphertext) = conn_auth.password {
return crate::utils::crypto::decrypt(ciphertext)
.map(Some)
.map_err(|e| AppError::Auth(format!("Failed to decrypt inline password: {e}")));
}
let Some(pw_id) = conn_auth.password_id.as_deref().filter(|id| !id.is_empty()) else {
return Ok(None);
};
let app = app.ok_or_else(|| AppError::Auth("No password for this connection".to_string()))?;
let pw_entry = crate::config::load_password_by_id(app, pw_id)?;
Ok(pw_entry.password)
}
fn resolve_proxy_jump(
app: &AppHandle,
conn: &crate::config::SavedConnection,
visited_proxy_jumps: &mut HashSet<String>,
) -> AppResult<Option<Box<SshConfig>>> {
let proxy_jump_id = conn
.network
.as_ref()
.and_then(|network| network.proxy_jump_id.as_deref());
let Some(proxy_jump_id) = proxy_jump_id else {
return Ok(None);
};
if !visited_proxy_jumps.insert(proxy_jump_id.to_string()) {
return Err(AppError::Config(format!(
"ProxyJump chain contains a cycle at '{}'",
proxy_jump_id
)));
}
let jump_conn = crate::config::load_connection_by_id(app, proxy_jump_id)?;
if !matches!(jump_conn.config, crate::config::ConnectionType::Ssh { .. }) {
return Err(AppError::Config(
"Only SSH connections can be used as jump hosts".to_string(),
));
}
Ok(Some(Box::new(resolve_saved_ssh_config(
app,
&jump_conn,
Some(proxy_jump_id.to_string()),
true,
visited_proxy_jumps,
)?)))
}
fn resolve_proxy(
app: &AppHandle,
conn: &crate::config::SavedConnection,
) -> AppResult<Option<crate::config::ProxySettings>> {
let proxy_id = conn.network.as_ref().and_then(|n| n.proxy_id.as_deref());
let Some(proxy_id) = proxy_id else {
return Ok(None);
};
let proxy_cfg = crate::config::load_proxy_by_id(app, proxy_id)?
.ok_or_else(|| AppError::Config(format!("Proxy '{}' not found", proxy_id)))?;
let password = proxy_cfg
.password
.as_ref()
.and_then(|ciphertext| crate::utils::crypto::decrypt(ciphertext).ok());
Ok(Some(crate::config::ProxySettings {
enabled: true,
protocol: proxy_cfg.protocol,
host: proxy_cfg.host,
port: proxy_cfg.port,
command: proxy_cfg.command,
username: proxy_cfg.username,
password,
}))
}
pub(super) async fn authenticate_handle(
handle: &mut client::Handle<SshHandler>,
config: &SshConfig,
app: &AppHandle,
password_error: &str,
key_error: &str,
) -> AppResult<()> {
let otp_info = config
.connection_id
.as_deref()
.and_then(|connection_id| resolve_otp_info(app, connection_id));
let mut updates = SshRuntimeAuthUpdates::default();
match &config.auth {
SshAuth::None => {
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"auth.start",
"Starting SSH authentication",
config.connection_id.as_deref(),
None,
Some(json!({
"host": config.host,
"port": config.port,
"username": config.username,
"auth_mode": "none",
})),
None,
);
let authenticated = handle
.authenticate_none(&config.username)
.await
.map_err(|error| AppError::Auth(format!("None auth failed: {}", error)))?;
try_keyboard_interactive_after_partial(
handle,
&authenticated,
&config.username,
config.connection_id.as_deref(),
&config.name,
config.owner_window_label.as_deref(),
app,
"Authentication failed: none auth rejected",
Some(KeyboardInteractiveMode::AdditionalFactor),
otp_info.as_ref(),
)
.await?;
}
SshAuth::Password { password } => {
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"auth.start",
"Starting SSH authentication",
config.connection_id.as_deref(),
None,
Some(json!({
"host": config.host,
"port": config.port,
"username": config.username,
"auth_mode": "password",
})),
None,
);
let password_outcome = authenticate_password_with_runtime_prompt(
handle,
config,
app,
password.as_deref(),
password_error,
None,
None,
otp_info.as_ref(),
)
.await?;
if let Some(secret) = password_outcome.password {
updates.password = Some(secret);
}
if let Some(secret) = password_outcome.key_passphrase {
updates.key_passphrase = Some(secret);
}
}
SshAuth::Key { .. } => {
let key_result = authenticate_publickey_with_runtime_selection(
handle,
config,
app,
key_error,
otp_info.as_ref(),
)
.await;
match key_result {
Ok(Some(secret)) => {
updates.key_passphrase = Some(secret);
}
Ok(None) => {}
Err(failure) if failure.password_available() => {
let reason = if failure.publickey_available() {
SshAuthPromptReason::KeyRejectedPasswordFallback
} else {
SshAuthPromptReason::PasswordRejected
};
let password_outcome = authenticate_password_with_runtime_prompt(
handle,
config,
app,
None,
key_error,
Some(reason),
Some(failure.remaining_methods.clone()),
otp_info.as_ref(),
)
.await?;
if let Some(secret) = password_outcome.password {
updates.password = Some(secret);
}
if let Some(secret) = password_outcome.key_passphrase {
updates.key_passphrase = Some(secret);
}
}
Err(failure) if failure.publickey_available() => {
let key_secret = authenticate_publickey_with_runtime_key_prompt(
handle,
config,
app,
key_error,
SshAuthPromptReason::PublickeyRejected,
failure.remaining_methods.clone(),
otp_info.as_ref(),
)
.await?;
if let Some(secret) = key_secret {
updates.key_passphrase = Some(secret);
}
}
Err(failure) => return Err(failure.into()),
}
}
}
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"auth.succeeded",
"SSH authentication succeeded",
config.connection_id.as_deref(),
None,
Some(json!({
"host": config.host,
"port": config.port,
"username": config.username,
})),
None,
);
persist_runtime_auth_updates(app, config, &updates)?;
Ok(())
}
const MAX_RUNTIME_SSH_AUTH_ATTEMPTS: u32 = 3;
async fn request_runtime_secret(
app: &AppHandle,
config: &SshConfig,
reason: SshAuthPromptReason,
attempt: u32,
can_save: bool,
) -> AppResult<RuntimeSecret> {
let response = request_runtime_auth_response(
app,
config,
reason,
attempt,
can_save,
default_available_methods(reason),
)
.await?;
let secret = response.secret.unwrap_or_default();
if secret.is_empty() {
return Err(AppError::Auth(
"SSH authentication response was empty".to_string(),
));
}
Ok(RuntimeSecret {
value: secret,
save: parse_runtime_secret_save(response.save, reason, can_save, None),
})
}
async fn request_runtime_key_id(
app: &AppHandle,
config: &SshConfig,
reason: SshAuthPromptReason,
attempt: u32,
available_methods: Vec<String>,
) -> AppResult<String> {
let response =
request_runtime_auth_response(app, config, reason, attempt, false, available_methods)
.await?;
let method = response.method.as_deref().unwrap_or("key");
if method != "key" && method != "publickey" {
return Err(AppError::Auth(format!(
"Unsupported SSH authentication method '{}'",
method
)));
}
response
.key_id
.filter(|id| !id.trim().is_empty())
.ok_or_else(|| AppError::Auth("No SSH key was selected".to_string()))
}
async fn request_runtime_auth_selection(
app: &AppHandle,
config: &SshConfig,
reason: SshAuthPromptReason,
attempt: u32,
can_save: bool,
available_methods: Vec<String>,
) -> AppResult<RuntimeAuthSelection> {
let response = request_runtime_auth_response(
app,
config,
reason,
attempt,
can_save,
available_methods.clone(),
)
.await?;
let method = response
.method
.as_deref()
.unwrap_or_else(|| {
if available_methods.iter().any(|method| method == "publickey")
&& !available_methods.iter().any(|method| method == "password")
&& !available_methods
.iter()
.any(|method| method == "keyboard-interactive")
{
"key"
} else {
"password"
}
})
.to_string();
match method.as_str() {
"password" => {
let secret = response.secret.unwrap_or_default();
if secret.is_empty() {
return Err(AppError::Auth(
"SSH authentication response was empty".to_string(),
));
}
Ok(RuntimeAuthSelection::Password(RuntimeSecret {
value: secret,
save: parse_runtime_secret_save(response.save, reason, can_save, None),
}))
}
"saved_password" => {
let password_id = response
.password_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string)
.ok_or_else(|| AppError::Auth("No saved password was selected".to_string()))?;
let password = crate::config::load_password_by_id(app, &password_id)?
.password
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::Auth("Selected saved password has no password data".to_string())
})?;
Ok(RuntimeAuthSelection::Password(RuntimeSecret {
value: password,
save: parse_runtime_secret_save(
response.save,
reason,
can_save,
Some(&password_id),
),
}))
}
"key" | "publickey" => response
.key_id
.filter(|id| !id.trim().is_empty())
.map(RuntimeAuthSelection::Key)
.ok_or_else(|| AppError::Auth("No SSH key was selected".to_string())),
other => Err(AppError::Auth(format!(
"Unsupported SSH authentication method '{}'",
other
))),
}
}
async fn request_runtime_auth_response(
app: &AppHandle,
config: &SshConfig,
reason: SshAuthPromptReason,
attempt: u32,
can_save: bool,
available_methods: Vec<String>,
) -> AppResult<SshAuthResponse> {
let pending_mgr = app
.try_state::<Arc<PendingSshAuthManager>>()
.ok_or_else(|| AppError::Auth("PendingSshAuthManager not available".to_string()))?;
let pending_mgr = pending_mgr.inner().clone();
let request_id = uuid::Uuid::new_v4().to_string();
let rx = pending_mgr.register(request_id.clone()).await;
let payload = SshAuthRequestPayload {
request_id: request_id.clone(),
connection_id: config.connection_id.clone(),
connection_name: config.name.clone(),
host: config.host.clone(),
port: config.port,
username: config.username.clone(),
reason: reason.as_str().to_string(),
prompt_kind: runtime_prompt_kind(reason, &available_methods).to_string(),
available_methods,
current_auth_mode: current_auth_mode(&config.auth).to_string(),
attempt,
can_save,
password_id: current_password_id(app, config.connection_id.as_deref()),
target_window_label: config.owner_window_label.clone(),
};
log_structured(
StructuredLogLevel::Info,
"security.flow",
"ssh_auth.requested",
"Forwarding SSH credential request to frontend",
config.connection_id.as_deref(),
Some(&request_id),
Some(json!({
"username": config.username,
"reason": payload.reason,
"prompt_kind": payload.prompt_kind,
"available_methods": payload.available_methods,
"current_auth_mode": payload.current_auth_mode,
"attempt": attempt,
"can_save": can_save,
})),
None,
);
let _ = app.emit("ssh-auth-request", &payload);
let response = match rx.await {
Ok(Some(response)) => response,
Ok(None) => {
return Err(AppError::Auth(
"SSH authentication cancelled by user".to_string(),
));
}
Err(_) => {
return Err(AppError::Auth(
"SSH authentication request dropped".to_string(),
));
}
};
Ok(response)
}
fn current_password_id(app: &AppHandle, connection_id: Option<&str>) -> Option<String> {
let connection_id = connection_id?;
let conn = crate::config::load_connection_by_id(app, connection_id).ok()?;
conn.auth?.password_id.filter(|id| !id.is_empty())
}
fn parse_runtime_secret_save(
save: Option<SshAuthSaveRequest>,
reason: SshAuthPromptReason,
can_save: bool,
selected_password_id: Option<&str>,
) -> Option<RuntimeSecretSave> {
if !can_save {
return None;
}
let save = save?;
match (reason.prompt_kind(), save.kind.as_str()) {
("passphrase", "key_passphrase") => Some(RuntimeSecretSave::KeyPassphrase),
("password", "connection") => {
if let Some(id) = selected_password_id {
Some(RuntimeSecretSave::SavedPasswordReference { id: id.to_string() })
} else {
Some(RuntimeSecretSave::ConnectionInline)
}
}
("password", "saved_password") => {
let name = save
.name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("SSH Password")
.to_string();
Some(RuntimeSecretSave::SavedPassword {
id: save.password_id.filter(|id| !id.is_empty()),
name,
})
}
_ => None,
}
}
async fn authenticate_password_with_runtime_prompt(
handle: &mut client::Handle<SshHandler>,
config: &SshConfig,
app: &AppHandle,
initial_password: Option<&str>,
fallback_error: &str,
first_prompt_reason: Option<SshAuthPromptReason>,
initial_available_methods: Option<Vec<String>>,
otp_info: Option<&OtpAutoFillInfo>,
) -> AppResult<RuntimePasswordAuthOutcome> {
let mut runtime_secret: Option<RuntimeSecret> = None;
let mut password = initial_password.map(str::to_string);
let can_save = config.connection_id.is_some();
let mut advertised_methods = initial_available_methods;
for attempt in 1..=MAX_RUNTIME_SSH_AUTH_ATTEMPTS {
if password.as_deref().is_none_or(str::is_empty) {
if attempt == 1
&& first_prompt_reason
.is_none_or(|reason| reason == SshAuthPromptReason::MissingPassword)
{
match discover_auth_methods_before_secret_prompt(
handle,
config,
app,
fallback_error,
otp_info,
)
.await
{
Ok(Some(outcome)) => return Ok(outcome),
Ok(None) => {}
Err(failure) if failure.password_available() => {
advertised_methods = Some(failure.remaining_methods.clone());
}
Err(failure) if failure.publickey_available() => {
let key_passphrase = authenticate_publickey_with_runtime_key_prompt(
handle,
config,
app,
fallback_error,
SshAuthPromptReason::PublickeyRequired,
failure.remaining_methods.clone(),
otp_info,
)
.await?;
return Ok(RuntimePasswordAuthOutcome {
password: None,
key_passphrase,
});
}
Err(failure) => return Err(failure.into()),
}
}
let reason = first_prompt_reason.unwrap_or(SshAuthPromptReason::MissingPassword);
match request_runtime_auth_selection(
app,
config,
reason,
attempt,
can_save,
advertised_methods
.clone()
.unwrap_or_else(|| default_available_methods(reason)),
)
.await?
{
RuntimeAuthSelection::Password(secret) => {
password = Some(secret.value.clone());
runtime_secret = Some(secret);
}
RuntimeAuthSelection::Key(key_id) => {
let key_passphrase = authenticate_runtime_key_by_id(
handle,
config,
app,
fallback_error,
&key_id,
otp_info,
)
.await?;
return Ok(RuntimePasswordAuthOutcome {
password: None,
key_passphrase,
});
}
}
}
let Some(current_password) = password.as_deref() else {
continue;
};
let authenticated = handle
.authenticate_password(&config.username, current_password)
.await
.map_err(|error| AppError::Auth(format!("Authentication failed: {}", error)))?;
match try_keyboard_interactive_after_partial(
handle,
&authenticated,
&config.username,
config.connection_id.as_deref(),
&config.name,
config.owner_window_label.as_deref(),
app,
fallback_error,
Some(KeyboardInteractiveMode::PasswordFallback {
password: current_password,
}),
otp_info,
)
.await
{
Ok(()) => {
return Ok(RuntimePasswordAuthOutcome {
password: runtime_secret,
key_passphrase: None,
});
}
Err(failure) if attempt < MAX_RUNTIME_SSH_AUTH_ATTEMPTS => {
if failure.password_available() {
advertised_methods = Some(failure.remaining_methods.clone());
match request_runtime_auth_selection(
app,
config,
SshAuthPromptReason::PasswordRejected,
attempt + 1,
can_save,
failure.remaining_methods.clone(),
)
.await?
{
RuntimeAuthSelection::Password(secret) => {
password = Some(secret.value.clone());
runtime_secret = Some(secret);
}
RuntimeAuthSelection::Key(key_id) => {
let key_passphrase = authenticate_runtime_key_by_id(
handle,
config,
app,
fallback_error,
&key_id,
otp_info,
)
.await?;
return Ok(RuntimePasswordAuthOutcome {
password: None,
key_passphrase,
});
}
}
} else if failure.publickey_available() {
let key_passphrase = authenticate_publickey_with_runtime_key_prompt(
handle,
config,
app,
fallback_error,
SshAuthPromptReason::PublickeyRequired,
failure.remaining_methods.clone(),
otp_info,
)
.await?;
return Ok(RuntimePasswordAuthOutcome {
password: None,
key_passphrase,
});
} else {
return Err(failure.into());
}
}
Err(failure) => return Err(failure.into()),
}
}
Err(AppError::Auth(fallback_error.to_string()))
}
async fn discover_auth_methods_before_secret_prompt(
handle: &mut client::Handle<SshHandler>,
config: &SshConfig,
app: &AppHandle,
fallback_error: &str,
otp_info: Option<&OtpAutoFillInfo>,
) -> Result<Option<RuntimePasswordAuthOutcome>, SshAuthFailure> {
let authenticated = handle
.authenticate_none(&config.username)
.await
.map_err(|error| SshAuthFailure {
message: format!("None auth probe failed: {error}"),
remaining_methods: Vec::new(),
partial_success: false,
})?;
match try_keyboard_interactive_after_partial(
handle,
&authenticated,
&config.username,
config.connection_id.as_deref(),
&config.name,
config.owner_window_label.as_deref(),
app,
fallback_error,
None,
otp_info,
)
.await
{
Ok(()) => Ok(Some(RuntimePasswordAuthOutcome::default())),
Err(failure) => Err(failure),
}
}
async fn decode_secret_key_with_runtime_prompt(
key_data: &str,
initial_passphrase: Option<&str>,
config: &SshConfig,
app: &AppHandle,
) -> AppResult<(russh::keys::PrivateKey, Option<RuntimeSecret>)> {
match russh::keys::decode_secret_key(key_data, initial_passphrase) {
Ok(key) => return Ok((key, None)),
Err(error) if initial_passphrase.is_some() => {
tracing::debug!(%error, "Stored SSH key passphrase failed, requesting runtime passphrase");
}
Err(error) => {
tracing::debug!(%error, "SSH key decode failed, requesting runtime passphrase");
}
}
let can_save = matches!(
&config.auth,
SshAuth::Key {
key_id: Some(_),
..
}
);
let mut last_error: Option<russh::keys::Error> = None;
for attempt in 1..=MAX_RUNTIME_SSH_AUTH_ATTEMPTS {
let secret = request_runtime_secret(
app,
config,
SshAuthPromptReason::KeyPassphraseRequired,
attempt,
can_save,
)
.await?;
match russh::keys::decode_secret_key(key_data, Some(&secret.value)) {
Ok(key) => return Ok((key, Some(secret))),
Err(error) => {
last_error = Some(error);
}
}
}
Err(last_error
.map(AppError::from)
.unwrap_or_else(|| AppError::Auth("Invalid SSH key passphrase".to_string())))
}
fn load_runtime_key_auth(app: &AppHandle, key_id: &str) -> AppResult<SshAuth> {
let ssh_key = crate::config::load_key_by_id(app, key_id)?;
let key_data = crate::config::decrypt_key_pem(&ssh_key)?
.ok_or_else(|| AppError::Auth("No key data stored".to_string()))?;
let cert_data = crate::config::decrypt_key_cert(&ssh_key)?;
Ok(SshAuth::Key {
key_id: Some(key_id.to_string()),
key_data,
cert_data,
passphrase: ssh_key.passphrase,
})
}
async fn authenticate_publickey_with_runtime_selection(
handle: &mut client::Handle<SshHandler>,
config: &SshConfig,
app: &AppHandle,
fallback_error: &str,
otp_info: Option<&OtpAutoFillInfo>,
) -> Result<Option<RuntimeKeyPassphrase>, SshAuthFailure> {
authenticate_publickey_attempt(handle, config, app, &config.auth, fallback_error, otp_info)
.await
}
async fn authenticate_publickey_with_runtime_key_prompt(
handle: &mut client::Handle<SshHandler>,
config: &SshConfig,
app: &AppHandle,
fallback_error: &str,
reason: SshAuthPromptReason,
available_methods: Vec<String>,
otp_info: Option<&OtpAutoFillInfo>,
) -> AppResult<Option<RuntimeKeyPassphrase>> {
let mut last_failure: Option<SshAuthFailure> = None;
for attempt in 1..=MAX_RUNTIME_SSH_AUTH_ATTEMPTS {
let key_id =
request_runtime_key_id(app, config, reason, attempt, available_methods.clone()).await?;
let key_auth = load_runtime_key_auth(app, &key_id)?;
match authenticate_publickey_attempt(
handle,
config,
app,
&key_auth,
fallback_error,
otp_info,
)
.await
{
Ok(secret) => return Ok(secret),
Err(failure) => {
last_failure = Some(failure);
}
}
}
Err(last_failure
.map(AppError::from)
.unwrap_or_else(|| AppError::Auth(fallback_error.to_string())))
}
async fn authenticate_runtime_key_by_id(
handle: &mut client::Handle<SshHandler>,
config: &SshConfig,
app: &AppHandle,
fallback_error: &str,
key_id: &str,
otp_info: Option<&OtpAutoFillInfo>,
) -> AppResult<Option<RuntimeKeyPassphrase>> {
let key_auth = load_runtime_key_auth(app, key_id)?;
authenticate_publickey_attempt(handle, config, app, &key_auth, fallback_error, otp_info)
.await
.map_err(AppError::from)
}
async fn authenticate_publickey_attempt(
handle: &mut client::Handle<SshHandler>,
config: &SshConfig,
app: &AppHandle,
auth: &SshAuth,
fallback_error: &str,
otp_info: Option<&OtpAutoFillInfo>,
) -> Result<Option<RuntimeKeyPassphrase>, SshAuthFailure> {
let SshAuth::Key {
key_id,
key_data,
cert_data,
passphrase,
} = auth
else {
return Err(SshAuthFailure {
message: "No SSH key is configured".to_string(),
remaining_methods: vec!["publickey".to_string()],
partial_success: false,
});
};
let (key, key_passphrase_secret) =
decode_secret_key_with_runtime_prompt(key_data, passphrase.as_deref(), config, app)
.await
.map_err(|error| SshAuthFailure::from_error("Invalid SSH key passphrase", error))?;
let hash_alg = handle
.best_supported_rsa_hash()
.await
.ok()
.flatten()
.flatten();
let cert = cert_data
.as_deref()
.map(russh::keys::Certificate::from_openssh)
.transpose()
.map_err(|error| SshAuthFailure {
message: format!("Invalid OpenSSH certificate: {error}"),
remaining_methods: vec!["publickey".to_string()],
partial_success: false,
})?;
let cert_algorithm = cert.as_ref().map(|cert| cert.algorithm().to_string());
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"auth.start",
"Starting SSH authentication",
config.connection_id.as_deref(),
None,
Some(json!({
"host": config.host,
"port": config.port,
"username": config.username,
"auth_mode": "publickey",
"key_id": key_id,
"key_algorithm": key.algorithm().to_string(),
"certificate": cert.is_some(),
"certificate_algorithm": cert_algorithm,
"rsa_hash": format!("{hash_alg:?}"),
})),
None,
);
let authenticated = if let Some(cert) = cert {
handle
.authenticate_openssh_cert(&config.username, Arc::new(key), cert)
.await
.map_err(|error| SshAuthFailure {
message: format!("Certificate auth failed: {error}"),
remaining_methods: vec!["publickey".to_string()],
partial_success: false,
})?
} else {
handle
.authenticate_publickey(
&config.username,
russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key), hash_alg),
)
.await
.map_err(|error| SshAuthFailure {
message: format!("Key auth failed: {error}"),
remaining_methods: vec!["publickey".to_string()],
partial_success: false,
})?
};
try_keyboard_interactive_after_partial(
handle,
&authenticated,
&config.username,
config.connection_id.as_deref(),
&config.name,
config.owner_window_label.as_deref(),
app,
fallback_error,
None,
otp_info,
)
.await?;
Ok(key_id.as_ref().and_then(|id| {
key_passphrase_secret.map(|secret| RuntimeKeyPassphrase {
key_id: id.clone(),
secret,
})
}))
}
fn persist_runtime_auth_updates(
app: &AppHandle,
config: &SshConfig,
updates: &SshRuntimeAuthUpdates,
) -> AppResult<()> {
if let Some(secret) = &updates.password {
persist_runtime_password(app, config, secret)?;
}
if let Some(secret) = &updates.key_passphrase {
persist_runtime_key_passphrase(app, secret)?;
}
Ok(())
}
fn persist_runtime_password(
app: &AppHandle,
config: &SshConfig,
secret: &RuntimeSecret,
) -> AppResult<()> {
let Some(save) = &secret.save else {
return Ok(());
};
let Some(connection_id) = config.connection_id.as_deref() else {
return Ok(());
};
match save {
RuntimeSecretSave::ConnectionInline => {
let mut sessions = crate::config::load_config(app)?;
if let Some(conn) = sessions
.connections
.iter_mut()
.find(|candidate| candidate.id == connection_id)
{
let auth = conn.auth.get_or_insert_with(Default::default);
auth.mode = "password".to_string();
auth.password = Some(crate::utils::crypto::encrypt(&secret.value)?);
auth.password_id = None;
auth.has_password = false;
crate::config::save_config(app, &sessions)?;
emit_config_changed(app);
}
}
RuntimeSecretSave::SavedPassword { id, name } => {
let password_id =
upsert_runtime_saved_password(app, id.as_deref(), name, &secret.value)?;
let mut sessions = crate::config::load_config(app)?;
if let Some(conn) = sessions
.connections
.iter_mut()
.find(|candidate| candidate.id == connection_id)
{
let auth = conn.auth.get_or_insert_with(Default::default);
auth.mode = "password".to_string();
auth.password_id = Some(password_id);
auth.password = None;
auth.has_password = false;
crate::config::save_config(app, &sessions)?;
emit_config_changed(app);
}
}
RuntimeSecretSave::SavedPasswordReference { id } => {
let mut sessions = crate::config::load_config(app)?;
if let Some(conn) = sessions
.connections
.iter_mut()
.find(|candidate| candidate.id == connection_id)
{
let auth = conn.auth.get_or_insert_with(Default::default);
auth.mode = "password".to_string();
auth.password_id = Some(id.clone());
auth.password = None;
auth.has_password = false;
crate::config::save_config(app, &sessions)?;
emit_config_changed(app);
}
}
RuntimeSecretSave::KeyPassphrase => {}
}
Ok(())
}
fn upsert_runtime_saved_password(
app: &AppHandle,
id: Option<&str>,
name: &str,
value: &str,
) -> AppResult<String> {
let mut cfg = crate::config::load_passwords(app)?;
let target_id = id
.filter(|id| cfg.passwords.iter().any(|password| password.id == *id))
.map(str::to_string)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let entry_name = cfg
.passwords
.iter()
.find(|password| password.id == target_id)
.map(|password| password.name.clone())
.unwrap_or_else(|| name.to_string());
let entry = crate::config::SavedPassword {
id: target_id.clone(),
name: entry_name,
password: Some(crate::utils::crypto::encrypt(value)?),
has_password: false,
};
if let Some(existing) = cfg
.passwords
.iter_mut()
.find(|password| password.id == target_id)
{
*existing = entry;
} else {
cfg.passwords.push(entry);
}
crate::config::save_passwords(app, &cfg)?;
Ok(target_id)
}
fn persist_runtime_key_passphrase(
app: &AppHandle,
key_secret: &RuntimeKeyPassphrase,
) -> AppResult<()> {
if !matches!(
key_secret.secret.save,
Some(RuntimeSecretSave::KeyPassphrase)
) {
return Ok(());
}
let mut cfg = crate::config::load_keys(app)?;
if let Some(key) = cfg
.keys
.iter_mut()
.find(|candidate| candidate.id == key_secret.key_id)
{
key.passphrase = Some(crate::utils::crypto::encrypt(&key_secret.secret.value)?);
key.has_key_data = false;
key.has_cert_data = false;
crate::config::save_keys(app, &cfg)?;
emit_config_changed(app);
}
Ok(())
}
fn emit_config_changed(app: &AppHandle) {
let _ = app.emit("connections-changed", ());
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
crate::core::cloud_sync::notify_config_changed(&app_handle).await;
});
}
struct OtpAutoFillInfo {
otp_id: String,
auto_fill: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct TotpUseCandidate {
otp_id: String,
code: String,
time_step: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct UsedTotpCode {
code: String,
time_step: u64,
}
struct PreparedOtpResponses {
responses: Vec<String>,
used_totp: Option<TotpUseCandidate>,
}
static USED_TOTP_CODES: OnceLock<StdMutex<HashMap<String, UsedTotpCode>>> = OnceLock::new();
fn used_totp_codes() -> &'static StdMutex<HashMap<String, UsedTotpCode>> {
USED_TOTP_CODES.get_or_init(|| StdMutex::new(HashMap::new()))
}
fn unix_seconds_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before UNIX epoch")
.as_secs()
}
fn seconds_until_next_totp_step(now: u64, period: u64) -> u64 {
let period = period.max(1);
let remaining = period - (now % period);
remaining.max(1)
}
fn is_totp_code_reused(candidate: &TotpUseCandidate) -> bool {
let used = used_totp_codes()
.lock()
.expect("used TOTP code cache poisoned");
used.get(&candidate.otp_id).is_some_and(|record| {
record.code == candidate.code && record.time_step == candidate.time_step
})
}
fn record_totp_code_use(candidate: TotpUseCandidate) {
let mut used = used_totp_codes()
.lock()
.expect("used TOTP code cache poisoned");
used.insert(
candidate.otp_id,
UsedTotpCode {
code: candidate.code,
time_step: candidate.time_step,
},
);
}
#[derive(Debug, Clone, Copy)]
enum KeyboardInteractiveMode<'a> {
AdditionalFactor,
PasswordFallback { password: &'a str },
}
impl<'a> KeyboardInteractiveMode<'a> {
fn label(self) -> &'static str {
match self {
Self::AdditionalFactor => "additional-factor",
Self::PasswordFallback { .. } => "password-fallback",
}
}
fn password(self) -> Option<&'a str> {
match self {
Self::AdditionalFactor => None,
Self::PasswordFallback { password } => Some(password),
}
}
}
const MAX_KEYBOARD_INTERACTIVE_RESTARTS: u32 = 8;
fn resolve_otp_info(app: &AppHandle, connection_id: &str) -> Option<OtpAutoFillInfo> {
let conn = crate::config::load_connection_by_id(app, connection_id).ok()?;
let auth = conn.auth.as_ref()?;
let otp_id = auth.otp_id.clone()?;
Some(OtpAutoFillInfo {
otp_id,
auto_fill: auth.auto_fill_otp,
})
}
async fn wait_for_next_totp_code(
connection_id: Option<&str>,
otp_id: &str,
code: &crate::cmd::otp::TotpCodeResult,
) {
log_structured(
StructuredLogLevel::Info,
"security.flow",
"otp.reuse_wait",
"Waiting for next TOTP code before submitting keyboard-interactive response",
connection_id,
None,
Some(json!({
"otp_entry_id": otp_id,
"time_step": code.time_step,
"wait_seconds": code.remaining_seconds,
})),
None,
);
tokio::time::sleep(std::time::Duration::from_secs(
seconds_until_next_totp_step(unix_seconds_now(), code.period),
))
.await;
}
async fn generate_keyboard_interactive_otp_responses(
app: &AppHandle,
info: &OtpAutoFillInfo,
prompt_count: usize,
connection_id: Option<&str>,
) -> AppResult<PreparedOtpResponses> {
let now = unix_seconds_now();
let Some(mut code) = crate::cmd::otp::generate_totp_code_for_entry_at(app, &info.otp_id, now)?
else {
let result = crate::cmd::otp::generate_otp_for_entry(app, &info.otp_id)?;
return Ok(PreparedOtpResponses {
responses: vec![result.code; prompt_count],
used_totp: None,
});
};
let mut candidate = TotpUseCandidate {
otp_id: info.otp_id.clone(),
code: code.code.clone(),
time_step: code.time_step,
};
if is_totp_code_reused(&candidate) {
wait_for_next_totp_code(connection_id, &info.otp_id, &code).await;
code = crate::cmd::otp::generate_totp_code_for_entry_at(
app,
&info.otp_id,
unix_seconds_now(),
)?
.ok_or_else(|| AppError::Auth("OTP entry is no longer TOTP".to_string()))?;
candidate = TotpUseCandidate {
otp_id: info.otp_id.clone(),
code: code.code.clone(),
time_step: code.time_step,
};
}
Ok(PreparedOtpResponses {
responses: vec![code.code; prompt_count],
used_totp: Some(candidate),
})
}
async fn prepare_manual_otp_responses(
app: &AppHandle,
otp_info: Option<&OtpAutoFillInfo>,
responses: Vec<String>,
connection_id: Option<&str>,
) -> AppResult<PreparedOtpResponses> {
let Some(info) = otp_info else {
return Ok(PreparedOtpResponses {
responses,
used_totp: None,
});
};
if responses.is_empty() {
return Ok(PreparedOtpResponses {
responses,
used_totp: None,
});
}
let Some(mut code) =
crate::cmd::otp::generate_totp_code_for_entry_at(app, &info.otp_id, unix_seconds_now())?
else {
return Ok(PreparedOtpResponses {
responses,
used_totp: None,
});
};
let matching_indices: Vec<usize> = responses
.iter()
.enumerate()
.filter_map(|(index, response)| (response == &code.code).then_some(index))
.collect();
if matching_indices.is_empty() {
return Ok(PreparedOtpResponses {
responses,
used_totp: None,
});
}
let mut candidate = TotpUseCandidate {
otp_id: info.otp_id.clone(),
code: code.code.clone(),
time_step: code.time_step,
};
let mut responses = responses;
if is_totp_code_reused(&candidate) {
wait_for_next_totp_code(connection_id, &info.otp_id, &code).await;
code = crate::cmd::otp::generate_totp_code_for_entry_at(
app,
&info.otp_id,
unix_seconds_now(),
)?
.ok_or_else(|| AppError::Auth("OTP entry is no longer TOTP".to_string()))?;
for index in matching_indices {
if let Some(response) = responses.get_mut(index) {
*response = code.code.clone();
}
}
candidate = TotpUseCandidate {
otp_id: info.otp_id.clone(),
code: code.code.clone(),
time_step: code.time_step,
};
}
Ok(PreparedOtpResponses {
responses,
used_totp: Some(candidate),
})
}
/// Runs the keyboard-interactive auth state machine, emitting `otp-request` events
/// to the frontend for each `InfoRequest` that contains prompts, and automatically
/// responding with an empty array for empty `InfoRequest`s.
///
/// When `otp_info` is present with `auto_fill == true`, the OTP code is generated
/// automatically and used as the response without prompting the user.
async fn finish_keyboard_interactive(
handle: &mut client::Handle<SshHandler>,
username: &str,
connection_id: Option<&str>,
connection_name: &str,
target_window_label: Option<&str>,
app: &AppHandle,
mode: KeyboardInteractiveMode<'_>,
otp_info: Option<&OtpAutoFillInfo>,
) -> AppResult<()> {
let pending_mgr = app
.try_state::<Arc<PendingAuthManager>>()
.ok_or_else(|| AppError::Auth("PendingAuthManager not available".to_string()))?;
let pending_mgr = pending_mgr.inner().clone();
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"keyboard_interactive.start",
"Starting keyboard-interactive authentication",
connection_id,
None,
Some(json!({
"username": username,
"mode": mode.label(),
"auth_round": 0,
"partial_success_restart": false,
"response_kind": "start",
})),
None,
);
let mut step = handle
.authenticate_keyboard_interactive_start(username, None)
.await
.map_err(|error| AppError::Auth(format!("Keyboard-interactive start failed: {}", error)))?;
let mut pending_totp_use: Option<TotpUseCandidate> = None;
let mut round: u32 = 0;
let mut restart_count: u32 = 0;
loop {
match step {
KeyboardInteractiveAuthResponse::Success => {
if let Some(candidate) = pending_totp_use.take() {
record_totp_code_use(candidate);
}
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"keyboard_interactive.succeeded",
"Keyboard-interactive authentication succeeded",
connection_id,
None,
Some(json!({
"username": username,
"mode": mode.label(),
"auth_round": round,
"response_kind": "success",
})),
None,
);
return Ok(());
}
KeyboardInteractiveAuthResponse::Failure {
remaining_methods,
partial_success,
} => {
let keyboard_interactive_available =
remaining_methods.contains(&MethodKind::KeyboardInteractive);
if partial_success && keyboard_interactive_available {
if let Some(candidate) = pending_totp_use.take() {
record_totp_code_use(candidate);
}
restart_count = restart_count.saturating_add(1);
if restart_count <= MAX_KEYBOARD_INTERACTIVE_RESTARTS {
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"keyboard_interactive.partial_success",
"Keyboard-interactive partial success, restarting keyboard-interactive authentication",
connection_id,
None,
Some(json!({
"username": username,
"mode": mode.label(),
"remaining_methods": format!("{remaining_methods:?}"),
"partial_success": partial_success,
"partial_success_restart": true,
"auth_round": round,
"restart_count": restart_count,
"max_restarts": MAX_KEYBOARD_INTERACTIVE_RESTARTS,
"response_kind": "failure",
})),
None,
);
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"keyboard_interactive.start",
"Restarting keyboard-interactive authentication after partial success",
connection_id,
None,
Some(json!({
"username": username,
"mode": mode.label(),
"auth_round": round,
"restart_count": restart_count,
"partial_success_restart": true,
"response_kind": "start",
})),
None,
);
step = handle
.authenticate_keyboard_interactive_start(username, None)
.await
.map_err(|error| {
AppError::Auth(format!(
"Keyboard-interactive restart failed: {}",
error
))
})?;
continue;
}
}
log_structured(
StructuredLogLevel::Warn,
"ssh.auth",
"keyboard_interactive.failed",
"Keyboard-interactive authentication failed",
connection_id,
None,
Some(json!({
"username": username,
"mode": mode.label(),
"remaining_methods": format!("{remaining_methods:?}"),
"partial_success": partial_success,
"partial_success_restart": partial_success && keyboard_interactive_available,
"auth_round": round,
"restart_count": restart_count,
"max_restarts": MAX_KEYBOARD_INTERACTIVE_RESTARTS,
"response_kind": "failure",
})),
None,
);
return Err(AppError::Auth(
"Keyboard-interactive authentication failed".to_string(),
));
}
KeyboardInteractiveAuthResponse::InfoRequest {
name,
instructions,
prompts,
} => {
round = round.saturating_add(1);
let hidden_prompts = prompts.iter().filter(|prompt| !prompt.echo).count();
log_structured(
StructuredLogLevel::Debug,
"ssh.auth",
"keyboard_interactive.prompts_received",
"Received keyboard-interactive prompts",
connection_id,
None,
Some(json!({
"username": username,
"mode": mode.label(),
"prompt_count": prompts.len(),
"hidden_prompts": hidden_prompts,
"round": round,
"auth_round": round,
"response_kind": "info_request",
})),
None,
);
let responses = if prompts.is_empty() {
Vec::new()
} else if let Some(password) = mode
.password()
.filter(|_| should_auto_fill_password_prompts(&prompts))
{
log_structured(
StructuredLogLevel::Info,
"security.flow",
"keyboard_interactive.password_autofill",
"Auto-filling password for keyboard-interactive auth",
connection_id,
None,
Some(json!({
"username": username,
"prompt_count": prompts.len(),
})),
None,
);
vec![password.to_string()]
} else if let Some(info) =
otp_info.filter(|i| i.auto_fill && should_auto_fill_otp_prompts(&prompts))
{
log_structured(
StructuredLogLevel::Info,
"security.flow",
"otp.auto_fill",
"Auto-filling OTP for keyboard-interactive auth",
connection_id,
None,
Some(json!({
"username": username,
"otp_entry_id": info.otp_id,
"prompt_count": prompts.len(),
"round": round,
"auth_round": round,
})),
None,
);
let prepared = generate_keyboard_interactive_otp_responses(
app,
info,
prompts.len(),
connection_id,
)
.await?;
pending_totp_use = prepared.used_totp;
prepared.responses
} else {
let request_id = uuid::Uuid::new_v4().to_string();
let rx = pending_mgr.register(request_id.clone()).await;
let payload = OtpRequestPayload {
request_id: request_id.clone(),
connection_name: connection_name.to_string(),
name: normalize_optional_keyboard_interactive_text(&name),
instructions: normalize_optional_keyboard_interactive_text(&instructions),
round,
prompts: prompts
.iter()
.map(|prompt| OtpPrompt {
prompt: prompt.prompt.clone(),
echo: prompt.echo,
})
.collect(),
otp_entry_id: otp_info.map(|i| i.otp_id.clone()),
target_window_label: target_window_label.map(str::to_string),
};
log_structured(
StructuredLogLevel::Info,
"security.flow",
"otp.requested",
"Forwarding keyboard-interactive prompts to frontend",
connection_id,
Some(&request_id),
Some(json!({
"username": username,
"prompt_count": payload.prompts.len(),
"otp_entry_id": payload.otp_entry_id,
"round": payload.round,
"auth_round": round,
"response_kind": "info_request",
})),
None,
);
let _ = app.emit("otp-request", &payload);
let responses = match rx.await {
Ok(Some(responses)) => responses,
Ok(None) => {
return Err(AppError::Auth(
"2FA authentication cancelled by user".to_string(),
));
}
Err(_) => {
return Err(AppError::Auth(
"2FA authentication request dropped".to_string(),
));
}
};
let prepared =
prepare_manual_otp_responses(app, otp_info, responses, connection_id)
.await?;
pending_totp_use = prepared.used_totp;
prepared.responses
};
step = handle
.authenticate_keyboard_interactive_respond(responses)
.await
.map_err(|error| {
AppError::Auth(format!("Keyboard-interactive respond failed: {}", error))
})?;
}
}
}
}
/// After primary auth returns `Failure`, continue with keyboard-interactive when
/// the server advertises it and either partial success or an explicit fallback applies.
async fn try_keyboard_interactive_after_partial(
handle: &mut client::Handle<SshHandler>,
auth_result: &client::AuthResult,
username: &str,
connection_id: Option<&str>,
connection_name: &str,
target_window_label: Option<&str>,
app: &AppHandle,
fallback_error: &str,
keyboard_interactive_fallback: Option<KeyboardInteractiveMode<'_>>,
otp_info: Option<&OtpAutoFillInfo>,
) -> Result<(), SshAuthFailure> {
match auth_result {
client::AuthResult::Success => Ok(()),
client::AuthResult::Failure {
remaining_methods,
partial_success,
} => {
let keyboard_interactive_available =
remaining_methods.contains(&MethodKind::KeyboardInteractive);
let can_retry_keyboard_interactive =
keyboard_interactive_available && keyboard_interactive_fallback.is_some();
if *partial_success && keyboard_interactive_available {
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"auth.partial_success",
"Primary auth partial success, continuing with keyboard-interactive",
connection_id,
None,
Some(json!({
"username": username,
"remaining_methods": format!("{remaining_methods:?}"),
})),
None,
);
finish_keyboard_interactive(
handle,
username,
connection_id,
connection_name,
target_window_label,
app,
KeyboardInteractiveMode::AdditionalFactor,
otp_info,
)
.await
.map_err(|error| {
SshAuthFailure::from_auth_result(
format!("{fallback_error}: {error}"),
remaining_methods,
*partial_success,
)
})
} else if can_retry_keyboard_interactive {
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"auth.keyboard_interactive_fallback",
"Primary auth rejected, retrying with keyboard-interactive",
connection_id,
None,
Some(json!({
"username": username,
"remaining_methods": format!("{remaining_methods:?}"),
})),
None,
);
let Some(mode) = keyboard_interactive_fallback else {
return Err(SshAuthFailure::from_auth_result(
fallback_error,
remaining_methods,
*partial_success,
));
};
finish_keyboard_interactive(
handle,
username,
connection_id,
connection_name,
target_window_label,
app,
mode,
otp_info,
)
.await
.map_err(|error| {
SshAuthFailure::from_auth_result(
format!("{fallback_error}: {error}"),
remaining_methods,
*partial_success,
)
})
} else {
log_structured(
StructuredLogLevel::Warn,
"ssh.auth",
"auth.failed",
"SSH authentication failed without usable keyboard-interactive fallback",
connection_id,
None,
Some(json!({
"username": username,
"remaining_methods": format!("{remaining_methods:?}"),
"partial_success": partial_success,
})),
Some(json!({
"message": fallback_error,
})),
);
Err(SshAuthFailure::from_auth_result(
fallback_error,
remaining_methods,
*partial_success,
))
}
}
}
}
fn should_auto_fill_password_prompts(prompts: &[client::Prompt]) -> bool {
prompts.len() == 1
&& !prompts[0].echo
&& is_password_keyboard_interactive_prompt(&prompts[0].prompt)
}
fn should_auto_fill_otp_prompts(prompts: &[client::Prompt]) -> bool {
prompts.len() == 1 && is_otp_keyboard_interactive_prompt(&prompts[0].prompt)
}
fn is_otp_keyboard_interactive_prompt(prompt: &str) -> bool {
let normalized = prompt.to_lowercase();
let selection_markers = [
"select",
"choose",
"choice",
"option",
"method",
"delivery",
"send to",
"send via",
"push",
"sms/email",
"sms or email",
"email or sms",
"选择",
"请选择",
"选项",
"方式",
"方法",
"发送到",
"发送至",
];
if selection_markers
.iter()
.any(|marker| normalized.contains(marker))
{
return false;
}
[
"otp",
"totp",
"hotp",
"2fa",
"mfa",
"one-time",
"one time",
"verification code",
"authentication code",
"auth code",
"authenticator",
"passcode",
"token",
"验证码",
"校验码",
"动态码",
"动态密码",
"动态口令",
"一次性",
"令牌",
"双因素",
"二次",
"两步",
]
.iter()
.any(|marker| normalized.contains(marker))
}
fn is_password_keyboard_interactive_prompt(prompt: &str) -> bool {
let normalized = prompt.to_lowercase();
let additional_factor_markers = [
"otp",
"totp",
"hotp",
"2fa",
"mfa",
"one-time",
"one time",
"verification",
"authentication code",
"auth code",
"authenticator",
"passcode",
"token",
"code",
"验证码",
"校验码",
"动态码",
"动态密码",
"动态口令",
"一次性",
"令牌",
"双因素",
"二次",
"两步",
];
if additional_factor_markers
.iter()
.any(|marker| normalized.contains(marker))
{
return false;
}
["password", "passphrase", "密码", "口令"]
.iter()
.any(|marker| normalized.contains(marker))
}
fn normalize_optional_keyboard_interactive_text(text: &str) -> Option<String> {
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}
#[cfg(test)]
mod tests {
use super::{
KeyboardInteractiveMode, TotpUseCandidate, is_otp_keyboard_interactive_prompt,
is_password_keyboard_interactive_prompt, is_totp_code_reused, record_totp_code_use,
resolve_password_material, seconds_until_next_totp_step, should_auto_fill_otp_prompts,
should_auto_fill_password_prompts, used_totp_codes,
};
use crate::config::ConnectionAuth;
use russh::client::Prompt;
#[test]
fn auto_fills_single_hidden_keyboard_interactive_prompt() {
let prompts = vec![Prompt {
prompt: "Password: ".to_string(),
echo: false,
}];
assert!(should_auto_fill_password_prompts(&prompts));
}
#[test]
fn does_not_auto_fill_single_otp_keyboard_interactive_prompt() {
let prompts = vec![Prompt {
prompt: "Verification code: ".to_string(),
echo: false,
}];
assert!(!should_auto_fill_password_prompts(&prompts));
}
#[test]
fn does_not_treat_passcode_as_password_prompt() {
assert!(!is_password_keyboard_interactive_prompt("Passcode: "));
assert!(!is_password_keyboard_interactive_prompt("OTP Password: "));
assert!(!is_password_keyboard_interactive_prompt("动态口令: "));
assert!(!is_password_keyboard_interactive_prompt("验证码: "));
}
#[test]
fn recognizes_password_keyboard_interactive_prompts() {
assert!(is_password_keyboard_interactive_prompt(
"root@example.com's password: "
));
assert!(is_password_keyboard_interactive_prompt("Passphrase: "));
assert!(is_password_keyboard_interactive_prompt("密码: "));
assert!(is_password_keyboard_interactive_prompt("口令: "));
}
#[test]
fn does_not_auto_fill_multiple_keyboard_interactive_prompts() {
let prompts = vec![
Prompt {
prompt: "Password: ".to_string(),
echo: false,
},
Prompt {
prompt: "Verification code: ".to_string(),
echo: false,
},
];
assert!(!should_auto_fill_password_prompts(&prompts));
}
#[test]
fn does_not_auto_fill_echoed_prompt() {
let prompts = vec![Prompt {
prompt: "Username: ".to_string(),
echo: true,
}];
assert!(!should_auto_fill_password_prompts(&prompts));
}
#[test]
fn auto_fills_single_otp_keyboard_interactive_prompt() {
let prompts = vec![Prompt {
prompt: "Verification code: ".to_string(),
echo: false,
}];
assert!(should_auto_fill_otp_prompts(&prompts));
assert!(is_otp_keyboard_interactive_prompt("验证码: "));
}
#[test]
fn does_not_auto_fill_mfa_selection_prompts() {
for prompt in ["Select method:", "Option:", "1) SMS 2) Email"] {
let prompts = vec![Prompt {
prompt: prompt.to_string(),
echo: true,
}];
assert!(!should_auto_fill_otp_prompts(&prompts));
}
}
#[test]
fn password_prompt_does_not_trigger_otp_autofill() {
let prompts = vec![Prompt {
prompt: "Password: ".to_string(),
echo: false,
}];
assert!(should_auto_fill_password_prompts(&prompts));
assert!(!should_auto_fill_otp_prompts(&prompts));
}
#[test]
fn does_not_auto_fill_otp_for_multiple_prompts() {
let prompts = vec![
Prompt {
prompt: "Verification code: ".to_string(),
echo: false,
},
Prompt {
prompt: "Backup code: ".to_string(),
echo: false,
},
];
assert!(!should_auto_fill_otp_prompts(&prompts));
}
#[test]
fn additional_factor_mode_never_exposes_password_fallback() {
assert!(
KeyboardInteractiveMode::AdditionalFactor
.password()
.is_none()
);
}
#[test]
fn password_mode_without_material_returns_recoverable_error() {
let auth = ConnectionAuth {
mode: "password".to_string(),
..Default::default()
};
let password = resolve_password_material(None, &auth).unwrap();
assert_eq!(password, None);
}
#[test]
fn totp_step_wait_uses_remaining_period() {
assert_eq!(seconds_until_next_totp_step(31, 30), 29);
assert_eq!(seconds_until_next_totp_step(59, 30), 1);
assert_eq!(seconds_until_next_totp_step(60, 30), 30);
}
#[test]
fn used_totp_cache_matches_same_code_and_step_only() {
used_totp_codes()
.lock()
.expect("used TOTP code cache poisoned")
.clear();
let candidate = TotpUseCandidate {
otp_id: "otp-1".to_string(),
code: "123456".to_string(),
time_step: 42,
};
assert!(!is_totp_code_reused(&candidate));
record_totp_code_use(candidate.clone());
assert!(is_totp_code_reused(&candidate));
assert!(!is_totp_code_reused(&TotpUseCandidate {
time_step: 43,
..candidate.clone()
}));
assert!(!is_totp_code_reused(&TotpUseCandidate {
code: "654321".to_string(),
..candidate
}));
}
}