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..49d16b3a6 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")] @@ -263,21 +268,25 @@ impl RdpServerBuilder { self } - /// Set a credential validator for TLS-mode connections. + /// Set a credential validator for accepted client credentials. /// - /// When set, credentials received from the client during - /// `SecureSettingsExchange` (`ClientInfoPdu`) are passed to this - /// validator before the session is established. Rejection or a backend + /// When set, credentials surfaced by the acceptor are passed to this + /// validator before the session is established. This includes + /// `SecureSettingsExchange` (`ClientInfoPdu`) credentials and, when + /// available, CredSSP/Hybrid delegated credentials. Rejection or a backend /// error closes the connection. Pass `None` (the default) to skip /// validation entirely. - /// - /// Not used for CredSSP/Hybrid connections (those use pre-loaded - /// credentials for NTLM challenge-response). pub fn with_credential_validator(mut self, validator: Option>) -> Self { self.state.credential_validator = validator; 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 +318,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..e3cf1efa0 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -3,7 +3,7 @@ use core::net::SocketAddr; use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use core::time::Duration; use std::rc::Rc; -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex}; use anyhow::{Context as _, Result, bail}; use ironrdp_acceptor::{Acceptor, AcceptorResult, BeginResult, DesktopSize}; @@ -36,12 +36,12 @@ use tracing::{debug, error, trace, warn}; use crate::autodetect::{AutoDetectManager, RttSnapshot}; use crate::clipboard::CliprdrServerFactory; -use crate::display::{DisplayUpdate, RdpServerDisplay}; +use crate::display::{DisplayUpdate, RdpServerDisplay, RdpServerDisplayUpdates}; use crate::echo::{EchoDvcBridge, EchoServerHandle, EchoServerMessage, build_echo_request}; use crate::encoder::{UpdateEncoder, UpdateEncoderCodecs}; #[cfg(feature = "egfx")] use crate::gfx::{EgfxServerMessage, GfxServerFactory}; -use crate::handler::RdpServerInputHandler; +use crate::handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler}; use crate::{SoundServerFactory, builder, capabilities}; /// TCP listen backlog size for the RDP server socket. @@ -190,6 +190,133 @@ pub trait CredentialValidator: Send + Sync { async fn validate(&self, credentials: &Credentials) -> Result; } +/// Display/input objects bound after the server authenticates a client. +/// +/// Servers with per-user desktop/session isolation can start with placeholder +/// display and input handlers, authenticate 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 once authenticated credentials are available 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; +} + +struct BoundDisplaySlot { + default: Box, + bound: Arc>>>, +} + +impl BoundDisplaySlot { + fn new(default: Box, bound: Arc>>>) -> Self { + Self { default, bound } + } +} + +#[async_trait::async_trait] +impl RdpServerDisplay for BoundDisplaySlot { + async fn size(&mut self) -> DesktopSize { + let bound_display = { + let mut bound = self.bound.lock().expect("bound display lock poisoned"); + bound.take() + }; + + if let Some(mut display) = bound_display { + let size = display.size().await; + *self.bound.lock().expect("bound display lock poisoned") = Some(display); + size + } else { + self.default.size().await + } + } + + async fn request_initial_size(&mut self, client_size: DesktopSize) -> DesktopSize { + let bound_display = { + let mut bound = self.bound.lock().expect("bound display lock poisoned"); + bound.take() + }; + + if let Some(mut display) = bound_display { + let size = display.request_initial_size(client_size).await; + *self.bound.lock().expect("bound display lock poisoned") = Some(display); + size + } else { + self.default.request_initial_size(client_size).await + } + } + + async fn updates(&mut self) -> Result> { + let bound_display = { + let mut bound = self.bound.lock().expect("bound display lock poisoned"); + bound.take() + }; + + if let Some(mut display) = bound_display { + let updates = display.updates().await; + *self.bound.lock().expect("bound display lock poisoned") = Some(display); + updates + } else { + self.default.updates().await + } + } + + fn request_layout(&mut self, layout: DisplayControlMonitorLayout) { + let mut bound = self.bound.lock().expect("bound display lock poisoned"); + if let Some(display) = bound.as_mut() { + display.request_layout(layout); + } else { + drop(bound); + self.default.request_layout(layout); + } + } +} + +struct BoundInputSlot { + default: Box, + bound: Arc>>>, +} + +impl BoundInputSlot { + fn new( + default: Box, + bound: Arc>>>, + ) -> Self { + Self { default, bound } + } +} + +impl RdpServerInputHandler for BoundInputSlot { + fn keyboard(&mut self, event: KeyboardEvent) { + let mut bound = self.bound.lock().expect("bound input lock poisoned"); + if let Some(handler) = bound.as_mut() { + handler.keyboard(event); + } else { + drop(bound); + self.default.keyboard(event); + } + } + + fn mouse(&mut self, event: MouseEvent) { + let mut bound = self.bound.lock().expect("bound input lock poisoned"); + if let Some(handler) = bound.as_mut() { + handler.mouse(event); + } else { + drop(bound); + self.default.mouse(event); + } + } +} + /// A built-in [`CredentialValidator`] that accepts exactly one fixed set of credentials. /// /// This is the validation-policy equivalent of the acceptor's pre-loaded @@ -436,6 +563,8 @@ pub struct RdpServer { // FIXME: replace with a channel and poll/process the handler? handler: Arc>>, display: Arc>>, + bound_handler: Arc>>>, + bound_display: Arc>>>, static_channels: StaticChannelSet, sound_factory: Option>, cliprdr_factory: Option>, @@ -448,6 +577,7 @@ pub struct RdpServer { ev_receiver: Arc>>, creds: Option, credential_validator: Option>, + connection_binder: Option>, local_addr: Option, autodetect: Option, connection_handler: Option>, @@ -530,10 +660,21 @@ impl RdpServer { if let Some(gfx) = gfx_factory.as_mut() { gfx.set_sender(ev_sender.clone()); } + let bound_handler = Arc::new(StdMutex::new(None)); + let bound_display = Arc::new(StdMutex::new(None)); + Self { opts, - handler: Arc::new(Mutex::new(handler)), - display: Arc::new(Mutex::new(display)), + handler: Arc::new(Mutex::new(Box::new(BoundInputSlot::new( + handler, + Arc::clone(&bound_handler), + )))), + display: Arc::new(Mutex::new(Box::new(BoundDisplaySlot::new( + display, + Arc::clone(&bound_display), + )))), + bound_handler, + bound_display, static_channels: StaticChannelSet::new(), sound_factory, cliprdr_factory, @@ -546,6 +687,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, @@ -563,11 +705,12 @@ impl RdpServer { builder::RdpServerBuilder::new() } - /// Set or clear the credential validator for TLS-mode connections. + /// Set or clear the credential validator for accepted client credentials. /// - /// When set, credentials received from the client during - /// `SecureSettingsExchange` are validated through this callback before - /// the session is established. If the validator returns + /// When set, credentials surfaced by the acceptor are validated through + /// this callback before the session is established. This includes + /// `SecureSettingsExchange` (`ClientInfoPdu`) credentials and, when + /// available, CredSSP/Hybrid delegated credentials. If the validator returns /// [`CredentialDecision::Reject`] (or a [`CredentialValidationError`]), /// the connection is rejected. Passing `None` clears any previously /// configured validator. @@ -576,12 +719,29 @@ impl RdpServer { /// the builder's `with_credential_validator` method /// ([`RdpServer::builder`]); this setter exists for dynamic /// post-construction reconfiguration. - /// - /// Not used for CredSSP/Hybrid connections (those use pre-loaded credentials). pub fn set_credential_validator(&mut self, validator: Option>) { self.credential_validator = validator; } + /// Set or clear a post-auth connection binder. + /// + /// When set, the binder is called only when authenticated credentials are + /// available. 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; + } + + async fn install_bound_connection(&mut self, bound: BoundConnection) { + *self.bound_display.lock().expect("bound display lock poisoned") = Some(bound.display); + *self.bound_handler.lock().expect("bound input lock poisoned") = Some(bound.input); + } + + async fn clear_bound_connection(&mut self) { + self.bound_display.lock().expect("bound display lock poisoned").take(); + self.bound_handler.lock().expect("bound input lock poisoned").take(); + } + pub fn event_sender(&self) -> &mpsc::UnboundedSender { &self.ev_sender } @@ -974,6 +1134,7 @@ impl RdpServer { error!(?error, "Connection error"); } + self.clear_bound_connection().await; self.static_channels = StaticChannelSet::new(); if let Some(ref mut handler) = self.connection_handler { @@ -1338,27 +1499,48 @@ impl RdpServer { // Validate credentials if a validator is configured. The validator runs here, in the // 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"); - } + // closing, matching the acceptor's exact-match denial path. Reactivation still validates + // credentials again if the client resends them before channel state is reused. + let authenticated_credentials = match resolve_authenticated_credentials( + self.credential_validator.clone(), + result.credentials.as_ref(), + result.reactivation, + ) + .await + { + Ok(credentials) => credentials, + Err(e) => { + send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; + return Err(e); + } + }; + + if !result.reactivation { + if let Some(binder) = self.connection_binder.clone() { + if self.credential_validator.is_none() && !matches!(self.opts.security, RdpServerSecurity::Hybrid(_)) { + warn!("Connection binder requires authenticated credentials from a validator or CredSSP/Hybrid"); + send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; + bail!("connection binder requires authenticated credentials"); + } + + 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 = match binder.bind_connection(credentials).await { + Ok(bound) => bound, 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"); + return Err(e).context("connection binder failed"); } - } - } else { - debug!("Skipping credential validation (no credentials in AcceptorResult)"); + }; + self.install_bound_connection(bound).await; + debug!("Connection binder installed display/input handlers"); } + } else if self.connection_binder.is_some() { + debug!("Skipping connection binder during reactivation"); } if !result.input_events.is_empty() { @@ -1728,6 +1910,39 @@ impl RdpServer { } } +async fn resolve_authenticated_credentials( + credential_validator: Option>, + result_credentials: Option<&Credentials>, + reactivation: bool, +) -> Result> { + if let Some(creds) = result_credentials { + if let Some(validator) = credential_validator { + match validator.validate(creds).await { + Ok(CredentialDecision::Accept) => { + debug!("Credential validation accepted"); + Ok(Some(creds)) + } + 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 { + Ok(Some(creds)) + } + } else if reactivation { + debug!("Skipping credential validation for reactivation without credentials"); + Ok(None) + } else { + debug!("Skipping credential validation (no credentials in AcceptorResult)"); + 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 +2057,58 @@ 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_does_not_retain_validated_identity() { + let validator = Arc::new(AllowUserValidator("alice")); + let initial_credentials = creds("alice"); + + let first = resolve_authenticated_credentials(Some(validator.clone()), Some(&initial_credentials), false) + .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) + .await + .expect("missing reactivation credentials is not a backend error"); + assert!(reactivated.is_none()); + } + + #[tokio::test] + async fn reactivation_with_credentials_revalidates_resent_identity() { + let validator = Arc::new(AllowUserValidator("alice")); + let reactivation_credentials = creds("alice"); + + let reactivated = resolve_authenticated_credentials(Some(validator), Some(&reactivation_credentials), true) + .await + .expect("resent reactivation credentials should be validated") + .expect("resent reactivation credentials should remain available"); + assert_eq!(reactivated.username, "alice"); + } +} 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.