-
Notifications
You must be signed in to change notification settings - Fork 247
Wrdp/reactivation credential cache #1412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Rui Carmo (rcarmo)
wants to merge
2
commits into
Devolutions:master
from
rcarmo:wrdp/reactivation-credential-cache
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -190,6 +190,28 @@ pub trait CredentialValidator: Send + Sync { | |
| async fn validate(&self, credentials: &Credentials) -> Result<CredentialDecision, CredentialValidationError>; | ||
| } | ||
|
|
||
| /// 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<dyn RdpServerDisplay>, | ||
| pub input: Box<dyn RdpServerInputHandler>, | ||
| } | ||
|
|
||
| /// 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<BoundConnection>; | ||
| } | ||
|
|
||
| /// 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<Mutex<mpsc::UnboundedReceiver<ServerEvent>>>, | ||
| creds: Option<Credentials>, | ||
| credential_validator: Option<Arc<dyn CredentialValidator>>, | ||
| connection_binder: Option<Arc<dyn ConnectionBinder>>, | ||
| local_addr: Option<SocketAddr>, | ||
| autodetect: Option<AutoDetectManager>, | ||
| connection_handler: Option<Box<dyn ConnectionHandler>>, | ||
|
|
@@ -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<Arc<dyn ConnectionBinder>>) { | ||
| self.connection_binder = binder; | ||
| } | ||
|
|
||
| pub fn event_sender(&self) -> &mpsc::UnboundedSender<ServerEvent> { | ||
| &self.ev_sender | ||
| } | ||
|
|
@@ -1328,6 +1361,7 @@ impl RdpServer { | |
| reader: &mut Framed<R>, | ||
| writer: &mut Framed<W>, | ||
| result: AcceptorResult, | ||
| authenticated_credentials_cache: &mut Option<Credentials>, | ||
| ) -> Result<RunState> | ||
| 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"); | ||
| } | ||
|
Comment on lines
+1384
to
1398
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed on the canonical |
||
|
|
||
| if !result.input_events.is_empty() { | ||
|
|
@@ -1690,14 +1726,19 @@ 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 | ||
| .context("failed to accept client during finalize")?; | ||
|
|
||
| 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<Arc<dyn CredentialValidator>>, | ||
| result_credentials: Option<&Credentials>, | ||
| reactivation: bool, | ||
| authenticated_credentials_cache: &mut Option<Credentials>, | ||
| ) -> Result<Option<Credentials>> { | ||
| 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<CredentialDecision, CredentialValidationError> { | ||
| 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()); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed on the canonical
rcarmo/IronRDP:wrdpbranch inb51bd0a68fb26bfc179571f59ea137eccc850fceand retained at3b05f6fc70d91c2529df8454ce73a292c05195e6: credential rejection/backend-error paths now send the denial PDU before returning the error.