Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1634a4e
Add post-auth connection binder hook
rcarmo Jul 5, 2026
5bafaad
Scope reactivation credential cache to connection
rcarmo Jul 5, 2026
8b022ab
Expose CredSSP delegated credentials to servers
rcarmo Jul 4, 2026
be4c612
Address post-auth binder review feedback
rcarmo Jul 5, 2026
aee82d3
Avoid rebinding during RDP reactivation
rcarmo Jul 5, 2026
61e6f8a
Keep bound server handlers connection-local
Jul 6, 2026
8f091bd
Clarify credential checks during reactivation
Jul 6, 2026
3d2834c
Update server credential hook docs
Jul 6, 2026
7074e63
Avoid caching authenticated credentials
Jul 6, 2026
9ae3bc2
Document bound handler scoping
Jul 8, 2026
c4d47b8
Track accepted credential origin
Jul 8, 2026
f96d408
Preserve bound handlers during reactivation
Jul 11, 2026
0613f4e
Pair received credentials with origin
Jul 11, 2026
cc260ea
Move credential handoff docs into rustdoc
Jul 11, 2026
db9e3c0
Preserve credential validator backend errors
rcarmo Jul 14, 2026
c2d292a
Validate credentials before session activation
rcarmo Aug 3, 2026
66808d2
Pass negotiated desktop size to connection binder
rcarmo Aug 3, 2026
3abac00
Make fork CI and fuzz workflows manual-only
rcarmo Aug 5, 2026
f6c972c
Make fork RDP automation manual-only
rcarmo Aug 5, 2026
109461c
Make Advanced Input own mouse delivery
rcarmo Aug 5, 2026
3c9728b
Preserve HYBRID_EX credential denial response
rcarmo Aug 21, 2026
fe2b70e
Send ERRINFO when credential hooks reject
rcarmo Aug 21, 2026
0e13fcc
Restore bound displays when async calls cancel
rcarmo Aug 21, 2026
ce61861
Remove dead credential reactivation state
rcarmo Aug 21, 2026
df3cd29
Make bound connection slot updates synchronous
rcarmo Aug 21, 2026
8b9ae8a
Preserve credential validator API compatibility
rcarmo Aug 21, 2026
ac2c412
Keep delegated credentials out of acceptor results
rcarmo Aug 21, 2026
0086d11
Restore upstream CI and fuzz triggers
rcarmo Aug 21, 2026
0c31427
Activate Advanced Input ownership on first event
rcarmo Aug 21, 2026
4019fc7
Defer validation for auto-reconnect attempts
rcarmo Aug 21, 2026
b736ce1
Name credential hooks for connection setup
rcarmo Aug 21, 2026
0d8af13
fix(server): preserve authorization semantics
mamoreau-devolutions Oct 8, 2026
36b30ad
fix(server): keep the accept-loop future compact
mamoreau-devolutions Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/ironrdp-acceptor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public
ironrdp-connector = { path = "../ironrdp-connector", version = "0.10" } # public
ironrdp-async = { path = "../ironrdp-async", version = "0.10" } # public
rand = "0.9"
async-trait = "0.1"
tracing = { version = "0.1", features = ["log"] }

[lints]
Expand Down
218 changes: 202 additions & 16 deletions crates/ironrdp-acceptor/src/connection.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use core::any::TypeId;
use core::mem;

use ironrdp_connector::sspi::{AuthIdentity, UsernameParts};
use ironrdp_connector::{
ConnectorError, ConnectorErrorExt as _, ConnectorResult, DesktopSize, MonotonicInstant, Sequence, State, Written,
encode_x224_packet, general_err, reason_err,
Expand Down Expand Up @@ -51,7 +52,8 @@ pub struct Acceptor {
static_channels: StaticChannelSet,
saved_for_reactivation: AcceptorState,
pub(crate) creds: Option<Credentials>,
received_credentials: Option<Credentials>,
received_credentials: Option<ReceivedCredentials>,
credentials_handled: bool,
received_auto_reconnect: Option<ClientAutoReconnect>,
reactivation: bool,
honor_client_desktop_size: Option<DesktopSize>,
Expand Down Expand Up @@ -141,6 +143,37 @@ fn set_bitmap_desktop_size(capabilities: &mut [CapabilitySet], size: DesktopSize
}
}

/// Protocol source and handshake-authentication status of received credentials.
///
/// Servers must not infer this from their configured security mode: the origin
/// records what the acceptor actually received during negotiation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialOrigin {
/// Received in the ClientInfoPdu (MS-RDPBCGR 2.2.1.11).
///
/// These credentials are client-supplied and have not been authenticated by
/// the protocol handshake. A server should validate them before using them
/// to select identity-bound resources.
ClientInfo,
/// Delegated TSPasswordCreds decrypted by CredSSP (MS-CSSP).
///
/// CredSSP authenticated the principal during the exchange. A server may
/// still run authorization policy before starting or selecting a session.
CredSspDelegated,
}

