feat: certificate authentication for SFTP VFS provider (#3716)

Co-authored-by: sxyazi <sxyazi@gmail.com>
This commit is contained in:
itsvyle 2026-03-07 08:45:27 +01:00 committed by GitHub
parent f10b8910ad
commit 741f84e22b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 99 additions and 25 deletions

View file

@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
- Custom tab name ([#3666])
- New `--in` for `search` action to set search directory ([#3696])
- Multi-file spotter ([#3733])
- Certificate authentication for SFTP VFS provider ([#3716])
- New `hovered` condition specifying different icons for hovered files ([#3728])
- Allow using `ps.sub()` in `init.lua` directly without a plugin ([#3638])
- New `sort_fallback` option to control fallback sorting behavior ([#3077])
@ -1672,6 +1673,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
[#3689]: https://github.com/sxyazi/yazi/pull/3689
[#3696]: https://github.com/sxyazi/yazi/pull/3696
[#3708]: https://github.com/sxyazi/yazi/pull/3708
[#3716]: https://github.com/sxyazi/yazi/pull/3716
[#3725]: https://github.com/sxyazi/yazi/pull/3725
[#3728]: https://github.com/sxyazi/yazi/pull/3728
[#3733]: https://github.com/sxyazi/yazi/pull/3733

27
Cargo.lock generated
View file

@ -2047,9 +2047,9 @@ dependencies = [
[[package]]
name = "inotify"
version = "0.11.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
dependencies = [
"bitflags 2.11.0",
"inotify-sys",
@ -4255,12 +4255,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -4677,9 +4677,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.0.4+spec-1.1.0"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c94c3321114413476740df133f0d8862c61d87c8d26f04c6841e033c8c80db47"
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
dependencies = [
"indexmap 2.13.0",
"serde_core",
@ -4903,9 +4903,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.21.0"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"atomic",
"getrandom 0.4.2",
@ -5214,9 +5214,9 @@ dependencies = [
[[package]]
name = "which"
version = "8.0.0"
version = "8.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d"
checksum = "3a824aeba0fbb27264f815ada4cff43d65b1741b7a4ed7629ff9089148c4a4e0"
dependencies = [
"env_home",
"rustix 1.1.4",
@ -5594,9 +5594,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winsafe"
@ -6238,6 +6238,7 @@ name = "yazi-vfs"
version = "26.2.2"
dependencies = [
"anyhow",
"chrono",
"deadpool",
"either",
"futures",

View file

@ -39,6 +39,7 @@ ansi-to-tui = "8.0.1"
anyhow = "1.0.102"
base64 = "0.22.1"
bitflags = { version = "2.11.0", features = [ "serde" ] }
chrono = "0.4.44"
clap = { version = "4.5.60", features = [ "derive" ] }
core-foundation-sys = "0.8.7"
crossterm = { version = "0.29.0", features = [ "event-stream" ] }
@ -71,7 +72,7 @@ thiserror = "2.0.18"
tokio = { version = "1.50.0", features = [ "full" ] }
tokio-stream = "0.1.18"
tokio-util = "0.7.18"
toml = { version = "1.0.4" }
toml = { version = "1.0.6" }
tracing = { version = "0.1.44", features = [ "max_level_debug", "release_max_level_debug" ] }
twox-hash = { version = "2.1.2", default-features = false, features = [ "std", "random", "xxhash3_128" ] }
typed-path = "0.12.3"

View file

@ -39,6 +39,10 @@ pub struct ServiceSftp {
pub key_file: PathBuf,
pub key_passphrase: Option<String>,
#[serde(default)]
pub cert_file: PathBuf,
#[serde(default)]
pub no_cert_verify: bool,
#[serde(default)]
pub identity_agent: PathBuf,
}
@ -49,6 +53,11 @@ impl ServiceSftp {
.ok_or_else(|| io::Error::other("key_file must be either empty or an absolute path"))?;
}
if !self.cert_file.as_os_str().is_empty() {
self.cert_file = normalize_path(mem::take(&mut self.cert_file))
.ok_or_else(|| io::Error::other("cert_file must be either empty or an absolute path"))?;
}
self.identity_agent = if self.identity_agent.as_os_str().is_empty() {
std::env::var_os("SSH_AUTH_SOCK")
.map(PathBuf::from)

View file

@ -21,6 +21,7 @@ yazi-shared = { path = "../yazi-shared", version = "26.2.2" }
# External dependencies
anyhow = { workspace = true }
chrono = { workspace = true }
deadpool = { version = "0.13.0", default-features = false, features = [ "managed", "rt_tokio_1" ] }
either = { workspace = true }
futures = { workspace = true }

View file

@ -1,4 +1,4 @@
use std::{io, sync::Arc, time::Duration};
use std::{io, sync::Arc, time::{Duration, SystemTime}};
use russh::keys::PrivateKeyWithHashAlg;
use yazi_config::vfs::ServiceSftp;
@ -10,6 +10,12 @@ pub(super) struct Conn {
pub(super) config: &'static ServiceSftp,
}
macro_rules! cfg_err {
($($args:tt)*) => {
russh::Error::InvalidConfig(format!($($args)*))
};
}
impl russh::client::Handler for Conn {
type Error = russh::Error;
@ -81,6 +87,10 @@ impl Conn {
let session = if self.config.password.is_some() {
self.connect_by_password(pref).await
} else if !self.config.key_file.as_os_str().is_empty()
&& !self.config.cert_file.as_os_str().is_empty()
{
self.connect_by_key_and_cert(pref).await
} else if !self.config.key_file.as_os_str().is_empty() {
self.connect_by_key(pref).await
} else {
@ -97,7 +107,7 @@ impl Conn {
pref: Arc<russh::client::Config>,
) -> Result<russh::client::Handle<Self>, russh::Error> {
let Some(password) = &self.config.password else {
return Err(russh::Error::InvalidConfig("Password not provided".to_owned()));
return Err(cfg_err!("Password not provided"));
};
let mut session =
@ -106,7 +116,7 @@ impl Conn {
if session.authenticate_password(&self.config.user, password).await?.success() {
Ok(session)
} else {
Err(russh::Error::InvalidConfig("Password authentication failed".to_owned()))
Err(cfg_err!("Password authentication failed"))
}
}
@ -116,14 +126,13 @@ impl Conn {
) -> Result<russh::client::Handle<Self>, russh::Error> {
let key_file = &self.config.key_file;
if key_file.as_os_str().is_empty() {
return Err(russh::Error::InvalidConfig("Key file not provided".to_owned()));
return Err(cfg_err!("Key file not provided"));
};
let key = Local::regular(key_file)
.read_to_string()
.await
.map_err(|e| russh::Error::InvalidConfig(format!("Failed to read key file: {e}")))?;
.map_err(|e| cfg_err!("Failed to read key file: {e}"))?;
let key = russh::keys::decode_secret_key(&key, self.config.key_passphrase.as_deref())?;
let mut session =
@ -139,10 +148,61 @@ impl Conn {
)
.await?;
if result.success() {
if result.success() { Ok(session) } else { Err(cfg_err!("Public key authentication failed")) }
}
async fn connect_by_key_and_cert(
self,
pref: Arc<russh::client::Config>,
) -> Result<russh::client::Handle<Self>, russh::Error> {
let key_file = &self.config.key_file;
if key_file.as_os_str().is_empty() {
return Err(cfg_err!("Key file not provided"));
};
let cert_file = &self.config.cert_file;
if cert_file.as_os_str().is_empty() {
return Err(cfg_err!("Cert file not provided"));
};
// Decode the key and cert files
let key = Local::regular(key_file)
.read_to_string()
.await
.map_err(|e| cfg_err!("Failed to read key file: {e}"))?;
let key = russh::keys::decode_secret_key(&key, self.config.key_passphrase.as_deref())?;
let cert = Local::regular(cert_file)
.read_to_string()
.await
.map_err(|e| cfg_err!("Failed to read cert file: {e}"))?;
let cert = russh::keys::Certificate::from_openssh(&cert)?;
// Verify the certificate
if !self.config.no_cert_verify {
cert
.verify_signature()
.map_err(|e| cfg_err!("Certificate signature verification failed: {e}"))?;
let now: chrono::DateTime<chrono::Local> = SystemTime::now().into();
let start: chrono::DateTime<chrono::Local> = cert.valid_after_time().into();
let end: chrono::DateTime<chrono::Local> = cert.valid_before_time().into();
if now < start || now > end {
return Err(cfg_err!(
"Certificate is out of the validity range of '{}' to '{}'",
start.to_rfc2822(),
end.to_rfc2822()
));
}
}
let mut session =
russh::client::connect(pref, (self.config.host.as_str(), self.config.port), self).await?;
if session.authenticate_openssh_cert(&self.config.user, Arc::new(key), cert).await?.success() {
Ok(session)
} else {
Err(russh::Error::InvalidConfig("Public key authentication failed".to_owned()))
Err(cfg_err!("Public key with certificate authentication failed"))
}
}
@ -152,7 +212,7 @@ impl Conn {
) -> Result<russh::client::Handle<Self>, russh::Error> {
let identity_agent = &self.config.identity_agent;
if identity_agent.as_os_str().is_empty() {
return Err(russh::Error::InvalidConfig("Identity agent not provided".to_owned()));
return Err(cfg_err!("Identity agent not provided"));
};
#[cfg(unix)]
@ -163,7 +223,7 @@ impl Conn {
let keys = agent.request_identities().await?;
if keys.is_empty() {
return Err(russh::Error::InvalidConfig("No keys found in SSH agent".to_owned()));
return Err(cfg_err!("No keys found in SSH agent"));
}
let mut session =
@ -179,6 +239,6 @@ impl Conn {
}
}
Err(russh::Error::InvalidConfig("Public key authentication via agent failed".to_owned()))
Err(cfg_err!("Public key authentication via agent failed"))
}
}