From 1bf634a788506dcd39c8fa90976fd4f5b2b61278 Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Tue, 17 Mar 2026 22:46:39 +0100 Subject: [PATCH 01/19] add host certificate support --- russh/src/cert.rs | 16 +++++- russh/src/client/kex.rs | 64 +++++++++++++++------ russh/src/client/mod.rs | 12 +++- russh/src/client/test.rs | 5 +- russh/src/kex/mod.rs | 4 +- russh/src/lib_inner.rs | 3 +- russh/src/negotiation.rs | 116 ++++++++++++++++++++++++++++----------- russh/src/server/kex.rs | 68 +++++++++++++++++------ russh/src/server/mod.rs | 4 ++ 9 files changed, 221 insertions(+), 71 deletions(-) diff --git a/russh/src/cert.rs b/russh/src/cert.rs index 2a101049..620b59a7 100644 --- a/russh/src/cert.rs +++ b/russh/src/cert.rs @@ -7,9 +7,9 @@ use { use crate::keys::key::PrivateKeyWithHashAlg; -#[derive(Debug)] +#[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] -pub(crate) enum PublicKeyOrCertificate { +pub enum PublicKeyOrCertificate { PublicKey { key: PublicKey, hash_alg: Option, @@ -17,6 +17,18 @@ pub(crate) enum PublicKeyOrCertificate { Certificate(Certificate), } +impl PublicKeyOrCertificate { + /// Returns the public key, extracting it from the certificate if necessary. + pub fn public_key(&self) -> PublicKey { + match self { + PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(), + PublicKeyOrCertificate::Certificate(cert) => { + PublicKey::new(cert.public_key().clone(), "") + } + } + } +} + impl From<&PrivateKeyWithHashAlg> for PublicKeyOrCertificate { fn from(key: &PrivateKeyWithHashAlg) -> Self { PublicKeyOrCertificate::PublicKey { diff --git a/russh/src/client/kex.rs b/russh/src/client/kex.rs index d6523318..9b9b1036 100644 --- a/russh/src/client/kex.rs +++ b/russh/src/client/kex.rs @@ -6,9 +6,11 @@ use std::sync::Arc; use bytes::Bytes; use log::{debug, error, warn}; use ssh_encoding::{Decode, Encode}; -use ssh_key::{Mpint, PublicKey, Signature}; +use signature::Verifier; +use ssh_key::{Certificate, Mpint, PublicKey, Signature}; use super::IncomingSshPacket; +use crate::cert::PublicKeyOrCertificate; use crate::client::{Config, NewKeys}; use crate::kex::dh::groups::DhGroup; use crate::kex::{KEXES, KexAlgorithm, KexAlgorithmImplementor, KexCause, KexProgress}; @@ -36,7 +38,7 @@ enum ClientKexState { kex: KexAlgorithm, }, WaitingForNewKeys { - server_host_key: PublicKey, + server_host_key: PublicKeyOrCertificate, newkeys: NewKeys, }, } @@ -122,6 +124,7 @@ impl ClientKex { &input.buffer, &self.config.preferred, None, + None, &self.cause, )? }; @@ -263,12 +266,7 @@ impl ClientKex { #[allow(clippy::indexing_slicing)] // length checked let r = &mut &input.buffer[1..]; - let server_host_key = Bytes::decode(r)?; // server public key. - let server_host_key = parse_public_key(&server_host_key)?; - debug!( - "received server host key: {:?}", - server_host_key.to_openssh() - ); + let server_host_key_bytes = Bytes::decode(r)?; // server public key. let server_ephemeral = Bytes::decode(r)?; self.exchange @@ -276,8 +274,8 @@ impl ClientKex { .extend_from_slice(&server_ephemeral); kex.compute_shared_secret(&self.exchange.server_ephemeral)?; - let mut pubkey_vec = Vec::new(); - server_host_key.to_bytes()?.encode(&mut pubkey_vec)?; + let mut pubkey_vec = CryptoVec::new(); + server_host_key_bytes.encode(&mut pubkey_vec)?; let exchange = &self.exchange; let hash = HASH_BUFFER.with({ @@ -291,12 +289,46 @@ impl ClientKex { let signature = Bytes::decode(r)?; let signature = Signature::decode(&mut &signature[..])?; - if let Err(e) = - signature::Verifier::verify(&server_host_key, hash.as_ref(), &signature) - { - debug!("wrong server sig: {e:?}"); - return Err(Error::WrongServerSig); - } + let server_host_key = match Certificate::from_bytes(&server_host_key_bytes) { + // Host certificate verification + Ok(server_certificate) => { + debug!( + "received server certificate: {:?}", + server_certificate.to_openssh() + ); + debug!("Parsed server host key as Certificate"); + let inner_key_data = server_certificate.public_key().clone(); + let inner_public_key = PublicKey::new(inner_key_data, ""); + + if let Err(e) = + Verifier::verify(&inner_public_key, hash.as_ref(), &signature) + { + debug!("Certificate inner key verification failed: {e}"); + return Err(Error::WrongServerSig); + } + + PublicKeyOrCertificate::Certificate(server_certificate) + } + Err(_) => { + // Host key verification + let server_host_key = parse_public_key(&server_host_key_bytes)?; + debug!( + "received server host key: {:?}", + server_host_key.to_openssh() + ); + if let Err(e) = + Verifier::verify(&server_host_key, hash.as_ref(), &signature) + { + debug!("wrong server sig: {e:?}"); + return Err(Error::WrongServerSig); + } + + PublicKeyOrCertificate::PublicKey { + key: server_host_key, + hash_alg: None, + } + } + }; let newkeys = compute_keys( hash, diff --git a/russh/src/client/mod.rs b/russh/src/client/mod.rs index 5f2e5088..b91d66c8 100644 --- a/russh/src/client/mod.rs +++ b/russh/src/client/mod.rs @@ -1872,13 +1872,21 @@ pub trait Handler: Sized + Send { async { Ok(()) } } - /// Called to check the server's public key. This is a very important + /// Called to check the server's public key or certificate. This is a very important /// step to help prevent man-in-the-middle attacks. The default /// implementation rejects all keys. + /// + /// When the server presents an OpenSSH certificate, the argument will be + /// [`PublicKeyOrCertificate::Certificate`], allowing you to validate the CA + /// signature, validity period, and principals. For plain host keys it will + /// be [`PublicKeyOrCertificate::PublicKey`]. + /// + /// Use [`PublicKeyOrCertificate::public_key()`] to obtain the inner public + /// key in either case. #[allow(unused_variables)] fn check_server_key( &mut self, - server_public_key: &ssh_key::PublicKey, + server_public_key: &crate::cert::PublicKeyOrCertificate, ) -> impl Future> + Send { async { Ok(false) } } diff --git a/russh/src/client/test.rs b/russh/src/client/test.rs index 94069a12..f8321a62 100644 --- a/russh/src/client/test.rs +++ b/russh/src/client/test.rs @@ -72,7 +72,10 @@ mod tests { impl Handler for Client { type Error = Error; - async fn check_server_key(&mut self, _: &ssh_key::PublicKey) -> Result { + async fn check_server_key( + &mut self, + _: &crate::cert::PublicKeyOrCertificate, + ) -> Result { Ok(true) } } diff --git a/russh/src/kex/mod.rs b/russh/src/kex/mod.rs index 2da167b0..641a00c9 100644 --- a/russh/src/kex/mod.rs +++ b/russh/src/kex/mod.rs @@ -44,8 +44,8 @@ use p521::NistP521; use sha1::Sha1; use sha2::{Sha256, Sha384, Sha512}; use ssh_encoding::{Encode, Writer}; -use ssh_key::PublicKey; +use crate::cert::PublicKeyOrCertificate; use crate::cipher::CIPHERS; use crate::client::GexParams; use crate::mac::{self, MACS}; @@ -120,7 +120,7 @@ pub(crate) enum KexProgress { reset_seqn: bool, }, Done { - server_host_key: Option, + server_host_key: Option, newkeys: NewKeys, }, } diff --git a/russh/src/lib_inner.rs b/russh/src/lib_inner.rs index 81370d0d..ed1ad542 100644 --- a/russh/src/lib_inner.rs +++ b/russh/src/lib_inner.rs @@ -14,7 +14,8 @@ mod tests; mod auth; -mod cert; +/// Certificate and public key types. +pub mod cert; /// Cipher names pub mod cipher; /// Compression algorithm names diff --git a/russh/src/negotiation.rs b/russh/src/negotiation.rs index c1d97105..aca8ad56 100644 --- a/russh/src/negotiation.rs +++ b/russh/src/negotiation.rs @@ -13,14 +13,15 @@ // limitations under the License. // use std::borrow::Cow; +use std::str::FromStr; -use log::debug; -use rand_core::Rng; +use log::{debug, error}; +use rand::RngCore; use ssh_encoding::{Decode, Encode}; -use ssh_key::{Algorithm, EcdsaCurve, HashAlg, PrivateKey}; +use ssh_key::{Algorithm, Certificate, EcdsaCurve, HashAlg, PrivateKey}; use crate::cipher::CIPHERS; -use crate::helpers::NameList; +use crate::helpers::{AlgorithmExt, NameList}; use crate::kex::{ EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, KexCause, }; @@ -34,6 +35,7 @@ use crate::{AlgorithmKind, Error, cipher, compression, kex, mac, msg}; /// WASM-only stub pub struct Config { keys: Vec, + certificates: Vec, } #[derive(Debug, Clone)] @@ -84,19 +86,53 @@ pub(crate) fn is_key_compatible_with_algo(key: &PrivateKey, algo: &Algorithm) -> } impl Preferred { - pub(crate) fn possible_host_key_algos_for_keys( + pub(crate) fn gather_possible_agorithms( &self, available_host_keys: &[PrivateKey], - ) -> Vec { - self.key - .iter() - .filter(|n| { - available_host_keys - .iter() - .any(|k| is_key_compatible_with_algo(k, n)) - }) - .cloned() - .collect::>() + available_certificates: Option<&[Certificate]>, + ) -> Vec { + let mut algos = Vec::new(); + if let Some(certs) = available_certificates { + debug!("found Certs"); + for c in certs { + // For RSA certificates, advertise all three RSA cert algorithm + // variants (ssh-rsa, rsa-sha2-256, rsa-sha2-512) since the same + // certificate can be used with any of these signing hashes. + let variants: Vec = match c.algorithm() { + Algorithm::Rsa { .. } => vec![ + Algorithm::Rsa { hash: None }.to_certificate_type(), + Algorithm::Rsa { + hash: Some(HashAlg::Sha256), + } + .to_certificate_type(), + Algorithm::Rsa { + hash: Some(HashAlg::Sha512), + } + .to_certificate_type(), + ], + _ => vec![c.algorithm().to_certificate_type()], + }; + for a in variants { + if !algos.contains(&a) { + debug!("Push Cert algo {a}"); + algos.push(a); + } + } + } + } + for algo in self.key.iter() { + if available_host_keys + .iter() + .any(|k| is_key_compatible_with_algo(k, algo)) + { + let a = algo.to_string(); + if !algos.contains(&a) { + debug!("Push key algo {a}"); + algos.push(a); + } + } + } + algos } } @@ -207,6 +243,7 @@ pub(crate) trait Select { buffer: &[u8], pref: &Preferred, available_host_keys: Option<&[PrivateKey]>, + available_certificates: Option<&[Certificate]>, cause: &KexCause, ) -> Result { let &Some(mut r) = &buffer.get(17..) else { @@ -264,8 +301,10 @@ pub(crate) trait Select { let key_string = String::decode(&mut r)?; let possible_host_key_algos = match available_host_keys { - Some(available_host_keys) => pref.possible_host_key_algos_for_keys(available_host_keys), - None => pref.key.iter().map(ToOwned::to_owned).collect::>(), + Some(available_host_keys) => { + pref.gather_possible_agorithms(available_host_keys, available_certificates) + } + None => pref.key.iter().map(ToString::to_string).collect::>(), }; let (key_both_first, key_algorithm) = Self::select( @@ -344,7 +383,9 @@ pub(crate) trait Select { let follows = u8::decode(&mut r)? != 0; Ok(Names { kex: kex_algorithm, - key: key_algorithm, + key: Algorithm::new_certificate_ext(&key_algorithm) + .or_else(|_| Algorithm::from_str(&key_algorithm)) + .map_err(|_| Error::UnknownKey)?, cipher, client_mac, server_mac, @@ -454,20 +495,33 @@ pub(crate) fn write_kex( if let Some(server_config) = server_config { // Only advertise host key algorithms that we have keys for. - NameList( - prefs - .key + let mut algos = Vec::new(); + // Prepend certificates + for cert in &server_config.certificates { + let algo_name = cert.algorithm().to_certificate_type(); + if !algos.contains(&algo_name) { + debug!("Prepare Cert {algo_name} for advertisement"); + algos.push(algo_name.clone()); + } else { + error!("No key for Cert {algo_name}, will not advertise"); + } + } + + // Add keys + for algo in prefs.key.iter() { + if server_config + .keys .iter() - .filter(|algo| { - server_config - .keys - .iter() - .any(|k| is_key_compatible_with_algo(k, algo)) - }) - .map(|x| x.to_string()) - .collect(), - ) - .encode(w)?; + .any(|k| is_key_compatible_with_algo(k, algo)) + { + let algo_name = algo.to_string(); + if !algos.contains(&algo_name) { + algos.push(algo_name); + } + } + } + + NameList(algos).encode(w)?; } else { NameList(prefs.key.iter().map(ToString::to_string).collect()).encode(w)?; } diff --git a/russh/src/server/kex.rs b/russh/src/server/kex.rs index 236235d6..ab390681 100644 --- a/russh/src/server/kex.rs +++ b/russh/src/server/kex.rs @@ -10,9 +10,9 @@ use ssh_key::Algorithm; use super::*; use crate::helpers::sign_with_hash_alg; use crate::kex::dh::biguint_to_mpint; -use crate::kex::{KexAlgorithm, KexAlgorithmImplementor, KexCause, KEXES}; +use crate::kex::{KEXES, KexAlgorithm, KexAlgorithmImplementor, KexCause}; use crate::keys::key::PrivateKeyWithHashAlg; -use crate::negotiation::{is_key_compatible_with_algo, Names, Select}; +use crate::negotiation::{Names, Select, is_key_compatible_with_algo}; use crate::{msg, negotiation}; thread_local! { @@ -109,11 +109,14 @@ impl ServerKex { } let names = { - self.exchange.client_kex_init.extend_from_slice(&input.buffer); + self.exchange + .client_kex_init + .extend_from_slice(&input.buffer); negotiation::Server::read_kex( &input.buffer, &self.config.preferred, Some(&self.config.keys), + Some(&self.config.certificates), &self.cause, )? }; @@ -177,7 +180,9 @@ impl ServerKex { debug!("client requests a gex group: {gex_params:?}"); let Some(dh_group) = handler.lookup_dh_gex_group(&gex_params).await? else { - debug!("server::Handler impl did not find a matching DH group (is lookup_dh_gex_group implemented?)"); + debug!( + "server::Handler impl did not find a matching DH group (is lookup_dh_gex_group implemented?)" + ); return Err(Error::Kex)?; }; @@ -240,19 +245,38 @@ impl ServerKex { let exchange = &mut self.exchange; kex.server_dh(exchange, &input.buffer)?; - let Some(matching_key_index) = self - .config - .keys - .iter() - .position(|key| is_key_compatible_with_algo(key, &names.key)) - else { - debug!("we don't have a host key of type {:?}", names.key); - return Err(Error::UnknownKey.into()); + let (key, certificate) = if let Some(cert) = + self.config.certificates.iter().find(|c| { + // RSA certificates are usable with any RSA cert algorithm + // variant (ssh-rsa-cert, rsa-sha2-256-cert, rsa-sha2-512-cert) + // since the hash variant controls the KEx signing algorithm, + // not the certificate itself. + match (&c.algorithm(), &names.key) { + (Algorithm::Rsa { .. }, Algorithm::Rsa { .. }) => true, + _ => { + c.algorithm().to_certificate_type() + == names.key.to_certificate_type() + } + } + }) { + let key = self + .config + .keys + .iter() + .find(|k| k.public_key().key_data() == cert.public_key()) + .ok_or(Error::UnknownKey)?; + (key, Some(cert)) + } else { + let key = self + .config + .keys + .iter() + .find(|key| is_key_compatible_with_algo(key, &names.key)) + .ok_or(Error::UnknownKey)?; + (key, None) }; // Look up the key we'll be using to sign the exchange hash - #[allow(clippy::indexing_slicing)] // key index checked - let key = &self.config.keys[matching_key_index]; let signature_hash_alg = match &names.key { Algorithm::Rsa { hash } => *hash, _ => None, @@ -263,7 +287,13 @@ impl ServerKex { buffer.clear(); let mut pubkey_vec = Vec::new(); - key.public_key().to_bytes()?.encode(&mut pubkey_vec)?; + if let Some(cert) = certificate { + let mut buf = Vec::new(); + cert.encode(&mut buf)?; + buf.encode(&mut pubkey_vec)?; + } else { + key.public_key().to_bytes()?.encode(&mut pubkey_vec)?; + } let hash = kex.compute_exchange_hash(&pubkey_vec, exchange, &mut buffer)?; @@ -284,7 +314,13 @@ impl ServerKex { false => &msg::KEX_ECDH_REPLY, } .encode(w)?; - key.public_key().to_bytes()?.encode(w)?; + if let Some(cert) = certificate { + let mut buf = CryptoVec::new(); + cert.encode(&mut buf)?; + buf.encode(w)?; + } else { + key.public_key().to_bytes()?.encode(w)?; + } exchange.server_ephemeral.encode(w)?; signature.encode(w)?; Ok(()) diff --git a/russh/src/server/mod.rs b/russh/src/server/mod.rs index b57cd074..b2cfac1f 100644 --- a/russh/src/server/mod.rs +++ b/russh/src/server/mod.rs @@ -75,6 +75,8 @@ pub struct Config { pub auth_rejection_time_initial: Option, /// The server's keys. The first key pair in the client's preference order will be chosen. pub keys: Vec, + /// The server's host certificates. + pub certificates: Vec, /// The bytes and time limits before key re-exchange. pub limits: Limits, /// The initial size of a channel (used for flow control). @@ -112,6 +114,7 @@ impl Default for Config { auth_rejection_time: std::time::Duration::from_secs(1), auth_rejection_time_initial: None, keys: Vec::new(), + certificates: Vec::new(), window_size: 2097152, maximum_packet_size: 32768, channel_buffer_size: 100, @@ -139,6 +142,7 @@ impl Debug for Config { &self.auth_rejection_time_initial, ) .field("keys", &"***") + .field("certificates", &"***") .field("window_size", &self.window_size) .field("maximum_packet_size", &self.maximum_packet_size) .field("channel_buffer_size", &self.channel_buffer_size) From 385e4c177bc890f8552a890b131fdfaea3a758eb Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Tue, 17 Mar 2026 22:46:40 +0100 Subject: [PATCH 02/19] add example for host certificates --- russh/examples/echoserver_certificates.rs | 188 ++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 russh/examples/echoserver_certificates.rs diff --git a/russh/examples/echoserver_certificates.rs b/russh/examples/echoserver_certificates.rs new file mode 100644 index 00000000..e7c0d77e --- /dev/null +++ b/russh/examples/echoserver_certificates.rs @@ -0,0 +1,188 @@ +use clap::Parser; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use russh::keys::{Certificate, *}; +use russh::server::{Msg, Server as _, Session}; +use russh::*; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +#[derive(Parser, Debug)] +#[clap( + name = "echoserver_custom_keys", + about = "Echo server with custom keys" +)] +struct Cli { + /// Path to the private key file + #[clap(short, long)] + key: PathBuf, + + /// Path to the certificate file (optional) + #[clap(short, long)] + cert: Option, + + /// Port to listen on + #[clap(short, long, default_value_t = 2222)] + port: u16, +} + +#[tokio::main] +async fn main() { + env_logger::builder() + .filter_level(log::LevelFilter::Debug) + .init(); + + let args = Cli::parse(); + + // Load private key + let key = russh::keys::load_secret_key(&args.key, None).expect("Could not load private key"); + + // Load certificate if provided + let mut certs = Vec::new(); + if let Some(cert_path) = args.cert { + let cert = + russh::keys::load_openssh_certificate(&cert_path).expect("Could not load certificate"); + certs.push(cert); + } + + let config = russh::server::Config { + inactivity_timeout: Some(std::time::Duration::from_secs(3600)), + auth_rejection_time: std::time::Duration::from_secs(3), + auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)), + keys: vec![key], + certificates: certs, + preferred: Preferred { + // kex: std::borrow::Cow::Owned(vec![russh::kex::DH_GEX_SHA256]), + ..Preferred::default() + }, + ..Default::default() + }; + let config = Arc::new(config); + let mut sh = Server { + clients: Arc::new(Mutex::new(HashMap::new())), + id: 0, + }; + + let socket = TcpListener::bind(("0.0.0.0", args.port)).await.unwrap(); + let server = sh.run_on_socket(config, &socket); + let handle = server.handle(); + + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(600)).await; + handle.shutdown("Server shutting down after 10 minutes".into()); + }); + + println!("Listening on port {}", args.port); + server.await.unwrap() +} + +#[derive(Clone)] +struct Server { + clients: Arc>>, + id: usize, +} + +impl Server { + async fn post(&mut self, data: Vec) { + let mut clients = self.clients.lock().await; + for (id, (channel, s)) in clients.iter_mut() { + if *id != self.id { + let _ = s.data(*channel, data.clone()).await; + } + } + } +} + +impl server::Server for Server { + type Handler = Self; + fn new_client(&mut self, _: Option) -> Self { + let s = self.clone(); + self.id += 1; + s + } + fn handle_session_error(&mut self, _error: ::Error) { + eprintln!("Session error: {_error:#?}"); + } +} + +impl server::Handler for Server { + type Error = russh::Error; + + async fn channel_open_session( + &mut self, + channel: Channel, + session: &mut Session, + ) -> Result { + { + let mut clients = self.clients.lock().await; + clients.insert(self.id, (channel.id(), session.handle())); + } + Ok(true) + } + + async fn auth_publickey( + &mut self, + _: &str, + _key: &ssh_key::PublicKey, + ) -> Result { + Ok(server::Auth::Accept) + } + + async fn auth_openssh_certificate( + &mut self, + _user: &str, + _certificate: &Certificate, + ) -> Result { + Ok(server::Auth::Accept) + } + + async fn data( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + // Sending Ctrl+C ends the session and disconnects the client + if data == [3] { + return Err(russh::Error::Disconnect); + } + + let data = format!("Got data: {}\r\n", String::from_utf8_lossy(data)).into_bytes(); + self.post(data.clone()).await; + session.data(channel, data)?; + Ok(()) + } + + async fn tcpip_forward( + &mut self, + address: &str, + port: &mut u32, + session: &mut Session, + ) -> Result { + let handle = session.handle(); + let address = address.to_string(); + let port = *port; + tokio::spawn(async move { + let channel = handle + .channel_open_forwarded_tcpip(address, port, "1.2.3.4", 1234) + .await + .unwrap(); + let _ = channel.data(&b"Hello from a forwarded port"[..]).await; + let _ = channel.eof().await; + }); + Ok(true) + } +} + +impl Drop for Server { + fn drop(&mut self) { + let id = self.id; + let clients = self.clients.clone(); + tokio::spawn(async move { + let mut clients = clients.lock().await; + clients.remove(&id); + }); + } +} From 64e027435364544e96b75e9335123001ce302a6e Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Tue, 17 Mar 2026 22:46:41 +0100 Subject: [PATCH 03/19] add test for host certificates --- russh/src/tests.rs | 10 +- russh/tests/test_backpressure.rs | 5 +- russh/tests/test_data_stream.rs | 5 +- russh/tests/test_kex_shared_secret.rs | 4 +- russh/tests/test_mlkem_kex.rs | 2 +- russh/tests/test_rekey_strict_kex.rs | 2 +- russh/tests/test_server_cert.rs | 337 ++++++++++++++++++++++++++ 7 files changed, 354 insertions(+), 11 deletions(-) create mode 100644 russh/tests/test_server_cert.rs diff --git a/russh/src/tests.rs b/russh/src/tests.rs index 232ac9fa..10e13303 100644 --- a/russh/src/tests.rs +++ b/russh/src/tests.rs @@ -129,7 +129,7 @@ mod compress { async fn check_server_key( &mut self, - _server_public_key: &crate::keys::ssh_key::PublicKey, + _server_public_key: &crate::cert::PublicKeyOrCertificate, ) -> Result { // println!("check_server_key: {:?}", server_public_key); Ok(true) @@ -222,7 +222,7 @@ mod channels { async fn check_server_key( &mut self, - _server_public_key: &crate::keys::ssh_key::PublicKey, + _server_public_key: &crate::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } @@ -302,7 +302,7 @@ mod channels { async fn check_server_key( &mut self, - _server_public_key: &crate::keys::ssh_key::PublicKey, + _server_public_key: &crate::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } @@ -396,7 +396,7 @@ mod channels { async fn check_server_key( &mut self, - _server_public_key: &crate::keys::ssh_key::PublicKey, + _server_public_key: &crate::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } @@ -550,7 +550,7 @@ mod channels { async fn check_server_key( &mut self, - _server_public_key: &crate::keys::ssh_key::PublicKey, + _server_public_key: &crate::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/tests/test_backpressure.rs b/russh/tests/test_backpressure.rs index 1f0f7843..181658e0 100644 --- a/russh/tests/test_backpressure.rs +++ b/russh/tests/test_backpressure.rs @@ -148,7 +148,10 @@ struct Client; impl russh::client::Handler for Client { type Error = anyhow::Error; - async fn check_server_key(&mut self, _: &ssh_key::PublicKey) -> Result { + async fn check_server_key( + &mut self, + _: &russh::cert::PublicKeyOrCertificate, + ) -> Result { Ok(true) } } diff --git a/russh/tests/test_data_stream.rs b/russh/tests/test_data_stream.rs index 305265cd..3b648b13 100644 --- a/russh/tests/test_data_stream.rs +++ b/russh/tests/test_data_stream.rs @@ -217,7 +217,10 @@ struct Client; impl russh::client::Handler for Client { type Error = anyhow::Error; - async fn check_server_key(&mut self, _: &ssh_key::PublicKey) -> Result { + async fn check_server_key( + &mut self, + _: &russh::cert::PublicKeyOrCertificate, + ) -> Result { Ok(true) } } diff --git a/russh/tests/test_kex_shared_secret.rs b/russh/tests/test_kex_shared_secret.rs index 7bf53e9a..d1714291 100644 --- a/russh/tests/test_kex_shared_secret.rs +++ b/russh/tests/test_kex_shared_secret.rs @@ -399,7 +399,7 @@ impl client::Handler for TestClientWithKexCapture { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &russh::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } @@ -433,7 +433,7 @@ impl client::Handler for TestClientWithRekeyCapture { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &russh::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/tests/test_mlkem_kex.rs b/russh/tests/test_mlkem_kex.rs index 88cd5f84..f985e1be 100644 --- a/russh/tests/test_mlkem_kex.rs +++ b/russh/tests/test_mlkem_kex.rs @@ -365,7 +365,7 @@ impl client::Handler for TestClient { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &russh::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/tests/test_rekey_strict_kex.rs b/russh/tests/test_rekey_strict_kex.rs index 897127e8..6bcc35b6 100644 --- a/russh/tests/test_rekey_strict_kex.rs +++ b/russh/tests/test_rekey_strict_kex.rs @@ -153,7 +153,7 @@ impl client::Handler for TestClient { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &russh::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/tests/test_server_cert.rs b/russh/tests/test_server_cert.rs new file mode 100644 index 00000000..468ab141 --- /dev/null +++ b/russh/tests/test_server_cert.rs @@ -0,0 +1,337 @@ +#![cfg(not(target_arch = "wasm32"))] +use russh::keys::ssh_key::certificate::{Builder, CertType}; +use russh::keys::ssh_key::rand_core::OsRng; +use russh::keys::ssh_key::{self, Algorithm, HashAlg, PrivateKey}; +use russh::*; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::net::TcpListener; + +#[tokio::test] +async fn test_server_certificate_auth() { + let _ = env_logger::try_init(); + + // Generate CA key + let ca_key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap(); + let ca_public_key = ca_key.public_key(); + + // Generate Server key + let server_key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap(); + let server_public_key = server_key.public_key(); + + //. Create Server Certificate signed by CA + let start = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let end = start + 3600; + + let mut builder = + Builder::new_with_random_nonce(&mut OsRng, server_public_key.clone(), start, end).unwrap(); + builder.serial(42).unwrap(); + builder.key_id("test-server").unwrap(); + builder.cert_type(CertType::Host).unwrap(); + builder.valid_principal("localhost").unwrap(); + + let cert = builder.sign(&ca_key).unwrap(); + + // Configure Server + let mut config = server::Config::default(); + config.keys.push(server_key); + config.certificates.push(cert); + let config = Arc::new(config); + + // Start Server + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_finished = Arc::new(Mutex::new(false)); + let server_finished_clone = server_finished.clone(); + + tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + server::run_stream(config, socket, TestServer {}) + .await + .unwrap(); + *server_finished_clone.lock().unwrap() = true; + }); + + // Configure Client + let mut client_config = client::Config::default(); + + // Add certificate algorithm to preferred keys + let mut preferred_keys = client_config.preferred.key.into_owned(); + preferred_keys.insert( + 0, + Algorithm::from_str(&Algorithm::Ed25519.to_certificate_type()).unwrap(), + ); + + client_config.preferred.key = std::borrow::Cow::Owned(preferred_keys); + client_config.preferred.kex = + std::borrow::Cow::Owned(vec![russh::kex::CURVE25519, russh::kex::ECDH_SHA2_NISTP256]); + let client_config = Arc::new(client_config); + + let client = TestClient { + ca_public_key: ca_public_key.clone(), + verified: Arc::new(Mutex::new(false)), + }; + + // Connect Client + let session = client::connect(client_config, addr, client).await.unwrap(); + + session + .disconnect(Disconnect::ByApplication, "", "") + .await + .unwrap(); +} +#[tokio::test] +async fn test_server_wrong_ca_certificate_auth() { + let _ = env_logger::try_init(); + + //Generate CA key + let ca_key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap(); + let ca_public_key = ca_key.public_key(); + + //Generate second CA key + let evil_ca_key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap(); + + assert_ne!(evil_ca_key, ca_key); + + // Generate Server key + let server_key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap(); + let server_public_key = server_key.public_key(); + + // Create Server Certificate signed by CA + // Builder::new_with_random_nonce(rng, public_key, valid_after, valid_before) + let start = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let end = start + 3600; + + let mut builder = + Builder::new_with_random_nonce(&mut OsRng, server_public_key.clone(), start, end).unwrap(); + builder.serial(42).unwrap(); + builder.key_id("test-server").unwrap(); + builder.cert_type(CertType::Host).unwrap(); + builder.valid_principal("localhost").unwrap(); + + let cert = builder.sign(&evil_ca_key).unwrap(); + + // Configure Server + let mut config = server::Config::default(); + config.keys.push(server_key); + config.certificates.push(cert); + let config = Arc::new(config); + + // Start Server + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_finished = Arc::new(Mutex::new(false)); + let server_finished_clone = server_finished.clone(); + + tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + server::run_stream(config, socket, TestServer {}) + .await + .unwrap(); + *server_finished_clone.lock().unwrap() = true; + }); + + // Configure Client + let mut client_config = client::Config::default(); + + // Add certificate algorithm to preferred keys + let mut preferred_keys = client_config.preferred.key.into_owned(); + preferred_keys.insert( + 0, + Algorithm::from_str(&Algorithm::Ed25519.to_certificate_type()).unwrap(), + ); + + client_config.preferred.key = std::borrow::Cow::Owned(preferred_keys); + client_config.preferred.kex = + std::borrow::Cow::Owned(vec![russh::kex::CURVE25519, russh::kex::ECDH_SHA2_NISTP256]); + let client_config = Arc::new(client_config); + + let client = TestClient { + ca_public_key: ca_public_key.clone(), + verified: Arc::new(Mutex::new(false)), + }; + + // Connect Client + if let Ok(session) = client::connect(client_config, addr, client).await { + session + .disconnect(Disconnect::ByApplication, "", "") + .await + .unwrap(); + panic!("client connected to server with wrong ca in certificate"); + } +} + +#[tokio::test] +async fn test_server_rsa_sha2_512_certificate_auth() { + let _ = env_logger::try_init(); + + // Generate CA key (RSA, SHA-512) + let ca_key = PrivateKey::random( + &mut OsRng, + Algorithm::Rsa { + hash: Some(HashAlg::Sha512), + }, + ) + .unwrap(); + let ca_public_key = ca_key.public_key(); + + // Generate Server key (RSA, SHA-512) + let server_key = PrivateKey::random( + &mut OsRng, + Algorithm::Rsa { + hash: Some(HashAlg::Sha512), + }, + ) + .unwrap(); + let server_public_key = server_key.public_key(); + + // Create Server Certificate signed by CA + let start = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let end = start + 3600; + + let mut builder = + Builder::new_with_random_nonce(&mut OsRng, server_public_key.clone(), start, end).unwrap(); + builder.serial(42).unwrap(); + builder.key_id("test-server-rsa").unwrap(); + builder.cert_type(CertType::Host).unwrap(); + builder.valid_principal("localhost").unwrap(); + + let cert = builder.sign(&ca_key).unwrap(); + + // Configure Server + let mut config = server::Config::default(); + config.keys.push(server_key); + config.certificates.push(cert); + let config = Arc::new(config); + + // Start Server + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_finished = Arc::new(Mutex::new(false)); + let server_finished_clone = server_finished.clone(); + + tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + server::run_stream(config, socket, TestServer {}) + .await + .unwrap(); + *server_finished_clone.lock().unwrap() = true; + }); + + // Configure Client + let mut client_config = client::Config::default(); + + // Advertise rsa-sha2-512-cert-v01@openssh.com as first preferred host key algorithm + let cert_algo_str = Algorithm::Rsa { + hash: Some(HashAlg::Sha512), + } + .to_certificate_type(); + let mut preferred_keys = client_config.preferred.key.into_owned(); + preferred_keys.insert(0, Algorithm::from_str(&cert_algo_str).unwrap()); + + client_config.preferred.key = std::borrow::Cow::Owned(preferred_keys); + client_config.preferred.kex = + std::borrow::Cow::Owned(vec![russh::kex::CURVE25519, russh::kex::ECDH_SHA2_NISTP256]); + let client_config = Arc::new(client_config); + + let client = TestClient { + ca_public_key: ca_public_key.clone(), + verified: Arc::new(Mutex::new(false)), + }; + + // Connect Client — must succeed and certificate must validate + let session = client::connect(client_config, addr, client).await.unwrap(); + + session + .disconnect(Disconnect::ByApplication, "", "") + .await + .unwrap(); +} +struct TestServer {} + +impl server::Handler for TestServer { + type Error = russh::Error; + + async fn auth_publickey( + &mut self, + _: &str, + _: &ssh_key::PublicKey, + ) -> Result { + Ok(server::Auth::Accept) + } +} + +struct TestClient { + ca_public_key: ssh_key::PublicKey, + verified: Arc>, +} + +impl client::Handler for TestClient { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + server_public_key: &russh::cert::PublicKeyOrCertificate, + ) -> Result { + println!("check_server_key: {server_public_key:?}"); + match server_public_key { + russh::cert::PublicKeyOrCertificate::Certificate(cert) => { + // Perform the signature verification using your trusted CA public key. + // This checks if the certificate was genuinely signed by your CA. + let fingerprint = self.ca_public_key.fingerprint(HashAlg::Sha256); + if let Err(e) = cert.validate([&fingerprint]) { + eprintln!("Host certificate signature verification failed: {}", e); + return Ok(false); + } + + // Check the certificate's validity period. + let current_unix_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if current_unix_time < cert.valid_after() || current_unix_time > cert.valid_before() + { + eprintln!("Host certificate is outside its validity period."); + return Ok(false); + } + + // (Optional but recommended) Check the certificate's valid principals. + let target_hostname = "localhost"; + if !cert + .valid_principals() + .contains(&target_hostname.to_string()) + { + eprintln!( + "Host certificate is not valid for principal: {}", + target_hostname + ); + return Ok(false); + } + + // If all checks pass, the certificate is valid. + println!("Host certificate successfully validated."); + Ok(true) + } + russh::cert::PublicKeyOrCertificate::PublicKey { .. } => { + // If the server presents a plain public key (not a certificate), decide + // whether to accept it. For certificate-only environments, you might reject. + eprintln!("Server presented a plain public key, not a certificate."); + Ok(false) + } + } + } +} From 78e99d904577c882b23529e426f5102f47b2d496 Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Tue, 17 Mar 2026 22:46:42 +0100 Subject: [PATCH 04/19] Examples use new PlublicKeyOrCertificate enum --- russh/examples/client_exec_interactive.rs | 2 +- russh/examples/client_exec_simple.rs | 2 +- russh/examples/client_open_direct_tcpip.rs | 2 +- russh/examples/sftp_client.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/russh/examples/client_exec_interactive.rs b/russh/examples/client_exec_interactive.rs index 5bf0b399..7e7b4c89 100644 --- a/russh/examples/client_exec_interactive.rs +++ b/russh/examples/client_exec_interactive.rs @@ -69,7 +69,7 @@ impl client::Handler for Client { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &russh::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/examples/client_exec_simple.rs b/russh/examples/client_exec_simple.rs index ab30ca74..db3ca51e 100644 --- a/russh/examples/client_exec_simple.rs +++ b/russh/examples/client_exec_simple.rs @@ -63,7 +63,7 @@ impl client::Handler for Client { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &russh::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/examples/client_open_direct_tcpip.rs b/russh/examples/client_open_direct_tcpip.rs index a563f47e..3bbad2b2 100644 --- a/russh/examples/client_open_direct_tcpip.rs +++ b/russh/examples/client_open_direct_tcpip.rs @@ -62,7 +62,7 @@ impl client::Handler for Client { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &russh::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/examples/sftp_client.rs b/russh/examples/sftp_client.rs index 18d755d1..30bdba46 100644 --- a/russh/examples/sftp_client.rs +++ b/russh/examples/sftp_client.rs @@ -14,7 +14,7 @@ impl client::Handler for Client { async fn check_server_key( &mut self, - server_public_key: &ssh_key::PublicKey, + server_public_key: &russh::cert::PublicKeyOrCertificate, ) -> Result { info!("check_server_key: {server_public_key:?}"); Ok(true) From bf675802df699b4dbe6e4eb8a42ca1050c5c8251 Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Tue, 17 Mar 2026 22:46:43 +0100 Subject: [PATCH 05/19] mention host certificates in readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d80d3b82..311943fa 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ This is a fork of [Thrussh](https://nest.pijul.com/pijul/thrussh) by Pierre-Éti * `ecdsa-sha2-nistp256` ✨ * `ecdsa-sha2-nistp384` ✨ * `ecdsa-sha2-nistp521` ✨ + * OpenSSH certificates ✨ * Authentication methods: * `password` * `publickey` @@ -245,4 +246,4 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d -This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! \ No newline at end of file From 548a8da0fac3eeb64867d0b85994fb47aa036105 Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Wed, 18 Mar 2026 23:29:13 +0100 Subject: [PATCH 06/19] add ca_public_key to client config this adds ca_public_key to the client config, if not none this adds the the algorhism corresponding to the ones advertised while connecting to a host --- russh/src/client/kex.rs | 21 ++++- russh/src/client/mod.rs | 10 ++ russh/src/negotiation.rs | 162 ++++++++++++++++++++++++++++++-- russh/src/server/kex.rs | 1 + russh/tests/test_server_cert.rs | 37 ++------ 5 files changed, 188 insertions(+), 43 deletions(-) diff --git a/russh/src/client/kex.rs b/russh/src/client/kex.rs index 9b9b1036..8a2b9a73 100644 --- a/russh/src/client/kex.rs +++ b/russh/src/client/kex.rs @@ -12,6 +12,7 @@ use ssh_key::{Certificate, Mpint, PublicKey, Signature}; use super::IncomingSshPacket; use crate::cert::PublicKeyOrCertificate; use crate::client::{Config, NewKeys}; +use crate::helpers::EncodedExt; use crate::kex::dh::groups::DhGroup; use crate::kex::{KEXES, KexAlgorithm, KexAlgorithmImplementor, KexCause, KexProgress}; use crate::keys::key::parse_public_key; @@ -89,8 +90,17 @@ impl ClientKex { } pub fn kexinit(&mut self, output: &mut PacketWriter) -> Result<(), Error> { - self.exchange.client_kex_init = - negotiation::write_kex(&self.config.preferred, output, None)?; + let certificates = if self.config.certificates.is_empty() { + None + } else { + Some(self.config.certificates.as_slice()) + }; + self.exchange.client_kex_init = negotiation::write_client_kex( + &self.config.preferred, + output, + certificates, + self.config.ca_public_keys.as_deref(), + )?; Ok(()) } @@ -120,11 +130,13 @@ impl ClientKex { self.exchange .server_kex_init .extend_from_slice(&input.buffer); + let ca_keys = self.config.ca_public_keys.as_deref(); negotiation::Client::read_kex( &input.buffer, &self.config.preferred, None, None, + ca_keys, &self.cause, )? }; @@ -297,8 +309,9 @@ impl ClientKex { server_certificate.to_openssh() ); debug!("Parsed server host key as Certificate"); - let inner_key_data = server_certificate.public_key().clone(); - let inner_public_key = PublicKey::new(inner_key_data, ""); + let inner_key_data = server_certificate.public_key(); + let inner_key_bytes = inner_key_data.encoded()?; + let inner_public_key = parse_public_key(&inner_key_bytes)?; if let Err(e) = Verifier::verify(&inner_public_key, hash.as_ref(), &signature) diff --git a/russh/src/client/mod.rs b/russh/src/client/mod.rs index b91d66c8..81ba0601 100644 --- a/russh/src/client/mod.rs +++ b/russh/src/client/mod.rs @@ -1821,6 +1821,14 @@ pub struct Config { pub gex: GexParams, /// If active, invoke `set_nodelay(true)` on the ssh socket; disabled by default (i.e. Nagle's algorithm is active). pub nodelay: bool, + /// Trusted CA public keys for validating server certificates. + /// When provided, the corresponding certificate algorithm types will be + /// automatically added to the preferred host key algorithms. + pub ca_public_keys: Option>, + /// Client certificates used for hostkey-based authentication. + /// When provided, the corresponding certificate algorithm types will be + /// advertised to the server during key exchange. + pub certificates: Vec, } impl Default for Config { @@ -1843,6 +1851,8 @@ impl Default for Config { anonymous: false, gex: Default::default(), nodelay: false, + ca_public_keys: None, + certificates: Vec::new(), } } } diff --git a/russh/src/negotiation.rs b/russh/src/negotiation.rs index aca8ad56..27d3f3ea 100644 --- a/russh/src/negotiation.rs +++ b/russh/src/negotiation.rs @@ -16,20 +16,20 @@ use std::borrow::Cow; use std::str::FromStr; use log::{debug, error}; -use rand::RngCore; +use rand_core::Rng; use ssh_encoding::{Decode, Encode}; -use ssh_key::{Algorithm, Certificate, EcdsaCurve, HashAlg, PrivateKey}; +use ssh_key::{Algorithm, Certificate, EcdsaCurve, HashAlg, PrivateKey, PublicKey}; use crate::cipher::CIPHERS; use crate::helpers::{AlgorithmExt, NameList}; use crate::kex::{ - EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, KexCause, + KexCause, EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, }; use crate::keys::key::safe_rng; #[cfg(not(target_arch = "wasm32"))] use crate::server::Config; use crate::sshbuffer::PacketWriter; -use crate::{AlgorithmKind, Error, cipher, compression, kex, mac, msg}; +use crate::{cipher, compression, kex, mac, msg, AlgorithmKind, Error}; #[cfg(target_arch = "wasm32")] /// WASM-only stub @@ -86,10 +86,11 @@ pub(crate) fn is_key_compatible_with_algo(key: &PrivateKey, algo: &Algorithm) -> } impl Preferred { - pub(crate) fn gather_possible_agorithms( + pub(crate) fn gather_possible_agories( &self, available_host_keys: &[PrivateKey], available_certificates: Option<&[Certificate]>, + ca_public_keys: Option<&[PublicKey]>, ) -> Vec { let mut algos = Vec::new(); if let Some(certs) = available_certificates { @@ -120,6 +121,33 @@ impl Preferred { } } } + + // Add certificate algorithms from CA public keys + if let Some(ca_keys) = ca_public_keys { + for ca_key in ca_keys { + let variants: Vec = match ca_key.algorithm() { + Algorithm::Rsa { .. } => vec![ + Algorithm::Rsa { hash: None }.to_certificate_type(), + Algorithm::Rsa { + hash: Some(HashAlg::Sha256), + } + .to_certificate_type(), + Algorithm::Rsa { + hash: Some(HashAlg::Sha512), + } + .to_certificate_type(), + ], + _ => vec![ca_key.algorithm().to_certificate_type()], + }; + for a in variants { + if !algos.contains(&a) { + debug!("Push CA cert algo {a}"); + algos.push(a); + } + } + } + } + for algo in self.key.iter() { if available_host_keys .iter() @@ -244,6 +272,7 @@ pub(crate) trait Select { pref: &Preferred, available_host_keys: Option<&[PrivateKey]>, available_certificates: Option<&[Certificate]>, + ca_public_keys: Option<&[PublicKey]>, cause: &KexCause, ) -> Result { let &Some(mut r) = &buffer.get(17..) else { @@ -301,10 +330,40 @@ pub(crate) trait Select { let key_string = String::decode(&mut r)?; let possible_host_key_algos = match available_host_keys { - Some(available_host_keys) => { - pref.gather_possible_agorithms(available_host_keys, available_certificates) + Some(available_host_keys) => pref.gather_possible_agories( + available_host_keys, + available_certificates, + ca_public_keys, + ), + None => { + // When no host keys available, use preferred + CA cert algorithms + let mut algos = pref.key.iter().map(ToString::to_string).collect::>(); + if let Some(ca_keys) = ca_public_keys { + for ca_key in ca_keys { + let variants: Vec = match ca_key.algorithm() { + Algorithm::Rsa { .. } => vec![ + Algorithm::Rsa { hash: None }.to_certificate_type(), + Algorithm::Rsa { + hash: Some(HashAlg::Sha256), + } + .to_certificate_type(), + Algorithm::Rsa { + hash: Some(HashAlg::Sha512), + } + .to_certificate_type(), + ], + _ => vec![ca_key.algorithm().to_certificate_type()], + }; + for a in variants { + if !algos.contains(&a) { + debug!("Push CA cert algo {a}"); + algos.push(a); + } + } + } + } + algos } - None => pref.key.iter().map(ToString::to_string).collect::>(), }; let (key_both_first, key_algorithm) = Self::select( @@ -580,3 +639,90 @@ pub(crate) fn write_kex( Ok(()) }) } + +pub(crate) fn write_client_kex( + prefs: &Preferred, + writer: &mut PacketWriter, + certificates: Option<&[Certificate]>, + ca_public_keys: Option<&[PublicKey]>, +) -> Result, Error> { + writer.packet(|w| { + msg::KEXINIT.encode(w)?; + + let mut cookie = [0; 16]; + safe_rng().fill_bytes(&mut cookie); + for b in cookie { + b.encode(w)?; + } + + NameList( + prefs + .kex + .iter() + .filter(|k| { + ![ + crate::kex::EXTENSION_SUPPORT_AS_SERVER, + crate::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, + ] + .contains(*k) + }) + .map(|x| x.as_ref().to_owned()) + .collect(), + ) + .encode(w)?; + + let algos = if certificates.is_some() || ca_public_keys.is_some() { + prefs.gather_possible_agories(&[], certificates, ca_public_keys) + } else { + prefs.key.iter().map(ToString::to_string).collect() + }; + NameList(algos).encode(w)?; + + NameList( + prefs + .cipher + .iter() + .map(|x| x.as_ref().to_string()) + .collect(), + ) + .encode(w)?; + + NameList( + prefs + .cipher + .iter() + .map(|x| x.as_ref().to_string()) + .collect(), + ) + .encode(w)?; + + NameList(prefs.mac.iter().map(|x| x.as_ref().to_string()).collect()).encode(w)?; + + NameList(prefs.mac.iter().map(|x| x.as_ref().to_string()).collect()).encode(w)?; + + NameList( + prefs + .compression + .iter() + .map(|x| x.as_ref().to_string()) + .collect(), + ) + .encode(w)?; + + NameList( + prefs + .compression + .iter() + .map(|x| x.as_ref().to_string()) + .collect(), + ) + .encode(w)?; + + Vec::::new().encode(w)?; + Vec::::new().encode(w)?; + + 0u8.encode(w)?; + 0u32.encode(w)?; + Ok(()) + }) +} diff --git a/russh/src/server/kex.rs b/russh/src/server/kex.rs index ab390681..388d3d79 100644 --- a/russh/src/server/kex.rs +++ b/russh/src/server/kex.rs @@ -117,6 +117,7 @@ impl ServerKex { &self.config.preferred, Some(&self.config.keys), Some(&self.config.certificates), + None, &self.cause, )? }; diff --git a/russh/tests/test_server_cert.rs b/russh/tests/test_server_cert.rs index 468ab141..a822bd64 100644 --- a/russh/tests/test_server_cert.rs +++ b/russh/tests/test_server_cert.rs @@ -3,7 +3,6 @@ use russh::keys::ssh_key::certificate::{Builder, CertType}; use russh::keys::ssh_key::rand_core::OsRng; use russh::keys::ssh_key::{self, Algorithm, HashAlg, PrivateKey}; use russh::*; -use std::str::FromStr; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::net::TcpListener; @@ -60,16 +59,9 @@ async fn test_server_certificate_auth() { // Configure Client let mut client_config = client::Config::default(); - // Add certificate algorithm to preferred keys - let mut preferred_keys = client_config.preferred.key.into_owned(); - preferred_keys.insert( - 0, - Algorithm::from_str(&Algorithm::Ed25519.to_certificate_type()).unwrap(), - ); + // Set CA public key - this will automatically add cert algorithms to preferred + client_config.ca_public_keys = Some(vec![ca_public_key.clone()]); - client_config.preferred.key = std::borrow::Cow::Owned(preferred_keys); - client_config.preferred.kex = - std::borrow::Cow::Owned(vec![russh::kex::CURVE25519, russh::kex::ECDH_SHA2_NISTP256]); let client_config = Arc::new(client_config); let client = TestClient { @@ -143,16 +135,9 @@ async fn test_server_wrong_ca_certificate_auth() { // Configure Client let mut client_config = client::Config::default(); - // Add certificate algorithm to preferred keys - let mut preferred_keys = client_config.preferred.key.into_owned(); - preferred_keys.insert( - 0, - Algorithm::from_str(&Algorithm::Ed25519.to_certificate_type()).unwrap(), - ); + // Set CA public key - this will automatically add cert algorithms to preferred + client_config.ca_public_keys = Some(vec![ca_public_key.clone()]); - client_config.preferred.key = std::borrow::Cow::Owned(preferred_keys); - client_config.preferred.kex = - std::borrow::Cow::Owned(vec![russh::kex::CURVE25519, russh::kex::ECDH_SHA2_NISTP256]); let client_config = Arc::new(client_config); let client = TestClient { @@ -234,17 +219,9 @@ async fn test_server_rsa_sha2_512_certificate_auth() { // Configure Client let mut client_config = client::Config::default(); - // Advertise rsa-sha2-512-cert-v01@openssh.com as first preferred host key algorithm - let cert_algo_str = Algorithm::Rsa { - hash: Some(HashAlg::Sha512), - } - .to_certificate_type(); - let mut preferred_keys = client_config.preferred.key.into_owned(); - preferred_keys.insert(0, Algorithm::from_str(&cert_algo_str).unwrap()); + // Set CA public key - this will automatically add cert algorithms to preferred + client_config.ca_public_keys = Some(vec![ca_public_key.clone()]); - client_config.preferred.key = std::borrow::Cow::Owned(preferred_keys); - client_config.preferred.kex = - std::borrow::Cow::Owned(vec![russh::kex::CURVE25519, russh::kex::ECDH_SHA2_NISTP256]); let client_config = Arc::new(client_config); let client = TestClient { @@ -286,7 +263,6 @@ impl client::Handler for TestClient { &mut self, server_public_key: &russh::cert::PublicKeyOrCertificate, ) -> Result { - println!("check_server_key: {server_public_key:?}"); match server_public_key { russh::cert::PublicKeyOrCertificate::Certificate(cert) => { // Perform the signature verification using your trusted CA public key. @@ -323,7 +299,6 @@ impl client::Handler for TestClient { } // If all checks pass, the certificate is valid. - println!("Host certificate successfully validated."); Ok(true) } russh::cert::PublicKeyOrCertificate::PublicKey { .. } => { From 30bdf179ce4be2b6677a14315870af7504371c45 Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Sat, 21 Mar 2026 11:15:17 +0100 Subject: [PATCH 07/19] use PublicKeyORCertificate in max_channel_packet_size test --- russh/tests/test_max_channel_packet_size.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/russh/tests/test_max_channel_packet_size.rs b/russh/tests/test_max_channel_packet_size.rs index f1e1f59e..10738ae7 100644 --- a/russh/tests/test_max_channel_packet_size.rs +++ b/russh/tests/test_max_channel_packet_size.rs @@ -188,7 +188,7 @@ impl client::Handler for TestClient { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &russh::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } From d028b102730672d51fbe671ddc8b3cf31a0cf667 Mon Sep 17 00:00:00 2001 From: Joe Grund Date: Fri, 3 Apr 2026 14:09:44 -0400 Subject: [PATCH 08/19] Bump rand 3 (#673) Co-authored-by: louib Co-authored-by: Eugene --- russh/Cargo.toml | 32 +++++++++++++------------------- russh/src/client/kex.rs | 7 +++++++ russh/src/negotiation.rs | 4 ++-- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/russh/Cargo.toml b/russh/Cargo.toml index 1850e859..fbe0d77e 100644 --- a/russh/Cargo.toml +++ b/russh/Cargo.toml @@ -42,20 +42,18 @@ block-padding = { version = "0.3", features = ["std"] } byteorder.workspace = true bytes.workspace = true cbc = { version = "0.1" } -cbc_0_2 = { package = "cbc", version = "0.2.0" } cipher = "0.5.1" # only pinned due to a cargo-minimal-versions failure in 0.5.0 ctr = "0.9" -ctr_0_10 = { package = "ctr", version = "0.10.0" } -curve25519-dalek = "=5.0.0-pre.6" -crypto-bigint = { version = "=0.7.0-rc.28", features = ["alloc"] } +curve25519-dalek = "5.0.0-pre.6" +crypto-bigint = { version = "0.7.0-rc.28", features = ["alloc"] } data-encoding = "2.3" delegate.workspace = true digest.workspace = true der = "0.8" des = { version = "0.8.1", optional = true } -ecdsa = "=0.17.0-rc.16" -ed25519-dalek = { version = "=3.0.0-pre.6", features = ["alloc", "rand_core", "pkcs8"] } -elliptic-curve = { version = "=0.14.0-rc.28", features = ["ecdh"] } +ecdsa = "0.17.0-rc.16" +ed25519-dalek = { version = "3.0.0-pre.6", features = ["alloc", "rand_core", "pkcs8"] } +elliptic-curve = { version = "0.14.0-rc.28", features = ["ecdh"] } enum_dispatch = "0.3.13" flate2 = { version = "1.0.15", optional = true } futures.workspace = true @@ -70,22 +68,20 @@ inout = { version = "0.1", features = ["std"] } keccak = "0.2.0" log.workspace = true md5 = "0.7" -ml-kem = "=0.3.0-rc.1" +ml-kem = "0.3.0-rc.1" module-lattice = "0.2" # num-bigint 0.4.x only supports rand 0.8. PR #338 adds rand 0.10 support # via the `rand_0_10` feature flag. Replace with upstream once a release with # rand 0.10 support is published. https://github.com/rust-num/num-bigint/pull/338 num-bigint = { package = "internal-russh-num-bigint", version = "=0.5.0", features = ["rand_0_10"] } -num_bigint_0_4 = { package = "num-bigint", version = "0.4.6" } # num-integer = "0.1" -p256 = { version = "=0.14.0-rc.7", features = ["ecdh"] } -p384 = { version = "=0.14.0-rc.7", features = ["ecdh"] } -p521 = { version = "=0.14.0-rc.7", features = ["ecdh"] } +p256 = { version = "0.14.0-rc.7", features = ["ecdh"] } +p384 = { version = "0.14.0-rc.7", features = ["ecdh"] } +p521 = { version = "0.14.0-rc.7", features = ["ecdh"] } pbkdf2 = "0.12" -pbkdf2_0_13 = { package = "pbkdf2", version = "0.13.0" } -pkcs1 = { version = "=0.8.0-rc.4", optional = true } -pkcs5 = "=0.8.0-rc.13" -pkcs8 = { version = "=0.11.0-rc.11", features = ["encryption", "std"] } +pkcs1 = { version = "0.8.0-rc.4", optional = true } +pkcs5 = "0.8.0-rc.13" +pkcs8 = { version = "0.11.0-rc.11", features = ["encryption", "std"] } polyval = "0.7.1" # only pinned due to a cargo-minimal-versions failure in 0.7.0 rand_core = { version = "0.10.0" } rand.workspace = true @@ -95,8 +91,6 @@ russh-cryptovec = { version = "0.59.0", path = "../cryptovec", features = [ "ssh-encoding", ] } russh-util = { version = "0.52.0", path = "../russh-util" } -salsa20 = "0.11.0" -scrypt = "0.12.0" sec1 = { version = "0.8", features = ["der"] } sha1.workspace = true sha1_0_11 = { package = "sha1", version = "0.11.0" } @@ -104,7 +98,7 @@ sha2.workspace = true sha2_0_11 = { package = "sha2", version = "0.11.0" } sha3 = "0.11.0" signature.workspace = true -spki = "=0.8.0-rc.4" +spki = "0.8.0-rc.4" ssh-encoding.workspace = true ssh-key.workspace = true subtle = "2.4" diff --git a/russh/src/client/kex.rs b/russh/src/client/kex.rs index 8a2b9a73..de16541d 100644 --- a/russh/src/client/kex.rs +++ b/russh/src/client/kex.rs @@ -343,6 +343,13 @@ impl ClientKex { } }; + if let Err(e) = + signature::Verifier::verify(&server_host_key, hash.as_ref(), &signature) + { + debug!("wrong server sig: {e:?}"); + return Err(Error::WrongServerSig); + } + let newkeys = compute_keys( hash, kex, diff --git a/russh/src/negotiation.rs b/russh/src/negotiation.rs index 27d3f3ea..887d9558 100644 --- a/russh/src/negotiation.rs +++ b/russh/src/negotiation.rs @@ -23,13 +23,13 @@ use ssh_key::{Algorithm, Certificate, EcdsaCurve, HashAlg, PrivateKey, PublicKey use crate::cipher::CIPHERS; use crate::helpers::{AlgorithmExt, NameList}; use crate::kex::{ - KexCause, EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, + EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, KexCause, }; use crate::keys::key::safe_rng; #[cfg(not(target_arch = "wasm32"))] use crate::server::Config; use crate::sshbuffer::PacketWriter; -use crate::{cipher, compression, kex, mac, msg, AlgorithmKind, Error}; +use crate::{AlgorithmKind, Error, cipher, compression, kex, mac, msg}; #[cfg(target_arch = "wasm32")] /// WASM-only stub From 5466dffbb70ed99d81b30274d858170edbd7a59d Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Tue, 17 Mar 2026 22:46:39 +0100 Subject: [PATCH 09/19] add host certificate support --- russh/src/client/kex.rs | 7 ------- russh/src/negotiation.rs | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/russh/src/client/kex.rs b/russh/src/client/kex.rs index de16541d..8a2b9a73 100644 --- a/russh/src/client/kex.rs +++ b/russh/src/client/kex.rs @@ -343,13 +343,6 @@ impl ClientKex { } }; - if let Err(e) = - signature::Verifier::verify(&server_host_key, hash.as_ref(), &signature) - { - debug!("wrong server sig: {e:?}"); - return Err(Error::WrongServerSig); - } - let newkeys = compute_keys( hash, kex, diff --git a/russh/src/negotiation.rs b/russh/src/negotiation.rs index 887d9558..927c5574 100644 --- a/russh/src/negotiation.rs +++ b/russh/src/negotiation.rs @@ -148,6 +148,7 @@ impl Preferred { } } + for algo in self.key.iter() { if available_host_keys .iter() From 8b8ebb6ee03b3c3c6cde7749f46328f9217caf07 Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Wed, 18 Mar 2026 23:29:13 +0100 Subject: [PATCH 10/19] add ca_public_key to client config this adds ca_public_key to the client config, if not none this adds the the algorhism corresponding to the ones advertised while connecting to a host --- russh/src/negotiation.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/russh/src/negotiation.rs b/russh/src/negotiation.rs index 927c5574..27d3f3ea 100644 --- a/russh/src/negotiation.rs +++ b/russh/src/negotiation.rs @@ -23,13 +23,13 @@ use ssh_key::{Algorithm, Certificate, EcdsaCurve, HashAlg, PrivateKey, PublicKey use crate::cipher::CIPHERS; use crate::helpers::{AlgorithmExt, NameList}; use crate::kex::{ - EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, KexCause, + KexCause, EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, }; use crate::keys::key::safe_rng; #[cfg(not(target_arch = "wasm32"))] use crate::server::Config; use crate::sshbuffer::PacketWriter; -use crate::{AlgorithmKind, Error, cipher, compression, kex, mac, msg}; +use crate::{cipher, compression, kex, mac, msg, AlgorithmKind, Error}; #[cfg(target_arch = "wasm32")] /// WASM-only stub @@ -148,7 +148,6 @@ impl Preferred { } } - for algo in self.key.iter() { if available_host_keys .iter() From e7ea52ebcbb72d128d327582cb2e1ea1efa7a13a Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Thu, 30 Apr 2026 23:29:37 +0200 Subject: [PATCH 11/19] fix test compilation after check_server_key signature change - Update check_server_key impls in tests.rs to accept &PublicKeyOrCertificate - Replace removed OsRng import with rand::rng() in test_server_cert.rs --- russh/src/tests.rs | 4 ++-- russh/tests/test_server_cert.rs | 21 ++++++++++----------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/russh/src/tests.rs b/russh/src/tests.rs index 10e13303..f87b397f 100644 --- a/russh/src/tests.rs +++ b/russh/src/tests.rs @@ -480,7 +480,7 @@ mod channels { async fn check_server_key( &mut self, - _server_public_key: &crate::keys::ssh_key::PublicKey, + _server_public_key: &crate::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } @@ -893,7 +893,7 @@ mod future_certificate { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &crate::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/tests/test_server_cert.rs b/russh/tests/test_server_cert.rs index a822bd64..809296db 100644 --- a/russh/tests/test_server_cert.rs +++ b/russh/tests/test_server_cert.rs @@ -1,6 +1,5 @@ #![cfg(not(target_arch = "wasm32"))] use russh::keys::ssh_key::certificate::{Builder, CertType}; -use russh::keys::ssh_key::rand_core::OsRng; use russh::keys::ssh_key::{self, Algorithm, HashAlg, PrivateKey}; use russh::*; use std::sync::{Arc, Mutex}; @@ -12,11 +11,11 @@ async fn test_server_certificate_auth() { let _ = env_logger::try_init(); // Generate CA key - let ca_key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap(); + let ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); let ca_public_key = ca_key.public_key(); // Generate Server key - let server_key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap(); + let server_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); let server_public_key = server_key.public_key(); //. Create Server Certificate signed by CA @@ -27,7 +26,7 @@ async fn test_server_certificate_auth() { let end = start + 3600; let mut builder = - Builder::new_with_random_nonce(&mut OsRng, server_public_key.clone(), start, end).unwrap(); + Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end).unwrap(); builder.serial(42).unwrap(); builder.key_id("test-server").unwrap(); builder.cert_type(CertType::Host).unwrap(); @@ -82,16 +81,16 @@ async fn test_server_wrong_ca_certificate_auth() { let _ = env_logger::try_init(); //Generate CA key - let ca_key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap(); + let ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); let ca_public_key = ca_key.public_key(); //Generate second CA key - let evil_ca_key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap(); + let evil_ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); assert_ne!(evil_ca_key, ca_key); // Generate Server key - let server_key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).unwrap(); + let server_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); let server_public_key = server_key.public_key(); // Create Server Certificate signed by CA @@ -103,7 +102,7 @@ async fn test_server_wrong_ca_certificate_auth() { let end = start + 3600; let mut builder = - Builder::new_with_random_nonce(&mut OsRng, server_public_key.clone(), start, end).unwrap(); + Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end).unwrap(); builder.serial(42).unwrap(); builder.key_id("test-server").unwrap(); builder.cert_type(CertType::Host).unwrap(); @@ -161,7 +160,7 @@ async fn test_server_rsa_sha2_512_certificate_auth() { // Generate CA key (RSA, SHA-512) let ca_key = PrivateKey::random( - &mut OsRng, + &mut rand::rng(), Algorithm::Rsa { hash: Some(HashAlg::Sha512), }, @@ -171,7 +170,7 @@ async fn test_server_rsa_sha2_512_certificate_auth() { // Generate Server key (RSA, SHA-512) let server_key = PrivateKey::random( - &mut OsRng, + &mut rand::rng(), Algorithm::Rsa { hash: Some(HashAlg::Sha512), }, @@ -187,7 +186,7 @@ async fn test_server_rsa_sha2_512_certificate_auth() { let end = start + 3600; let mut builder = - Builder::new_with_random_nonce(&mut OsRng, server_public_key.clone(), start, end).unwrap(); + Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end).unwrap(); builder.serial(42).unwrap(); builder.key_id("test-server-rsa").unwrap(); builder.cert_type(CertType::Host).unwrap(); From d8e22ac533bfeaab5214c66393f4ccd548fcca0a Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Fri, 1 May 2026 00:33:52 +0200 Subject: [PATCH 12/19] fix: accept host certificates with infinite validity (valid_before=u64::MAX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenSSH PROTOCOL.certkeys specifies that valid_before=0xffffffffffffffff (u64::MAX) means the certificate never expires. The forked ssh-key crate previously rejected this value in UnixTime::new, causing Certificate::from_bytes to fail for infinite-validity certs. The client kex code silently fell through to the plain public-key path, which also failed — breaking connections to any server whose host certificate was generated without the -V flag. Fixes: - russh-ssh-key: add FOREVER_SECS sentinel and cap its SystemTime at MAX_SECS so u64::MAX round-trips through encoding correctly - Cargo.toml: patch ssh-encoding to the bundled path dep to avoid two-instance type mismatch between path and registry versions of the same crate - tests: add test_server_infinite_validity_certificate_auth regression test --- Cargo.toml | 6 ++- russh/src/client/kex.rs | 2 +- russh/src/server/encrypted.rs | 12 ++++-- russh/tests/test_server_cert.rs | 70 +++++++++++++++++++++++++++++++-- 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9c530b80..3faa878b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,4 +29,8 @@ ssh-key = { version = "=0.6.18", features = [ ], package = "internal-russh-forked-ssh-key" } thiserror = "2.0.18" tokio = { version = "1.17.0" } -tokio-stream = { version = "0.1.3", features = ["net", "sync"] } \ No newline at end of file +tokio-stream = { version = "0.1.3", features = ["net", "sync"] } + +[patch.crates-io] +internal-russh-forked-ssh-key = { git = "https://github.com/gvz/russh-RustCrypto-SSH.git" } +ssh-encoding = { git = "https://github.com/gvz/russh-RustCrypto-SSH.git" } diff --git a/russh/src/client/kex.rs b/russh/src/client/kex.rs index 8a2b9a73..11a95f1c 100644 --- a/russh/src/client/kex.rs +++ b/russh/src/client/kex.rs @@ -7,7 +7,7 @@ use bytes::Bytes; use log::{debug, error, warn}; use ssh_encoding::{Decode, Encode}; use signature::Verifier; -use ssh_key::{Certificate, Mpint, PublicKey, Signature}; +use ssh_key::{Certificate, Mpint, Signature}; use super::IncomingSshPacket; use crate::cert::PublicKeyOrCertificate; diff --git a/russh/src/server/encrypted.rs b/russh/src/server/encrypted.rs index de290410..bffecade 100644 --- a/russh/src/server/encrypted.rs +++ b/russh/src/server/encrypted.rs @@ -333,9 +333,14 @@ impl Encrypted { let pubkey = match pk_or_cert { PublicKeyOrCertificate::PublicKey { ref key, .. } => key.clone(), PublicKeyOrCertificate::Certificate(ref cert) => { - // Validate certificate expiration + // Validate certificate expiration. + // valid_before == u64::MAX is OpenSSH's "no expiry" sentinel + // (PROTOCOL.certkeys); skip the upper-bound check in that case. let now = SystemTime::now(); - if now < cert.valid_after_time() || now > cert.valid_before_time() { + let expired = now < cert.valid_after_time() + || (cert.valid_before() != u64::MAX + && now > cert.valid_before_time()); + if expired { warn!("Certificate is expired or not yet valid"); reject_auth_request(until, &mut self.write, auth_request).await?; return Ok(()); @@ -476,7 +481,8 @@ impl Encrypted { Err(e) => match e { ssh_key::Error::AlgorithmUnknown | ssh_key::Error::AlgorithmUnsupported { .. } - | ssh_key::Error::CertificateValidation => { + | ssh_key::Error::CertificateValidation + | ssh_key::Error::Time => { debug!("public key error: {e}"); reject_auth_request(until, &mut self.write, auth_request).await?; Ok(()) diff --git a/russh/tests/test_server_cert.rs b/russh/tests/test_server_cert.rs index 809296db..5898c715 100644 --- a/russh/tests/test_server_cert.rs +++ b/russh/tests/test_server_cert.rs @@ -26,7 +26,8 @@ async fn test_server_certificate_auth() { let end = start + 3600; let mut builder = - Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end).unwrap(); + Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end) + .unwrap(); builder.serial(42).unwrap(); builder.key_id("test-server").unwrap(); builder.cert_type(CertType::Host).unwrap(); @@ -102,7 +103,8 @@ async fn test_server_wrong_ca_certificate_auth() { let end = start + 3600; let mut builder = - Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end).unwrap(); + Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end) + .unwrap(); builder.serial(42).unwrap(); builder.key_id("test-server").unwrap(); builder.cert_type(CertType::Host).unwrap(); @@ -186,7 +188,8 @@ async fn test_server_rsa_sha2_512_certificate_auth() { let end = start + 3600; let mut builder = - Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end).unwrap(); + Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end) + .unwrap(); builder.serial(42).unwrap(); builder.key_id("test-server-rsa").unwrap(); builder.cert_type(CertType::Host).unwrap(); @@ -236,6 +239,67 @@ async fn test_server_rsa_sha2_512_certificate_auth() { .await .unwrap(); } +#[tokio::test] +async fn test_server_infinite_validity_certificate_auth() { + let _ = env_logger::try_init(); + + // Generate CA key + let ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let ca_public_key = ca_key.public_key(); + + // Generate Server key + let server_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let server_public_key = server_key.public_key(); + + // Create host cert with valid_after=0 and valid_before=u64::MAX (OpenSSH "no expiry" + // sentinel per PROTOCOL.certkeys), matching what `ssh-keygen -s ca -h key.pub` generates + // without the -V flag. + let mut builder = + Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), 0, u64::MAX) + .unwrap(); + builder.serial(42).unwrap(); + builder.key_id("test-server-infinite").unwrap(); + builder.cert_type(CertType::Host).unwrap(); + builder.valid_principal("localhost").unwrap(); + + let cert = builder.sign(&ca_key).unwrap(); + + // Configure Server + let mut config = server::Config::default(); + config.keys.push(server_key); + config.certificates.push(cert); + let config = Arc::new(config); + + // Start Server + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + server::run_stream(config, socket, TestServer {}) + .await + .unwrap(); + }); + + // Configure Client + let mut client_config = client::Config::default(); + client_config.ca_public_keys = Some(vec![ca_public_key.clone()]); + let client_config = Arc::new(client_config); + + let client = TestClient { + ca_public_key: ca_public_key.clone(), + verified: Arc::new(Mutex::new(false)), + }; + + // Must connect successfully — infinite-validity certs were previously rejected + // because valid_before=u64::MAX caused certificate parsing to fail. + let session = client::connect(client_config, addr, client).await.unwrap(); + session + .disconnect(Disconnect::ByApplication, "", "") + .await + .unwrap(); +} + struct TestServer {} impl server::Handler for TestServer { From bae961fb11d4acce000c789a111c1bb7d2bfd4ee Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Sat, 9 May 2026 11:41:21 +0200 Subject: [PATCH 13/19] use rsa 0.10.0-rc.18 --- russh/Cargo.toml | 2 +- russh/src/keys/format/pkcs8.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/russh/Cargo.toml b/russh/Cargo.toml index fbe0d77e..1858ebb9 100644 --- a/russh/Cargo.toml +++ b/russh/Cargo.toml @@ -86,7 +86,7 @@ polyval = "0.7.1" # only pinned due to a cargo-minimal-versions failure in 0.7.0 rand_core = { version = "0.10.0" } rand.workspace = true ring = { version = "0.17.14", optional = true } -rsa = { version = "=0.10.0-rc.16", optional = true } +rsa = { version = "=0.10.0-rc.18", optional = true } russh-cryptovec = { version = "0.59.0", path = "../cryptovec", features = [ "ssh-encoding", ] } diff --git a/russh/src/keys/format/pkcs8.rs b/russh/src/keys/format/pkcs8.rs index 6be2293f..6cd10593 100644 --- a/russh/src/keys/format/pkcs8.rs +++ b/russh/src/keys/format/pkcs8.rs @@ -129,7 +129,7 @@ pub fn encode_pkcs8_encrypted( rng.fill_bytes(&mut iv); let doc = pvi.encrypt_with_params( - pkcs5::pbes2::Parameters::pbkdf2_sha256_aes256cbc(rounds, &salt, iv) + pkcs5::pbes2::Parameters::generate_pbkdf2_sha256_aes256cbc(rounds, &salt, iv) .map_err(|_| Error::InvalidParameters)?, pass, )?; From e3fb59d10612b677387773c4ea1c3ca1b50a9d6c Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Sat, 9 May 2026 16:28:18 +0200 Subject: [PATCH 14/19] fix: bump p256/p384/p521 minimum to rc.9 for minimal-versions CI primefield 0.14.0-rc.7 fails to compile on Rust 1.88.0 due to const generic type inference regression. Raising the floor on p256/p384/p521 to rc.9 ensures cargo minimal-versions resolves primefield to rc.9+. --- russh/Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/russh/Cargo.toml b/russh/Cargo.toml index 1858ebb9..49a1e9be 100644 --- a/russh/Cargo.toml +++ b/russh/Cargo.toml @@ -75,9 +75,9 @@ module-lattice = "0.2" # rand 0.10 support is published. https://github.com/rust-num/num-bigint/pull/338 num-bigint = { package = "internal-russh-num-bigint", version = "=0.5.0", features = ["rand_0_10"] } # num-integer = "0.1" -p256 = { version = "0.14.0-rc.7", features = ["ecdh"] } -p384 = { version = "0.14.0-rc.7", features = ["ecdh"] } -p521 = { version = "0.14.0-rc.7", features = ["ecdh"] } +p256 = { version = "0.14.0-rc.9", features = ["ecdh"] } +p384 = { version = "0.14.0-rc.9", features = ["ecdh"] } +p521 = { version = "0.14.0-rc.9", features = ["ecdh"] } pbkdf2 = "0.12" pkcs1 = { version = "0.8.0-rc.4", optional = true } pkcs5 = "0.8.0-rc.13" From 86e3f938c106f35dbbec9ed43070d879480e2ac5 Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Sat, 9 May 2026 16:42:45 +0200 Subject: [PATCH 15/19] fix: add ed25519 floor pin and bump ed25519-dalek minimum for minimal-versions CI ed25519 rc.4 and ed25519-dalek pre.6 use Error::KeyMalformed as a unit variant, incompatible with pkcs8 0.11 stable which changed it to a tuple variant. Pin ed25519 to 3.0.0 stable and raise ed25519-dalek floor to pre.7 so cargo minimal-versions never selects the broken versions. --- russh/Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/russh/Cargo.toml b/russh/Cargo.toml index 49a1e9be..97c0ec30 100644 --- a/russh/Cargo.toml +++ b/russh/Cargo.toml @@ -52,7 +52,8 @@ digest.workspace = true der = "0.8" des = { version = "0.8.1", optional = true } ecdsa = "0.17.0-rc.16" -ed25519-dalek = { version = "3.0.0-pre.6", features = ["alloc", "rand_core", "pkcs8"] } +ed25519 = "3.0.0" # floor pin: rc.4 uses Error::KeyMalformed as unit variant, incompatible with pkcs8 0.11 stable +ed25519-dalek = { version = "3.0.0-pre.7", features = ["alloc", "rand_core", "pkcs8"] } elliptic-curve = { version = "0.14.0-rc.28", features = ["ecdh"] } enum_dispatch = "0.3.13" flate2 = { version = "1.0.15", optional = true } From 3e2d30b4fd04e6d7d008311bf0eb5db4d61c98a4 Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Sat, 9 May 2026 16:51:50 +0200 Subject: [PATCH 16/19] fix: add num-bigint floor pin for minimal-versions CI num-bigint 0.4.0 calls div_ceil(&x) which broke when Rust 1.73 stabilized div_ceil(x) by value. yasna 0.5.0 depends on num-bigint with no floor, so minimal-versions resolves to 0.4.0. --- russh/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/russh/Cargo.toml b/russh/Cargo.toml index 97c0ec30..f7fc311c 100644 --- a/russh/Cargo.toml +++ b/russh/Cargo.toml @@ -75,6 +75,7 @@ module-lattice = "0.2" # via the `rand_0_10` feature flag. Replace with upstream once a release with # rand 0.10 support is published. https://github.com/rust-num/num-bigint/pull/338 num-bigint = { package = "internal-russh-num-bigint", version = "=0.5.0", features = ["rand_0_10"] } +num-bigint-std = { package = "num-bigint", version = "0.4.6" } # floor pin: 0.4.0 uses div_ceil(&x) which broke when Rust 1.73 stabilized div_ceil(x) by value # num-integer = "0.1" p256 = { version = "0.14.0-rc.9", features = ["ecdh"] } p384 = { version = "0.14.0-rc.9", features = ["ecdh"] } From cfe5d8a99d5dd57fcef927f2ea8bbf7ba26ceef4 Mon Sep 17 00:00:00 2001 From: Georg von Zengen Date: Sat, 9 May 2026 17:00:05 +0200 Subject: [PATCH 17/19] fix: add ctr 0.10 floor pin for minimal-versions CI ctr 0.10.0-rc.3 uses cipher::common::BlockSizes which doesn't exist in cipher 0.5 stable. Add aliased floor pin to force 0.10.0 stable while keeping russh's direct dep on ctr 0.9 (compatible with aes 0.8). --- russh/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/russh/Cargo.toml b/russh/Cargo.toml index f7fc311c..376d6a8b 100644 --- a/russh/Cargo.toml +++ b/russh/Cargo.toml @@ -44,6 +44,7 @@ bytes.workspace = true cbc = { version = "0.1" } cipher = "0.5.1" # only pinned due to a cargo-minimal-versions failure in 0.5.0 ctr = "0.9" +ctr_0_10 = { package = "ctr", version = "0.10.0" } # floor pin: rc.3 uses cipher::common::BlockSizes which doesn't exist in cipher 0.5 stable curve25519-dalek = "5.0.0-pre.6" crypto-bigint = { version = "0.7.0-rc.28", features = ["alloc"] } data-encoding = "2.3" From c36fe693faa75c42ffba0b617371bbc1e5234c31 Mon Sep 17 00:00:00 2001 From: Eugene Date: Mon, 17 Aug 2026 23:35:22 +0200 Subject: [PATCH 18/19] fixup --- russh/examples/echoserver_certificates.rs | 6 +- russh/src/negotiation.rs | 97 +++++- russh/src/server/kex.rs | 43 +-- russh/tests/test_server_cert.rs | 389 +++++++++------------- 4 files changed, 261 insertions(+), 274 deletions(-) diff --git a/russh/examples/echoserver_certificates.rs b/russh/examples/echoserver_certificates.rs index e7c0d77e..f15855af 100644 --- a/russh/examples/echoserver_certificates.rs +++ b/russh/examples/echoserver_certificates.rs @@ -113,13 +113,15 @@ impl server::Handler for Server { async fn channel_open_session( &mut self, channel: Channel, + reply: server::ChannelOpenHandle, session: &mut Session, - ) -> Result { + ) -> Result<(), Self::Error> { { let mut clients = self.clients.lock().await; clients.insert(self.id, (channel.id(), session.handle())); } - Ok(true) + reply.accept().await; + Ok(()) } async fn auth_publickey( diff --git a/russh/src/negotiation.rs b/russh/src/negotiation.rs index f4f8e0bf..2f9c9009 100644 --- a/russh/src/negotiation.rs +++ b/russh/src/negotiation.rs @@ -107,9 +107,10 @@ pub(crate) fn is_key_compatible_with_algo(key: &PrivateKey, algo: &Algorithm) -> /// Certificate algorithm names a server can honor: those of its certificates /// that come with a matching private key to sign the exchange with. An RSA -/// certificate is offered under every RSA variant, since the hash picks the -/// exchange-signature algorithm, not the certificate. +/// certificate is offered under the RSA variants pref.key allows since the +/// hash picks the exchange-signature algorithm, not the certificate. pub(crate) fn server_certificate_names( + pref: &Preferred, certificates: &[Certificate], keys: &[PrivateKey], ) -> Vec { @@ -123,15 +124,12 @@ pub(crate) fn server_certificate_names( continue; } let variants = match cert.algorithm() { - Algorithm::Rsa { .. } => vec![ - Algorithm::Rsa { - hash: Some(HashAlg::Sha512), - }, - Algorithm::Rsa { - hash: Some(HashAlg::Sha256), - }, - Algorithm::Rsa { hash: None }, - ], + Algorithm::Rsa { .. } => pref + .key + .iter() + .filter(|a| matches!(a, Algorithm::Rsa { .. })) + .cloned() + .collect(), a => vec![a], }; for name in variants.iter().map(Algorithm::to_certificate_type) { @@ -343,7 +341,9 @@ pub(crate) trait Select { // [`Names`]. let certificate_names = if Self::is_server() { match (available_certificates, available_host_keys) { - (Some(certificates), Some(keys)) => server_certificate_names(certificates, keys), + (Some(certificates), Some(keys)) => { + server_certificate_names(pref, certificates, keys) + } _ => Vec::new(), } } else { @@ -554,7 +554,7 @@ pub(crate) fn write_kex( // certificate algorithms ahead of plain keys so a client that // accepts both is served the certificate. NameList( - server_certificate_names(&server_config.certificates, &server_config.keys) + server_certificate_names(prefs, &server_config.certificates, &server_config.keys) .into_iter() .chain( prefs @@ -764,6 +764,7 @@ mod tests { hash: Some(HashAlg::Sha512), }]), None, + None, &KexCause::Initial, ) .unwrap(); @@ -854,4 +855,74 @@ mod tests { assert!(!names.host_key_is_certificate); assert!(names.ignore_guessed); } + + fn host_cert(subject: &PrivateKey, ca: &PrivateKey) -> Certificate { + let mut builder = ssh_key::certificate::Builder::new_with_random_nonce( + &mut rand::rng(), + subject.public_key(), + 0, + u64::MAX, + ) + .unwrap(); + builder.key_id("test").unwrap(); + builder + .cert_type(ssh_key::certificate::CertType::Host) + .unwrap(); + builder.valid_principal("localhost").unwrap(); + builder.sign(ca).unwrap() + } + + /// A certificate without a matching private key can never be honored and + /// must not be advertised. + #[test] + fn certificate_without_matching_key_is_not_advertised() { + let ca = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let stale_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let good_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let stale_cert = host_cert(&stale_key, &ca); + let good_cert = host_cert(&good_key, &ca); + + let keys = vec![good_key]; + assert_eq!( + server_certificate_names(&Preferred::DEFAULT, &[stale_cert], &keys), + Vec::::new() + ); + assert_eq!( + server_certificate_names(&Preferred::DEFAULT, &[good_cert], &keys), + vec![ED25519_CERT.to_string()] + ); + } + + /// The RSA cert variants a server advertises follow `pref.key`, so the + /// preference list stays the policy knob for e.g. banning SHA-1. + #[test] + fn rsa_certificate_variants_follow_preferences() { + let ca = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let rsa_key = + PrivateKey::random(&mut rand::rng(), Algorithm::Rsa { hash: None }).unwrap(); + let cert = host_cert(&rsa_key, &ca); + let keys = vec![rsa_key]; + + // Default preferences allow all three variants, in preference order. + assert_eq!( + server_certificate_names(&Preferred::DEFAULT, std::slice::from_ref(&cert), &keys), + vec![ + "rsa-sha2-512-cert-v01@openssh.com".to_string(), + "rsa-sha2-256-cert-v01@openssh.com".to_string(), + "ssh-rsa-cert-v01@openssh.com".to_string(), + ] + ); + + // Preferences without `ssh-rsa` must not advertise its cert variant. + let no_sha1 = Preferred { + key: Cow::Owned(vec![Algorithm::Rsa { + hash: Some(HashAlg::Sha512), + }]), + ..Preferred::DEFAULT + }; + assert_eq!( + server_certificate_names(&no_sha1, &[cert], &keys), + vec!["rsa-sha2-512-cert-v01@openssh.com".to_string()] + ); + } } diff --git a/russh/src/server/kex.rs b/russh/src/server/kex.rs index 7b270a11..7374dea9 100644 --- a/russh/src/server/kex.rs +++ b/russh/src/server/kex.rs @@ -252,11 +252,10 @@ impl ServerKex { // `names.key` is then the plain algorithm the certificate // contains, which is what signs the exchange below. let (key, certificate) = if names.host_key_is_certificate { - let cert = self - .config + self.config .certificates .iter() - .find(|c| { + .filter(|c| { // RSA certificates are usable with any RSA cert algorithm // variant (ssh-rsa-cert, rsa-sha2-256-cert, rsa-sha2-512-cert) // since the hash variant controls the KEx signing algorithm, @@ -269,14 +268,16 @@ impl ServerKex { } } }) - .ok_or(Error::UnknownKey)?; - let key = self - .config - .keys - .iter() - .find(|k| k.public_key().key_data() == cert.public_key()) - .ok_or(Error::UnknownKey)?; - (key, Some(cert)) + // Only certificates with a matching private key were + // advertised, so skip any without one here as well. + .find_map(|c| { + self.config + .keys + .iter() + .find(|k| k.public_key().key_data() == c.public_key()) + .map(|k| (k, Some(c))) + }) + .ok_or(Error::UnknownKey)? } else { let key = self .config @@ -287,6 +288,14 @@ impl ServerKex { (key, None) }; + let certificate_blob = certificate + .map(|cert| { + let mut blob = Vec::new(); + cert.encode(&mut blob)?; + Ok::<_, Error>(blob) + }) + .transpose()?; + // Look up the key we'll be using to sign the exchange hash let signature_hash_alg = match &names.key { Algorithm::Rsa { hash } => *hash, @@ -298,10 +307,8 @@ impl ServerKex { buffer.clear(); let mut pubkey_vec = Vec::new(); - if let Some(cert) = certificate { - let mut buf = Vec::new(); - cert.encode(&mut buf)?; - buf.encode(&mut pubkey_vec)?; + if let Some(blob) = &certificate_blob { + blob.encode(&mut pubkey_vec)?; } else { key.public_key().to_bytes()?.encode(&mut pubkey_vec)?; } @@ -325,10 +332,8 @@ impl ServerKex { false => &msg::KEX_ECDH_REPLY, } .encode(w)?; - if let Some(cert) = certificate { - let mut buf = CryptoVec::new(); - cert.encode(&mut buf)?; - buf.encode(w)?; + if let Some(blob) = &certificate_blob { + blob.encode(w)?; } else { key.public_key().to_bytes()?.encode(w)?; } diff --git a/russh/tests/test_server_cert.rs b/russh/tests/test_server_cert.rs index 5898c715..037ae6c8 100644 --- a/russh/tests/test_server_cert.rs +++ b/russh/tests/test_server_cert.rs @@ -1,153 +1,135 @@ #![cfg(not(target_arch = "wasm32"))] +use std::borrow::Cow; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use russh::keys::PublicKeyOrCertificate; use russh::keys::ssh_key::certificate::{Builder, CertType}; use russh::keys::ssh_key::{self, Algorithm, HashAlg, PrivateKey}; use russh::*; -use std::sync::{Arc, Mutex}; -use std::time::{SystemTime, UNIX_EPOCH}; use tokio::net::TcpListener; -#[tokio::test] -async fn test_server_certificate_auth() { - let _ = env_logger::try_init(); - - // Generate CA key - let ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); - let ca_public_key = ca_key.public_key(); - - // Generate Server key - let server_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); - let server_public_key = server_key.public_key(); - - //. Create Server Certificate signed by CA - let start = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let end = start + 3600; - - let mut builder = - Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end) - .unwrap(); +fn host_cert( + subject: &PrivateKey, + signing_ca: &PrivateKey, + valid_after: u64, + valid_before: u64, +) -> russh::keys::Certificate { + let mut builder = Builder::new_with_random_nonce( + &mut rand::rng(), + subject.public_key().clone(), + valid_after, + valid_before, + ) + .unwrap(); builder.serial(42).unwrap(); builder.key_id("test-server").unwrap(); builder.cert_type(CertType::Host).unwrap(); builder.valid_principal("localhost").unwrap(); + builder.sign(signing_ca).unwrap() +} - let cert = builder.sign(&ca_key).unwrap(); - - // Configure Server - let mut config = server::Config::default(); - config.keys.push(server_key); - config.certificates.push(cert); +/// Spin up a server with `config` and connect a client that trusts +/// `trusted_ca` and advertises the certificate algorithm `cert_algo`. +/// Returns the connect result. +async fn serve_and_connect( + config: server::Config, + cert_algo: Algorithm, + trusted_ca: &PrivateKey, +) -> Result, russh::Error> { let config = Arc::new(config); - // Start Server let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - let server_finished = Arc::new(Mutex::new(false)); - let server_finished_clone = server_finished.clone(); - tokio::spawn(async move { let (socket, _) = listener.accept().await.unwrap(); - server::run_stream(config, socket, TestServer {}) + let _ = server::run_stream(config, socket, TestServer {}) .await .unwrap(); - *server_finished_clone.lock().unwrap() = true; }); - // Configure Client let mut client_config = client::Config::default(); - - // Set CA public key - this will automatically add cert algorithms to preferred - client_config.ca_public_keys = Some(vec![ca_public_key.clone()]); - + // Opt into host certificates: advertise the certificate algorithm. + client_config.preferred.host_key_certificates = Cow::Owned(vec![cert_algo]); let client_config = Arc::new(client_config); let client = TestClient { - ca_public_key: ca_public_key.clone(), - verified: Arc::new(Mutex::new(false)), + ca_public_key: trusted_ca.public_key().clone(), }; - // Connect Client - let session = client::connect(client_config, addr, client).await.unwrap(); + client::connect(client_config, addr, client).await +} + +/// Spin up a server presenting a host certificate for `key_algo` signed by +/// `signing_ca`, and connect a client that trusts `trusted_ca` and advertises +/// the certificate algorithm `cert_algo`. Returns the connect result. +async fn connect_with_cert( + key_algo: Algorithm, + cert_algo: Algorithm, + valid_after: u64, + valid_before: u64, + trusted_ca: &PrivateKey, + signing_ca: &PrivateKey, +) -> Result, russh::Error> { + let server_key = PrivateKey::random(&mut rand::rng(), key_algo).unwrap(); + let cert = host_cert(&server_key, signing_ca, valid_after, valid_before); + + let mut config = server::Config::default(); + config.keys.push(server_key); + config.certificates.push(cert); + + serve_and_connect(config, cert_algo, trusted_ca).await +} + +#[tokio::test] +async fn test_server_certificate_auth() { + let _ = env_logger::try_init(); + + let ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let session = connect_with_cert( + Algorithm::Ed25519, + Algorithm::Ed25519, + now, + now + 3600, + &ca_key, + &ca_key, + ) + .await + .unwrap(); session .disconnect(Disconnect::ByApplication, "", "") .await .unwrap(); } + #[tokio::test] async fn test_server_wrong_ca_certificate_auth() { let _ = env_logger::try_init(); - //Generate CA key let ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); - let ca_public_key = ca_key.public_key(); - - //Generate second CA key let evil_ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); - - assert_ne!(evil_ca_key, ca_key); - - // Generate Server key - let server_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); - let server_public_key = server_key.public_key(); - - // Create Server Certificate signed by CA - // Builder::new_with_random_nonce(rng, public_key, valid_after, valid_before) - let start = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); - let end = start + 3600; - - let mut builder = - Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end) - .unwrap(); - builder.serial(42).unwrap(); - builder.key_id("test-server").unwrap(); - builder.cert_type(CertType::Host).unwrap(); - builder.valid_principal("localhost").unwrap(); - - let cert = builder.sign(&evil_ca_key).unwrap(); - - // Configure Server - let mut config = server::Config::default(); - config.keys.push(server_key); - config.certificates.push(cert); - let config = Arc::new(config); - - // Start Server - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let server_finished = Arc::new(Mutex::new(false)); - let server_finished_clone = server_finished.clone(); - - tokio::spawn(async move { - let (socket, _) = listener.accept().await.unwrap(); - server::run_stream(config, socket, TestServer {}) - .await - .unwrap(); - *server_finished_clone.lock().unwrap() = true; - }); - - // Configure Client - let mut client_config = client::Config::default(); - - // Set CA public key - this will automatically add cert algorithms to preferred - client_config.ca_public_keys = Some(vec![ca_public_key.clone()]); - - let client_config = Arc::new(client_config); - - let client = TestClient { - ca_public_key: ca_public_key.clone(), - verified: Arc::new(Mutex::new(false)), - }; - // Connect Client - if let Ok(session) = client::connect(client_config, addr, client).await { + if let Ok(session) = connect_with_cert( + Algorithm::Ed25519, + Algorithm::Ed25519, + now, + now + 3600, + &ca_key, + &evil_ca_key, + ) + .await + { session .disconnect(Disconnect::ByApplication, "", "") .await @@ -160,140 +142,76 @@ async fn test_server_wrong_ca_certificate_auth() { async fn test_server_rsa_sha2_512_certificate_auth() { let _ = env_logger::try_init(); - // Generate CA key (RSA, SHA-512) - let ca_key = PrivateKey::random( - &mut rand::rng(), - Algorithm::Rsa { - hash: Some(HashAlg::Sha512), - }, - ) - .unwrap(); - let ca_public_key = ca_key.public_key(); - - // Generate Server key (RSA, SHA-512) - let server_key = PrivateKey::random( - &mut rand::rng(), - Algorithm::Rsa { - hash: Some(HashAlg::Sha512), - }, - ) - .unwrap(); - let server_public_key = server_key.public_key(); - - // Create Server Certificate signed by CA - let start = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let rsa = Algorithm::Rsa { + hash: Some(HashAlg::Sha512), + }; + let ca_key = PrivateKey::random(&mut rand::rng(), rsa.clone()).unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); - let end = start + 3600; - - let mut builder = - Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), start, end) - .unwrap(); - builder.serial(42).unwrap(); - builder.key_id("test-server-rsa").unwrap(); - builder.cert_type(CertType::Host).unwrap(); - builder.valid_principal("localhost").unwrap(); - - let cert = builder.sign(&ca_key).unwrap(); - - // Configure Server - let mut config = server::Config::default(); - config.keys.push(server_key); - config.certificates.push(cert); - let config = Arc::new(config); - - // Start Server - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let server_finished = Arc::new(Mutex::new(false)); - let server_finished_clone = server_finished.clone(); - - tokio::spawn(async move { - let (socket, _) = listener.accept().await.unwrap(); - server::run_stream(config, socket, TestServer {}) - .await - .unwrap(); - *server_finished_clone.lock().unwrap() = true; - }); - - // Configure Client - let mut client_config = client::Config::default(); - - // Set CA public key - this will automatically add cert algorithms to preferred - client_config.ca_public_keys = Some(vec![ca_public_key.clone()]); - - let client_config = Arc::new(client_config); - let client = TestClient { - ca_public_key: ca_public_key.clone(), - verified: Arc::new(Mutex::new(false)), - }; - - // Connect Client — must succeed and certificate must validate - let session = client::connect(client_config, addr, client).await.unwrap(); + let session = connect_with_cert(rsa.clone(), rsa, now, now + 3600, &ca_key, &ca_key) + .await + .unwrap(); session .disconnect(Disconnect::ByApplication, "", "") .await .unwrap(); } + #[tokio::test] async fn test_server_infinite_validity_certificate_auth() { let _ = env_logger::try_init(); - // Generate CA key let ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); - let ca_public_key = ca_key.public_key(); - - // Generate Server key - let server_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); - let server_public_key = server_key.public_key(); - - // Create host cert with valid_after=0 and valid_before=u64::MAX (OpenSSH "no expiry" - // sentinel per PROTOCOL.certkeys), matching what `ssh-keygen -s ca -h key.pub` generates - // without the -V flag. - let mut builder = - Builder::new_with_random_nonce(&mut rand::rng(), server_public_key.clone(), 0, u64::MAX) - .unwrap(); - builder.serial(42).unwrap(); - builder.key_id("test-server-infinite").unwrap(); - builder.cert_type(CertType::Host).unwrap(); - builder.valid_principal("localhost").unwrap(); - let cert = builder.sign(&ca_key).unwrap(); - - // Configure Server - let mut config = server::Config::default(); - config.keys.push(server_key); - config.certificates.push(cert); - let config = Arc::new(config); + // A host cert with valid_after=0 and valid_before=u64::MAX (OpenSSH + // "always valid" sentinels per PROTOCOL.certkeys), matching what + // `ssh-keygen -s ca -h key.pub` generates without the -V flag. + let session = connect_with_cert( + Algorithm::Ed25519, + Algorithm::Ed25519, + 0, + u64::MAX, + &ca_key, + &ca_key, + ) + .await + .unwrap(); - // Start Server - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); + session + .disconnect(Disconnect::ByApplication, "", "") + .await + .unwrap(); +} - tokio::spawn(async move { - let (socket, _) = listener.accept().await.unwrap(); - server::run_stream(config, socket, TestServer {}) - .await - .unwrap(); - }); +/// Regression test: a certificate whose private key is absent from +/// `config.keys` must be skipped at presentation time (not only at +/// advertisement time), so a later certificate that does have its key +/// still works. +#[tokio::test] +async fn test_server_stale_certificate_is_skipped() { + let _ = env_logger::try_init(); - // Configure Client - let mut client_config = client::Config::default(); - client_config.ca_public_keys = Some(vec![ca_public_key.clone()]); - let client_config = Arc::new(client_config); + let ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let stale_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let good_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); - let client = TestClient { - ca_public_key: ca_public_key.clone(), - verified: Arc::new(Mutex::new(false)), - }; + let mut config = server::Config::default(); + config + .certificates + .push(host_cert(&stale_key, &ca_key, 0, u64::MAX)); + config + .certificates + .push(host_cert(&good_key, &ca_key, 0, u64::MAX)); + config.keys.push(good_key); + + let session = serve_and_connect(config, Algorithm::Ed25519, &ca_key) + .await + .unwrap(); - // Must connect successfully — infinite-validity certs were previously rejected - // because valid_before=u64::MAX caused certificate parsing to fail. - let session = client::connect(client_config, addr, client).await.unwrap(); session .disconnect(Disconnect::ByApplication, "", "") .await @@ -316,7 +234,6 @@ impl server::Handler for TestServer { struct TestClient { ca_public_key: ssh_key::PublicKey, - verified: Arc>, } impl client::Handler for TestClient { @@ -324,49 +241,41 @@ impl client::Handler for TestClient { async fn check_server_key( &mut self, - server_public_key: &russh::cert::PublicKeyOrCertificate, + server_public_key: &PublicKeyOrCertificate, ) -> Result { match server_public_key { - russh::cert::PublicKeyOrCertificate::Certificate(cert) => { - // Perform the signature verification using your trusted CA public key. - // This checks if the certificate was genuinely signed by your CA. + PublicKeyOrCertificate::Certificate(cert) => { + // Check that the certificate was signed by the trusted CA. let fingerprint = self.ca_public_key.fingerprint(HashAlg::Sha256); if let Err(e) = cert.validate([&fingerprint]) { - eprintln!("Host certificate signature verification failed: {}", e); + eprintln!("Host certificate signature verification failed: {e}"); return Ok(false); } // Check the certificate's validity period. - let current_unix_time = SystemTime::now() + let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); - - if current_unix_time < cert.valid_after() || current_unix_time > cert.valid_before() - { + if now < cert.valid_after() || now > cert.valid_before() { eprintln!("Host certificate is outside its validity period."); return Ok(false); } - // (Optional but recommended) Check the certificate's valid principals. + // Check the certificate's valid principals. let target_hostname = "localhost"; if !cert .valid_principals() .contains(&target_hostname.to_string()) { - eprintln!( - "Host certificate is not valid for principal: {}", - target_hostname - ); + eprintln!("Host certificate is not valid for principal: {target_hostname}"); return Ok(false); } - // If all checks pass, the certificate is valid. Ok(true) } - russh::cert::PublicKeyOrCertificate::PublicKey { .. } => { - // If the server presents a plain public key (not a certificate), decide - // whether to accept it. For certificate-only environments, you might reject. + PublicKeyOrCertificate::PublicKey { .. } => { + // Certificate-only environment: reject plain host keys. eprintln!("Server presented a plain public key, not a certificate."); Ok(false) } From 6af5b9fbc3a62de791d6191b1c656e6bb792377e Mon Sep 17 00:00:00 2001 From: Eugene Date: Mon, 17 Aug 2026 23:47:23 +0200 Subject: [PATCH 19/19] Update negotiation.rs --- russh/src/negotiation.rs | 50 ++++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/russh/src/negotiation.rs b/russh/src/negotiation.rs index 2f9c9009..89a279b0 100644 --- a/russh/src/negotiation.rs +++ b/russh/src/negotiation.rs @@ -106,9 +106,10 @@ pub(crate) fn is_key_compatible_with_algo(key: &PrivateKey, algo: &Algorithm) -> } /// Certificate algorithm names a server can honor: those of its certificates -/// that come with a matching private key to sign the exchange with. An RSA -/// certificate is offered under the RSA variants pref.key allows since the -/// hash picks the exchange-signature algorithm, not the certificate. +/// that come with a matching private key to sign the exchange with, gated by +/// `pref.key` so the preference list stays the policy knob for certificates +/// too. An RSA certificate is offered under every RSA variant pref.key allows +/// since the hash picks the exchange-signature algorithm, not the certificate. pub(crate) fn server_certificate_names( pref: &Preferred, certificates: &[Certificate], @@ -123,15 +124,15 @@ pub(crate) fn server_certificate_names( debug!("no host key matching certificate {:?}", cert.key_id()); continue; } - let variants = match cert.algorithm() { - Algorithm::Rsa { .. } => pref - .key - .iter() - .filter(|a| matches!(a, Algorithm::Rsa { .. })) - .cloned() - .collect(), - a => vec![a], - }; + let variants: Vec = pref + .key + .iter() + .filter(|a| match (&cert.algorithm(), a) { + (Algorithm::Rsa { .. }, Algorithm::Rsa { .. }) => true, + (c, a) => c == *a, + }) + .cloned() + .collect(); for name in variants.iter().map(Algorithm::to_certificate_type) { if !names.contains(&name) { names.push(name); @@ -893,6 +894,31 @@ mod tests { ); } + /// A certificate whose algorithm is excluded from `pref.key` must not be + /// advertised: the preference list gates certificates like plain keys. + #[test] + fn certificate_for_banned_algorithm_is_not_advertised() { + let ca = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let cert = host_cert(&key, &ca); + let keys = vec![key]; + + let no_ed25519 = Preferred { + key: Cow::Owned(vec![Algorithm::Rsa { + hash: Some(HashAlg::Sha512), + }]), + ..Preferred::DEFAULT + }; + assert_eq!( + server_certificate_names(&no_ed25519, std::slice::from_ref(&cert), &keys), + Vec::::new() + ); + assert_eq!( + server_certificate_names(&Preferred::DEFAULT, &[cert], &keys), + vec![ED25519_CERT.to_string()] + ); + } + /// The RSA cert variants a server advertises follow `pref.key`, so the /// preference list stays the policy knob for e.g. banning SHA-1. #[test]