/// Credentials received by the acceptor together with their protocol origin.
///
/// Keeping both values in one type makes it impossible to expose credentials
/// without the provenance required to interpret their authentication status.
#[derive(Clone, Debug)]
pub struct ReceivedCredentials {
/// CredSSP UPNs remain intact in `username`, with no `domain`.
/// Down-level names separate the account name and optional NetBIOS domain.
pub credentials: Credentials,
pub origin: CredentialOrigin,
}

#[derive(Debug)]
pub struct AcceptorResult {
pub static_channels: StaticChannelSet,
Expand Down Expand Up @@ -198,11 +231,11 @@ pub struct AcceptorResult {
/// Credentials received from the client during SecureSettingsExchange.
///
/// 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. For CredSSP/Hybrid connections, credentials are
/// handled before capability exchange and are not exposed here.
///
/// Servers that need to validate credentials (e.g., via PAM or LDAP)
/// can use this field for post-handshake validation.
/// should use a [`ConnectionSetupHandler`](crate::ConnectionSetupHandler).
pub credentials: Option<Credentials>,
/// Client Auto-Reconnect Packet received in the Client Info PDU.
///
Expand Down Expand Up @@ -237,6 +270,7 @@ impl Acceptor {
saved_for_reactivation: Default::default(),
creds,
received_credentials: None,
credentials_handled: false,
received_auto_reconnect: None,
reactivation: false,
honor_client_desktop_size: None,
Expand Down Expand Up @@ -472,6 +506,7 @@ impl Acceptor {
saved_for_reactivation,
creds: consumed.creds,
received_credentials: consumed.received_credentials,
credentials_handled: consumed.credentials_handled,
received_auto_reconnect: consumed.received_auto_reconnect,
reactivation: true,
honor_client_desktop_size: consumed.honor_client_desktop_size,
Expand Down Expand Up @@ -542,6 +577,94 @@ impl Acceptor {
matches!(self.state, AcceptorState::Credssp { .. })
}

pub(crate) fn desktop_size(&self) -> DesktopSize {
self.desktop_size
}

pub(crate) fn is_reactivation(&self) -> bool {
self.reactivation
}

/// Whether the client supplied a well-formed Client Auto-Reconnect Packet.
///
/// The server still has to verify its security verifier before accepting
/// the connection.
pub(crate) fn is_auto_reconnect_attempt(&self) -> bool {
self.received_auto_reconnect.is_some()
}

pub(crate) fn is_ready_for_capability_exchange(&self) -> bool {
matches!(self.state, AcceptorState::CapabilitiesSendServer { .. })
}

/// Returns credentials received during the current handshake, if any.
pub(crate) fn received_credentials(&self) -> Option<&ReceivedCredentials> {
self.received_credentials.as_ref()
}

/// Whether credentials need handling after channel joins and ClientInfo.
pub(crate) fn credentials_need_handling(&self) -> bool {
matches!(self.state, AcceptorState::LicensingExchange { .. })
&& !self.is_auto_reconnect_attempt()
&& self.received_credentials.is_some()
&& !self.credentials_handled
}

pub(crate) fn mark_credentials_handled(&mut self) {
self.credentials_handled = true;
}

fn credentials_for_result(&mut self) -> Option<Credentials> {
self.received_credentials
.take()
.filter(|received| received.origin == CredentialOrigin::ClientInfo)
.map(|received| received.credentials)
}

/// Encodes a Set Error Info PDU for a user authorization failure ([MS-RDPBCGR] section 2.2.5.1.1).
///
/// Returns zero without writing if the client did not advertise error-info support.
///
/// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/
pub fn encode_access_denied(&self, output: &mut WriteBuf) -> ConnectorResult<usize> {
if !self
.early_capability_flags
.contains(gcc::ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU)
{
debug!("Omitting access-denied PDU because the client did not advertise error-info support");
return Ok(0);
}

let info = wrap_share_data(
rdp::headers::ShareDataPdu::ServerSetErrorInfo(ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode(
ProtocolIndependentCode::ServerInsufficientPrivileges,
))),
0,
);
debug!(message = ?info, "Send");
util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &info, output)
}

/// Stores CredSSP/NLA credentials for the same validation and binding path as TLS ClientInfo credentials.
pub(crate) fn set_received_credssp_credentials(&mut self, identity: AuthIdentity) {
let (username, domain) = match identity.username.parts() {
UsernameParts::UserPrincipalName(parts) => (parts.upn().to_owned(), None),
UsernameParts::DownLevelLogonName(parts) => (
parts.account_name().to_owned(),
parts.netbios_domain().map(str::to_owned),
),
};
self.received_credentials = Some(ReceivedCredentials {
credentials: Credentials {
username,
password: identity.password.as_ref().clone(),
domain,
},
origin: CredentialOrigin::CredSspDelegated,
});
self.credentials_handled = false;
}

/// # Panics
///
/// Panics if state is not [AcceptorState::Credssp].
Expand Down Expand Up @@ -576,7 +699,7 @@ impl Acceptor {
multitransport_response_success: self.received_multitransport_response,
client_early_capability_flags: self.early_capability_flags,
reactivation: self.reactivation,
credentials: self.received_credentials.take(),
credentials: self.credentials_for_result(),
auto_reconnect: self.received_auto_reconnect.take(),
}),
previous_state => {
Expand Down Expand Up @@ -1088,22 +1211,17 @@ impl Sequence for Acceptor {

if let Some(expected) = &self.creds {
if expected != &creds {
// FIXME: How authorization should be denied with standard RDP security?
// Since standard RDP security is not a priority, we just send a ServerDeniedConnection ServerSetErrorInfo PDU.
let info = ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode(
ProtocolIndependentCode::ServerDeniedConnection,
));

debug!(message = ?info, "Send");

util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &info, output)?;

self.encode_access_denied(output)?;
return Err(ConnectorError::general("invalid credentials"));
}
}

// Store credentials for later retrieval via AcceptorResult.
self.received_credentials = Some(creds);
self.received_credentials = Some(ReceivedCredentials {
credentials: creds,
origin: CredentialOrigin::ClientInfo,
});
self.credentials_handled = false;
}

