ESXi 等只在键盘交互式认证里收密码的主机,用保存的密码自动登录

ESXi 的 sshd 关了 password 方式,只开 publickey 和 keyboard-interactive。保存的密码只走
password 方式,于是测试连接报「服务器要求键盘交互式认证」,外部 CLI 和端口转发连不上,
在应用里每次连接都要手输。

现在「密码」登录遇到键盘交互式认证的第一个单独的隐藏问题,就用保存的密码(测试连接时是
对话框里填的)回答,同 WinSCP 默认的「用密码响应第一个提示」。一次登录里保存的密码只发
一次;密码之后的验证码等问题照样问人。被拒或没有密码时,不能询问的连接报缺的是密码,应用
里的询问写明保存的密码被拒。没有问题的一轮直接回空,不再弹框。外部 CLI 遇到密码以外的问题
不再让人「保存密码」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-10 17:12:19 +08:00
1 parent 5fdfce239a
commit 0aa1d4452d
5 files changed
+247 -28

No files matched your search

+1 -1
View File
@@ -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.
+1 -1
View File
@@ -16,7 +16,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
主机对话框的「认证方式」有三种:
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。服务器只在键盘交互式认证里收密码时(如 ESXi),保存的密码会自动回答它唯一的隐藏问题;还有验证码等别的问题时照样询问。
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。
- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。
+2 -2
View File
@@ -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
+89 -21
View File
@@ -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<Zeroizing<String>>,
/// The server refused the saved password.
rejected: bool,
}
type Agent = russh::keys::agent::client::AgentClient<
Box<dyn russh::keys::agent::client::AgentStream + Send + Unpin>,
>;
@@ -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<bool> {
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")))?;
}
+154 -3
View File
@@ -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::Response<'_>>) -> 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<server::Response<'a>>,
) -> Result<server::Auth, Self::Error> {
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 {