Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,11 @@
"type": "string",
"description": "Path to lib XMF files."
},
"kerberos_credential_injection": {
"type": "boolean",
"default": false,
"description": "Whether to enable proxy-based RDP credential injection against Kerberos-enforced targets."
},
"enable_unstable": {
"type": "boolean",
"default": false,
Expand Down
116 changes: 9 additions & 107 deletions devolutions-gateway/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1837,101 +1837,6 @@ pub mod dto {
}
}

/// Domain user credentials.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainUser {
/// Username in FQDN format (e.g. "pw13@example.com").
///
/// **Note**: the user's domain part must match the internal KDC realm.
/// The KDC realm is derived from the gateway ID using the [KerberosServer::realm] method.
pub fqdn: String,
/// User password.
#[serde(serialize_with = "serialize_secret_string")]
pub password: SecretString,
/// Salt for generating the user's key.
///
/// Usually, it is equal to `{REALM}{username}` (e.g. "EXAMPLEpw13").
pub salt: String,
}

impl From<DomainUser> for kdc::config::DomainUser {
fn from(user: DomainUser) -> Self {
let DomainUser { fqdn, password, salt } = user;

Self {
username: fqdn,
password: password.expose_secret().to_owned(),
salt,
}
}
}

/// Kerberos server config
///
/// This config is used to configure the Kerberos server during RDP proxying.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KerberosServer {
/// Users credentials inside fake KDC.
pub users: Vec<DomainUser>,
/// The maximum allowed time difference between client and proxy clocks.
///
/// The value must be in seconds. [RFC 4120 8.2. Recommended KDC Values](https://www.rfc-editor.org/rfc/rfc4120#section-8.2):
/// > Acceptable clock skew 5 minutes
pub max_time_skew: u64,
/// `krbtgt` service key.
///
/// This key is used to encrypt/decrypt TGT tickets.
pub krbtgt_key: Vec<u8>,
/// Ticket decryption key.
///
/// This key is used to decrypt the TGS ticket sent by the client. If you do not plan
/// to use Kerberos U2U authentication, then the `ticket_decryption_key` is required.
pub ticket_decryption_key: Option<Vec<u8>>,
/// The domain user credentials for the Kerberos U2U authentication.
///
/// This field is needed only for Kerberos User-to-User authentication. If you do not plan
/// to use Kerberos U2U, do not specify it.
pub service_user: Option<DomainUser>,
}

impl KerberosServer {
/// Returns the internal KDC realm for the given gateway ID.
pub fn realm(&self, gateway_id: Uuid) -> String {
format!("{gateway_id}.jet")
}

/// Converts the [KerberosServer] into a [kdc::config::KerberosServer] for the given gateway ID.
pub fn into_kdc_kerberos_config(self, gateway_id: Uuid) -> kdc::config::KerberosServer {
let realm = self.realm(gateway_id);

let KerberosServer {
users,
max_time_skew,
krbtgt_key,
ticket_decryption_key,
service_user,
} = self;

kdc::config::KerberosServer {
realm,
users: users.into_iter().map(Into::into).collect(),
max_time_skew,
krbtgt_key,
ticket_decryption_key,
service_user: service_user.map(Into::into),
}
}
}

/// The Kerberos credentials-injection configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KerberosConfig {
/// Kerberos server and KDC configuration.
pub kerberos_server: KerberosServer,
/// Real KDC address for the Kerberos proxy client.
pub kdc_url: Option<Url>,
}

