diff --git a/config_schema.json b/config_schema.json index dadad26e1..0ac6594c2 100644 --- a/config_schema.json +++ b/config_schema.json @@ -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, diff --git a/devolutions-gateway/src/config.rs b/devolutions-gateway/src/config.rs index 893263bdc..36765fab7 100644 --- a/devolutions-gateway/src/config.rs +++ b/devolutions-gateway/src/config.rs @@ -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 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, - /// 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, - /// 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>, - /// 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, - } - - 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, - } - /// (Unstable) QUIC-based agent tunnel configuration #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "PascalCase")] @@ -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, + /// 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)] @@ -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(), } } @@ -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() } } @@ -2418,13 +2327,6 @@ pub mod dto { } } - fn serialize_secret_string(value: &SecretString, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(value.expose_secret()) - } - fn serialize_opt_secret_string(value: &Option, serializer: S) -> Result where S: serde::Serializer, diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index 2bf2d3366..d36ebabe4 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -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()); @@ -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, ); diff --git a/devolutions-gateway/src/rdp_proxy.rs b/devolutions-gateway/src/rdp_proxy.rs index 49ab7e755..856210fa1 100644 --- a/devolutions-gateway/src/rdp_proxy.rs +++ b/devolutions-gateway/src/rdp_proxy.rs @@ -121,7 +121,8 @@ 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, @@ -129,32 +130,18 @@ where 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, ); @@ -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, + pub client: Option, +} + +/// 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> { - if !conf.debug.enable_unstable || conf.debug.kerberos.is_none() { - return Ok(None); +) -> anyhow::Result { + 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)] @@ -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)); + } +}