diff --git a/Cargo.lock b/Cargo.lock index 24c7c42d6..7aa9c7448 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1182,7 +1182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 2.0.111", + "syn 1.0.109", ] [[package]] @@ -1864,7 +1864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2147,8 +2147,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -2679,7 +2679,7 @@ dependencies = [ "tokio 1.48.0", "tower-service", "tracing", - "windows-registry 0.6.1", + "windows-registry 0.5.3", ] [[package]] @@ -2694,7 +2694,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4163,7 +4163,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5163,7 +5163,7 @@ dependencies = [ "once_cell", "socket2 0.6.1", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5585,7 +5585,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6195,9 +6195,9 @@ dependencies = [ [[package]] name = "sspi" -version = "0.18.5" +version = "0.18.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f73fe6be958ae27fa8e982d9acc42d16f34eb74714d95bb53015528667cae4" +checksum = "b2f4823ee743a4a0cc2153eb640e28ff95b55ca25c88085b559bae59fb6c317a" dependencies = [ "async-dnssd", "async-recursion", @@ -6438,7 +6438,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7828,7 +7828,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index 3d1c7a928..282f1360a 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -3,6 +3,7 @@ use std::net::SocketAddr; use std::sync::Arc; use anyhow::Context as _; +use ironrdp_connector::sspi; use ironrdp_pdu::nego; use ironrdp_rdcleanpath::RDCleanPathPdu; use tap::prelude::*; @@ -11,7 +12,7 @@ use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; use tracing::field; use crate::config::Conf; -use crate::credential::CredentialStoreHandle; +use crate::credential::{CredentialEntry, CredentialStoreHandle}; use crate::proxy::Proxy; use crate::recording::ActiveRecordings; use crate::session::{ConnectionModeDetails, DisconnectInterest, DisconnectedInfo, SessionInfo, SessionMessageSender}; @@ -166,7 +167,6 @@ struct CleanPathResult { x224_rsp: Vec, } -#[allow(clippy::too_many_arguments)] async fn process_cleanpath( cleanpath_pdu: RDCleanPathPdu, client_addr: SocketAddr, @@ -175,7 +175,6 @@ async fn process_cleanpath( jrl: &CurrentJrl, active_recordings: &ActiveRecordings, sessions: &SessionMessageSender, - _credential_store: &CredentialStoreHandle, ) -> Result { use crate::utils; @@ -212,7 +211,7 @@ async fn process_cleanpath( span.record("session_id", claims.jet_aid.to_string()); - // Sanity check + // Sanity check. match cleanpath_pdu.destination.as_deref() { Some(destination) => match TargetAddr::parse(destination, 3389) { Ok(destination) if !destination.eq(targets.first()) => { @@ -235,19 +234,19 @@ async fn process_cleanpath( debug!(%selected_target, "Connected to destination server"); span.record("target", selected_target.to_string()); - // Send preconnection blob if applicable + // Send preconnection blob if applicable. if let Some(pcb) = cleanpath_pdu.preconnection_blob { server_stream.write_all(pcb.as_bytes()).await?; } - // Send X224 connection request + // Send X224 connection request. let x224_req = cleanpath_pdu .x224_connection_pdu .context("request is missing X224 connection PDU") .map_err(CleanPathError::BadRequest)?; server_stream.write_all(x224_req.as_bytes()).await?; - // Receive server X224 connection response + // == Receive server X224 connection response == trace!("Receiving X224 response"); @@ -258,7 +257,7 @@ async fn process_cleanpath( trace!("Establishing TLS connection with server"); - // Establish TLS connection with server + // == Establish TLS connection with server == let server_stream = crate::tls::dangerous_connect(selected_target.host().to_owned(), server_stream) .await @@ -277,9 +276,9 @@ async fn process_cleanpath( } /// Handle RDP connection with credential injection via CredSSP MITM -#[allow(clippy::too_many_arguments)] +#[expect(clippy::too_many_arguments)] async fn handle_with_credential_injection( - mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, + mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send, client_addr: SocketAddr, conf: Arc, token_cache: &TokenCache, @@ -288,25 +287,44 @@ async fn handle_with_credential_injection( subscriber_tx: SubscriberSender, active_recordings: &ActiveRecordings, cleanpath_pdu: RDCleanPathPdu, - _credential_entry: crate::credential::ArcCredentialEntry, + credential_entry: Arc, ) -> anyhow::Result<()> { - let token = cleanpath_pdu.proxy_auth.as_ref().context("missing token")?; + let tls_conf = conf + .tls + .as_ref() + .context("TLS configuration required for credential injection feature")?; - // Authorize the token - let claims = authorize(client_addr, token, &conf, token_cache, jrl, active_recordings, None) - .map_err(|e| anyhow::anyhow!("authorization failed: {}", e))?; + let gateway_hostname = conf.hostname.clone(); - let crate::token::ConnectionMode::Fwd { targets: _ } = claims.jet_cm else { - anyhow::bail!("unexpected connection mode"); - }; - - let span = tracing::Span::current(); - span.record("session_id", claims.jet_aid.to_string()); + let credential_mapping = credential_entry.mapping.as_ref().context("no credential mapping")?; - info!("Credential injection: performing CredSSP MITM"); + let x224_req = cleanpath_pdu + .x224_connection_pdu + .as_ref() + .context("request is missing X224 connection request PDU")?; + let received_connection_request: ironrdp_pdu::x224::X224 = + ironrdp_core::decode(x224_req.as_bytes()).context("decode X224 connection request PDU from client")?; + + // Choose the security protocol to use with the client. + let received_connection_request_protocol = received_connection_request.0.protocol; + let client_security_protocol = if received_connection_request_protocol.contains(nego::SecurityProtocol::HYBRID_EX) { + nego::SecurityProtocol::HYBRID_EX + } else if received_connection_request + .0 + .protocol + .contains(nego::SecurityProtocol::HYBRID) + { + nego::SecurityProtocol::HYBRID + } else { + anyhow::bail!( + "client does not support CredSSP (received {})", + received_connection_request.0.protocol + ) + }; - // Run normal RDCleanPath flow (this will handle server-side TLS and get certs) + // Run normal RDCleanPath flow (this will handle server-side TLS and get certs). let CleanPathResult { + claims, destination, server_addr, server_stream, @@ -320,12 +338,17 @@ async fn handle_with_credential_injection( jrl, active_recordings, &sessions, - &CredentialStoreHandle::new(), // Dummy, not used in process_cleanpath ) .await - .map_err(|e| anyhow::anyhow!("RDCleanPath processing failed: {}", e))?; + .context("RDCleanPath processing failed")?; - // Extract server security protocol from X224 response (before x224_rsp is moved) + // Retrieve the Gateway TLS public key that must be used for client-proxy CredSSP later on. + let gateway_cert_chain_handle = tokio::spawn(crate::tls::get_cert_chain_for_acceptor_cached( + gateway_hostname.clone(), + tls_conf.acceptor.clone(), + )); + + // Extract server security protocol from X224 response (before x224_rsp is moved). let x224_confirm: ironrdp_pdu::x224::X224 = ironrdp_core::decode(&x224_rsp).context("decode X224 connection confirm")?; let server_security_protocol = match &x224_confirm.0 { @@ -342,91 +365,132 @@ async fn handle_with_credential_injection( } }; - // Send RDCleanPath response to client (includes server certs) - let x509_chain = server_stream - .get_ref() - .1 - .peer_certificates() - .context("no peer certificate found in TLS transport")? - .iter() - .map(|cert| cert.to_vec()); - - trace!("Sending RDCleanPath response"); - - let rdcleanpath_rsp = RDCleanPathPdu::new_response(server_addr.to_string(), x224_rsp, x509_chain) - .map_err(|e| anyhow::anyhow!("couldn't build RDCleanPath response: {e}"))?; - - send_clean_path_response(&mut client_stream, &rdcleanpath_rsp).await?; - - info!("RDCleanPath response sent, now performing CredSSP MITM"); - - // Verify TLS is configured - conf.tls.as_ref().context("TLS required for credential injection")?; - - // Get credential mapping - let credential_mapping = _credential_entry.mapping.as_ref().context("no credential mapping")?; - - // Extract server public key from TLS stream let server_public_key = - crate::rdp_proxy::extract_tls_server_public_key(&server_stream).context("extract server TLS public key")?; + crate::tls::extract_stream_peer_public_key(&server_stream).context("extract target server TLS public key")?; - // Wrap streams in TokioFramed for CredSSP - let mut client_framed = ironrdp_tokio::TokioFramed::new(client_stream); - let mut server_framed = ironrdp_tokio::TokioFramed::new(server_stream); + let gateway_cert_chain = gateway_cert_chain_handle.await??; + let gateway_public_key = crate::tls::extract_public_key(gateway_cert_chain.first().context("no leaf")?) + .context("extract Gateway public key")?; - // Use HYBRID_EX for client (web clients typically use this) - let client_security_protocol = nego::SecurityProtocol::HYBRID_EX; + // Send RDCleanPath response to client using Devolutions Gateway certification chain. + // (When performing credential injection, the client performs CredSSP against the Devolutions Gateway.) + trace!("Sending RDCleanPath response"); + let rd_clean_path_rsp = RDCleanPathPdu::new_response( + server_addr.to_string(), + x224_rsp, + gateway_cert_chain.iter().map(|cert| cert.to_vec()), + ) + .context("couldn't build RDCleanPath response")?; + send_clean_path_response(&mut client_stream, &rd_clean_path_rsp).await?; + debug!("RDCleanPath response sent, now performing CredSSP MITM"); + + // -- Perform the CredSSP authentication with the client (acting as a server) and the server (acting as a client) -- // + + let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream); + let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); + + let krb_server_config = if conf.debug.enable_unstable + && let Some(crate::config::dto::KerberosConfig { + kerberos_server: + crate::config::dto::KerberosServer { + max_time_skew, + ticket_decryption_key, + service_user, + .. + }, + kdc_url: _, + }) = conf.debug.kerberos.as_ref() + { + let user = service_user.as_ref().map(|user| { + let crate::config::dto::DomainUser { + fqdn, + password, + salt: _, + } = user; + + // The username is in the FQDN format. Thus, the domain field can be empty. + sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8(fqdn, "", password)) + }); + + Some(sspi::KerberosServerConfig { + kerberos_config: sspi::KerberosConfig { + // The sspi-rs can automatically resolve the KDC host via DNS and/or env variable. + kdc_url: None, + client_computer_name: Some(client_addr.to_string()), + }, + server_properties: sspi::kerberos::ServerProperties::new( + &["TERMSRV", &gateway_hostname], + user, + std::time::Duration::from_secs(*max_time_skew), + ticket_decryption_key.clone(), + )?, + }) + } else { + None + }; - // Perform CredSSP MITM (in parallel) - // Note: Client expects server's public key (since we sent server certs in RDCleanPath response) let client_credssp_fut = crate::rdp_proxy::perform_credssp_with_client( &mut client_framed, client_addr.ip(), - server_public_key.clone(), + gateway_public_key, client_security_protocol, &credential_mapping.proxy, - None, - None, + krb_server_config, ); + let krb_client_config = if conf.debug.enable_unstable + && let Some(crate::config::dto::KerberosConfig { + kerberos_server: _, + kdc_url, + }) = conf.debug.kerberos.as_ref() + { + Some(ironrdp_connector::credssp::KerberosConfig { + kdc_proxy_url: kdc_url.clone(), + hostname: Some(gateway_hostname.clone()), + }) + } else { + None + }; + let server_credssp_fut = crate::rdp_proxy::perform_credssp_with_server( &mut server_framed, destination.host().to_owned(), server_public_key, server_security_protocol, &credential_mapping.target, - None, - None, + krb_client_config, ); - let (client_res, server_res) = tokio::join!(client_credssp_fut, server_credssp_fut); - client_res.context("CredSSP with client failed")?; - server_res.context("CredSSP with server failed")?; + let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); + client_credssp_res.context("CredSSP with client")?; + server_credssp_res.context("CredSSP with server")?; + + debug!("CredSSP MITM completed successfully"); - info!("CredSSP MITM completed successfully"); + // -- Intercept the Connect Confirm PDU, to override the server_security_protocol field -- // + + crate::rdp_proxy::intercept_connect_confirm(&mut client_framed, &mut server_framed, server_security_protocol) + .await?; - // Extract streams and any leftover bytes let (mut client_stream, client_leftover) = client_framed.into_inner(); let (mut server_stream, server_leftover) = server_framed.into_inner(); - // Forward any leftover bytes - if !server_leftover.is_empty() { - client_stream - .write_all(&server_leftover) - .await - .context("write server leftover to client")?; - } - if !client_leftover.is_empty() { - server_stream - .write_all(&client_leftover) - .await - .context("write client leftover to server")?; - } + // -- At this point, proceed to the usual two-way forwarding -- // info!("RDP-TLS forwarding (credential injection)"); + client_stream + .write_all(&server_leftover) + .await + .context("write server leftover to client")?; + + server_stream + .write_all(&client_leftover) + .await + .context("write client leftover to server")?; + // Build SessionInfo for forwarding - let session_info = SessionInfo::builder() + let info = SessionInfo::builder() .id(claims.jet_aid) .application_protocol(claims.jet_ap) .details(ConnectionModeDetails::Fwd { @@ -442,7 +506,7 @@ async fn handle_with_credential_injection( // Plain forwarding for now Proxy::builder() .conf(conf) - .session_info(session_info) + .session_info(info) .address_a(client_addr) .transport_a(client_stream) .address_b(server_addr) @@ -456,10 +520,10 @@ async fn handle_with_credential_injection( .context("proxy failed") } -#[allow(clippy::too_many_arguments)] +#[expect(clippy::too_many_arguments)] #[instrument("fwd", skip_all, fields(session_id = field::Empty, target = field::Empty))] pub async fn handle( - mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, + mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send, client_addr: SocketAddr, conf: Arc, token_cache: &TokenCache, @@ -475,21 +539,24 @@ pub async fn handle( let cleanpath_pdu = read_cleanpath_pdu(&mut client_stream) .await - .context("couldn't read clean cleanpath PDU")?; + .context("couldn't read cleanpath PDU")?; - // Early credential detection: check if we should use RdpProxy instead + // Early credential detection: check if we should use RdpProxy instead. let token = cleanpath_pdu .proxy_auth .as_deref() - .ok_or_else(|| anyhow::anyhow!("missing token in RDCleanPath PDU"))?; + .context("missing token in RDCleanPath PDU")?; + // If a credential mapping has been pushed, we automatically switch to + // proxy-based credential injection mode. Otherwise, we continue the usual + // clean path procedure. if let Some(entry) = crate::token::extract_jti(token) .ok() .and_then(|token_id| credential_store.get(token_id)) .filter(|entry| entry.mapping.is_some()) { - // Credentials found! Switch to RdpProxy for credential injection - info!("Switching to RdpProxy for credential injection (WebSocket)"); + anyhow::ensure!(token == entry.token, "token mismatch"); + debug!("Switching to RdpProxy for credential injection (WebSocket)"); return handle_with_credential_injection( client_stream, @@ -522,7 +589,6 @@ pub async fn handle( jrl, active_recordings, &sessions, - credential_store, ) .await { @@ -536,7 +602,7 @@ pub async fn handle( } }; - // Send success RDCleanPathPdu response + // == Send success RDCleanPathPdu response == let x509_chain = server_stream .get_ref() @@ -549,11 +615,12 @@ pub async fn handle( trace!("Sending RDCleanPath response"); let rdcleanpath_rsp = RDCleanPathPdu::new_response(server_addr.to_string(), x224_rsp, x509_chain) - .map_err(|e| anyhow::anyhow!("couldn’t build RDCleanPath response: {e}"))?; + .context("couldn’t build RDCleanPath response")?; + // .map_err(|e| anyhow::anyhow!("couldn’t build RDCleanPath response: {e}"))?; send_clean_path_response(&mut client_stream, &rdcleanpath_rsp).await?; - // Start actual RDP session + // == Start actual RDP session == let info = SessionInfo::builder() .id(claims.jet_aid) @@ -613,8 +680,7 @@ fn io_to_rdcleanpath_err(err: &io::Error) -> RDCleanPathPdu { } } -#[allow(non_camel_case_types, clippy::upper_case_acronyms)] -#[allow(dead_code)] +#[expect(dead_code, non_camel_case_types, clippy::upper_case_acronyms)] #[repr(u16)] #[derive(Clone, Copy, PartialEq, Eq)] enum WsaError { diff --git a/devolutions-gateway/src/rdp_proxy.rs b/devolutions-gateway/src/rdp_proxy.rs index 423e4b42a..0ff02eb1f 100644 --- a/devolutions-gateway/src/rdp_proxy.rs +++ b/devolutions-gateway/src/rdp_proxy.rs @@ -1,23 +1,17 @@ use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; -use std::time::Duration; use anyhow::Context as _; use ironrdp_acceptor::credssp::CredsspProcessGenerator as CredsspServerProcessGenerator; -use ironrdp_connector::credssp::{CredsspProcessGenerator as CredsspClientProcessGenerator, KerberosConfig}; -use ironrdp_connector::sspi::credssp::{ClientState, ServerError, ServerState}; +use ironrdp_connector::credssp::CredsspProcessGenerator as CredsspClientProcessGenerator; +use ironrdp_connector::sspi; use ironrdp_connector::sspi::generator::{GeneratorState, NetworkRequest}; -use ironrdp_connector::sspi::kerberos::ServerProperties; -use ironrdp_connector::sspi::{ - self, AuthIdentityBuffers, CredentialsBuffers, KerberosConfig as SspiKerberosConfig, KerberosServerConfig, -}; use ironrdp_pdu::{mcs, nego, x224}; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use typed_builder::TypedBuilder; use crate::api::kdc_proxy::send_krb_message; use crate::config::Conf; -use crate::config::dto::{DomainUser, KerberosServer}; use crate::credential::{AppCredentialMapping, ArcCredentialEntry}; use crate::proxy::Proxy; use crate::session::{DisconnectInterest, SessionInfo, SessionMessageSender}; @@ -42,8 +36,8 @@ pub struct RdpProxy { impl RdpProxy where - A: AsyncWrite + AsyncRead + Unpin + Send + Sync, - B: AsyncWrite + AsyncRead + Unpin + Send + Sync, + A: AsyncWrite + AsyncRead + Unpin + Send, + B: AsyncWrite + AsyncRead + Unpin + Send, { pub async fn run(self) -> anyhow::Result<()> { handle(self).await @@ -53,8 +47,8 @@ where #[instrument("rdp_proxy", skip_all, fields(session_id = proxy.session_info.id.to_string(), target = proxy.server_addr.to_string()))] async fn handle(proxy: RdpProxy) -> anyhow::Result<()> where - C: AsyncRead + AsyncWrite + Unpin + Send + Sync, - S: AsyncRead + AsyncWrite + Unpin + Send + Sync, + C: AsyncRead + AsyncWrite + Unpin + Send, + S: AsyncRead + AsyncWrite + Unpin + Send, { let RdpProxy { conf, @@ -81,15 +75,16 @@ where // -- Retrieve the Gateway TLS public key that must be used for client-proxy CredSSP later on -- // - let gateway_public_key_handle = tokio::spawn(get_cached_gateway_public_key( + let gateway_cert_chain_handle = tokio::spawn(crate::tls::get_cert_chain_for_acceptor_cached( gateway_hostname.clone(), tls_conf.acceptor.clone(), )); // -- Dual handshake with the client and the server until the TLS security upgrade -- // - let mut client_framed = ironrdp_tokio::TokioFramed::new_with_leftover(client_stream, client_stream_leftover_bytes); - let mut server_framed = ironrdp_tokio::TokioFramed::new(server_stream); + let mut client_framed = + ironrdp_tokio::MovableTokioFramed::new_with_leftover(client_stream, client_stream_leftover_bytes); + let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); let handshake_result = dual_handshake_until_tls_upgrade(&mut client_framed, &mut server_framed, credential_mapping).await?; @@ -108,18 +103,21 @@ where let server_stream = server_stream.context("TLS upgrade with server failed")?; let server_public_key = - extract_tls_server_public_key(&server_stream).context("extract target server TLS public key")?; - let gateway_public_key = gateway_public_key_handle.await??; + crate::tls::extract_stream_peer_public_key(&server_stream).context("extract target server TLS public key")?; + + let gateway_cert_chain = gateway_cert_chain_handle.await??; + let gateway_public_key = crate::tls::extract_public_key(gateway_cert_chain.first().context("no leaf")?) + .context("extract Gateway public key")?; // -- Perform the CredSSP authentication with the client (acting as a server) and the server (acting as a client) -- // - let mut client_framed = ironrdp_tokio::TokioFramed::new(client_stream); - let mut server_framed = ironrdp_tokio::TokioFramed::new(server_stream); + let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream); + let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); - let (krb_server_config, network_client) = if conf.debug.enable_unstable + let krb_server_config = if conf.debug.enable_unstable && let Some(crate::config::dto::KerberosConfig { kerberos_server: - KerberosServer { + crate::config::dto::KerberosServer { max_time_skew, ticket_decryption_key, service_user, @@ -129,33 +127,31 @@ where }) = conf.debug.kerberos.as_ref() { let user = service_user.as_ref().map(|user| { - let DomainUser { + let crate::config::dto::DomainUser { fqdn, password, salt: _, } = user; + // The username is in the FQDN format. Thus, the domain field can be empty. - CredentialsBuffers::AuthIdentity(AuthIdentityBuffers::from_utf8(fqdn, "", password)) + sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8(fqdn, "", password)) }); - ( - Some(KerberosServerConfig { - kerberos_config: SspiKerberosConfig { - // The sspi-rs can automatically resolve the KDC host via DNS and/or env variable. - kdc_url: None, - client_computer_name: Some(client_addr.to_string()), - }, - server_properties: ServerProperties::new( - &["TERMSRV", &gateway_hostname], - user, - Duration::from_secs(*max_time_skew), - ticket_decryption_key.clone(), - )?, - }), - Some(NetworkClient::new()), - ) + Some(sspi::KerberosServerConfig { + kerberos_config: sspi::KerberosConfig { + // The sspi-rs can automatically resolve the KDC host via DNS and/or env variable. + kdc_url: None, + client_computer_name: Some(client_addr.to_string()), + }, + server_properties: sspi::kerberos::ServerProperties::new( + &["TERMSRV", &gateway_hostname], + user, + std::time::Duration::from_secs(*max_time_skew), + ticket_decryption_key.clone(), + )?, + }) } else { - (None, None) + None }; let client_credssp_fut = perform_credssp_with_client( @@ -164,25 +160,21 @@ where gateway_public_key, handshake_result.client_security_protocol, &credential_mapping.proxy, - network_client, krb_server_config, ); - let (krb_client_config, network_client) = if conf.debug.enable_unstable + let krb_client_config = if conf.debug.enable_unstable && let Some(crate::config::dto::KerberosConfig { kerberos_server: _, kdc_url, }) = conf.debug.kerberos.as_ref() { - ( - Some(KerberosConfig { - kdc_proxy_url: kdc_url.clone(), - hostname: Some(gateway_hostname.clone()), - }), - Some(NetworkClient::new()), - ) + Some(ironrdp_connector::credssp::KerberosConfig { + kdc_proxy_url: kdc_url.clone(), + hostname: Some(gateway_hostname.clone()), + }) } else { - (None, None) + None }; let server_credssp_fut = perform_credssp_with_server( @@ -192,7 +184,6 @@ where handshake_result.server_security_protocol, &credential_mapping.target, krb_client_config, - network_client, ); let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); @@ -250,14 +241,14 @@ struct HandshakeResult { } #[instrument(level = "debug", ret, skip_all)] -async fn intercept_connect_confirm( - client_framed: &mut ironrdp_tokio::TokioFramed, - server_framed: &mut ironrdp_tokio::TokioFramed, +pub(crate) async fn intercept_connect_confirm( + client_framed: &mut ironrdp_tokio::MovableTokioFramed, + server_framed: &mut ironrdp_tokio::MovableTokioFramed, server_security_protocol: nego::SecurityProtocol, ) -> anyhow::Result<()> where - C: AsyncWrite + AsyncRead + Unpin + Send + Sync, - S: AsyncWrite + AsyncRead + Unpin + Send + Sync, + C: AsyncWrite + AsyncRead + Unpin + Send, + S: AsyncWrite + AsyncRead + Unpin + Send, { let (_, received_frame) = client_framed .read_pdu() @@ -271,7 +262,7 @@ where let mut gcc_blocks = received_connect_initial.conference_create_request.into_gcc_blocks(); gcc_blocks.core.optional_data.server_selected_protocol = Some(server_security_protocol); - // Update the conference request with modified gcc_blocks + // Update the conference request with modified gcc_blocks. received_connect_initial.conference_create_request = ironrdp_pdu::gcc::ConferenceCreateRequest::new(gcc_blocks)?; trace!(message = ?received_connect_initial, "Send Connection Request PDU to server"); let x224_msg_buf = ironrdp_core::encode_vec(&received_connect_initial)?; @@ -287,13 +278,13 @@ where #[instrument(name = "dual_handshake", level = "debug", ret, skip_all)] async fn dual_handshake_until_tls_upgrade( - client_framed: &mut ironrdp_tokio::TokioFramed, - server_framed: &mut ironrdp_tokio::TokioFramed, + client_framed: &mut ironrdp_tokio::MovableTokioFramed, + server_framed: &mut ironrdp_tokio::MovableTokioFramed, mapping: &AppCredentialMapping, ) -> anyhow::Result where - C: AsyncWrite + AsyncRead + Unpin + Send + Sync, - S: AsyncWrite + AsyncRead + Unpin + Send + Sync, + C: AsyncWrite + AsyncRead + Unpin + Send, + S: AsyncWrite + AsyncRead + Unpin + Send, { let (_, received_frame) = client_framed.read_pdu().await.context("read PDU from client")?; let received_connection_request: x224::X224 = @@ -401,14 +392,13 @@ where } #[instrument(name = "server_credssp", level = "debug", ret, skip_all)] -pub async fn perform_credssp_with_server( +pub(crate) async fn perform_credssp_with_server( framed: &mut ironrdp_tokio::Framed, server_name: String, server_public_key: Vec, security_protocol: nego::SecurityProtocol, credentials: &crate::credential::AppCredential, - kerberos_config: Option, - network_client: Option, + kerberos_config: Option, ) -> anyhow::Result<()> where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, @@ -438,12 +428,7 @@ where loop { let client_state = { let mut generator = sequence.process_ts_request(ts_request); - - if let Some(network_client_ref) = network_client.as_ref() { - resolve_client_generator(&mut generator, network_client_ref).await? - } else { - generator.resolve_to_result().context("sspi generator resolve")? - } + resolve_client_generator(&mut generator).await? }; // drop generator buf.clear(); @@ -475,17 +460,19 @@ where async fn resolve_server_generator( generator: &mut CredsspServerProcessGenerator<'_>, - network_client: &NetworkClient, -) -> Result { +) -> Result { let mut state = generator.start(); loop { match state { GeneratorState::Suspended(request) => { - let response = network_client.send(&request).await.map_err(|err| ServerError { - ts_request: None, - error: sspi::Error::new(sspi::ErrorKind::InternalError, err), - })?; + let response = send_network_request(&request) + .await + .map_err(|err| sspi::credssp::ServerError { + ts_request: None, + error: sspi::Error::new(sspi::ErrorKind::InternalError, err), + })?; + state = generator.resume(Ok(response)); } GeneratorState::Completed(client_state) => { @@ -497,14 +484,13 @@ async fn resolve_server_generator( async fn resolve_client_generator( generator: &mut CredsspClientProcessGenerator<'_>, - network_client: &NetworkClient, -) -> anyhow::Result { +) -> anyhow::Result { let mut state = generator.start(); loop { match state { GeneratorState::Suspended(request) => { - let response = network_client.send(&request).await?; + let response = send_network_request(&request).await?; state = generator.resume(Ok(response)); } GeneratorState::Completed(client_state) => { @@ -517,14 +503,13 @@ async fn resolve_client_generator( } #[instrument(name = "client_credssp", level = "debug", ret, skip_all)] -pub async fn perform_credssp_with_client( +pub(crate) async fn perform_credssp_with_client( framed: &mut ironrdp_tokio::Framed, client_addr: IpAddr, gateway_public_key: Vec, security_protocol: nego::SecurityProtocol, credentials: &crate::credential::AppCredential, - network_client: Option, - kerberos_server_config: Option, + kerberos_server_config: Option, ) -> anyhow::Result<()> where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, @@ -544,7 +529,6 @@ where client_computer_name, gateway_public_key, credentials, - network_client, kerberos_server_config, ) .await; @@ -572,8 +556,7 @@ where client_computer_name: ironrdp_connector::ServerName, public_key: Vec, credentials: &crate::credential::AppCredential, - network_client: Option, - kerberos_server_config: Option, + kerberos_server_config: Option, ) -> anyhow::Result<()> where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, @@ -610,12 +593,7 @@ where let result = { let mut generator = sequence.process_ts_request(ts_request); - - if let Some(network_client_ref) = network_client.as_ref() { - resolve_server_generator(&mut generator, network_client_ref).await - } else { - generator.resolve_to_result() - } + resolve_server_generator(&mut generator).await }; // drop generator buf.clear(); @@ -634,99 +612,9 @@ where } } -pub async fn get_cached_gateway_public_key( - hostname: String, - acceptor: tokio_rustls::TlsAcceptor, -) -> anyhow::Result> { - const LIFETIME_SECS: i64 = 300; - - static CACHE: tokio::sync::Mutex = tokio::sync::Mutex::const_new(Cache { - key: Vec::new(), - update_timestamp: 0, - }); - - let now = time::OffsetDateTime::now_utc().unix_timestamp(); - - let mut guard = CACHE.lock().await; - - if now < guard.update_timestamp + LIFETIME_SECS { - return Ok(guard.key.clone()); - } - - let key = retrieve_gateway_public_key(hostname, acceptor).await?; - - *guard = Cache { - key: key.clone(), - update_timestamp: now, - }; - - return Ok(key); - - struct Cache { - key: Vec, - update_timestamp: i64, - } -} - -async fn retrieve_gateway_public_key(hostname: String, acceptor: tokio_rustls::TlsAcceptor) -> anyhow::Result> { - let (client_side, server_side) = tokio::io::duplex(4096); - - let connect_fut = crate::tls::dangerous_connect(hostname, client_side); - let accept_fut = acceptor.accept(server_side); - - let (connect_res, _) = tokio::join!(connect_fut, accept_fut); - - let tls_stream = connect_res.context("connect")?; - - let public_key = - extract_tls_server_public_key(&tls_stream).context("extract Devolutions Gateway TLS public key")?; - - Ok(public_key) -} - -pub fn extract_tls_server_public_key(tls_stream: &impl GetPeerCert) -> anyhow::Result> { - use x509_cert::der::Decode as _; - - let cert = tls_stream.get_peer_certificate().context("certificate is missing")?; - - let cert = x509_cert::Certificate::from_der(cert).context("parse X509 certificate")?; - - let server_public_key = cert - .tbs_certificate - .subject_public_key_info - .subject_public_key - .as_bytes() - .context("subject public key BIT STRING is not aligned")? - .to_owned(); - - Ok(server_public_key) -} - -pub trait GetPeerCert { - fn get_peer_certificate(&self) -> Option<&tokio_rustls::rustls::pki_types::CertificateDer<'static>>; -} - -impl GetPeerCert for tokio_rustls::client::TlsStream { - fn get_peer_certificate(&self) -> Option<&tokio_rustls::rustls::pki_types::CertificateDer<'static>> { - self.get_ref() - .1 - .peer_certificates() - .and_then(|certificates| certificates.first()) - } -} - -impl GetPeerCert for tokio_rustls::server::TlsStream { - fn get_peer_certificate(&self) -> Option<&tokio_rustls::rustls::pki_types::CertificateDer<'static>> { - self.get_ref() - .1 - .peer_certificates() - .and_then(|certificates| certificates.first()) - } -} - -async fn send_pdu(framed: &mut ironrdp_tokio::TokioFramed, pdu: &P) -> anyhow::Result<()> +async fn send_pdu(framed: &mut ironrdp_tokio::MovableTokioFramed, pdu: &P) -> anyhow::Result<()> where - S: AsyncWrite + Unpin + Send + Sync, + S: AsyncWrite + Unpin + Send, P: ironrdp_core::Encode, { use ironrdp_tokio::FramedWrite as _; @@ -736,18 +624,10 @@ where Ok(()) } -pub struct NetworkClient; - -impl NetworkClient { - fn new() -> Self { - Self {} - } - - async fn send(&self, request: &NetworkRequest) -> anyhow::Result> { - let target_addr = TargetAddr::parse(request.url.as_str(), Some(88))?; +async fn send_network_request(request: &NetworkRequest) -> anyhow::Result> { + let target_addr = TargetAddr::parse(request.url.as_str(), Some(88))?; - send_krb_message(&target_addr, &request.data) - .await - .map_err(|err| anyhow::Error::msg("failed to send KDC message").context(err)) - } + send_krb_message(&target_addr, &request.data) + .await + .map_err(|err| anyhow::Error::msg("failed to send KDC message").context(err)) } diff --git a/devolutions-gateway/src/tls.rs b/devolutions-gateway/src/tls.rs index 169826fa9..58ebf6e77 100644 --- a/devolutions-gateway/src/tls.rs +++ b/devolutions-gateway/src/tls.rs @@ -217,6 +217,134 @@ pub fn install_default_crypto_provider() { } } +/// Retrieves the TLS server public key from the given acceptor, with per-acceptor caching. +/// +/// This function extracts the public key from the server certificate presented by the provided +/// `TlsAcceptor` via an internal loopback TLS handshake. Results are cached per unique acceptor +/// configuration to avoid redundant handshakes. +/// +/// # Caching Strategy +/// +/// - Each unique `TlsAcceptor` configuration (identified by its underlying `ServerConfig` pointer address) +/// maintains a separate cache entry. +/// - Cache entries expire after `LIFETIME_SECS` seconds to handle certificate rotation. +/// - This ensures that different acceptors (e.g., with different certificates) maintain independent caches. +/// +/// # Arguments +/// +/// * `hostname` - The hostname to use for the internal TLS connection (typically the gateway's hostname). +/// * `acceptor` - The TLS acceptor whose server certificate public key will be extracted. +/// +/// # Returns +/// +/// The DER-encoded public key bytes from the server certificate's SubjectPublicKeyInfo. +pub async fn get_cert_chain_for_acceptor_cached( + hostname: String, + acceptor: tokio_rustls::TlsAcceptor, +) -> anyhow::Result>> { + const LIFETIME_SECS: i64 = 300; + + // Cache is keyed by the address of the acceptor's underlying ServerConfig Arc. + // This ensures each unique acceptor configuration has its own cache entry. + static CACHE: LazyLock>> = + LazyLock::new(|| tokio::sync::Mutex::new(HashMap::new())); + + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + + // Derive a unique cache key from the acceptor's config pointer address. + let cache_key = Arc::as_ptr(acceptor.config()).addr(); + + let mut cache_map = CACHE.lock().await; + + // Check if we have a valid cached entry for this acceptor. + if let Some(cache_entry) = cache_map.get(&cache_key) + && now < cache_entry.update_timestamp + LIFETIME_SECS + { + return Ok(cache_entry.cert_chain.clone()); + } + + // Cache miss or expired; retrieve the public key via TLS handshake. + let cert_chain = retrieve_gateway_cert_chain(hostname, acceptor).await?; + + // Update the cache for this acceptor. + cache_map.insert( + cache_key, + Cache { + cert_chain: cert_chain.clone(), + update_timestamp: now, + }, + ); + + return Ok(cert_chain); + + struct Cache { + cert_chain: Vec>, + update_timestamp: i64, + } + + async fn retrieve_gateway_cert_chain( + hostname: String, + acceptor: tokio_rustls::TlsAcceptor, + ) -> anyhow::Result>> { + let (client_side, server_side) = tokio::io::duplex(4096); + + let connect_fut = dangerous_connect(hostname, client_side); + let accept_fut = acceptor.accept(server_side); + + let (connect_res, _) = tokio::join!(connect_fut, accept_fut); + + let tls_stream = connect_res.context("connect")?; + + let cert_chain = tls_stream + .get_peer_certificates() + .context("extract Devolutions Gateway TLS certificate chain")? + .to_vec(); + + Ok(cert_chain) + } +} + +pub(crate) trait GetPeerCerts { + fn get_peer_certificates(&self) -> Option<&[pki_types::CertificateDer<'static>]>; +} + +impl GetPeerCerts for TlsStream { + fn get_peer_certificates(&self) -> Option<&[pki_types::CertificateDer<'static>]> { + self.get_ref().1.peer_certificates() + } +} + +impl GetPeerCerts for tokio_rustls::server::TlsStream { + fn get_peer_certificates(&self) -> Option<&[pki_types::CertificateDer<'static>]> { + self.get_ref().1.peer_certificates() + } +} + +pub(crate) fn extract_stream_peer_public_key(tls_stream: &impl GetPeerCerts) -> anyhow::Result> { + let cert = tls_stream + .get_peer_certificates() + .and_then(|certs| certs.first()) + .context("certificate is missing")?; + + extract_public_key(cert) +} + +pub(crate) fn extract_public_key(cert: &pki_types::CertificateDer<'static>) -> anyhow::Result> { + use x509_cert::der::Decode as _; + + let cert = x509_cert::Certificate::from_der(cert).context("parse X509 certificate")?; + + let public_key = cert + .tbs_certificate + .subject_public_key_info + .subject_public_key + .as_bytes() + .context("subject public key BIT STRING is not aligned")? + .to_owned(); + + Ok(public_key) +} + #[cfg(windows)] pub mod windows { use std::sync::Arc;