Files
navop/crates/ssh/tests/multi_factor_chain.rs
胡飞 23cb37bc6f chore(deps): 升级 gpui-fast v0.1.12、gpui-kit v0.8.1、russh 0.65.0 并适配 API 变更
gpui-fast(fork 分支 release/v0.1.12,快照 5e8e231f)
- 快照自含 24 个 crate + 全套 gpui-pre-* shim(0.3.126),并剥离 iOS:
  gpui_platform 不再引用 gpui_ios/gpui-pre-ios,navop 依赖解析不再需要该包
- 随上游同步:objc2 迁移、字体缓存、Taffy 0.15、wgpu 30.0.1、
  Windows 按需帧调度、未测量文本的测量修复

gpui-kit(fork 分支 port-ce-generic-to-main,rev 62035f8d,v0.8.1)
- gpui-component-shell 并入 gpui-shell,navop 改用 gpui_shell::components
  (components.rs 的 components()/init、渲染测试的 init/new_isolated_runtime)
- quickjs 系全部改 crates.io quickjs-jit 0.14.1(stdlib 已 vendor llrt 模块),
  删除 rquickjs 的 [patch.crates-io] 与 rquickjs-compat 依赖
- 保留 fork 的 gpui-base 状态桥:组件元素接受 gpui-base 铸造的 state 句柄。
  上游 #3505 会以 "expects the InputState from gpui-component" 拒绝,而扩展页
  的状态一律来自 gpui-base,故 fork 侧维持先认 base 句柄、诊断只拦真正异类句柄
- 新增 gpui_component::active_dialog_count(window, cx):对话框层改为 Presence
  动画后只能在渲染期采样,测试改为查询活动对话框数

API 适配
- ScrollWheelEvent → ScrollEvent,on_scroll_wheel → on_scroll(含 vendor/ferrum-flow)
- Modifiers::secondary() → secondary(cx):secondary 在 macOS 是 platform 键,
  需要 App 才能判定
- 动态字符串的 KeyBinding 统一走 one_core::keybindings::load_dynamic_keybinding
  (KeyBinding::load + context predicate),Keystroke::parse → parse_with_os(...,
  current_os());KeybindingKeystroke 不再实现 Display,改用 display(os)
- 测试侧:ScrollEvent 补 source 字段、用 VisualTestContext::read 取 &App 调
  secondary_key、Keystroke 断言改成 parse_with_os(..., Mac)

russh / russh-sftp
- russh 0.63.3 → 0.65.0,russh-sftp 3.0.0 → 3.0.1
- 0.65.0 仍钉 rsa 0.10.0-rc.18,与 quickjs-jit-stdlib 的 rsa 0.10.0-rc.19 冲突,
  继续用 fork feigeCode/russh 的 tag v0.65.0-navop.1(只改 rsa/pkcs1 版本,
  pkcs8 处改取 RsaPrivateKeyRef 以适配 pkcs1 rc.5 的泛型化)
- server::Handler::auth_publickey 改用 VerifiedPublicKey<'_>

验证
- RUSTFLAGS="-D warnings" cargo check --workspace 通过
- cargo test 覆盖 core、one_ui、markdown-editor、db_view、redis_view、
  terminal_view、ai_chat_view、notes、ssh、sftp、universal-plugins 全绿
2026-10-27 18:53:54 +08:00

632 lines
21 KiB
Rust

