diff --git a/crates/ironrdp-acceptor/src/connection.rs b/crates/ironrdp-acceptor/src/connection.rs index 5c8d7dbd1..ec6376f13 100644 --- a/crates/ironrdp-acceptor/src/connection.rs +++ b/crates/ironrdp-acceptor/src/connection.rs @@ -1,5 +1,6 @@ use core::mem; +use ironrdp_connector::sspi::AuthIdentity; use ironrdp_connector::{ ConnectorError, ConnectorErrorExt as _, ConnectorResult, DesktopSize, Sequence, State, Written, encode_x224_packet, general_err, reason_err, @@ -96,11 +97,11 @@ pub struct AcceptorResult { /// announce one. Servers can use it to pick a server-side keyboard layout /// matching the client without changing any local input state. pub keyboard_layout: u32, - /// Credentials received from the client during SecureSettingsExchange. + /// Credentials received from the client. /// /// Present for TLS-mode connections where the client sends credentials - /// in the ClientInfoPdu. `None` for CredSSP/Hybrid connections (where - /// authentication happens during the CredSSP exchange instead). + /// in the ClientInfoPdu, and for CredSSP/Hybrid connections once the + /// delegated TSPasswordCreds have been decrypted by CredSSP. /// /// Servers that need to validate credentials (e.g., via PAM or LDAP) /// can use this field for post-handshake validation. @@ -231,6 +232,17 @@ impl Acceptor { matches!(self.state, AcceptorState::Credssp { .. }) } + /// Store credentials delegated by CredSSP/NLA so server code can use the + /// same post-handshake validation and binding path as TLS ClientInfo + /// credentials. + pub(crate) fn set_received_credssp_credentials(&mut self, identity: AuthIdentity) { + self.received_credentials = Some(Credentials { + username: identity.username.account_name().to_owned(), + password: identity.password.as_ref().clone(), + domain: identity.username.domain_name().map(str::to_owned), + }); + } + /// # Panics /// /// Panics if state is not [AcceptorState::Credssp]. diff --git a/crates/ironrdp-acceptor/src/credssp.rs b/crates/ironrdp-acceptor/src/credssp.rs index e665724db..1be36eb09 100644 --- a/crates/ironrdp-acceptor/src/credssp.rs +++ b/crates/ironrdp-acceptor/src/credssp.rs @@ -153,18 +153,19 @@ impl<'a> CredsspSequence<'a> { &mut self, result: Result, output: &mut WriteBuf, - ) -> ConnectorResult { - let (ts_request, next_state) = match result { - Ok(ServerState::ReplyNeeded(ts_request)) => (Some(ts_request), CredsspState::Ongoing), - Ok(ServerState::Finished(_id)) => (None, CredsspState::Finished), + ) -> ConnectorResult<(Written, Option)> { + let (ts_request, next_state, credentials) = match result { + Ok(ServerState::ReplyNeeded(ts_request)) => (Some(ts_request), CredsspState::Ongoing, None), + Ok(ServerState::Finished(id)) => (None, CredsspState::Finished, Some(id)), Err(err) => ( err.ts_request.map(|ts_request| *ts_request), CredsspState::ServerError(err.error), + None, ), }; self.state = next_state; - if let Some(ts_request) = ts_request { + let written = if let Some(ts_request) = ts_request { debug!(?ts_request, "Send"); let length = usize::from(ts_request.buffer_len()); let unfilled_buffer = output.unfilled_to(length); @@ -175,9 +176,11 @@ impl<'a> CredsspSequence<'a> { output.advance(length); - Ok(Written::from_size(length)?) + Written::from_size(length)? } else { - Ok(Written::Nothing) - } + Written::Nothing + }; + + Ok((written, credentials)) } } diff --git a/crates/ironrdp-acceptor/src/lib.rs b/crates/ironrdp-acceptor/src/lib.rs index a8a709687..ba864fa6e 100644 --- a/crates/ironrdp-acceptor/src/lib.rs +++ b/crates/ironrdp-acceptor/src/lib.rs @@ -208,7 +208,10 @@ where }; // drop generator buf.clear(); - let written = sequence.handle_process_result(result, buf)?; + let (written, delegated_credentials) = sequence.handle_process_result(result, buf)?; + if let Some(credentials) = delegated_credentials { + acceptor.set_received_credssp_credentials(credentials); + } if let Some(response_len) = written.size() { let response = &buf[..response_len]; diff --git a/crates/ironrdp-server/src/builder.rs b/crates/ironrdp-server/src/builder.rs index 0795e6001..3779f3646 100644 --- a/crates/ironrdp-server/src/builder.rs +++ b/crates/ironrdp-server/src/builder.rs @@ -11,7 +11,9 @@ use super::display::{DesktopSize, RdpServerDisplay}; #[cfg(feature = "egfx")] use super::gfx::GfxServerFactory; use super::handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler}; -use super::server::{ConnectionHandler, CredentialValidator, RdpServer, RdpServerOptions, RdpServerSecurity}; +use super::server::{ + ConnectionBinder, ConnectionHandler, CredentialValidator, RdpServer, RdpServerOptions, RdpServerSecurity, +}; use crate::{DisplayUpdate, RdpServerDisplayUpdates, SoundServerFactory}; pub struct WantsAddr {} @@ -38,6 +40,7 @@ pub struct BuilderDone { sound_factory: Option>, connection_handler: Option>, credential_validator: Option>, + connection_binder: Option>, #[cfg(feature = "egfx")] gfx_factory: Option>, display_suppressed: Option>, @@ -137,6 +140,7 @@ impl RdpServerBuilder { cliprdr_factory: None, connection_handler: None, credential_validator: None, + connection_binder: None, codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, #[cfg(feature = "egfx")] @@ -159,6 +163,7 @@ impl RdpServerBuilder { cliprdr_factory: None, connection_handler: None, credential_validator: None, + connection_binder: None, codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, #[cfg(feature = "egfx")] @@ -278,6 +283,12 @@ impl RdpServerBuilder { self } + /// Set a binder that replaces display/input handlers after credentials are accepted. + pub fn with_connection_binder(mut self, binder: Option>) -> Self { + self.state.connection_binder = binder; + self + } + /// Inject a shared NetworkAutoDetect RTT handle (milliseconds, `u32::MAX` /// until the first measurement). The server writes the latest measured RTT /// to the same instance the backend reads. When not called, the server @@ -309,6 +320,7 @@ impl RdpServerBuilder { self.state.autodetect_rtt, ); server.set_credential_validator(self.state.credential_validator); + server.set_connection_binder(self.state.connection_binder); server } } diff --git a/crates/ironrdp-server/src/lib.rs b/crates/ironrdp-server/src/lib.rs index 505d07a2f..fcebab562 100644 --- a/crates/ironrdp-server/src/lib.rs +++ b/crates/ironrdp-server/src/lib.rs @@ -33,9 +33,9 @@ pub use handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler}; #[cfg(feature = "helper")] pub use helper::TlsIdentityCtx; pub use server::{ - ConnectionHandler, CredentialDecision, CredentialValidationError, CredentialValidator, Credentials, - ExactMatchCredentialValidator, PostConnectionAction, RdpServer, RdpServerOptions, RdpServerSecurity, ServerEvent, - ServerEventSender, TransportTls, + BoundConnection, ConnectionBinder, ConnectionHandler, CredentialDecision, CredentialValidationError, + CredentialValidator, Credentials, ExactMatchCredentialValidator, PostConnectionAction, RdpServer, RdpServerOptions, + RdpServerSecurity, ServerEvent, ServerEventSender, TransportTls, }; pub use sound::{RdpsndServerHandler, RdpsndServerMessage, SoundServerFactory}; diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index acdfe99dc..fc9bf8a79 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -190,6 +190,28 @@ pub trait CredentialValidator: Send + Sync { async fn validate(&self, credentials: &Credentials) -> Result; } +/// Display/input objects bound after a credential validator accepts a client. +/// +/// Servers with per-user desktop/session isolation can start with placeholder +/// display and input handlers, validate the client's credentials, and then +/// replace those placeholders with handlers attached to the authenticated +/// user's session before the RDP client loop starts. +pub struct BoundConnection { + pub display: Box, + pub input: Box, +} + +/// Async post-auth connection binder. +/// +/// This hook runs after [`CredentialValidator`] accepts credentials and before +/// static channels, display updates, or input dispatch begin. It lets a server +/// bind display/input resources to the authenticated identity without creating +/// per-user resources before authentication. +#[async_trait::async_trait] +pub trait ConnectionBinder: Send + Sync { + async fn bind_connection(&self, credentials: &Credentials) -> Result; +} + /// A built-in [`CredentialValidator`] that accepts exactly one fixed set of credentials. /// /// This is the validation-policy equivalent of the acceptor's pre-loaded @@ -448,6 +470,7 @@ pub struct RdpServer { ev_receiver: Arc>>, creds: Option, credential_validator: Option>, + connection_binder: Option>, local_addr: Option, autodetect: Option, connection_handler: Option>, @@ -546,6 +569,7 @@ impl RdpServer { ev_receiver: Arc::new(Mutex::new(ev_receiver)), creds: None, credential_validator: None, + connection_binder: None, local_addr: None, autodetect: None, connection_handler, @@ -582,6 +606,15 @@ impl RdpServer { self.credential_validator = validator; } + /// Set or clear a post-auth connection binder. + /// + /// When set, the binder is called after credentials have been validated. + /// The returned display/input handlers replace the server defaults for the + /// accepted connection. + pub fn set_connection_binder(&mut self, binder: Option>) { + self.connection_binder = binder; + } + pub fn event_sender(&self) -> &mpsc::UnboundedSender { &self.ev_sender } @@ -1328,6 +1361,7 @@ impl RdpServer { reader: &mut Framed, writer: &mut Framed, result: AcceptorResult, + authenticated_credentials_cache: &mut Option, ) -> Result where R: FramedRead, @@ -1339,26 +1373,28 @@ impl RdpServer { // async server layer, rather than in the sans-I/O acceptor, because real validators // (PAM/LDAP/DB) are I/O-bound. On rejection, deny with a ServerSetErrorInfoPdu before // closing, matching the acceptor's exact-match denial path. - if let Some(validator) = self.credential_validator.clone() { - if let Some(creds) = &result.credentials { - match validator.validate(creds).await { - Ok(CredentialDecision::Accept) => { - debug!("Credential validation accepted"); - } - Ok(CredentialDecision::Reject) => { - warn!("Credential validation rejected"); - send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; - bail!("credential validation rejected"); - } - Err(e) => { - error!(error = %e, "Credential validator backend error"); - send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; - bail!("credential validation backend error"); - } - } - } else { - debug!("Skipping credential validation (no credentials in AcceptorResult)"); - } + let authenticated_credentials = resolve_authenticated_credentials( + self.credential_validator.clone(), + result.credentials.as_ref(), + result.reactivation, + authenticated_credentials_cache, + ) + .await?; + + if let Some(binder) = self.connection_binder.clone() { + let Some(credentials) = authenticated_credentials.as_ref() else { + warn!("Connection binder configured but no authenticated credentials are available"); + send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; + bail!("no authenticated credentials for connection binding"); + }; + + let bound = binder + .bind_connection(credentials) + .await + .context("connection binder failed")?; + *self.display.lock().await = bound.display; + *self.handler.lock().await = bound.input; + debug!("Connection binder installed display/input handlers"); } if !result.input_events.is_empty() { @@ -1690,6 +1726,8 @@ impl RdpServer { where S: AsyncRead + AsyncWrite + Sync + Send + Unpin, { + let mut authenticated_credentials_cache = None; + loop { let (new_framed, result) = ironrdp_acceptor::accept_finalize(framed, &mut acceptor) .await @@ -1697,7 +1735,10 @@ impl RdpServer { let (mut reader, mut writer) = split_tokio_framed(new_framed); - match self.client_accepted(&mut reader, &mut writer, result).await? { + match self + .client_accepted(&mut reader, &mut writer, result, &mut authenticated_credentials_cache) + .await? + { RunState::Continue => { unreachable!(); } @@ -1728,6 +1769,51 @@ impl RdpServer { } } +async fn resolve_authenticated_credentials( + credential_validator: Option>, + result_credentials: Option<&Credentials>, + reactivation: bool, + authenticated_credentials_cache: &mut Option, +) -> Result> { + if let Some(validator) = credential_validator { + if let Some(creds) = result_credentials { + match validator.validate(creds).await { + Ok(CredentialDecision::Accept) => { + debug!("Credential validation accepted"); + *authenticated_credentials_cache = Some(creds.clone()); + Ok(Some(creds.clone())) + } + Ok(CredentialDecision::Reject) => { + warn!("Credential validation rejected"); + bail!("credential validation rejected"); + } + Err(e) => { + error!(error = %e, "Credential validator backend error"); + bail!("credential validation backend error"); + } + } + } else if reactivation { + let credentials = authenticated_credentials_cache.clone(); + if credentials.is_some() { + debug!("Reusing cached authenticated credentials for reactivation"); + } else { + debug!("Skipping credential validation for reactivation without cached credentials"); + } + Ok(credentials) + } else { + debug!("Skipping credential validation (no credentials in AcceptorResult)"); + Ok(None) + } + } else if let Some(creds) = result_credentials { + *authenticated_credentials_cache = Some(creds.clone()); + Ok(Some(creds.clone())) + } else if reactivation { + Ok(authenticated_credentials_cache.clone()) + } else { + Ok(None) + } +} + /// Encode a server-initiated Share Data PDU for the IO channel. /// /// `share_id` is hard-coded to 0, matching the existing convention in @@ -1842,3 +1928,84 @@ impl<'a, W: FramedWrite> SharedWriter<'a, W> { } } } + +#[cfg(test)] +mod wrdp_reactivation_tests { + use super::*; + + struct AllowUserValidator(&'static str); + + #[async_trait::async_trait] + impl CredentialValidator for AllowUserValidator { + async fn validate(&self, credentials: &Credentials) -> Result { + if credentials.username == self.0 { + Ok(CredentialDecision::Accept) + } else { + Ok(CredentialDecision::Reject) + } + } + } + + fn creds(username: &str) -> Credentials { + Credentials { + username: username.to_owned(), + password: "secret".to_owned(), + domain: None, + } + } + + #[tokio::test] + async fn reactivation_without_credentials_reuses_same_connection_validated_identity() { + let validator = Arc::new(AllowUserValidator("alice")); + let mut per_connection_cache = None; + + let first = resolve_authenticated_credentials( + Some(validator.clone()), + Some(&creds("alice")), + false, + &mut per_connection_cache, + ) + .await + .expect("initial validation should succeed") + .expect("initial validation should produce credentials"); + assert_eq!(first.username, "alice"); + + let reactivated = resolve_authenticated_credentials( + Some(validator), + None, + true, + &mut per_connection_cache, + ) + .await + .expect("reactivation should reuse same-connection cache") + .expect("reactivation should have cached credentials"); + assert_eq!(reactivated.username, "alice"); + } + + #[tokio::test] + async fn reactivation_without_credentials_cannot_use_previous_tcp_connection_cache() { + let validator = Arc::new(AllowUserValidator("alice")); + let mut first_connection_cache = None; + resolve_authenticated_credentials( + Some(validator.clone()), + Some(&creds("alice")), + false, + &mut first_connection_cache, + ) + .await + .expect("initial validation should succeed"); + assert!(first_connection_cache.is_some()); + + let mut second_connection_cache = None; + let reactivated = resolve_authenticated_credentials( + Some(validator), + None, + true, + &mut second_connection_cache, + ) + .await + .expect("missing same-connection cache is not a backend error"); + assert!(reactivated.is_none()); + assert!(second_connection_cache.is_none()); + } +} diff --git a/docs/wrdp/auth-delegation.md b/docs/wrdp/auth-delegation.md new file mode 100644 index 000000000..1f7f1cfb6 --- /dev/null +++ b/docs/wrdp/auth-delegation.md @@ -0,0 +1,14 @@ +# Post-auth connection binding for multi-user servers + +`wrdp` follows the same multi-user architecture model as `xrdp-sesman`: a +single public RDP listener authenticates the client first, then delegates the +connection to a per-user desktop/session stack. + +That model needs a server hook that runs after credentials have been accepted +but before display updates and input dispatch begin. The hook lets a server +start or locate the authenticated user's session and then replace placeholder +handlers with display/input handlers bound to that session. + +The `ConnectionBinder` API keeps protocol ownership inside IronRDP while +allowing downstream servers to keep user/session lifecycle code outside the RDP +state machine. diff --git a/docs/wrdp/credssp-delegated-credentials.md b/docs/wrdp/credssp-delegated-credentials.md new file mode 100644 index 000000000..032020be6 --- /dev/null +++ b/docs/wrdp/credssp-delegated-credentials.md @@ -0,0 +1,14 @@ +# CredSSP delegated credentials handoff + +`ironrdp-acceptor` already exposes credentials sent later in the TLS +SecureSettingsExchange path. CredSSP/Hybrid authentication completes earlier, so +servers that delegate final account checks after the protocol handshake also need +access to the decrypted `TSPasswordCreds` produced by the CredSSP server state +machine. + +This change carries the delegated identity from the CredSSP sequence into +`AcceptorResult::credentials`, matching the existing ClientInfoPdu handoff shape. + +This is useful for servers that follow the xrdp-sesman multi-user architecture +model: the RDP protocol stack authenticates the transport, then the embedding +server delegates account authorization and session launch to a separate service. diff --git a/docs/wrdp/reactivation-credential-cache.md b/docs/wrdp/reactivation-credential-cache.md new file mode 100644 index 000000000..2949076ac --- /dev/null +++ b/docs/wrdp/reactivation-credential-cache.md @@ -0,0 +1,10 @@ +# Reactivation credential cache scope + +During Deactivation-Reactivation some clients do not send a second credentials +PDU. A server that binds display/input handlers after authentication still needs +the identity accepted earlier on the same TCP connection. + +The cache introduced here is deliberately scoped to `accept_finalize()`, i.e. to +one TCP connection. It allows same-connection reactivation to reuse the validated +identity but prevents a new TCP connection from inheriting credentials accepted +on a previous connection.