(
Expand Down Expand Up @@ -1435,3 +1553,71 @@ fn create_gcc_blocks(
.map(|flags| gcc::MultiTransportChannelData { flags }),
}
}

#[cfg(test)]
mod tests {
use super::*;

fn credentials(username: &str) -> Credentials {
Credentials {
username: username.to_owned(),
password: "secret".to_owned(),
domain: None,
}
}

#[test]
fn acceptor_result_exposes_only_client_info_credentials() {
let mut acceptor = Acceptor::new(
SecurityProtocol::SSL,
DesktopSize {
width: 1024,
height: 768,
},
Vec::new(),
None,
);

acceptor.received_credentials = Some(ReceivedCredentials {
credentials: credentials("nla-user"),
origin: CredentialOrigin::CredSspDelegated,
});
assert!(acceptor.credentials_for_result().is_none());

acceptor.received_credentials = Some(ReceivedCredentials {
credentials: credentials("tls-user"),
origin: CredentialOrigin::ClientInfo,
});
assert_eq!(
acceptor
.credentials_for_result()
.expect("ClientInfo credentials should remain available")
.username,
"tls-user"
);
}

#[test]
fn auto_reconnect_defers_client_info_credentials() {
let mut acceptor = Acceptor::new(
SecurityProtocol::SSL,
DesktopSize {
width: 1024,
height: 768,
},
Vec::new(),
None,
);
acceptor.received_credentials = Some(ReceivedCredentials {
credentials: credentials("reconnect-user"),
origin: CredentialOrigin::ClientInfo,
});
acceptor.received_auto_reconnect = Some(ClientAutoReconnect {
logon_id: 7,
security_verifier: [0x5A; 16],
});

assert!(acceptor.is_auto_reconnect_attempt());
assert!(!acceptor.credentials_need_handling());
}
}
19 changes: 11 additions & 8 deletions crates/ironrdp-acceptor/src/credssp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,18 +153,19 @@ impl<'a> CredsspSequence<'a> {
&mut self,
result: Result<ServerState, ServerError>,
output: &mut WriteBuf,
) -> ConnectorResult<Written> {
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<AuthIdentity>)> {
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);
Expand All @@ -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))
}
}
Loading
Loading