diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c3190c..d1e8cb59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 1ec82542..7c5e7363 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index cbed9f95..6de5ac56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/yazi-config/src/vfs/service.rs b/yazi-config/src/vfs/service.rs index 1273b950..57578f67 100644 --- a/yazi-config/src/vfs/service.rs +++ b/yazi-config/src/vfs/service.rs @@ -39,6 +39,10 @@ pub struct ServiceSftp { pub key_file: PathBuf, pub key_passphrase: Option, #[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) diff --git a/yazi-vfs/Cargo.toml b/yazi-vfs/Cargo.toml index 21a0782b..4e209fa2 100644 --- a/yazi-vfs/Cargo.toml +++ b/yazi-vfs/Cargo.toml @@ -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 } diff --git a/yazi-vfs/src/provider/sftp/conn.rs b/yazi-vfs/src/provider/sftp/conn.rs index d77cf1c6..9c13b69d 100644 --- a/yazi-vfs/src/provider/sftp/conn.rs +++ b/yazi-vfs/src/provider/sftp/conn.rs @@ -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, ) -> Result, 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::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, + ) -> Result, 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 = SystemTime::now().into(); + let start: chrono::DateTime = cert.valid_after_time().into(); + let end: chrono::DateTime = 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::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")) } }