From 9296e62f22a77fa56ceeb9b9679ee9be9a13e5f9 Mon Sep 17 00:00:00 2001 From: Shubo Lan Date: Mon, 2 Mar 2026 17:01:06 +0900 Subject: [PATCH] Support host key certificates --- 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 | 3 +- russh/src/cert.rs | 2 +- russh/src/client/kex.rs | 55 ++++++++++++++++------ russh/src/client/mod.rs | 3 +- russh/src/client/test.rs | 2 +- russh/src/kex/mod.rs | 4 +- russh/src/lib_inner.rs | 2 +- russh/src/negotiation.rs | 47 +++++++++++++++--- russh/src/tests.rs | 12 ++--- russh/tests/test_backpressure.rs | 2 +- russh/tests/test_data_stream.rs | 2 +- russh/tests/test_kex_shared_secret.rs | 4 +- russh/tests/test_mlkem_kex.rs | 2 +- russh/tests/test_rekey_strict_kex.rs | 2 +- 17 files changed, 104 insertions(+), 44 deletions(-) diff --git a/russh/examples/client_exec_interactive.rs b/russh/examples/client_exec_interactive.rs index 725f5a5e..9d4f5deb 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: &cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/examples/client_exec_simple.rs b/russh/examples/client_exec_simple.rs index db956b59..539b6d22 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: &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..0a41e2db 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: &cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/examples/sftp_client.rs b/russh/examples/sftp_client.rs index 18d755d1..a4764a49 100644 --- a/russh/examples/sftp_client.rs +++ b/russh/examples/sftp_client.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use log::{LevelFilter, error, info}; -use russh::keys::*; use russh::*; use russh_sftp::client::SftpSession; use russh_sftp::protocol::OpenFlags; @@ -14,7 +13,7 @@ impl client::Handler for Client { async fn check_server_key( &mut self, - server_public_key: &ssh_key::PublicKey, + server_public_key: &cert::PublicKeyOrCertificate, ) -> Result { info!("check_server_key: {server_public_key:?}"); Ok(true) diff --git a/russh/src/cert.rs b/russh/src/cert.rs index 2a101049..d38d78a4 100644 --- a/russh/src/cert.rs +++ b/russh/src/cert.rs @@ -9,7 +9,7 @@ use crate::keys::key::PrivateKeyWithHashAlg; #[derive(Debug)] #[allow(clippy::large_enum_variant)] -pub(crate) enum PublicKeyOrCertificate { +pub enum PublicKeyOrCertificate { PublicKey { key: PublicKey, hash_alg: Option, diff --git a/russh/src/client/kex.rs b/russh/src/client/kex.rs index fbda79ea..9ed08221 100644 --- a/russh/src/client/kex.rs +++ b/russh/src/client/kex.rs @@ -6,11 +6,13 @@ use std::sync::Arc; use bytes::Bytes; use log::{debug, error, warn}; use signature::Verifier; -use ssh_encoding::{Decode, Encode}; -use ssh_key::{Mpint, PublicKey, Signature}; +use ssh_encoding::{Decode, Encode }; +use ssh_key::{Algorithm, Certificate, Mpint, Signature}; use super::IncomingSshPacket; +use crate::cert::PublicKeyOrCertificate; use crate::client::{Config, NewKeys}; +use crate::helpers::AlgorithmExt; use crate::kex::dh::groups::DhGroup; use crate::kex::{KexAlgorithm, KexAlgorithmImplementor, KexCause, KexProgress, KEXES}; use crate::keys::key::parse_public_key; @@ -37,7 +39,7 @@ enum ClientKexState { kex: KexAlgorithm, }, WaitingForNewKeys { - server_host_key: PublicKey, + server_host_key: PublicKeyOrCertificate, newkeys: NewKeys, }, } @@ -262,20 +264,41 @@ 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 mut pubkey_vec = CryptoVec::new(); + let server_host_key_bytes = Bytes::decode(r)?; + let algo = String::decode(&mut &server_host_key_bytes[..])?; + + // SSH supports two modes for server authentication during the handshake: + // In normal mode, the server sends a raw public key during the SSH handshake: + // host_key = raw public key (e.g. "ssh-rsa ") + // + // In host certificate mode, the server sends a certificate instead: + // host_key = certificate (e.g. "ssh-rsa-cert-v01@openssh.com ") + // + // The cert blob contains the public key itself, the CA signature, and metadata + // such as principals and validity period. The server's private key remains the + // same — the cert is essentially a CA-endorsed wrapper around the public key. + let server_host_key = if Algorithm::new_certificate(&algo).is_ok() { + let cert = Certificate::from_bytes(&server_host_key_bytes)?; + server_host_key_bytes.as_ref().encode(&mut pubkey_vec)?; + PublicKeyOrCertificate::Certificate(cert) + } else { + let public_key = parse_public_key(&server_host_key_bytes)?; + debug!( + "received server host key: {:?}", + public_key.to_openssh() + ); + public_key.to_bytes()?.encode(&mut pubkey_vec)?; + let hash_alg = Algorithm::new(&algo) + .ok() + .and_then(|algorithm| algorithm.hash_alg()); + PublicKeyOrCertificate::PublicKey { key: public_key, hash_alg } + }; + let server_ephemeral = Bytes::decode(r)?; self.exchange.server_ephemeral.extend(&server_ephemeral); kex.compute_shared_secret(&self.exchange.server_ephemeral)?; - let mut pubkey_vec = CryptoVec::new(); - server_host_key.to_bytes()?.encode(&mut pubkey_vec)?; - let exchange = &self.exchange; let hash = HASH_BUFFER.with({ |buffer| { @@ -287,8 +310,12 @@ impl ClientKex { let signature = Bytes::decode(r)?; let signature = Signature::decode(&mut &signature[..])?; + let public_key = match &server_host_key { + PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(), + PublicKeyOrCertificate::Certificate(cert) => cert.public_key().clone().into(), + }; - if let Err(e) = Verifier::verify(&server_host_key, hash.as_ref(), &signature) { + if let Err(e) = Verifier::verify(&public_key, hash.as_ref(), &signature) { debug!("wrong server sig: {e:?}"); return Err(Error::WrongServerSig); } diff --git a/russh/src/client/mod.rs b/russh/src/client/mod.rs index c888f44c..efc1366a 100644 --- a/russh/src/client/mod.rs +++ b/russh/src/client/mod.rs @@ -61,6 +61,7 @@ use crate::channels::{ Channel, ChannelMsg, ChannelReadHalf, ChannelRef, ChannelWriteHalf, WindowSizeRef, }; use crate::cipher::{self, OpeningKey, clear}; +use crate::cert::PublicKeyOrCertificate; use crate::kex::{KexAlgorithmImplementor, KexCause, KexProgress, SessionKexState}; use crate::keys::PrivateKeyWithHashAlg; use crate::msg::{is_kex_msg, validate_server_msg_strict_kex}; @@ -1743,7 +1744,7 @@ pub trait Handler: Sized + Send { #[allow(unused_variables)] fn check_server_key( &mut self, - server_public_key: &ssh_key::PublicKey, + server_public_key: &PublicKeyOrCertificate, ) -> impl Future> + Send { async { Ok(false) } } diff --git a/russh/src/client/test.rs b/russh/src/client/test.rs index ea7111d8..610fd1a6 100644 --- a/russh/src/client/test.rs +++ b/russh/src/client/test.rs @@ -72,7 +72,7 @@ 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 d322dc73..ed24c9e0 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 f64b0208..53eb602d 100644 --- a/russh/src/lib_inner.rs +++ b/russh/src/lib_inner.rs @@ -14,7 +14,7 @@ mod tests; mod auth; -mod cert; +pub mod cert; /// Cipher names pub mod cipher; /// Compression algorithm names diff --git a/russh/src/negotiation.rs b/russh/src/negotiation.rs index ab6c91f3..8b4c4bec 100644 --- a/russh/src/negotiation.rs +++ b/russh/src/negotiation.rs @@ -20,7 +20,7 @@ use ssh_encoding::{Decode, Encode}; use ssh_key::{Algorithm, 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, }; @@ -194,6 +194,15 @@ pub(crate) fn parse_kex_algo_list(list: &str) -> Vec<&str> { list.split(',').collect() } +fn host_key_algorithm_names(algo: &Algorithm) -> Vec { + let mut names = vec![algo.to_string()]; + let cert_name = algo.to_certificate_type(); + if cert_name != algo.as_ref() { + names.push(cert_name); + } + names +} + pub(crate) trait Select { fn is_server() -> bool; @@ -269,11 +278,34 @@ pub(crate) trait Select { None => pref.key.iter().map(ToOwned::to_owned).collect::>(), }; - let (key_both_first, key_algorithm) = Self::select( - &possible_host_key_algos[..], - &parse_kex_algo_list(&key_string), - AlgorithmKind::Key, - )?; + let (key_both_first, key_algorithm) = if Self::is_server() { + Self::select( + &possible_host_key_algos[..], + &parse_kex_algo_list(&key_string), + AlgorithmKind::Key, + )? + } else { + // For client-side matching, extend preferred host key names with their + // OpenSSH certificate variants (e.g. "*-cert-v01@openssh.com"). + let possible_host_key_algo_names = possible_host_key_algos + .iter() + .flat_map(host_key_algorithm_names) + .collect::>(); + + let (key_both_first, key_algorithm_name) = Self::select( + &possible_host_key_algo_names[..], + &parse_kex_algo_list(&key_string), + AlgorithmKind::Key, + )?; + + let key_algorithm = if key_algorithm_name.ends_with("-cert-v01@openssh.com") { + Algorithm::new_certificate_ext(&key_algorithm_name)? + } else { + Algorithm::new(&key_algorithm_name)? + }; + + (key_both_first, key_algorithm) + }; // Cipher @@ -470,7 +502,8 @@ pub(crate) fn write_kex( ) .encode(w)?; } else { - NameList(prefs.key.iter().map(ToString::to_string).collect()).encode(w)?; + // Support for host cert is added, so post the OpenSSH certificate variants of algorithms to server as well. + NameList(prefs.key.iter().flat_map(host_key_algorithm_names).collect()).encode(w)?; } // cipher client to server diff --git a/russh/src/tests.rs b/russh/src/tests.rs index e5f80882..dbcf3b14 100644 --- a/russh/src/tests.rs +++ b/russh/src/tests.rs @@ -130,7 +130,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) @@ -220,12 +220,12 @@ mod channels { #[derive(Debug)] struct Client {} - impl client::Handler for Client { + impl client::Handler for Client { type Error = crate::Error; async fn check_server_key( &mut self, - _server_public_key: &crate::keys::ssh_key::PublicKey, + _server_public_key: &crate::cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } @@ -305,7 +305,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) } @@ -399,7 +399,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) } @@ -474,7 +474,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 0ba61852..176b6f27 100644 --- a/russh/tests/test_backpressure.rs +++ b/russh/tests/test_backpressure.rs @@ -149,7 +149,7 @@ 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 b9cf1308..18ccd6f7 100644 --- a/russh/tests/test_data_stream.rs +++ b/russh/tests/test_data_stream.rs @@ -219,7 +219,7 @@ 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 5e07013c..006fe4e9 100644 --- a/russh/tests/test_kex_shared_secret.rs +++ b/russh/tests/test_kex_shared_secret.rs @@ -329,7 +329,7 @@ impl client::Handler for TestClientWithKexCapture { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } @@ -363,7 +363,7 @@ impl client::Handler for TestClientWithRekeyCapture { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/tests/test_mlkem_kex.rs b/russh/tests/test_mlkem_kex.rs index 888eda24..20fbf852 100644 --- a/russh/tests/test_mlkem_kex.rs +++ b/russh/tests/test_mlkem_kex.rs @@ -366,7 +366,7 @@ impl client::Handler for TestClient { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &cert::PublicKeyOrCertificate, ) -> Result { Ok(true) } diff --git a/russh/tests/test_rekey_strict_kex.rs b/russh/tests/test_rekey_strict_kex.rs index d94eeaf3..53861407 100644 --- a/russh/tests/test_rekey_strict_kex.rs +++ b/russh/tests/test_rekey_strict_kex.rs @@ -154,7 +154,7 @@ impl client::Handler for TestClient { async fn check_server_key( &mut self, - _server_public_key: &ssh_key::PublicKey, + _server_public_key: &cert::PublicKeyOrCertificate, ) -> Result { Ok(true) }