/// (Unstable) QUIC-based agent tunnel configuration
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
Expand Down Expand Up @@ -1999,10 +1904,14 @@ pub mod dto {
#[serde(default = "ws_keep_alive_interval_default_value")]
pub ws_keep_alive_interval: u64,

/// Kerberos application server configuration
/// Enable proxy-based RDP credential injection against Kerberos-enforced targets
///
/// It is used only during RDP proxying.
pub kerberos: Option<KerberosConfig>,
/// Turns on the in-process KDC acceptor the Gateway presents to the client when injecting
/// credentials for accounts that can't fall back to NTLM (e.g. AD Protected Users).
/// Target-side KDC routing is not configured here. Off by default; still requires
/// `enable_unstable`.
#[serde(default)]
pub kerberos_credential_injection: bool,

/// Enable unstable features which may break at any point
#[serde(default)]
Expand All @@ -2021,7 +1930,7 @@ pub mod dto {
capture_path: None,
lib_xmf_path: None,
enable_unstable: false,
kerberos: None,
kerberos_credential_injection: false,
ws_keep_alive_interval: ws_keep_alive_interval_default_value(),
}
}
Expand All @@ -2036,7 +1945,7 @@ pub mod dto {
&& self.capture_path.is_none()
&& self.lib_xmf_path.is_none()
&& !self.enable_unstable
&& self.kerberos.is_none()
&& !self.kerberos_credential_injection
&& self.ws_keep_alive_interval == ws_keep_alive_interval_default_value()
}
}
Expand Down Expand Up @@ -2418,13 +2327,6 @@ pub mod dto {
}
}

fn serialize_secret_string<S>(value: &SecretString, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(value.expose_secret())
}

fn serialize_opt_secret_string<S>(value: &Option<SecretString>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
Expand Down
26 changes: 8 additions & 18 deletions devolutions-gateway/src/rd_clean_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,8 +419,12 @@ async fn handle_with_credential_injection(
let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream);
let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream);

let krb_server_config =
crate::rdp_proxy::credential_injection_kerberos_server_config(&conf, client_addr, &credential_injection_kdc)?;
let krb_configs = crate::rdp_proxy::credential_injection_kerberos_configs(
&conf,
client_addr,
&gateway_hostname,
&credential_injection_kdc,
)?;

let kdc_connector =
crate::kdc_connector::KdcConnector::new(claims.jet_aid, claims.jet_agent_id, agent_tunnel_handle.clone());
Expand All @@ -431,32 +435,18 @@ async fn handle_with_credential_injection(
gateway_public_key,
client_security_protocol,
credential_injection_kdc.proxy_credential(),
krb_server_config,
krb_configs.server,
&credential_injection_kdc,
&kdc_connector,
);

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: gateway_hostname.clone(),
})
} else {
None
};

let server_credssp_fut = crate::rdp_proxy::perform_credssp_as_client(
&mut server_framed,
destination.host().to_owned(),
server_public_key,
server_security_protocol,
credential_injection_kdc.target_credential(),
krb_client_config,
krb_configs.client,
&kdc_connector,
);

Expand Down
108 changes: 75 additions & 33 deletions devolutions-gateway/src/rdp_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,40 +121,27 @@ where
let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream);
let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream);

let krb_server_config = credential_injection_kerberos_server_config(&conf, client_addr, &credential_injection_kdc)?;
let krb_configs =
credential_injection_kerberos_configs(&conf, client_addr, &gateway_hostname, &credential_injection_kdc)?;

let client_credssp_fut = perform_credssp_as_server(
&mut client_framed,
client_addr.ip(),
gateway_public_key,
handshake_result.client_security_protocol,
credential_injection_kdc.proxy_credential(),
krb_server_config,
krb_configs.server,
&credential_injection_kdc,
&kdc_connector,
);

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: gateway_hostname.clone(),
})
} else {
None
};

let server_credssp_fut = perform_credssp_as_client(
&mut server_framed,
server_dns_name,
server_public_key,
handshake_result.server_security_protocol,
credential_injection_kdc.target_credential(),
krb_client_config,
krb_configs.client,
&kdc_connector,
);

Expand Down Expand Up @@ -363,27 +350,59 @@ where
handshake_result
}

