diff --git a/docs/manual.en.md b/docs/manual.en.md index d5b81f1..39d5df2 100644 --- a/docs/manual.en.md +++ b/docs/manual.en.md @@ -16,7 +16,7 @@ Each item saved in the left sidebar is a host: an address, a port and a way to a The host dialog’s “Authentication” has three options: -- **Password**: enter a username and password; the password is kept in the system keychain. Leave the password empty to be asked at each connection. Only the password is used: the SSH agent and private keys aren’t tried first. +- **Password**: enter a username and password; the password is kept in the system keychain. Leave the password empty to be asked at each connection. Only the password is used: the SSH agent and private keys aren’t tried first. When the server takes passwords only through keyboard-interactive authentication (ESXi, for one), the saved password answers its single hidden question; other questions, such as a verification code, are still asked. - **Credential**: choose a saved credential. The username and the way to log in both come from the credential (see “Credentials” below), so the dialog no longer asks for a username or password. “Test connection” also uses the password saved in the credential. To log in with a private key, create a key credential. - **No password**: enter only the username. ShellRS tries logging in without authentication, then the SSH agent, then the default private keys in `~/.ssh` (`id_ed25519`, `id_ecdsa`, `id_rsa`), the same as the `ssh` command does by default. If the server asks for a password, the connection fails with the reason instead of asking; if the server asks for a verification code after the key, you’re still asked for it. diff --git a/docs/manual.md b/docs/manual.md index acd4db7..fa4c94b 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -16,7 +16,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 主机对话框的「认证方式」有三种: -- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。 +- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。服务器只在键盘交互式认证里收密码时(如 ESXi),保存的密码会自动回答它唯一的隐藏问题;还有验证码等别的问题时照样询问。 - **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。 - **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。 diff --git a/locales/ssh.yml b/locales/ssh.yml index 47a2bc2..63cefce 100644 --- a/locales/ssh.yml +++ b/locales/ssh.yml @@ -365,5 +365,5 @@ ssh.exec.saved_passphrase_rejected: zh-CN: 保存的私钥口令不正确:请先在 ShellRS 中连接一次这台主机并保存密码 en: The saved private key passphrase is incorrect. Connect to this host in ShellRS once and save the right one ssh.exec.keyboard_interactive: - zh-CN: 服务器要求键盘交互式认证:请先在 ShellRS 中连接一次这台主机并保存密码 - en: The server asks for keyboard-interactive authentication. Connect to this host in ShellRS once and save the password + zh-CN: 服务器在键盘交互式认证中提出了密码以外的问题(如验证码),外部 CLI 无法回答 + en: The server asks keyboard-interactive questions besides the password (such as a verification code), which the external CLI can’t answer diff --git a/src/ssh/connection.rs b/src/ssh/connection.rs index 9102e17..297374b 100644 --- a/src/ssh/connection.rs +++ b/src/ssh/connection.rs @@ -950,10 +950,14 @@ async fn authenticate( } if method == LoginMethod::Password || partial { + // The saved password goes to the server once, by whichever method + // asks for it first. + let mut saved_offered = false; + let mut saved_rejected = false; if methods.contains(&MethodKind::Password) { // Try what the host has saved before bothering anyone. - let mut saved_rejected = false; if let Some(saved) = saved_secret(secrets, &login.password) { + saved_offered = true; let result = handle .authenticate_password(user, saved.to_string()) .await @@ -1001,8 +1005,20 @@ async fn authenticate( } } if methods.contains(&MethodKind::KeyboardInteractive) { + // A password login whose server takes passwords only this way + // (ESXi) is asked for it as a single hidden question. After a + // password that got partway, the question is a second factor. + let mut password = + (method == LoginMethod::Password && !partial).then(|| KeyboardPassword { + saved: if saved_offered { + None + } else { + saved_secret(secrets, &login.password) + }, + rejected: saved_rejected, + }); for _ in 0..AUTH_RETRIES { - if keyboard_interactive(handle, user, broker).await? { + if keyboard_interactive(handle, user, broker, password.as_mut()).await? { return Ok(()); } } @@ -1011,6 +1027,14 @@ async fn authenticate( bail!(t!("ssh.auth.failed")) } +/// What a password login brings to keyboard-interactive authentication. +struct KeyboardPassword { + /// The saved password, until it is sent. + saved: Option>, + /// The server refused the saved password. + rejected: bool, +} + type Agent = russh::keys::agent::client::AgentClient< Box, >; @@ -1234,44 +1258,88 @@ async fn ask_one_secret( } } +/// One keyboard-interactive exchange, `false` when the server refuses it. +/// +/// For a password login, the exchange's first single hidden question asks +/// for the password: the saved one answers it unasked, as WinSCP's does, +/// and a connection that cannot ask says the password is what it lacks. async fn keyboard_interactive( handle: &mut SshHandle, user: &str, broker: Asker<'_>, + mut password: Option<&mut KeyboardPassword>, ) -> Result { let mut response = handle .authenticate_keyboard_interactive_start(user, None) .await .map_err(|_| anyhow!(t!("ssh.auth.keyboard_start_failed")))?; + let mut password_asked = false; + let mut saved_sent = false; loop { match response { KeyboardInteractiveAuthResponse::Success => return Ok(true), - KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false), + KeyboardInteractiveAuthResponse::Failure { .. } => { + if saved_sent && let Some(password) = password.as_deref_mut() { + password.rejected = true; + } + return Ok(false); + } KeyboardInteractiveAuthResponse::InfoRequest { name, instructions, prompts, } => { - let fields = prompts - .into_iter() - .map(|prompt| ConnectionPromptField::new(prompt.prompt, prompt.echo)) - .collect(); - let reply = broker - .ask_credential( - MissingCredential::KeyboardInteractive, - ConnectionPromptKind::authentication(name, instructions, fields), - ) - .await?; - let ConnectionPromptReply::Answers(answers) = reply else { - bail!(t!("ssh.auth.keyboard_cancelled")) + let asks_password = password.is_some() + && !password_asked + && matches!(prompts.as_slice(), [prompt] if !prompt.echo); + password_asked |= asks_password; + let saved = password + .as_deref_mut() + .filter(|_| asks_password) + .and_then(|password| password.saved.take()); + let answers = if prompts.is_empty() { + // Only something to read, which OpenSSH passes on from + // PAM this way; the server waits for the empty reply. + Vec::new() + } else if let Some(saved) = saved { + saved_sent = true; + vec![saved.to_string()] + } else { + let rejected = password + .as_deref() + .is_some_and(|password| password.rejected); + let need = if asks_password { + MissingCredential::Password { rejected } + } else { + MissingCredential::KeyboardInteractive + }; + let instructions = if !(asks_password && rejected) { + instructions + } else if instructions.trim().is_empty() { + t!("ssh.auth.saved_password_rejected").to_string() + } else { + format!("{}\n{instructions}", t!("ssh.auth.saved_password_rejected")) + }; + let fields = prompts + .into_iter() + .map(|prompt| ConnectionPromptField::new(prompt.prompt, prompt.echo)) + .collect(); + let reply = broker + .ask_credential( + need, + ConnectionPromptKind::authentication(name, instructions, fields), + ) + .await?; + let ConnectionPromptReply::Answers(answers) = reply else { + bail!(t!("ssh.auth.keyboard_cancelled")) + }; + answers + .into_iter() + .map(|answer| answer.into_inner()) + .collect() }; response = handle - .authenticate_keyboard_interactive_respond( - answers - .into_iter() - .map(|answer| answer.into_inner()) - .collect(), - ) + .authenticate_keyboard_interactive_respond(answers) .await .map_err(|_| anyhow!(t!("ssh.auth.keyboard_failed")))?; } diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs index 627b6cf..3b41021 100644 --- a/src/ssh/transport.rs +++ b/src/ssh/transport.rs @@ -416,6 +416,9 @@ mod tests { auth: TestAuth, /// What an exec channel writes back for a given command. probe_reply: fn(&str) -> Option<&'static str>, + /// This connection answered [`TestAuth::KeyboardPassword`]'s + /// password question correctly. + password_answered: bool, } #[derive(Clone)] @@ -423,6 +426,8 @@ mod tests { Password, PublicKey(PublicKey), KeyboardInteractive, + /// The password, through keyboard-interactive only, as ESXi takes it. + KeyboardPassword, } impl TestAuth { @@ -430,12 +435,50 @@ mod tests { let method = match self { Self::Password => MethodKind::Password, Self::PublicKey(_) => MethodKind::PublicKey, - Self::KeyboardInteractive => MethodKind::KeyboardInteractive, + Self::KeyboardInteractive | Self::KeyboardPassword => { + MethodKind::KeyboardInteractive + } }; MethodSet::from(&[method][..]) } } + impl TestServer { + /// One round of ESXi's exchange: the password as a single hidden + /// question, then, as OpenSSH's PAM can, a round with nothing to + /// answer. + fn password_question(&mut self, response: Option>) -> server::Auth { + let Some(response) = response else { + self.password_answered = false; + return server::Auth::Partial { + name: Cow::Borrowed(""), + instructions: Cow::Borrowed(""), + prompts: Cow::Owned(vec![(Cow::Borrowed("Password: "), false)]), + }; + }; + let answers: Vec<_> = response.collect(); + match answers.as_slice() { + [answer] if answer.as_ref() == TEST_PASSWORD.as_bytes() => { + self.password_answered = true; + server::Auth::Partial { + name: Cow::Borrowed(""), + instructions: Cow::Borrowed("Password expires in 7 days"), + prompts: Cow::Owned(Vec::new()), + } + } + [] if self.password_answered => server::Auth::Accept, + // Refused, with keyboard-interactive still on offer, as + // OpenSSH does. + _ => server::Auth::Reject { + proceed_with_methods: Some(MethodSet::from( + &[MethodKind::KeyboardInteractive][..], + )), + partial_success: false, + }, + } + } + } + impl server::Server for TestServer { type Handler = Self; @@ -486,8 +529,10 @@ mod tests { _: &str, response: Option>, ) -> Result { - if !matches!(self.auth, TestAuth::KeyboardInteractive) { - return Ok(server::Auth::UnsupportedMethod); + match self.auth { + TestAuth::KeyboardInteractive => {} + TestAuth::KeyboardPassword => return Ok(self.password_question(response)), + _ => return Ok(server::Auth::UnsupportedMethod), } let Some(mut response) = response else { return Ok(server::Auth::Partial { @@ -693,6 +738,7 @@ mod tests { state: server_state, auth, probe_reply, + password_answered: false, }; let running = server.run_on_socket(config, &listener); ready_tx.send(Ok((port, running.handle()))).unwrap(); @@ -1919,6 +1965,111 @@ mod tests { assert!(saw_challenge); } + #[test] + fn a_connection_test_answers_a_keyboard_interactive_password_question() { + let Some(server) = start_server(TestAuth::KeyboardPassword) else { + eprintln!("loopback sockets are unavailable in this sandbox; skipping"); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + let test = |request| { + test_login( + request, + &known_hosts, + Arc::new(InMemorySecretStore::default()), + true, + ) + .0 + }; + + assert_eq!( + test(login_request(server.port).with_password(TEST_PASSWORD)), + Ok(()) + ); + assert_eq!( + test(login_request(server.port).with_password("wrong")), + Err("用户名或密码错误".to_string()) + ); + assert_eq!( + test(login_request(server.port)), + Err("未填写密码".to_string()) + ); + } + + #[test] + fn exec_answers_a_keyboard_interactive_password_question_with_the_saved_password() { + let Some(server) = start_server(TestAuth::KeyboardPassword) else { + eprintln!("loopback sockets are unavailable in this sandbox; skipping"); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + trust_server(server.port, &known_hosts); + let host = password_host(server.port); + let saved = |password: Option<&str>| { + let keychain = Arc::new(InMemorySecretStore::default()); + if let Some(password) = password { + keychain.set(&host.password_secret(), password).unwrap(); + } + keychain + }; + + let (result, stdout, _) = run_cli_command(&host, &known_hosts, saved(Some(TEST_PASSWORD))); + assert_eq!(result, Ok(crate::ssh::ExecExit::Code(3))); + assert_eq!(stdout, b"out"); + + for (password, message) in [ + (None, "没有保存密码"), + (Some("wrong"), "保存的密码被服务器拒绝"), + ] { + let (result, _, _) = run_cli_command(&host, &known_hosts, saved(password)); + let error = result.unwrap_err(); + assert_eq!(error.kind, crate::ssh::ExecErrorKind::MissingCredential); + assert!(error.message.contains(message), "{}", error.message); + } + } + + #[test] + fn a_rejected_saved_password_asks_the_keyboard_interactive_question_again() { + let Some(server) = start_server(TestAuth::KeyboardPassword) else { + eprintln!("loopback sockets are unavailable in this sandbox; skipping"); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let host = password_host(server.port); + let keychain = Arc::new(InMemorySecretStore::default()); + keychain.set(&host.password_secret(), "wrong").unwrap(); + + let report = connect_with_secrets( + host, + &directory.path().join("known_hosts"), + keychain, + |prompt| match prompt { + ConnectionPromptKind::UnknownHost(_) => ConnectionPromptReply::TrustAndSave, + ConnectionPromptKind::Authentication(authentication) => { + assert_eq!( + authentication.instructions(), + "已保存的密码被服务器拒绝,请重新输入" + ); + assert_eq!(authentication.fields().len(), 1); + assert_eq!(authentication.fields()[0].label(), "Password: "); + assert!(!authentication.fields()[0].echo()); + ConnectionPromptReply::Answers(vec![ConnectionSecret::new(TEST_PASSWORD)]) + } + other => panic!("unexpected prompt: {other:?}"), + }, + ); + // Asked once, for the password; the round with nothing to answer + // after it is answered without asking. + let asked = report + .prompts + .iter() + .filter(|prompt| matches!(prompt, ConnectionPromptKind::Authentication(_))) + .count(); + assert_eq!(asked, 1); + } + #[test] fn changed_host_key_is_reported_and_blocked() { let Some(server) = start_server(TestAuth::Password) else {