//! 多因素认证链回归:防火墙、交换机等设备会要求 `AuthenticationMethods password,publickey`
//! (或反过来),一次登录必须依次通过多个认证因素。
//!
//! 假服务器复刻设备侧的三种常见形态:
//! - 先密码后公钥 / 先公钥后密码:服务器在拒绝时给出还没满足的方法。
//! - 公钥被拒但服务器仍把公钥列在方法表里(真实设备常见的「组合要求未满足」形态),
//! 客户端必须在密码通过后回头再试公钥。
//! - 两个因素之后还要求动态验证码(keyboard-interactive)。
use std::borrow::Cow;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use russh::MethodSet;
use russh::keys::ssh_key::LineEnding;
use russh::keys::{Algorithm, PrivateKey};
use russh::server::{Auth, Response, Server as _};
use ssh::{
HostKeyVerifier, KeyboardInteractiveRequest, KeyboardInteractiveResponder, RusshClient,
SshAuth, SshClient, SshConnectConfig,
};
use tokio::net::TcpListener;
const SAVED_PASSWORD: &str = "saved-password";
const VERIFICATION_CODE: &str = "123456";
const USERNAME: &str = "tester";
/// 服务器观察到的客户端认证动作,按发生顺序记录。
#[derive(Debug, Clone, PartialEq, Eq)]
enum AuthEvent {
/// RFC 4252 的 "none" 方法探测,不算认证动作。
NoneProbe,
Password(String),
/// 客户端提交了带签名的公钥认证请求。
PublicKey,
KeyboardInteractiveStart,
KeyboardInteractiveAnswers(Vec<String>),
}
/// 去掉探测事件,只看真实的认证动作。
fn auth_actions(events: &[AuthEvent]) -> Vec<AuthEvent> {
events
.iter()
.filter(|event| !matches!(event, AuthEvent::NoneProbe))
.cloned()
.collect()
}
fn count_passwords(events: &[AuthEvent]) -> usize {
events
.iter()
.filter(|event| matches!(event, AuthEvent::Password(_)))
.count()
}
/// 服务器要求的认证阶段。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Stage {
Password,
PublicKey,
VerificationCode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChainMode {
/// 服务器只要求密码。
PasswordOnly,
/// `AuthenticationMethods password,publickey`
PasswordThenPublicKey,
/// `AuthenticationMethods publickey,password`
PublicKeyThenPassword,
/// 要求先密码后公钥,但方法表里把公钥列在前面,且拒绝公钥时仍列出公钥。
MessyPublicKeyListedWhilePasswordPending,
/// `AuthenticationMethods password,publickey,keyboard-interactive`
PasswordPublicKeyThenVerificationCode,
}
impl ChainMode {
/// 服务器实际要求的阶段,按顺序排列。
fn required_stages(self) -> Vec<Stage> {
match self {
ChainMode::PasswordOnly => vec![Stage::Password],
ChainMode::PasswordThenPublicKey
| ChainMode::MessyPublicKeyListedWhilePasswordPending => {
vec![Stage::Password, Stage::PublicKey]
}
ChainMode::PublicKeyThenPassword => vec![Stage::PublicKey, Stage::Password],
ChainMode::PasswordPublicKeyThenVerificationCode => {
vec![Stage::Password, Stage::PublicKey, Stage::VerificationCode]
}
}
}
/// 服务器在方法表里公布的方法列表。
fn advertised_methods(self) -> MethodSet {
let kinds: Vec<russh::MethodKind> = match self {
ChainMode::PasswordOnly => vec![russh::MethodKind::Password],
ChainMode::PasswordThenPublicKey => {
vec![russh::MethodKind::Password, russh::MethodKind::PublicKey]
}
ChainMode::PublicKeyThenPassword
| ChainMode::MessyPublicKeyListedWhilePasswordPending => {
vec![russh::MethodKind::PublicKey, russh::MethodKind::Password]
}
ChainMode::PasswordPublicKeyThenVerificationCode => vec![
russh::MethodKind::Password,
russh::MethodKind::PublicKey,
russh::MethodKind::KeyboardInteractive,
],
};
MethodSet::from(&kinds[..])
}
}
fn stage_method_kind(stage: Stage) -> russh::MethodKind {
match stage {
Stage::Password => russh::MethodKind::Password,
Stage::PublicKey => russh::MethodKind::PublicKey,
Stage::VerificationCode => russh::MethodKind::KeyboardInteractive,
}
}
#[derive(Clone)]
struct FakeChainServer {
mode: ChainMode,
public_key: russh::keys::PublicKey,
events: Arc<Mutex<Vec<AuthEvent>>>,
}
impl FakeChainServer {
fn new(mode: ChainMode, public_key: russh::keys::PublicKey) -> Self {
Self {
mode,
public_key,
events: Arc::new(Mutex::new(Vec::new())),
}
}
fn events(&self) -> Vec<AuthEvent> {
self.events.lock().expect("events lock").clone()
}
}
#[derive(Clone)]
struct FakeChainHandler {
mode: ChainMode,
public_key: russh::keys::PublicKey,
events: Arc<Mutex<Vec<AuthEvent>>>,
/// 已完成的认证阶段。
completed: Vec<Stage>,
}
impl FakeChainHandler {
fn record(&self, event: AuthEvent) {
self.events.lock().expect("events lock").push(event);
}
fn next_stage(&self) -> Option<Stage> {
self.mode
.required_stages()
.into_iter()
.find(|stage| !self.completed.contains(stage))
}
/// 还没满足的阶段对应的方法列表,按服务器要求的顺序给出。
fn remaining_methods(&self) -> Vec<russh::MethodKind> {
self.mode
.required_stages()
.into_iter()
.filter(|stage| !self.completed.contains(stage))
.map(stage_method_kind)
.collect()
}
fn reject_with(methods: &[russh::MethodKind], partial_success: bool) -> Auth {
Auth::Reject {
proceed_with_methods: Some(MethodSet::from(methods)),
partial_success,
}
}
/// 服务器公布的方法表,用于「要求的阶段还没轮到这个方法」时的拒绝。
fn reject_listing_advertised(&self) -> Auth {
Self::reject_with(&self.mode.advertised_methods(), false)
}
/// 当前阶段通过:还有后续阶段时给部分成功,否则完成认证。
fn accept_stage(&mut self, stage: Stage) -> Auth {
self.completed.push(stage);
match self.next_stage() {
None => Auth::Accept,
Some(_) => {
let remaining = self.remaining_methods();
Self::reject_with(&remaining, true)
}
}
}
fn answer_password(&mut self, password: &str) -> Auth {
self.record(AuthEvent::Password(password.to_string()));
if self.next_stage() != Some(Stage::Password) {
// 还没轮到密码(例如设备要求先公钥),按公布的方法表拒绝。
return self.reject_listing_advertised();
}
if password != SAVED_PASSWORD {
let remaining = self.remaining_methods();
return Self::reject_with(&remaining, false);
}
self.accept_stage(Stage::Password)
}
fn answer_public_key(&mut self, public_key: &russh::keys::PublicKey) -> Auth {
self.record(AuthEvent::PublicKey);
if public_key != &self.public_key {
let remaining = self.remaining_methods();
return Self::reject_with(&remaining, false);
}
if self.next_stage() != Some(Stage::PublicKey) {
return self.reject_listing_advertised();
}
self.accept_stage(Stage::PublicKey)
}
fn answer_keyboard_interactive(&mut self, response: Option<Response<'_>>) -> Auth {
match response {
None => {
self.record(AuthEvent::KeyboardInteractiveStart);
if self.next_stage() != Some(Stage::VerificationCode) {
let remaining = self.remaining_methods();
return Self::reject_with(&remaining, false);
}
Auth::Partial {
name: "MFA".into(),
instructions: "".into(),
prompts: Cow::Owned(vec![(Cow::Borrowed("Verification code: "), true)]),
}
}
Some(response) => {
let answers: Vec<String> = response
.map(|answer| String::from_utf8_lossy(&answer).to_string())
.collect();
self.record(AuthEvent::KeyboardInteractiveAnswers(answers.clone()));
if self.next_stage() == Some(Stage::VerificationCode)
&& answers == [VERIFICATION_CODE]
{
return self.accept_stage(Stage::VerificationCode);
}
let remaining = self.remaining_methods();
Self::reject_with(&remaining, false)
}
}
}
}
impl russh::server::Server for FakeChainServer {
type Handler = FakeChainHandler;
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Self::Handler {
FakeChainHandler {
mode: self.mode,
public_key: self.public_key.clone(),
events: self.events.clone(),
completed: Vec::new(),
}
}
}
impl russh::server::Handler for FakeChainHandler {
type Error = anyhow::Error;
async fn auth_none(&mut self, _: &str) -> Result<Auth, Self::Error> {
self.record(AuthEvent::NoneProbe);
Ok(Self::reject_with(&self.mode.advertised_methods(), false))
}
async fn auth_password(&mut self, _: &str, password: &str) -> Result<Auth, Self::Error> {
Ok(self.answer_password(password))
}
async fn auth_publickey(
&mut self,
_: &str,
public_key: russh::server::VerifiedPublicKey<'_>,
) -> Result<Auth, Self::Error> {
Ok(self.answer_public_key(public_key.public_key))
}
async fn auth_keyboard_interactive<'a>(
&'a mut self,
_: &str,
_: &str,
response: Option<Response<'a>>,
) -> Result<Auth, Self::Error> {
Ok(self.answer_keyboard_interactive(response))
}
}
/// 只回答动态验证码的 MFA 回调;密码由认证链自己提交。
struct VerificationCodeResponder {
requests: Mutex<Vec<KeyboardInteractiveRequest>>,
}
impl VerificationCodeResponder {
fn new() -> Arc<Self> {
Arc::new(Self {
requests: Mutex::new(Vec::new()),
})
}
fn prompt_lines(&self) -> Vec<String> {
self.requests
.lock()
.expect("requests lock")
.iter()
.map(|request| {
request
.prompts
.iter()
.map(|prompt| prompt.prompt.clone())
.collect::<Vec<_>>()
.join("|")
})
.collect()
}
}
#[async_trait::async_trait]
impl KeyboardInteractiveResponder for VerificationCodeResponder {
async fn respond(&self, request: KeyboardInteractiveRequest) -> anyhow::Result<Vec<String>> {
let answers = request
.prompts
.iter()
.map(|_| VERIFICATION_CODE.to_string())
.collect();
self.requests.lock().expect("requests lock").push(request);
Ok(answers)
}
}
/// 生成一对测试密钥,返回(客户端私钥 PEM,服务器侧公钥)。
fn test_key_pair() -> (String, russh::keys::PublicKey) {
let private_key = PrivateKey::random(&mut rand_010::rng(), Algorithm::Ed25519)
.expect("test client key should be generated");
let pem = private_key
.to_openssh(LineEnding::LF)
.expect("test client key should encode as OpenSSH PEM")
.to_string();
(pem, private_key.public_key().clone())
}
async fn spawn_server(
server: FakeChainServer,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
let socket = TcpListener::bind(("127.0.0.1", 0))
.await
.expect("fake chain server should bind");
let address = socket
.local_addr()
.expect("fake chain server should have an address");
let methods = server.mode.advertised_methods();
let server_config = Arc::new(russh::server::Config {
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
methods,
keys: vec![
PrivateKey::random(&mut rand_010::rng(), Algorithm::Ed25519)
.expect("test host key should be generated"),
],
..Default::default()
});
let task = tokio::spawn(async move {
let mut server = server;
let _ = server.run_on_socket(server_config, &socket).await;
});
(address, task)
}
fn connect_config(
address: std::net::SocketAddr,
auth: SshAuth,
responder: Option<Arc<VerificationCodeResponder>>,
) -> SshConnectConfig {
SshConnectConfig {
host: address.ip().to_string(),
port: address.port(),
username: USERNAME.to_string(),
auth,
timeout: Some(Duration::from_secs(30)),
keepalive_interval: None,
keepalive_max: None,
jump_server: None,
proxy: None,
keyboard_interactive_responder: responder
.map(|responder| responder as Arc<dyn KeyboardInteractiveResponder>),
host_key_verifier: HostKeyVerifier::insecure(),
x11_forwarding: false,
allow_legacy_algorithms: false,
forward_agent: false,
agent_identities: Vec::new(),
}
}
/// 「密码 + 密钥」组合:设备要求先密码后公钥时能完成认证。
#[tokio::test]
async fn password_then_public_key_chain_authenticates() {
let (private_key, public_key) = test_key_pair();
let server = FakeChainServer::new(ChainMode::PasswordThenPublicKey, public_key);
let (address, task) = spawn_server(server.clone()).await;
let result = RusshClient::connect(connect_config(
address,
SshAuth::Chain(vec![
SshAuth::Password(SAVED_PASSWORD.to_string()),
SshAuth::PrivateKeyContent {
private_key,
passphrase: None,
certificate_path: None,
},
]),
None,
))
.await;
task.abort();
let events = server.events();
assert!(
result.is_ok(),
"先密码后公钥的设备应能完成认证,实际错误:{:?},服务器观察到的动作:{events:?}",
result.err()
);
assert_eq!(
auth_actions(&events),
vec![
AuthEvent::Password(SAVED_PASSWORD.to_string()),
AuthEvent::PublicKey,
],
"配置顺序与设备要求一致时应依次提交密码与公钥"
);
}
/// 「密码 + 密钥」组合:设备要求先公钥后密码时,客户端要按服务器给出的顺序提交。
#[tokio::test]
async fn public_key_then_password_chain_authenticates() {
let (private_key, public_key) = test_key_pair();
let server = FakeChainServer::new(ChainMode::PublicKeyThenPassword, public_key);
let (address, task) = spawn_server(server.clone()).await;
let result = RusshClient::connect(connect_config(
address,
SshAuth::Chain(vec![
SshAuth::Password(SAVED_PASSWORD.to_string()),
SshAuth::PrivateKeyContent {
private_key,
passphrase: None,
certificate_path: None,
},
]),
None,
))
.await;
task.abort();
let events = server.events();
assert!(
result.is_ok(),
"先公钥后密码的设备应能完成认证,实际错误:{:?},服务器观察到的动作:{events:?}",
result.err()
);
assert_eq!(
auth_actions(&events),
vec![
AuthEvent::PublicKey,
AuthEvent::Password(SAVED_PASSWORD.to_string()),
],
"提交顺序应跟随服务器的 remaining_methods,而不是配置顺序"
);
}
/// 设备在密码通过前拒绝公钥、却仍把公钥列在方法表里时,客户端要在密码通过后回头再试公钥。
#[tokio::test]
async fn public_key_is_retried_after_password_succeeds() {
let (private_key, public_key) = test_key_pair();
let server = FakeChainServer::new(
ChainMode::MessyPublicKeyListedWhilePasswordPending,
public_key,
);
let (address, task) = spawn_server(server.clone()).await;
let result = RusshClient::connect(connect_config(
address,
SshAuth::Chain(vec![
SshAuth::Password(SAVED_PASSWORD.to_string()),
SshAuth::PrivateKeyContent {
private_key,
passphrase: None,
certificate_path: None,
},
]),
None,
))
.await;
task.abort();
let events = server.events();
assert!(
result.is_ok(),
"方法表顺序与真实要求不一致时仍应完成认证,实际错误:{:?},服务器观察到的动作:{events:?}",
result.err()
);
assert_eq!(
auth_actions(&events),
vec![
AuthEvent::PublicKey,
AuthEvent::Password(SAVED_PASSWORD.to_string()),
AuthEvent::PublicKey,
],
"公钥被拒后应先补密码,密码通过后再回头提交公钥"
);
}
/// 密码错误时每个因素只提交一次,不反复试错触发设备的失败次数限制。
#[tokio::test]
async fn wrong_password_does_not_repeat_attempts() {
let (private_key, public_key) = test_key_pair();
let server = FakeChainServer::new(ChainMode::PasswordThenPublicKey, public_key);
let (address, task) = spawn_server(server.clone()).await;
let result = RusshClient::connect(connect_config(
address,
SshAuth::Chain(vec![
SshAuth::Password("wrong-password".to_string()),
SshAuth::PrivateKeyContent {
private_key,
passphrase: None,
certificate_path: None,
},
]),
None,
))
.await;
task.abort();
let events = server.events();
assert!(result.is_err(), "凭据错误时必须报认证失败");
assert_eq!(
count_passwords(&events),
1,
"密码被拒后不应重复提交同一个因素,实际动作:{events:?}"
);
assert_eq!(
auth_actions(&events),
vec![
AuthEvent::Password("wrong-password".to_string()),
AuthEvent::PublicKey,
],
"每个因素在一次无进展期间只提交一次,实际动作:{events:?}"
);
}
/// 组合认证之后的动态验证码仍由 MFA 回调完成。
#[tokio::test]
async fn chain_then_verification_code_authenticates() {
let (private_key, public_key) = test_key_pair();
let server = FakeChainServer::new(ChainMode::PasswordPublicKeyThenVerificationCode, public_key);
let (address, task) = spawn_server(server.clone()).await;
let responder = VerificationCodeResponder::new();
let result = RusshClient::connect(connect_config(
address,
SshAuth::Chain(vec![
SshAuth::Password(SAVED_PASSWORD.to_string()),
SshAuth::PrivateKeyContent {
private_key,
passphrase: None,
certificate_path: None,
},
]),
Some(responder.clone()),
))
.await;
task.abort();
let events = server.events();
assert!(
result.is_ok(),
"密码 + 密钥 + 验证码的链路应能完成认证,实际错误:{:?},服务器观察到的动作:{events:?}",
result.err()
);
assert_eq!(
auth_actions(&events),
vec![
AuthEvent::Password(SAVED_PASSWORD.to_string()),
AuthEvent::PublicKey,
AuthEvent::KeyboardInteractiveStart,
AuthEvent::KeyboardInteractiveAnswers(vec![VERIFICATION_CODE.to_string()]),
],
"两个静态因素之后应继续用 MFA 回调完成动态验证码"
);
assert_eq!(
responder.prompt_lines(),
vec!["Verification code: ".to_string()],
"验证码提示应交给用户,密码由认证链自己提交"
);
}
/// 链里只剩一个因素时退化为普通认证,不做任何探测。
#[tokio::test]
async fn single_factor_chain_delegates_to_regular_authentication() {
let (_, public_key) = test_key_pair();
let server = FakeChainServer::new(ChainMode::PasswordOnly, public_key);
let (address, task) = spawn_server(server.clone()).await;
let result = RusshClient::connect(connect_config(
address,
SshAuth::Chain(vec![SshAuth::Password(SAVED_PASSWORD.to_string())]),
None,
))
.await;
task.abort();
assert!(result.is_ok(), "单因素链应直接完成认证:{:?}", result.err());
assert_eq!(
server.events(),
vec![AuthEvent::Password(SAVED_PASSWORD.to_string())],
"单因素链不应产生额外的探测请求"
);
}