diff --git a/README.md b/README.md index 5c2b2144..40669f41 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` @@ -260,4 +261,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 diff --git a/russh/examples/echoserver_certificates.rs b/russh/examples/echoserver_certificates.rs new file mode 100644 index 00000000..f15855af --- /dev/null +++ b/russh/examples/echoserver_certificates.rs @@ -0,0 +1,190 @@ +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, + reply: server::ChannelOpenHandle, + session: &mut Session, + ) -> Result<(), Self::Error> { + { + let mut clients = self.clients.lock().await; + clients.insert(self.id, (channel.id(), session.handle())); + } + reply.accept().await; + Ok(()) + } + + 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); + }); + } +} diff --git a/russh/src/client/kex.rs b/russh/src/client/kex.rs index 00e98404..c0bb7135 100644 --- a/russh/src/client/kex.rs +++ b/russh/src/client/kex.rs @@ -122,6 +122,7 @@ impl ClientKex { &input.buffer, &self.config.preferred, None, + None, &self.cause, )? }; diff --git a/russh/src/negotiation.rs b/russh/src/negotiation.rs index d8fc6b00..89a279b0 100644 --- a/russh/src/negotiation.rs +++ b/russh/src/negotiation.rs @@ -18,7 +18,7 @@ use bytes::Bytes; use log::debug; use rand_core::Rng; 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::{AlgorithmExt, NameList}; @@ -36,6 +36,7 @@ use crate::{cipher, compression, kex, mac, msg, AlgorithmKind, Error}; /// WASM-only stub pub struct Config { keys: Vec, + certificates: Vec, } #[derive(Debug, Clone)] @@ -79,11 +80,12 @@ pub struct Preferred { /// `key`, so a server that has a certificate presents it in preference /// to a bare key. /// - /// Empty by default, and only used by the client. Advertising a - /// certificate algorithm makes a server prove its identity with a - /// certificate instead of a bare key, which a client can only act on - /// once it knows which authorities it trusts (see - /// [`check_server_certificate`](crate::client::Handler::check_server_certificate)) + /// Empty by default, and only used by the client (a server advertises + /// certificates from [`Config::certificates`](crate::server::Config) + /// instead). Advertising a certificate algorithm makes a server prove + /// its identity with a certificate instead of a bare key, which a client + /// can only act on once it knows which authorities it trusts (see + /// [`check_server_key`](crate::client::Handler::check_server_key)) /// — so turning it on is the caller's decision, never a default. pub host_key_certificates: Cow<'static, [Algorithm]>, /// Preferred symmetric ciphers. @@ -103,6 +105,43 @@ 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, 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], + keys: &[PrivateKey], +) -> Vec { + let mut names = Vec::new(); + for cert in certificates { + if !keys + .iter() + .any(|k| k.public_key().key_data() == cert.public_key()) + { + debug!("no host key matching certificate {:?}", cert.key_id()); + continue; + } + 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); + } + } + } + names +} + impl Preferred { pub(crate) fn possible_host_key_algos_for_keys( &self, @@ -221,10 +260,12 @@ pub(crate) trait Select { ) -> Result<(bool, A), Error>; /// `available_host_keys`, if present, is used to limit the host key algorithms to the ones we have keys for. + /// `available_certificates` (server only) are the host certificates the server can present. fn read_kex( buffer: &[u8], pref: &Preferred, available_host_keys: Option<&[PrivateKey]>, + available_certificates: Option<&[Certificate]>, cause: &KexCause, ) -> Result { let &Some(mut r) = &buffer.get(17..) else { @@ -290,23 +331,35 @@ pub(crate) trait Select { None => pref.key.iter().map(ToOwned::to_owned).collect::>(), }; - // Only the client advertises certificate algorithms (`write_kex`), so - // only the client selects over them; a server has no certificate to - // present, and its preferences must stay limited to the keys it holds. - // Selection runs over the same combined name-list the peer saw — - // certificates ahead of plain keys — so `key_both_first` keeps its - // meaning for `first_kex_packet_follows`. The algorithm kept for a - // certificate is the plain one it contains: that is what signs the - // exchange, and it is what every later step needs; that a certificate - // was negotiated is recorded separately in [`Names`]. - let (key_both_first, key_algorithm, host_key_is_certificate) = if !Self::is_server() - && !pref.host_key_certificates.is_empty() - { - let certificate_names = pref - .host_key_certificates + // Certificate algorithms come from `pref.host_key_certificates` on the + // client and from the certificates the server holds (with a matching + // host key) on the server. Selection runs over the same combined + // name-list the peer saw — certificates ahead of plain keys — so + // `key_both_first` keeps its meaning for `first_kex_packet_follows`. + // The algorithm kept for a certificate is the plain one it contains: + // that is what signs the exchange, and it is what every later step + // needs; that a certificate was negotiated is recorded separately in + // [`Names`]. + let certificate_names = if Self::is_server() { + match (available_certificates, available_host_keys) { + (Some(certificates), Some(keys)) => { + server_certificate_names(pref, certificates, keys) + } + _ => Vec::new(), + } + } else { + pref.host_key_certificates .iter() .map(Algorithm::to_certificate_type) - .collect::>(); + .collect::>() + }; + let (key_both_first, key_algorithm, host_key_is_certificate) = if certificate_names + .is_empty() + { + let (both_first, algorithm) = + Self::select(&possible_host_key_algos[..], &key_list, AlgorithmKind::Key)?; + (both_first, algorithm, false) + } else { let advertised = certificate_names .iter() .cloned() @@ -324,10 +377,6 @@ pub(crate) trait Select { .ok_or(Error::KexInit)? }; (both_first, algorithm, is_certificate) - } else { - let (both_first, algorithm) = - Self::select(&possible_host_key_algos[..], &key_list, AlgorithmKind::Key)?; - (both_first, algorithm, false) }; // Cipher @@ -502,18 +551,24 @@ pub(crate) fn write_kex( .encode(w)?; // kex algo if let Some(server_config) = server_config { - // Only advertise host key algorithms that we have keys for. + // Only advertise host key algorithms that we have keys for, with + // certificate algorithms ahead of plain keys so a client that + // accepts both is served the certificate. NameList( - prefs - .key - .iter() - .filter(|algo| { - server_config - .keys + server_certificate_names(prefs, &server_config.certificates, &server_config.keys) + .into_iter() + .chain( + prefs + .key .iter() - .any(|k| is_key_compatible_with_algo(k, algo)) - }) - .map(|x| x.to_string()) + .filter(|algo| { + server_config + .keys + .iter() + .any(|k| is_key_compatible_with_algo(k, algo)) + }) + .map(|x| x.to_string()), + ) .collect(), ) .encode(w)?; @@ -659,7 +714,8 @@ mod tests { ], true, ); - let names = Server::read_kex(&buf, &Preferred::DEFAULT, None, &KexCause::Initial).unwrap(); + let names = Server::read_kex(&buf, &Preferred::DEFAULT, None, + None, &KexCause::Initial).unwrap(); assert_eq!(names.kex, kex::MLKEM768X25519_SHA256); assert!(names.ignore_guessed, "wrong guess must be ignored"); } @@ -668,7 +724,8 @@ mod tests { #[test] fn correct_guess_is_not_ignored() { let buf = build_kexinit(&["mlkem768x25519-sha256", "curve25519-sha256"], true); - let names = Server::read_kex(&buf, &Preferred::DEFAULT, None, &KexCause::Initial).unwrap(); + let names = Server::read_kex(&buf, &Preferred::DEFAULT, None, + None, &KexCause::Initial).unwrap(); assert!(!names.ignore_guessed, "correct guess must be honored"); } @@ -689,6 +746,7 @@ mod tests { &buf, &cert_prefs(&[Algorithm::Ed25519]), None, + None, &KexCause::Initial, ) .unwrap(); @@ -707,6 +765,7 @@ mod tests { hash: Some(HashAlg::Sha512), }]), None, + None, &KexCause::Initial, ) .unwrap(); @@ -724,7 +783,8 @@ mod tests { #[test] fn certificate_ignored_when_not_advertised() { let buf = build_kexinit_keys(KEX_FIRST, &[ED25519_CERT, "ssh-ed25519"], false); - let names = Client::read_kex(&buf, &Preferred::DEFAULT, None, &KexCause::Initial).unwrap(); + let names = Client::read_kex(&buf, &Preferred::DEFAULT, None, + None, &KexCause::Initial).unwrap(); assert!(!names.host_key_is_certificate); assert_eq!(names.key, Algorithm::Ed25519); } @@ -738,6 +798,7 @@ mod tests { &buf, &cert_prefs(&[Algorithm::Ed25519]), None, + None, &KexCause::Initial, ) .unwrap(); @@ -754,6 +815,7 @@ mod tests { &buf, &cert_prefs(&[Algorithm::Ed25519]), None, + None, &KexCause::Initial, ) .unwrap(); @@ -772,6 +834,7 @@ mod tests { &buf, &cert_prefs(&[Algorithm::Ed25519]), None, + None, &KexCause::Initial, ) .unwrap(); @@ -786,10 +849,106 @@ mod tests { &buf, &cert_prefs(&[Algorithm::Ed25519]), None, + None, &KexCause::Initial, ) .unwrap(); 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()] + ); + } + + /// 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] + 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/encrypted.rs b/russh/src/server/encrypted.rs index dec3c410..b0900817 100644 --- a/russh/src/server/encrypted.rs +++ b/russh/src/server/encrypted.rs @@ -890,7 +890,9 @@ impl Encrypted { let pubkey = match pk_or_cert { PublicKeyOrCertificate::PublicKey { ref key, .. } => key.clone(), PublicKeyOrCertificate::Certificate(ref cert) => { - // Validate certificate expiration + // Validate certificate expiration. The bounds are None + // for OpenSSH's "always valid" sentinels (PROTOCOL.certkeys), + // which skips the corresponding check. let now = SystemTime::now(); if cert.valid_after_time().map(|t| now < t).unwrap_or_default() || cert @@ -1047,7 +1049,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/src/server/kex.rs b/russh/src/server/kex.rs index ac6d5a1f..7374dea9 100644 --- a/russh/src/server/kex.rs +++ b/russh/src/server/kex.rs @@ -115,6 +115,7 @@ impl ServerKex { &input.buffer, &self.config.preferred, Some(&self.config.keys), + Some(&self.config.certificates), &self.cause, )? }; @@ -247,19 +248,55 @@ 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()); + // Present a certificate only when one was negotiated; + // `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 { + self.config + .certificates + .iter() + .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, + // not the certificate itself. + match (&c.algorithm(), &names.key) { + (Algorithm::Rsa { .. }, Algorithm::Rsa { .. }) => true, + _ => { + c.algorithm().to_certificate_type() + == names.key.to_certificate_type() + } + } + }) + // 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 + .keys + .iter() + .find(|key| is_key_compatible_with_algo(key, &names.key)) + .ok_or(Error::UnknownKey)?; + (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 - #[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, @@ -270,7 +307,11 @@ impl ServerKex { buffer.clear(); let mut pubkey_vec = Vec::new(); - key.public_key().to_bytes()?.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)?; + } let hash = kex.compute_exchange_hash(&pubkey_vec, exchange, &mut buffer)?; @@ -291,7 +332,11 @@ impl ServerKex { false => &msg::KEX_ECDH_REPLY, } .encode(w)?; - key.public_key().to_bytes()?.encode(w)?; + if let Some(blob) = &certificate_blob { + blob.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 31dcef82..ed351fb7 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) diff --git a/russh/tests/test_server_cert.rs b/russh/tests/test_server_cert.rs new file mode 100644 index 00000000..037ae6c8 --- /dev/null +++ b/russh/tests/test_server_cert.rs @@ -0,0 +1,284 @@ +#![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 tokio::net::TcpListener; + +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() +} + +/// 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); + + 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(); + let _ = server::run_stream(config, socket, TestServer {}) + .await + .unwrap(); + }); + + let mut client_config = client::Config::default(); + // 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: trusted_ca.public_key().clone(), + }; + + 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(); + + let ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let evil_ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + 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 + .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(); + + 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 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(); + + let ca_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + + // 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(); + + session + .disconnect(Disconnect::ByApplication, "", "") + .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(); + + 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 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(); + + 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, +} + +impl client::Handler for TestClient { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + server_public_key: &PublicKeyOrCertificate, + ) -> Result { + match server_public_key { + 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}"); + return Ok(false); + } + + // Check the certificate's validity period. + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + if now < cert.valid_after() || now > cert.valid_before() { + eprintln!("Host certificate is outside its validity period."); + return Ok(false); + } + + // 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); + } + + Ok(true) + } + PublicKeyOrCertificate::PublicKey { .. } => { + // Certificate-only environment: reject plain host keys. + eprintln!("Server presented a plain public key, not a certificate."); + Ok(false) + } + } + } +}