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

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

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

9 changes: 8 additions & 1 deletion devolutions-gateway/openapi/gateway-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'
Expand Down

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

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). 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)]
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
86 changes: 86 additions & 0 deletions devolutions-gateway/src/credential/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<crate::target_addr::TargetAddr>,
}

/// Cleartext credential received from the API, used for deserialization only.
Expand Down Expand Up @@ -105,13 +111,34 @@ 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<crate::target_addr::TargetAddr>,
Comment on lines +114 to +117
}

fn deserialize_optional_kdc_addr<'de, D>(deserializer: D) -> Result<Option<crate::target_addr::TargetAddr>, D::Error>
where
D: serde::Deserializer<'de>,
{
let krb_kdc = Option::<crate::target_addr::TargetAddr>::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 {
fn encrypt(self) -> anyhow::Result<AppCredentialMapping> {
Ok(AppCredentialMapping {
proxy: self.proxy.encrypt()?,
target: self.target.encrypt()?,
krb_kdc: self.krb_kdc,
})
}
}
Expand Down Expand Up @@ -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::<CleartextAppCredentialMapping>(&mapping)
.expect_err("unsupported KDC protocol should be rejected");

assert!(error.to_string().contains("unsupported KDC protocol: http"));
}
}
Loading
Loading