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:
1 parent
5fdfce239a
commit
0aa1d4452d
5 files changed
+247
-28
No files matched your search
+1
-1
@@ -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
@@ -16,7 +16,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
|
||||
主机对话框的「认证方式」有三种:
|
||||
|
||||
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。
|
||||
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。服务器只在键盘交互式认证里收密码时(如 ESXi),保存的密码会自动回答它唯一的隐藏问题;还有验证码等别的问题时照样询问。
|
||||
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。
|
||||
- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。
|
||||
|
||||
|
||||
+2
-2
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user