From b7e5d7ae7c1a6aaba1f38dc27d125753989e5583 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 21 Jul 2026 14:33:23 -0400 Subject: [PATCH 1/3] fix(dgw): use provisioned KDC for credential injection Route target-side Kerberos authentication through the KDC provisioned with the credential mapping, and add an explicit unstable opt-in for the client-facing Kerberos acceptor. Issue: DVLS-14697 --- config_schema.json | 5 + devolutions-gateway/src/config.rs | 116 ++---------------- devolutions-gateway/src/credential/mod.rs | 86 +++++++++++++ .../src/credential_injection_kdc.rs | 6 + devolutions-gateway/src/rd_clean_path.rs | 17 +-- devolutions-gateway/src/rdp_proxy.rs | 38 +++--- 6 files changed, 134 insertions(+), 134 deletions(-) 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..db89d7366 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). The real + /// KDC used for the target leg comes from the provisioned credentials, not from 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/credential/mod.rs b/devolutions-gateway/src/credential/mod.rs index 166be9412..ef9e668db 100644 --- a/devolutions-gateway/src/credential/mod.rs +++ b/devolutions-gateway/src/credential/mod.rs @@ -12,6 +12,7 @@ use async_trait::async_trait; use devolutions_gateway_task::{ShutdownSignal, Task}; use parking_lot::Mutex; use secrecy::ExposeSecret as _; +use serde::{Deserialize as _, de}; use uuid::Uuid; use self::crypto::MASTER_KEY; @@ -66,6 +67,11 @@ impl AppCredential { pub struct AppCredentialMapping { pub proxy: AppCredential, pub target: AppCredential, + /// Real KDC for Kerberos-enforced injection, provisioned alongside the credentials. + /// + /// The Gateway's target-side CredSSP leg uses this to obtain a real Kerberos ticket; realm is + /// derived from the target credential, so only the address is stored. `None` for NTLM targets. + pub krb_kdc: Option, } /// Cleartext credential received from the API, used for deserialization only. @@ -105,6 +111,26 @@ pub struct CleartextAppCredentialMapping { pub proxy: CleartextAppCredential, #[serde(rename = "target_credential")] pub target: CleartextAppCredential, + /// Real KDC for Kerberos-enforced injection, provisioned in the same call as the credentials. + /// Optional: absent for NTLM targets. + #[serde(default, deserialize_with = "deserialize_optional_kdc_addr")] + pub krb_kdc: Option, +} + +fn deserialize_optional_kdc_addr<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let krb_kdc = Option::::deserialize(deserializer)?; + + if let Some(krb_kdc) = &krb_kdc { + match krb_kdc.scheme() { + "tcp" | "udp" => {} + unsupported => return Err(de::Error::custom(format!("unsupported KDC protocol: {unsupported}"))), + } + } + + Ok(krb_kdc) } impl CleartextAppCredentialMapping { @@ -112,6 +138,7 @@ impl CleartextAppCredentialMapping { Ok(AppCredentialMapping { proxy: self.proxy.encrypt()?, target: self.target.encrypt()?, + krb_kdc: self.krb_kdc, }) } } @@ -234,3 +261,62 @@ async fn cleanup_task(handle: CredentialStoreHandle, mut shutdown_signal: Shutdo debug!("Task terminated"); } + +#[cfg(test)] +mod tests { + use super::CleartextAppCredentialMapping; + + const CREDENTIAL_MAPPING: &str = r#" + { + "proxy_credential": { + "kind": "username-password", + "username": "proxy", + "password": "proxy-password" + }, + "target_credential": { + "kind": "username-password", + "username": "target", + "password": "target-password" + } + } + "#; + + #[test] + fn credential_mapping_accepts_supported_kdc_protocols() { + for krb_kdc in ["tcp://dc.example.com:88", "udp://dc.example.com:88"] { + let mapping = CREDENTIAL_MAPPING.replace( + "\n }\n ", + &format!(",\n \"krb_kdc\": \"{krb_kdc}\"\n }}\n "), + ); + + let mapping: CleartextAppCredentialMapping = + serde_json::from_str(&mapping).expect("supported KDC protocol should deserialize"); + + assert_eq!( + mapping.krb_kdc.expect("KDC address should be present").as_str(), + krb_kdc + ); + } + } + + #[test] + fn credential_mapping_allows_missing_kdc() { + let mapping: CleartextAppCredentialMapping = + serde_json::from_str(CREDENTIAL_MAPPING).expect("credential mapping without KDC should deserialize"); + + assert!(mapping.krb_kdc.is_none()); + } + + #[test] + fn credential_mapping_rejects_unsupported_kdc_protocol() { + let mapping = CREDENTIAL_MAPPING.replace( + "\n }\n ", + ",\n \"krb_kdc\": \"http://dc.example.com:88\"\n }\n ", + ); + + let error = serde_json::from_str::(&mapping) + .expect_err("unsupported KDC protocol should be rejected"); + + assert!(error.to_string().contains("unsupported KDC protocol: http")); + } +} diff --git a/devolutions-gateway/src/credential_injection_kdc.rs b/devolutions-gateway/src/credential_injection_kdc.rs index b789c2b74..fb181cd68 100644 --- a/devolutions-gateway/src/credential_injection_kdc.rs +++ b/devolutions-gateway/src/credential_injection_kdc.rs @@ -165,6 +165,11 @@ impl CredentialInjectionKdc { &self.credential_mapping.target } + /// Real KDC for the target-side CredSSP leg, provisioned alongside the injected credentials. + pub(crate) fn krb_kdc(&self) -> Option<&crate::target_addr::TargetAddr> { + self.credential_mapping.krb_kdc.as_ref() + } + /// Selects the CredSSP acceptor backend Gateway should present to the RDP client. /// /// The acceptor side must mirror the target-side auth package. @@ -630,6 +635,7 @@ mod tests { username: target_username.to_owned(), password: SecretString::from("pwd"), }, + krb_kdc: None, } } diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index 2bf2d3366..d22f11003 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -436,19 +436,10 @@ async fn handle_with_credential_injection( &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 krb_client_config = crate::rdp_proxy::credential_injection_kerberos_client_config( + credential_injection_kdc.krb_kdc(), + &gateway_hostname, + )?; let server_credssp_fut = crate::rdp_proxy::perform_credssp_as_client( &mut server_framed, diff --git a/devolutions-gateway/src/rdp_proxy.rs b/devolutions-gateway/src/rdp_proxy.rs index 49ab7e755..451cfd8bd 100644 --- a/devolutions-gateway/src/rdp_proxy.rs +++ b/devolutions-gateway/src/rdp_proxy.rs @@ -20,6 +20,7 @@ use crate::kdc_connector::KdcConnector; use crate::proxy::Proxy; use crate::session::{DisconnectInterest, SessionInfo, SessionMessageSender}; use crate::subscriber::SubscriberSender; +use crate::target_addr::TargetAddr; #[derive(TypedBuilder)] pub struct RdpProxy { @@ -134,19 +135,8 @@ where &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 krb_client_config = + credential_injection_kerberos_client_config(credential_injection_kdc.krb_kdc(), &gateway_hostname)?; let server_credssp_fut = perform_credssp_as_client( &mut server_framed, @@ -363,12 +353,32 @@ where handshake_result } +/// Builds the target-side (Gateway-as-client) Kerberos config for credential injection. +/// +/// The KDC is the one DVLS provisioned alongside the injected credentials (from the gateway's +/// registered KDC). No KDC means the target leg authenticates over NTLM. +pub(crate) fn credential_injection_kerberos_client_config( + krb_kdc: Option<&TargetAddr>, + gateway_hostname: &str, +) -> anyhow::Result> { + let Some(krb_kdc) = krb_kdc else { + return Ok(None); + }; + + let kdc_proxy_url = url::Url::parse(krb_kdc.as_str()).context("parse provisioned krb_kdc as URL")?; + + Ok(Some(ironrdp_connector::credssp::KerberosConfig { + kdc_proxy_url: Some(kdc_proxy_url), + hostname: gateway_hostname.to_owned(), + })) +} + pub(crate) fn credential_injection_kerberos_server_config( conf: &Conf, client_addr: SocketAddr, credential_injection_kdc: &CredentialInjectionKdc, ) -> anyhow::Result> { - if !conf.debug.enable_unstable || conf.debug.kerberos.is_none() { + if !conf.debug.enable_unstable || !conf.debug.kerberos_credential_injection { return Ok(None); } From a56258fc8072bc75629361a9c1dcfd818205dddd Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 21 Jul 2026 14:33:47 -0400 Subject: [PATCH 2/3] feat(dgw): expose credential injection KDC in preflight Add the optional krb_kdc field to the provision-credentials OpenAPI contract and synchronize the generated .NET and TypeScript client models. Issue: DVLS-14697 --- .../openapi/dotnet-client/docs/PreflightOperation.md | 1 + .../Model/PreflightOperation.cs | 12 +++++++++++- devolutions-gateway/openapi/gateway-api.yaml | 9 ++++++++- .../ts-angular-client/model/preflightOperation.ts | 4 ++++ devolutions-gateway/src/openapi.rs | 4 ++++ 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md b/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md index 926754235..36aa61953 100644 --- a/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md +++ b/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md @@ -7,6 +7,7 @@ Name | Type | Description | Notes **HostToResolve** | **string** | The hostname to perform DNS resolution on. Required for \"resolve-host\" kind. | [optional] **Id** | **Guid** | Unique ID identifying the preflight operation. | **Kind** | **PreflightOperationKind** | | +**KrbKdc** | **string** | Real KDC address (e.g. \"tcp://dc.example.com:88\") for Kerberos-enforced credential injection. Optional for \"provision-credentials\" kind; omit for NTLM targets. | [optional] **ProxyCredential** | [**AppCredential**](AppCredential.md) | | [optional] **TargetCredential** | [**AppCredential**](AppCredential.md) | | [optional] **TimeToLive** | **int?** | Minimum persistance duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\" and \"provision-credentials\" kinds. | [optional] diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs index f2d1e9b56..4cc0bb794 100644 --- a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs @@ -50,15 +50,17 @@ protected PreflightOperation() { } /// The hostname to perform DNS resolution on. Required for \"resolve-host\" kind.. /// Unique ID identifying the preflight operation. (required). /// kind (required). + /// Real KDC address (e.g. \"tcp://dc.example.com:88\") for Kerberos-enforced credential injection. Optional for \"provision-credentials\" kind; omit for NTLM targets.. /// proxyCredential. /// targetCredential. /// Minimum persistance duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\" and \"provision-credentials\" kinds.. /// The token to be stored on the proxy-side. Required for \"provision-token\" and \"provision-credentials\" kinds.. - public PreflightOperation(string hostToResolve = default(string), Guid id = default(Guid), PreflightOperationKind kind = default(PreflightOperationKind), AppCredential proxyCredential = default(AppCredential), AppCredential targetCredential = default(AppCredential), int? timeToLive = default(int?), string token = default(string)) + public PreflightOperation(string hostToResolve = default(string), Guid id = default(Guid), PreflightOperationKind kind = default(PreflightOperationKind), string krbKdc = default(string), AppCredential proxyCredential = default(AppCredential), AppCredential targetCredential = default(AppCredential), int? timeToLive = default(int?), string token = default(string)) { this.Id = id; this.Kind = kind; this.HostToResolve = hostToResolve; + this.KrbKdc = krbKdc; this.ProxyCredential = proxyCredential; this.TargetCredential = targetCredential; this.TimeToLive = timeToLive; @@ -79,6 +81,13 @@ protected PreflightOperation() { } [DataMember(Name = "id", IsRequired = true, EmitDefaultValue = true)] public Guid Id { get; set; } + /// + /// Real KDC address (e.g. \"tcp://dc.example.com:88\") for Kerberos-enforced credential injection. Optional for \"provision-credentials\" kind; omit for NTLM targets. + /// + /// Real KDC address (e.g. \"tcp://dc.example.com:88\") for Kerberos-enforced credential injection. Optional for \"provision-credentials\" kind; omit for NTLM targets. + [DataMember(Name = "krb_kdc", EmitDefaultValue = true)] + public string KrbKdc { get; set; } + /// /// Gets or Sets ProxyCredential /// @@ -116,6 +125,7 @@ public override string ToString() sb.Append(" HostToResolve: ").Append(HostToResolve).Append("\n"); sb.Append(" Id: ").Append(Id).Append("\n"); sb.Append(" Kind: ").Append(Kind).Append("\n"); + sb.Append(" KrbKdc: ").Append(KrbKdc).Append("\n"); sb.Append(" ProxyCredential: ").Append(ProxyCredential).Append("\n"); sb.Append(" TargetCredential: ").Append(TargetCredential).Append("\n"); sb.Append(" TimeToLive: ").Append(TimeToLive).Append("\n"); diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 8fabd66fe..8325a0636 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -7,7 +7,7 @@ info: email: infos@devolutions.net license: name: MIT/Apache-2.0 - version: 2026.1.2 + version: 2026.2.3 paths: /jet/config: patch: @@ -1785,6 +1785,13 @@ components: description: Unique ID identifying the preflight operation. kind: $ref: '#/components/schemas/PreflightOperationKind' + krb_kdc: + type: string + description: |- + Real KDC address (e.g. "tcp://dc.example.com:88") for Kerberos-enforced credential injection. + + Optional for "provision-credentials" kind; omit for NTLM targets. + nullable: true proxy_credential: allOf: - $ref: '#/components/schemas/AppCredential' diff --git a/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts b/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts index 440e61c4a..488fd2d6a 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts @@ -21,6 +21,10 @@ export interface PreflightOperation { */ id: string; kind: PreflightOperationKind; + /** + * Real KDC address (e.g. \"tcp://dc.example.com:88\") for Kerberos-enforced credential injection. Optional for \"provision-credentials\" kind; omit for NTLM targets. + */ + krb_kdc?: string | null; proxy_credential?: AppCredential | null; target_credential?: AppCredential | null; /** diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 1e8b3e212..6d6a2b1d2 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -382,6 +382,10 @@ struct PreflightOperation { /// /// Required for "provision-credentials" kind. target_credential: Option, + /// Real KDC address (e.g. "tcp://dc.example.com:88") for Kerberos-enforced credential injection. + /// + /// Optional for "provision-credentials" kind; omit for NTLM targets. + krb_kdc: Option, /// The hostname to perform DNS resolution on. /// /// Required for "resolve-host" kind. From 44aea2b538b2f694678f6416cd20568c8eafecd9 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 21 Jul 2026 15:39:17 -0400 Subject: [PATCH 3/3] fix(dgw): decide credential-injection CredSSP protocol once for both legs Review follow-up (Copilot + end-to-end review). The two CredSSP legs previously chose Kerberos vs NTLM from independent inputs and could disagree (e.g. a provisioned krb_kdc with the opt-in off gave a Kerberos target leg but an NTLM acceptor), which fails the handshake reading one package as the other. - Collapse the two per-leg helpers into a single credential_injection_kerberos_configs that makes one decision (enable_unstable && kerberos_credential_injection && domain-qualified target) and builds both legs from it; require the provisioned KDC when Kerberos is chosen, else NTLM on both legs. - A stray krb_kdc (feature off or domainless target) is now ignored with a warning instead of silently diverging or hard-failing an NTLM-capable session. - Keep the generated PreflightOperation ctor source-compatible by appending the new optional krbKdc parameter last. - Add unit coverage for the protocol decision and the krb_kdc round-trip. --- .../Model/PreflightOperation.cs | 6 +- .../src/credential_injection_kdc.rs | 36 ++++++ devolutions-gateway/src/rd_clean_path.rs | 17 ++- devolutions-gateway/src/rdp_proxy.rs | 119 ++++++++++++------ 4 files changed, 129 insertions(+), 49 deletions(-) diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs index 4cc0bb794..b9ab5ac90 100644 --- a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs @@ -50,21 +50,21 @@ protected PreflightOperation() { } /// The hostname to perform DNS resolution on. Required for \"resolve-host\" kind.. /// Unique ID identifying the preflight operation. (required). /// kind (required). - /// Real KDC address (e.g. \"tcp://dc.example.com:88\") for Kerberos-enforced credential injection. Optional for \"provision-credentials\" kind; omit for NTLM targets.. /// proxyCredential. /// targetCredential. /// Minimum persistance duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\" and \"provision-credentials\" kinds.. /// The token to be stored on the proxy-side. Required for \"provision-token\" and \"provision-credentials\" kinds.. - public PreflightOperation(string hostToResolve = default(string), Guid id = default(Guid), PreflightOperationKind kind = default(PreflightOperationKind), string krbKdc = default(string), AppCredential proxyCredential = default(AppCredential), AppCredential targetCredential = default(AppCredential), int? timeToLive = default(int?), string token = default(string)) + /// Real KDC address (e.g. \"tcp://dc.example.com:88\") for Kerberos-enforced credential injection. Optional for \"provision-credentials\" kind; omit for NTLM targets.. + public PreflightOperation(string hostToResolve = default(string), Guid id = default(Guid), PreflightOperationKind kind = default(PreflightOperationKind), AppCredential proxyCredential = default(AppCredential), AppCredential targetCredential = default(AppCredential), int? timeToLive = default(int?), string token = default(string), string krbKdc = default(string)) { this.Id = id; this.Kind = kind; this.HostToResolve = hostToResolve; - this.KrbKdc = krbKdc; this.ProxyCredential = proxyCredential; this.TargetCredential = targetCredential; this.TimeToLive = timeToLive; this.Token = token; + this.KrbKdc = krbKdc; } /// diff --git a/devolutions-gateway/src/credential_injection_kdc.rs b/devolutions-gateway/src/credential_injection_kdc.rs index fb181cd68..b2381ab37 100644 --- a/devolutions-gateway/src/credential_injection_kdc.rs +++ b/devolutions-gateway/src/credential_injection_kdc.rs @@ -1030,4 +1030,40 @@ mod tests { assert_eq!(kdc.resolve_message_realm(&message), "example.invalid"); } + + #[test] + fn provisioned_krb_kdc_is_exposed_for_the_target_leg() { + let jti = Uuid::new_v4(); + let store = CredentialStoreHandle::new(); + store + .insert( + association_token(jti), + Some(CleartextAppCredentialMapping { + proxy: CleartextAppCredential::UsernamePassword { + username: "proxy@example.invalid".to_owned(), + password: SecretString::from("pwd"), + }, + target: CleartextAppCredential::UsernamePassword { + username: "administrator@example.invalid".to_owned(), + password: SecretString::from("pwd"), + }, + krb_kdc: Some( + crate::target_addr::TargetAddr::parse("tcp://kdc.example.invalid:88", Some(88)) + .expect("valid kdc addr"), + ), + }), + time::Duration::minutes(5), + ) + .expect("credential entry inserts"); + + let entry = store.get(jti).expect("credential entry is indexed by JTI"); + let session = Arc::new(derive_credential_injection_kdc_session("proxy@example.invalid", jti)); + let kdc = CredentialInjectionKdc::from_parts(jti, entry, "target.example".to_owned(), session) + .expect("valid credential-injection KDC"); + + assert_eq!( + kdc.krb_kdc().map(|addr| addr.as_str().to_owned()), + Some("tcp://kdc.example.invalid:88".to_owned()) + ); + } } diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index d22f11003..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,23 +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 = crate::rdp_proxy::credential_injection_kerberos_client_config( - credential_injection_kdc.krb_kdc(), - &gateway_hostname, - )?; - 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 451cfd8bd..d6be533f0 100644 --- a/devolutions-gateway/src/rdp_proxy.rs +++ b/devolutions-gateway/src/rdp_proxy.rs @@ -20,7 +20,6 @@ use crate::kdc_connector::KdcConnector; use crate::proxy::Proxy; use crate::session::{DisconnectInterest, SessionInfo, SessionMessageSender}; use crate::subscriber::SubscriberSender; -use crate::target_addr::TargetAddr; #[derive(TypedBuilder)] pub struct RdpProxy { @@ -122,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, @@ -130,21 +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 = - credential_injection_kerberos_client_config(credential_injection_kdc.krb_kdc(), &gateway_hostname)?; - 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, ); @@ -353,47 +350,72 @@ where handshake_result } -/// Builds the target-side (Gateway-as-client) Kerberos config for credential injection. +/// Kerberos configs for the two CredSSP legs of a credential-injection session. /// -/// The KDC is the one DVLS provisioned alongside the injected credentials (from the gateway's -/// registered KDC). No KDC means the target leg authenticates over NTLM. -pub(crate) fn credential_injection_kerberos_client_config( - krb_kdc: Option<&TargetAddr>, - gateway_hostname: &str, -) -> anyhow::Result> { - let Some(krb_kdc) = krb_kdc else { - return Ok(None); - }; - - let kdc_proxy_url = url::Url::parse(krb_kdc.as_str()).context("parse provisioned krb_kdc as URL")?; +/// `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, +} - Ok(Some(ironrdp_connector::credssp::KerberosConfig { - kdc_proxy_url: Some(kdc_proxy_url), - hostname: gateway_hostname.to_owned(), - })) +/// 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) } -pub(crate) fn credential_injection_kerberos_server_config( +/// Build the Kerberos config for both CredSSP legs from the single [`injection_uses_kerberos`] +/// decision. When Kerberos is chosen the DVLS-provisioned KDC is required; 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_credential_injection { - return Ok(None); - } - - match credential_injection_kdc.client_acceptor_protocol()? { - CredentialInjectionClientAcceptorProtocol::Kerberos => { - credential_injection_kdc.server_kerberos_config(client_addr).map(Some) - } - CredentialInjectionClientAcceptorProtocol::Ntlm => { - debug!( +) -> anyhow::Result { + let protocol = credential_injection_kdc.client_acceptor_protocol()?; + + if !injection_uses_kerberos( + conf.debug.enable_unstable, + conf.debug.kerberos_credential_injection, + protocol, + ) { + // A KDC only makes sense for Kerberos. If DVLS provisioned one anyway (feature off, or a + // domainless target that can't get a ticket), ignore it instead of failing an otherwise-fine + // NTLM session — but say so, since it signals a provisioning mismatch. + if credential_injection_kdc.krb_kdc().is_some() { + warn!( jti = %credential_injection_kdc.jti(), - "Credential-injection Kerberos acceptor disabled for NTLM target credential" + "Ignoring provisioned krb_kdc: credential injection is using NTLM (feature disabled or domainless target)" ); - Ok(None) } + return Ok(CredentialInjectionKerberosConfigs { + server: None, + client: None, + }); } + + let krb_kdc = credential_injection_kdc + .krb_kdc() + .context("Kerberos credential injection requires a provisioned KDC (krb_kdc), but none was provisioned")?; + let kdc_proxy_url = url::Url::parse(krb_kdc.as_str()).context("parse provisioned krb_kdc as URL")?; + + Ok(CredentialInjectionKerberosConfigs { + server: Some(credential_injection_kdc.server_kerberos_config(client_addr)?), + client: Some(ironrdp_connector::credssp::KerberosConfig { + kdc_proxy_url: Some(kdc_proxy_url), + hostname: gateway_hostname.to_owned(), + }), + }) } #[instrument(name = "server_credssp", level = "debug", ret, skip_all)] @@ -655,3 +677,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)); + } +}