pub(crate) fn credential_injection_kerberos_server_config(
/// Kerberos configs for the two CredSSP legs of a credential-injection session.
///
/// `server` drives the client-facing acceptor (Gateway-as-server); `client` drives the
/// target-facing leg (Gateway-as-client). `None` on a leg means that leg authenticates over NTLM.
pub(crate) struct CredentialInjectionKerberosConfigs {
pub server: Option<sspi::KerberosServerConfig>,
pub client: Option<ironrdp_connector::credssp::KerberosConfig>,
}

/// Whether a credential-injection session speaks Kerberos (vs NTLM). Decided once so both CredSSP
/// legs agree — sspi's acceptor and initiator must speak the same package or the handshake fails
/// reading one as the other. Kerberos needs the experimental opt-in AND a domain-qualified target
/// (a domainless account can't get a ticket).
fn injection_uses_kerberos(
enable_unstable: bool,
kerberos_credential_injection: bool,
protocol: CredentialInjectionClientAcceptorProtocol,
) -> bool {
enable_unstable
&& kerberos_credential_injection
&& matches!(protocol, CredentialInjectionClientAcceptorProtocol::Kerberos)
}

/// Build the Kerberos config for both CredSSP legs from the single [`injection_uses_kerberos`]
/// decision. Everything else is NTLM on both legs.
pub(crate) fn credential_injection_kerberos_configs(
conf: &Conf,
client_addr: SocketAddr,
gateway_hostname: &str,
credential_injection_kdc: &CredentialInjectionKdc,
) -> anyhow::Result<Option<sspi::KerberosServerConfig>> {
if !conf.debug.enable_unstable || conf.debug.kerberos.is_none() {
return Ok(None);
) -> anyhow::Result<CredentialInjectionKerberosConfigs> {
let protocol = credential_injection_kdc.client_acceptor_protocol()?;

if !injection_uses_kerberos(
conf.debug.enable_unstable,
conf.debug.kerberos_credential_injection,
protocol,
) {
return Ok(CredentialInjectionKerberosConfigs {
server: None,
client: None,
});
}

match credential_injection_kdc.client_acceptor_protocol()? {
CredentialInjectionClientAcceptorProtocol::Kerberos => {
credential_injection_kdc.server_kerberos_config(client_addr).map(Some)
}
CredentialInjectionClientAcceptorProtocol::Ntlm => {
debug!(
jti = %credential_injection_kdc.jti(),
"Credential-injection Kerberos acceptor disabled for NTLM target credential"
);
Ok(None)
}
}
Ok(CredentialInjectionKerberosConfigs {
server: Some(credential_injection_kdc.server_kerberos_config(client_addr)?),
client: Some(ironrdp_connector::credssp::KerberosConfig {
// TODO: Provision the target KDC through connection options after the store is generalized.
// See https://github.com/Devolutions/devolutions-gateway/pull/1862#pullrequestreview-4774565673.
kdc_proxy_url: None,
hostname: gateway_hostname.to_owned(),
}),
})
}

#[instrument(name = "server_credssp", level = "debug", ret, skip_all)]
Expand Down Expand Up @@ -645,3 +664,26 @@ where
framed.write_all(&payload).await.context("failed to write PDU")?;
Ok(())
}

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

// The two CredSSP legs are built from this single decision, so agreement is guaranteed by
// construction. These cases pin the decision itself (the bug was the two legs deciding
// independently): Kerberos requires BOTH opt-in flags AND a domain-qualified target.
#[test]
fn injection_uses_kerberos_requires_optin_and_domain_qualified_target() {
use CredentialInjectionClientAcceptorProtocol::{Kerberos, Ntlm};

assert!(injection_uses_kerberos(true, true, Kerberos));

// Either opt-in off => NTLM, even for a Kerberos-capable target.
assert!(!injection_uses_kerberos(false, true, Kerberos));
assert!(!injection_uses_kerberos(true, false, Kerberos));

// Domainless target can't get a ticket => NTLM regardless of the flags.
assert!(!injection_uses_kerberos(true, true, Ntlm));
assert!(!injection_uses_kerberos(false, false, Ntlm));
}
}
Loading