From ab9d1a7aefa45d871a878ca30c751743dbdc721f Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Wed, 29 Jul 2026 23:06:08 -0400 Subject: [PATCH 01/36] refactor(dgw): CredSSP enclosure, supermarket store, thin synthetic KDC Split rdp_proxy CredSSP into its own module. Delete CredentialService: DgwState holds ProvisioningStore + SyntheticKdcRegistry. from_provisioned builds PreparedCredentialInjection; register_if_kerberos publishes. take() consumes groceries once. Synthetic KDC keeps only fake-KDC runtime; credentials and target_kdc stay on the dish. --- devolutions-gateway/src/api/kdc_proxy.rs | 22 +- devolutions-gateway/src/api/preflight.rs | 15 +- devolutions-gateway/src/api/rdp.rs | 12 +- .../src/credential_injection.rs | 811 +++++++++++++ .../src/credential_injection_kdc.rs | 1073 ----------------- devolutions-gateway/src/generic_client.rs | 21 +- devolutions-gateway/src/lib.rs | 11 +- devolutions-gateway/src/listener.rs | 3 +- devolutions-gateway/src/ngrok.rs | 3 +- devolutions-gateway/src/provisioning.rs | 64 +- devolutions-gateway/src/rd_clean_path.rs | 38 +- devolutions-gateway/src/rdp_proxy.rs | 846 ------------- devolutions-gateway/src/rdp_proxy/credssp.rs | 571 +++++++++ devolutions-gateway/src/rdp_proxy/mod.rs | 278 +++++ devolutions-gateway/src/service.rs | 12 +- 15 files changed, 1762 insertions(+), 2018 deletions(-) create mode 100644 devolutions-gateway/src/credential_injection.rs delete mode 100644 devolutions-gateway/src/credential_injection_kdc.rs delete mode 100644 devolutions-gateway/src/rdp_proxy.rs create mode 100644 devolutions-gateway/src/rdp_proxy/credssp.rs create mode 100644 devolutions-gateway/src/rdp_proxy/mod.rs diff --git a/devolutions-gateway/src/api/kdc_proxy.rs b/devolutions-gateway/src/api/kdc_proxy.rs index 865fc369f..ad6aebd8c 100644 --- a/devolutions-gateway/src/api/kdc_proxy.rs +++ b/devolutions-gateway/src/api/kdc_proxy.rs @@ -5,9 +5,8 @@ use picky_krb::messages::KdcProxyMessage; use uuid::Uuid; use crate::DgwState; -use crate::credential_injection_kdc::{ - CredentialInjectionKdcInterception, CredentialInjectionKdcRequest, CredentialInjectionKdcResolveError, - kdc_proxy_message_realm, +use crate::credential_injection::{ + CredentialInjectionKdcInterception, CredentialInjectionKdcRequest, kdc_proxy_message_realm, }; use crate::extract::KdcToken; use crate::http::HttpError; @@ -22,7 +21,7 @@ pub fn make_router(state: DgwState) -> Router { async fn kdc_proxy( State(DgwState { conf_handle, - credentials, + synthetic_kdc_registry, agent_tunnel_handle, .. }): State, @@ -47,7 +46,9 @@ async fn kdc_proxy( KdcDestination::Inject { jti } => { enforce_credential_injection_enabled(jti, conf.debug.enable_unstable)?; - let kdc = credentials.kdc_for(jti).map_err(credential_injection_resolve_error)?; + let kdc = synthetic_kdc_registry + .get(jti) + .ok_or_else(|| HttpError::bad_request().msg("no live synthetic KDC published for this session"))?; debug!( jti = %kdc.jti(), @@ -92,17 +93,6 @@ async fn kdc_proxy( } } -fn credential_injection_resolve_error(error: CredentialInjectionKdcResolveError) -> HttpError { - match error { - CredentialInjectionKdcResolveError::BuildKdcConfig { .. } => HttpError::internal() - .with_msg("credential-injection KDC could not be initialized") - .build(error), - _ => HttpError::bad_request() - .with_msg("credential-injection state is not available") - .build(error), - } -} - // Forwards the request to the real KDC indicated by the token (or by the debug override) and // returns the response wrapped as a `KdcProxyMessage`. // diff --git a/devolutions-gateway/src/api/preflight.rs b/devolutions-gateway/src/api/preflight.rs index 5598d44ec..1477f737d 100644 --- a/devolutions-gateway/src/api/preflight.rs +++ b/devolutions-gateway/src/api/preflight.rs @@ -11,10 +11,9 @@ use uuid::Uuid; use crate::DgwState; use crate::config::Conf; -use crate::credential_injection_kdc::CredentialService; use crate::extract::PreflightScope; use crate::http::HttpError; -use crate::provisioning::InsertError; +use crate::provisioning::{InsertError, ProvisioningStore}; use crate::session::SessionMessageSender; const OP_GET_VERSION: &str = "get-version"; @@ -204,7 +203,7 @@ pub(super) async fn post_preflight( State(DgwState { conf_handle, sessions, - credentials, + provisioning, .. }): State, _scope: PreflightScope, @@ -231,13 +230,13 @@ pub(super) async fn post_preflight( let outputs = outputs.clone(); let conf = conf_handle.get_conf(); let sessions = sessions.clone(); - let credentials = credentials.clone(); + let provisioning = provisioning.clone(); async move { let operation_id = operation.id; trace!(%operation.id, "Process preflight operation"); - if let Err(error) = handle_operation(operation, &outputs, &conf, &sessions, &credentials).await { + if let Err(error) = handle_operation(operation, &outputs, &conf, &sessions, &provisioning).await { outputs.push(PreflightOutput { operation_id, kind: PreflightOutputKind::Alert { @@ -264,7 +263,7 @@ async fn handle_operation( outputs: &Outputs, conf: &Conf, sessions: &SessionMessageSender, - credentials: &CredentialService, + provisioning: &ProvisioningStore, ) -> Result<(), PreflightError> { match operation.kind.as_str() { OP_GET_VERSION => outputs.push(PreflightOutput { @@ -355,7 +354,7 @@ async fn handle_operation( })?; } - let replaced = credentials + let replaced = provisioning .insert_credentials(token, mapping, time_to_live) .inspect_err(|error| warn!(%operation.id, error = format!("{error:#}"), "Failed to insert credentials")) .map_err(|error| match error { @@ -397,7 +396,7 @@ async fn handle_operation( PreflightError::new(PreflightAlertStatus::InvalidParams, format!("invalid token: {error:#}")) })?; - let replaced = credentials.insert_connection_options(jti, connection_options, time_to_live); + let replaced = provisioning.insert_connection_options(jti, connection_options, time_to_live); if replaced { outputs.push(PreflightOutput { diff --git a/devolutions-gateway/src/api/rdp.rs b/devolutions-gateway/src/api/rdp.rs index b3d45dbcb..29e5d161e 100644 --- a/devolutions-gateway/src/api/rdp.rs +++ b/devolutions-gateway/src/api/rdp.rs @@ -25,7 +25,8 @@ pub async fn handler( subscriber_tx, recordings, shutdown_signal, - credentials, + provisioning, + synthetic_kdc_registry, agent_tunnel_handle, .. }): State, @@ -46,7 +47,8 @@ pub async fn handler( subscriber_tx, recordings.active_recordings, source_addr, - credentials, + provisioning, + synthetic_kdc_registry, agent_tunnel_handle, ) .instrument(span) @@ -66,7 +68,8 @@ async fn handle_socket( subscriber_tx: SubscriberSender, active_recordings: Arc, source_addr: SocketAddr, - credentials: crate::credential_injection_kdc::CredentialService, + provisioning: crate::provisioning::ProvisioningStore, + synthetic_kdc_registry: crate::credential_injection::SyntheticKdcRegistry, agent_tunnel_handle: Option>, ) { let (stream, close_handle) = crate::ws::handle( @@ -84,7 +87,8 @@ async fn handle_socket( sessions, subscriber_tx, &active_recordings, - &credentials, + &provisioning, + &synthetic_kdc_registry, agent_tunnel_handle, ) .await; diff --git a/devolutions-gateway/src/credential_injection.rs b/devolutions-gateway/src/credential_injection.rs new file mode 100644 index 000000000..7a2341a7f --- /dev/null +++ b/devolutions-gateway/src/credential_injection.rs @@ -0,0 +1,811 @@ +//! Credential-injection runtime: groceries → dish → synthetic KDC pass window. +//! +//! - Provisioned data lives in [`crate::provisioning::ProvisioningStore`] (supermarket). +//! - [`CredentialInjection`] is built by the RDP path from those groceries (chef). +//! - [`SyntheticKdcRegistry`] is the pass window: RDP publishes, `/jet/KdcProxy` looks up only. + +use std::collections::HashMap; +use std::fmt; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context as _; +use chacha20poly1305::aead::OsRng; +use chacha20poly1305::aead::rand_core::RngCore as _; +use ironrdp_connector::sspi; +use ironrdp_connector::sspi::generator::NetworkRequest; +use parking_lot::Mutex; +use picky_krb::messages::KdcProxyMessage; +use secrecy::{ExposeSecret as _, SecretBox, SecretString}; +use thiserror::Error; +use url::Url; +use uuid::Uuid; + +use crate::credential::{AppCredential, AppCredentialMapping}; +use crate::provisioning::ProvisioningEntry; +#[cfg(test)] +use crate::provisioning::ProvisioningStore; + +// The reserved `.invalid` TLD (RFC 6761) lets sspi-rs CredSSP server emit "KDC requests" that +// never leave the process: `intercept_network_request` recognises this hostname and dispatches +// the message into the in-process `kdc` server below. +// +// TODO(sspi-rs#664): replace this URL-trampoline with a pluggable KDC dispatcher trait once +// sspi-rs ships the API — see https://github.com/Devolutions/sspi-rs/issues/664. +const IN_PROCESS_KDC_HOST: &str = "cred.invalid"; + +/// In-process synthetic KDC for one Kerberos credential-injection session. +/// +/// Published to [`SyntheticKdcRegistry`] for `/jet/KdcProxy`. Holds only what the fake KDC and +/// CredSSP server-leg intercept need — not proxy/target passwords or routing bags. +pub(crate) struct CredentialInjectionKdc { + jti: Uuid, + target_hostname: String, + realm: String, + acceptor_principal_name: String, + acceptor_password: SecretString, + acceptor_long_term_key: SecretBox>, + // Built once from acceptor + proxy material; kdc crate API takes this by ref on each message. + kdc_config: kdc::config::KerberosServer, +} + +#[derive(Debug, Error)] +pub(crate) enum CredentialInjectionKdcResolveError { + #[error("credential-injection state is not available for {jti}")] + NonInjectionCredential { jti: Uuid }, + #[error("association token for {jti} is not valid for credential injection")] + InvalidAssociationToken { + jti: Uuid, + #[source] + source: anyhow::Error, + }, + #[error("credential-injection KDC config could not be initialized for {jti}")] + BuildKdcConfig { + jti: Uuid, + #[source] + source: anyhow::Error, + }, + #[error("Kerberos credential injection requires target connection option krb_kdc for {jti}")] + MissingKrbKdc { jti: Uuid }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("expected: {expected}, got: {actual}")] +pub(crate) struct RealmMismatch { + pub(crate) expected: String, + pub(crate) actual: String, +} + +#[derive(Debug)] +pub(crate) enum CredentialInjectionKdcInterception { + Intercepted(Vec), + NotInjectionRequest, + NotInjectionRealm(RealmMismatch), +} + +/// Session-scoped credential injection. Holding [`CredentialInjection::Kerberos`] proves the +/// synthetic KDC is registered in [`SyntheticKdcRegistry`] for this connection. +/// +/// Build path: [`CredentialInjection::from_provisioned`] → [`PreparedCredentialInjection`] → +/// [`PreparedCredentialInjection::register_if_kerberos`]. +pub(crate) enum CredentialInjection { + Kerberos( + KerberosCredentialInjection, + #[expect(dead_code, reason = "RAII lease: Drop unpublishes the synthetic KDC")] SyntheticKdcRegistration, + ), + Ntlm(NtlmCredentialInjection), +} + +/// Kerberos dish: credentials + real KDC address + shared synthetic KDC. +pub(crate) struct KerberosCredentialInjection { + credential_mapping: AppCredentialMapping, + target_kdc: Url, + synthetic: Arc, +} + +/// Chef output: protocol chosen; synthetic KDC built if needed, not yet published. +#[derive(Debug)] +pub(crate) enum PreparedCredentialInjection { + Kerberos(KerberosCredentialInjection), + Ntlm(NtlmCredentialInjection), +} + +impl PreparedCredentialInjection { + /// Publish the synthetic KDC when this is Kerberos; NTLM is a no-op pass-through. + pub(crate) fn register_if_kerberos(self, registry: &SyntheticKdcRegistry) -> CredentialInjection { + match self { + Self::Kerberos(injection) => { + let registration = registry.register(Arc::clone(&injection.synthetic)); + debug!( + jti = %injection.synthetic.jti(), + "registered synthetic KDC for credential-injection session" + ); + CredentialInjection::Kerberos(injection, registration) + } + Self::Ntlm(injection) => CredentialInjection::Ntlm(injection), + } + } +} + +impl KerberosCredentialInjection { + pub(crate) fn synthetic_kdc(&self) -> &CredentialInjectionKdc { + &self.synthetic + } + + pub(crate) fn target_kdc(&self) -> &Url { + &self.target_kdc + } +} + +impl fmt::Debug for KerberosCredentialInjection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("KerberosCredentialInjection") + .field("target_kdc", &self.target_kdc) + .field("synthetic", &self.synthetic) + .finish_non_exhaustive() + } +} + +/// NTLM injection carries credentials only — no synthetic KDC is published. +#[derive(Debug)] +pub(crate) struct NtlmCredentialInjection { + jti: Uuid, + credential_mapping: AppCredentialMapping, +} + +impl NtlmCredentialInjection { + pub(crate) fn jti(&self) -> Uuid { + self.jti + } + + pub(crate) fn proxy_credential(&self) -> &AppCredential { + &self.credential_mapping.proxy + } + + pub(crate) fn target_credential(&self) -> &AppCredential { + &self.credential_mapping.target + } +} + +impl CredentialInjection { + pub(crate) fn jti(&self) -> Uuid { + match self { + Self::Kerberos(k, _) => k.synthetic.jti(), + Self::Ntlm(ntlm) => ntlm.jti(), + } + } + + pub(crate) fn proxy_credential(&self) -> &AppCredential { + match self { + Self::Kerberos(k, _) => &k.credential_mapping.proxy, + Self::Ntlm(ntlm) => ntlm.proxy_credential(), + } + } + + pub(crate) fn target_credential(&self) -> &AppCredential { + match self { + Self::Kerberos(k, _) => &k.credential_mapping.target, + Self::Ntlm(ntlm) => ntlm.target_credential(), + } + } + + pub(crate) fn as_kerberos(&self) -> Option<&KerberosCredentialInjection> { + match self { + Self::Kerberos(k, _) => Some(k), + Self::Ntlm(_) => None, + } + } + + pub(crate) fn uses_kerberos(&self) -> bool { + matches!(self, Self::Kerberos(_, _)) + } + + /// RDP chef: owned groceries → prepared dish. Does not touch the registry. + pub(crate) fn from_provisioned( + jti: Uuid, + credential_entry: ProvisioningEntry, + kerberos_enabled: bool, + ) -> Result { + let ProvisioningEntry { + token, + mapping, + connection_options, + } = credential_entry; + + let mapping = mapping.ok_or_else(|| { + warn!(%jti, "credential-injection state has no mapping"); + CredentialInjectionKdcResolveError::NonInjectionCredential { jti } + })?; + + let target_hostname = crate::token::extract_credential_injection_target_hostname(&token).map_err(|source| { + warn!( + %jti, + error = format!("{source:#}"), + "invalid credential-injection association token" + ); + CredentialInjectionKdcResolveError::InvalidAssociationToken { jti, source } + })?; + + let target_username = match sspi::Username::parse(app_credential_username(&mapping.target)) { + Ok(u) => u, + Err(error) => { + warn!(%jti, error = format!("{error:#}"), "invalid target credential username"); + return Err(CredentialInjectionKdcResolveError::BuildKdcConfig { + jti, + source: anyhow::anyhow!("invalid target credential username: {error}"), + }); + } + }; + + let wants_kerberos = kerberos_enabled && target_username.domain_name().is_some(); + if !wants_kerberos { + return Ok(PreparedCredentialInjection::Ntlm(NtlmCredentialInjection { + jti, + credential_mapping: mapping, + })); + } + + let target_kdc = connection_options + .as_ref() + .and_then(|o| o.krb_kdc()) + .cloned() + .ok_or_else(|| { + warn!(%jti, "Kerberos credential injection requires krb_kdc"); + CredentialInjectionKdcResolveError::MissingKrbKdc { jti } + })?; + + let proxy_username = app_credential_username(&mapping.proxy).to_owned(); + let synthetic = CredentialInjectionKdc::new(jti, target_hostname, &proxy_username, &mapping.proxy) + .map_err(|source| CredentialInjectionKdcResolveError::BuildKdcConfig { jti, source })?; + + Ok(PreparedCredentialInjection::Kerberos(KerberosCredentialInjection { + credential_mapping: mapping, + target_kdc, + synthetic: Arc::new(synthetic), + })) + } +} + +pub(crate) struct CredentialInjectionKdcRequest { + message: KdcProxyMessage, +} + +impl CredentialInjectionKdcRequest { + pub(crate) fn from_token(message: KdcProxyMessage) -> Self { + Self { message } + } + + fn in_process(message: KdcProxyMessage) -> Self { + Self { message } + } +} + +impl fmt::Debug for CredentialInjectionKdc { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CredentialInjectionKdc") + .field("jti", &self.jti) + .field("target_hostname", &self.target_hostname) + .field("realm", &self.realm) + .field("kdc_config", &"") + .finish() + } +} + +impl CredentialInjectionKdc { + fn new( + jti: Uuid, + target_hostname: String, + proxy_username: &str, + proxy_credential: &AppCredential, + ) -> anyhow::Result { + let realm = realm_from_proxy_username(proxy_username, jti); + let acceptor_principal_name = "jet".to_owned(); + let acceptor_password = SecretString::from(hex::encode(random_32_bytes())); + let acceptor_long_term_key = SecretBox::new(Box::new(random_32_bytes())); + let krbtgt_key = random_32_bytes(); + + let kdc_config = build_kdc_config( + &realm, + proxy_credential, + &acceptor_principal_name, + acceptor_password.expose_secret(), + &krbtgt_key, + acceptor_long_term_key.expose_secret(), + )?; + + Ok(Self { + jti, + target_hostname, + realm, + acceptor_principal_name, + acceptor_password, + acceptor_long_term_key, + kdc_config, + }) + } + + pub(crate) fn jti(&self) -> Uuid { + self.jti + } + + pub(crate) fn server_kerberos_config(&self, client_addr: SocketAddr) -> anyhow::Result { + let user = sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8( + &self.acceptor_principal_name, + &self.realm, + self.acceptor_password.expose_secret(), + )); + + let kdc_url = self.in_process_kdc_url()?; + + // The SPN that the client puts on its AP-REQ ticket is the one for the target RDP + // server (`TERMSRV/`). Gateway-as-CredSSP-server is impersonating that target, + // so ServerProperties must claim the same SPN or sspi-rs rejects the ticket. + Ok(sspi::KerberosServerConfig { + kerberos_config: sspi::KerberosConfig { + kdc_url: Some(kdc_url), + client_computer_name: client_addr.to_string(), + }, + server_properties: sspi::kerberos::ServerProperties::new( + &["TERMSRV", &self.target_hostname], + Some(user), + Duration::from_secs(300), + Some(sspi::Secret::new(self.acceptor_long_term_key.expose_secret().clone())), + )?, + }) + } + + pub(crate) fn intercept_network_request( + &self, + request: &NetworkRequest, + ) -> anyhow::Result { + if request.url.host_str() != Some(IN_PROCESS_KDC_HOST) { + return Ok(CredentialInjectionKdcInterception::NotInjectionRequest); + } + + let url_jti = request + .url + .path() + .trim_start_matches('/') + .parse::() + .context("malformed in-process KDC URL")?; + anyhow::ensure!( + url_jti == self.jti, + "in-process KDC URL JTI does not match current CredSSP session", + ); + + debug!( + jti = %self.jti, + scheme = %request.url.scheme(), + "Credential-injection KDC intercepted in-process request" + ); + + let kdc_message = KdcProxyMessage::from_raw(&request.data).context("malformed in-process KDC proxy payload")?; + self.handle_kdc_proxy_request(CredentialInjectionKdcRequest::in_process(kdc_message)) + } + + pub(crate) fn handle_kdc_proxy_request( + &self, + request: CredentialInjectionKdcRequest, + ) -> anyhow::Result { + let request_realm = self.resolve_message_realm(&request.message); + debug!( + jti = %self.jti, + resolved_realm = %request_realm, + "Credential-injection KDC realm resolved" + ); + + if let Some(mismatch) = realm_mismatch(&self.realm, &request_realm) { + return Ok(CredentialInjectionKdcInterception::NotInjectionRealm(mismatch)); + } + + let reply = self.handle_message(request.message)?; + Ok(CredentialInjectionKdcInterception::Intercepted(reply)) + } + + fn in_process_kdc_url(&self) -> anyhow::Result { + Url::parse(&format!("http://{}/{}", IN_PROCESS_KDC_HOST, self.jti)).context("build in-process KDC URL") + } + + fn resolve_message_realm(&self, kdc_proxy_message: &KdcProxyMessage) -> String { + kdc_proxy_message_realm(kdc_proxy_message).unwrap_or_else(|| self.realm.clone()) + } + + fn handle_message(&self, kdc_proxy_message: KdcProxyMessage) -> anyhow::Result> { + let reply = kdc::handle_kdc_proxy_message(kdc_proxy_message, &self.kdc_config, &self.target_hostname) + .context("handle credential-injection KDC message")?; + + reply.to_vec().context("encode credential-injection KDC reply") + } +} + +fn app_credential_username(credential: &AppCredential) -> &str { + match credential { + AppCredential::UsernamePassword { username, password: _ } => username, + } +} + +pub(crate) fn kdc_proxy_message_realm(kdc_proxy_message: &KdcProxyMessage) -> Option { + kdc_proxy_message + .target_domain + .0 + .as_ref() + .map(|realm| realm.0.to_string()) + .filter(|realm| !realm.is_empty()) +} + +fn realm_mismatch(expected: &str, actual: &str) -> Option { + if expected.eq_ignore_ascii_case(actual) { + None + } else { + Some(RealmMismatch { + expected: expected.to_owned(), + actual: actual.to_owned(), + }) + } +} + +fn realm_from_proxy_username(proxy_username: &str, jti: Uuid) -> String { + proxy_username + .split_once('@') + .map(|(_, realm)| realm) + .filter(|realm| !realm.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| synthetic_realm(jti)) +} + +fn build_kdc_config( + realm: &str, + proxy_credential: &AppCredential, + acceptor_principal_name: &str, + acceptor_password: &str, + krbtgt_key: &[u8], + acceptor_long_term_key: &[u8], +) -> anyhow::Result { + let (proxy_user_name, proxy_password) = proxy_credential.decrypt_password()?; + let proxy_user_name = principal_for_realm(&proxy_user_name, realm); + let acceptor_principal_name = principal_for_realm(acceptor_principal_name, realm); + + Ok(kdc::config::KerberosServer { + realm: realm.to_owned(), + users: vec![ + kdc::config::DomainUser { + username: proxy_user_name.clone(), + password: proxy_password.expose_secret().to_owned(), + salt: kerberos_salt(realm, &proxy_user_name), + }, + kdc::config::DomainUser { + username: acceptor_principal_name.clone(), + password: acceptor_password.to_owned(), + salt: kerberos_salt(realm, &acceptor_principal_name), + }, + ], + max_time_skew: 300, + krbtgt_key: krbtgt_key.to_vec(), + ticket_decryption_key: Some(acceptor_long_term_key.to_vec()), + service_user: Some(kdc::config::DomainUser { + username: acceptor_principal_name.clone(), + password: acceptor_password.to_owned(), + salt: kerberos_salt(realm, &acceptor_principal_name), + }), + }) +} + +fn principal_for_realm(user_name: &str, realm: &str) -> String { + if user_name.contains('@') { + user_name.to_owned() + } else { + format!("{user_name}@{realm}") + } +} + +fn kerberos_salt(realm: &str, principal: &str) -> String { + let local_name = principal.split('@').next().unwrap_or(principal); + format!("{}{local_name}", realm.to_ascii_uppercase()) +} + +fn synthetic_realm(jti: Uuid) -> String { + format!("CRED-{}.INVALID", jti.simple()).to_ascii_uppercase() +} + +fn random_32_bytes() -> Vec { + let mut bytes = vec![0u8; 32]; + OsRng.fill_bytes(&mut bytes); + bytes +} + +/// Live synthetic KDCs published by active RDP credential-injection sessions. +/// +/// Pass window between handlers: +/// - RDP path publishes when it starts a Kerberos injection +/// - `/jet/KdcProxy` only looks up; it never builds a KDC from provisioned groceries +/// +/// Entries are connection-scoped via [`SyntheticKdcRegistration`]. Reconnects `register` again +/// (replace + bump generation); a late drop of an older registration is a no-op. +#[derive(Debug, Clone)] +pub struct SyntheticKdcRegistry { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct RegistryInner { + live: HashMap, + next_generation: HashMap, +} + +#[derive(Debug, Clone)] +struct PublishedSyntheticKdc { + generation: u64, + kdc: Arc, +} + +/// RAII lease for a published synthetic KDC. Dropping it unpublishes only this generation. +pub(crate) struct SyntheticKdcRegistration { + registry: SyntheticKdcRegistry, + jti: Uuid, + generation: u64, +} + +impl Drop for SyntheticKdcRegistration { + fn drop(&mut self) { + let mut inner = self.registry.inner.lock(); + let Some(current) = inner.live.get(&self.jti) else { + return; + }; + if current.generation == self.generation { + inner.live.remove(&self.jti); + debug!(jti = %self.jti, generation = self.generation, "unpublished synthetic KDC"); + } + } +} + +impl Default for SyntheticKdcRegistry { + fn default() -> Self { + Self::new() + } +} + +impl SyntheticKdcRegistry { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(RegistryInner::default())), + } + } + + fn allocate_generation(inner: &mut RegistryInner, jti: Uuid) -> u64 { + let slot = inner.next_generation.entry(jti).or_insert(0); + *slot = slot.wrapping_add(1); + *slot + } + + pub(crate) fn register(&self, kdc: Arc) -> SyntheticKdcRegistration { + let jti = kdc.jti(); + let mut inner = self.inner.lock(); + let generation = Self::allocate_generation(&mut inner, jti); + inner.live.insert(jti, PublishedSyntheticKdc { generation, kdc }); + debug!(%jti, generation, "published synthetic KDC"); + SyntheticKdcRegistration { + registry: self.clone(), + jti, + generation, + } + } + + pub(crate) fn get(&self, jti: Uuid) -> Option> { + self.inner.lock().live.get(&jti).map(|e| Arc::clone(&e.kdc)) + } +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use ironrdp_connector::sspi::network_client::NetworkProtocol; + use secrecy::SecretString; + + use super::*; + use crate::credential::{CleartextAppCredential, CleartextAppCredentialMapping}; + use crate::target_connection_options::TargetConnectionOptions; + + fn cleartext_mapping_with_target_username(target_username: &str) -> CleartextAppCredentialMapping { + CleartextAppCredentialMapping { + proxy: CleartextAppCredential::UsernamePassword { + username: "proxy@example.invalid".to_owned(), + password: SecretString::from("pwd"), + }, + target: CleartextAppCredential::UsernamePassword { + username: target_username.to_owned(), + password: SecretString::from("pwd"), + }, + } + } + + fn unsigned_jws(payload: serde_json::Value) -> String { + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode(r#"{"alg":"RS256"}"#); + let payload = engine.encode(serde_json::to_vec(&payload).expect("payload serializes")); + let signature = engine.encode(b"signature"); + format!("{header}.{payload}.{signature}") + } + + fn association_token(jti: Uuid) -> String { + unsigned_jws(serde_json::json!({ + "jti": jti, + "dst_hst": "target.example:3389" + })) + } + + fn kdc_options() -> TargetConnectionOptions { + serde_json::from_value(serde_json::json!({ "krb_kdc": "tcp://dc.example:88" })).expect("options") + } + + fn stock_with_mapping(jti: Uuid, target_username: &str) -> ProvisioningStore { + let store = ProvisioningStore::new(); + store + .insert_credentials( + association_token(jti), + Some(cleartext_mapping_with_target_username(target_username)), + time::Duration::minutes(5), + ) + .expect("insert"); + store + } + + fn dummy_entry(jti: Uuid, target_username: &str) -> ProvisioningEntry { + stock_with_mapping(jti, target_username).take(jti).expect("entry") + } + + fn dummy_kdc(jti: Uuid) -> CredentialInjectionKdc { + let entry = dummy_entry(jti, "target"); + let mapping = entry.mapping.expect("mapping"); + CredentialInjectionKdc::new( + jti, + "target.example".to_owned(), + app_credential_username(&mapping.proxy), + &mapping.proxy, + ) + .expect("valid KDC") + } + + fn network_request(url: &str) -> NetworkRequest { + NetworkRequest { + protocol: NetworkProtocol::Http, + url: Url::parse(url).expect("url"), + data: Vec::new(), + } + } + + #[test] + fn proxy_user_at_realm_is_used_as_realm() { + assert_eq!( + realm_from_proxy_username("proxy@example.invalid", Uuid::new_v4()), + "example.invalid" + ); + } + + #[test] + fn bare_proxy_username_yields_synthetic_realm() { + let jti = Uuid::new_v4(); + assert_eq!(realm_from_proxy_username("just-a-uuid", jti), synthetic_realm(jti)); + } + + #[test] + fn from_provisioned_selects_ntlm_when_kerberos_disabled() { + let jti = Uuid::new_v4(); + let entry = dummy_entry(jti, "administrator@example.invalid"); + let registry = SyntheticKdcRegistry::new(); + let injection = CredentialInjection::from_provisioned(jti, entry, false) + .expect("prepared") + .register_if_kerberos(®istry); + assert!(!injection.uses_kerberos()); + assert!(registry.get(jti).is_none()); + } + + #[test] + fn from_provisioned_selects_ntlm_for_domainless_target() { + let jti = Uuid::new_v4(); + let entry = dummy_entry(jti, "Administrator"); + let registry = SyntheticKdcRegistry::new(); + let injection = CredentialInjection::from_provisioned(jti, entry, true) + .expect("prepared") + .register_if_kerberos(®istry); + assert!(!injection.uses_kerberos()); + } + + #[test] + fn from_provisioned_requires_krb_kdc_for_kerberos() { + let jti = Uuid::new_v4(); + let entry = dummy_entry(jti, "administrator@example.invalid"); + let err = CredentialInjection::from_provisioned(jti, entry, true).expect_err("kdc"); + assert!(matches!(err, CredentialInjectionKdcResolveError::MissingKrbKdc { .. })); + } + + #[test] + fn from_provisioned_publishes_synthetic_kdc_for_kerberos() { + let jti = Uuid::new_v4(); + let store = stock_with_mapping(jti, "administrator@example.invalid"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let entry = store.take(jti).expect("entry"); + assert!(store.take(jti).is_none(), "take consumes groceries"); + let registry = SyntheticKdcRegistry::new(); + let prepared = CredentialInjection::from_provisioned(jti, entry, true).expect("prepared"); + assert!(registry.get(jti).is_none(), "not published until register_if_kerberos"); + let injection = prepared.register_if_kerberos(®istry); + assert!(injection.uses_kerberos()); + assert!(registry.get(jti).is_some()); + assert_eq!( + registry.get(jti).expect("live kdc").jti(), + injection.as_kerberos().expect("kerberos").synthetic_kdc().jti() + ); + } + + #[test] + fn provisioned_krb_kdc_is_carried_on_kerberos_injection() { + // Pins provision → from_provisioned → target_kdc for the CredSSP client leg. + let jti = Uuid::new_v4(); + let store = stock_with_mapping(jti, "administrator@example.invalid"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let entry = store.take(jti).expect("entry"); + let injection = CredentialInjection::from_provisioned(jti, entry, true) + .expect("prepared") + .register_if_kerberos(&SyntheticKdcRegistry::new()); + + assert_eq!( + injection.as_kerberos().expect("kerberos").target_kdc().as_str(), + "tcp://dc.example:88", + "provisioned krb_kdc must be the URL CredSSP will use as kdc_proxy_url", + ); + } + + #[test] + fn registry_replace_and_guarded_drop_keeps_successor() { + let registry = SyntheticKdcRegistry::new(); + let jti = Uuid::new_v4(); + let first = Arc::new(dummy_kdc(jti)); + let first_reg = registry.register(Arc::clone(&first)); + assert!(Arc::ptr_eq(®istry.get(jti).expect("first"), &first)); + let second = Arc::new(dummy_kdc(jti)); + let second_reg = registry.register(Arc::clone(&second)); + assert!(Arc::ptr_eq(®istry.get(jti).expect("second"), &second)); + drop(first_reg); + assert!(Arc::ptr_eq(®istry.get(jti).expect("still second"), &second)); + drop(second_reg); + assert!(registry.get(jti).is_none()); + } + + #[test] + fn kdc_proxy_cannot_invent_from_groceries() { + let jti = Uuid::new_v4(); + let _store = stock_with_mapping(jti, "administrator@example.invalid"); + let registry = SyntheticKdcRegistry::new(); + assert!(registry.get(jti).is_none()); + } + + #[test] + fn new_kdc_uses_jti_in_in_process_url() { + let jti = Uuid::new_v4(); + let kdc = dummy_kdc(jti); + let url = kdc.in_process_kdc_url().expect("url"); + assert!(url.path().contains(&jti.to_string())); + } + + #[test] + fn intercept_ignores_non_injection_host() { + let kdc = dummy_kdc(Uuid::new_v4()); + let result = kdc + .intercept_network_request(&network_request("http://kdc.real.example/path")) + .expect("intercept"); + assert!(matches!( + result, + CredentialInjectionKdcInterception::NotInjectionRequest + )); + } + + #[test] + fn intercept_rejects_malformed_url_path() { + let kdc = dummy_kdc(Uuid::new_v4()); + let err = kdc + .intercept_network_request(&network_request("http://cred.invalid/not-a-uuid")) + .expect_err("malformed path"); + assert!(format!("{err:#}").contains("malformed in-process KDC URL")); + } +} diff --git a/devolutions-gateway/src/credential_injection_kdc.rs b/devolutions-gateway/src/credential_injection_kdc.rs deleted file mode 100644 index 38031781c..000000000 --- a/devolutions-gateway/src/credential_injection_kdc.rs +++ /dev/null @@ -1,1073 +0,0 @@ -//! In-memory Kerberos KDC used by proxy-based credential injection. -//! -//! This module owns the Kerberos side of credential injection end-to-end: -//! per-session fake-KDC material, the session store, KDC proxy handling, and the -//! in-process KDC requests emitted by the server-side CredSSP acceptor. -//! Callers should only decide whether credential injection applies; once it does, this -//! component owns the Kerberos-specific behavior. - -use std::collections::HashMap; -use std::fmt; -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Context as _; -use async_trait::async_trait; -use chacha20poly1305::aead::OsRng; -use chacha20poly1305::aead::rand_core::RngCore as _; -use devolutions_gateway_task::{ShutdownSignal, Task}; -use ironrdp_connector::sspi; -use ironrdp_connector::sspi::generator::NetworkRequest; -use parking_lot::Mutex; -use picky_krb::messages::KdcProxyMessage; -use secrecy::{ExposeSecret as _, SecretBox, SecretString}; -use thiserror::Error; -use url::Url; -use uuid::Uuid; - -use crate::config::ConfHandle; -use crate::credential::{AppCredential, AppCredentialMapping}; -use crate::provisioning::{ArcProvisioningEntry, ProvisioningStore}; -use crate::target_connection_options::TargetConnectionOptions; - -// The reserved `.invalid` TLD (RFC 6761) lets sspi-rs CredSSP server emit "KDC requests" that -// never leave the process: `intercept_network_request` recognises this hostname and dispatches -// the message into the in-process `kdc` server below. -// -// TODO(sspi-rs#664): replace this URL-trampoline with a pluggable KDC dispatcher trait once -// sspi-rs ships the API — see https://github.com/Devolutions/sspi-rs/issues/664. -const IN_PROCESS_KDC_HOST: &str = "cred.invalid"; - -pub(crate) struct CredentialInjectionKdc { - jti: Uuid, - raw_token: String, - credential_mapping: AppCredentialMapping, - connection_options: Option, - // Client target hostname. It is not a hostname of the end machine, but a DGW hostname the client - // uses when connecting. - target_hostname: String, - session: Arc, - // The KDC crate models users with plaintext passwords, so this object owns those secrets - // for the lifetime of the credential-injection KDC. Keep Debug redacted. - kdc_config: kdc::config::KerberosServer, -} - -#[derive(Debug, Error)] -pub(crate) enum CredentialInjectionKdcResolveError { - #[error("credential-injection state is not available for {jti}")] - MissingCredential { jti: Uuid }, - #[error("credential-injection state is not available for {jti}")] - NonInjectionCredential { jti: Uuid }, - #[error("association token for {jti} is not valid for credential injection")] - InvalidAssociationToken { - jti: Uuid, - #[source] - source: anyhow::Error, - }, - #[error("credential-injection KDC config could not be initialized for {jti}")] - BuildKdcConfig { - jti: Uuid, - #[source] - source: anyhow::Error, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RealmMismatch { - pub(crate) expected: String, - pub(crate) actual: String, -} - -impl fmt::Display for RealmMismatch { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "expected: {}, got: {}", self.expected, self.actual) - } -} - -impl std::error::Error for RealmMismatch {} - -#[derive(Debug)] -pub(crate) enum CredentialInjectionKdcInterception { - Intercepted(Vec), - NotInjectionRequest, - NotInjectionRealm(RealmMismatch), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CredentialInjectionClientAcceptorProtocol { - Kerberos, - Ntlm, -} - -pub(crate) struct CredentialInjectionKdcRequest { - message: KdcProxyMessage, -} - -impl CredentialInjectionKdcRequest { - pub(crate) fn from_token(message: KdcProxyMessage) -> Self { - Self { message } - } - - fn in_process(message: KdcProxyMessage) -> Self { - Self { message } - } -} - -impl fmt::Debug for CredentialInjectionKdc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialInjectionKdc") - .field("jti", &self.jti) - .field("target_hostname", &self.target_hostname) - .field("realm", &self.session.realm) - .field("kdc_config", &"") - .finish() - } -} - -impl CredentialInjectionKdc { - fn from_parts( - jti: Uuid, - credential_entry: ArcProvisioningEntry, - target_hostname: String, - session: Arc, - ) -> anyhow::Result { - let mapping = credential_entry - .mapping - .as_ref() - .context("credential entry has no credential-injection mapping")?; - anyhow::ensure!( - jti == session.jti, - "credential entry JTI does not match credential-injection KDC session JTI", - ); - - let kdc_config = build_kdc_config(&session, &mapping.proxy)?; - - Ok(Self { - jti, - raw_token: credential_entry.token.clone(), - credential_mapping: mapping.clone(), - connection_options: credential_entry.connection_options.clone(), - target_hostname, - session, - kdc_config, - }) - } - - pub(crate) fn krb_kdc(&self) -> Option<&Url> { - self.connection_options.as_ref()?.krb_kdc() - } - - pub(crate) fn jti(&self) -> Uuid { - self.jti - } - - pub(crate) fn raw_token(&self) -> &str { - &self.raw_token - } - - pub(crate) fn proxy_credential(&self) -> &AppCredential { - &self.credential_mapping.proxy - } - - pub(crate) fn target_credential(&self) -> &AppCredential { - &self.credential_mapping.target - } - - /// Selects the CredSSP acceptor backend Gateway should present to the RDP client. - /// - /// The acceptor side must mirror the target-side auth package. - /// Domainless target credentials cannot acquire Kerberos tickets. - /// Enabling the Kerberos acceptor for those sessions would make incoming NTLMSSP tokens fail in Kerberos parsing. - pub(crate) fn client_acceptor_protocol(&self) -> anyhow::Result { - let target_username = sspi::Username::parse(app_credential_username(self.target_credential())) - .context("invalid target credential username")?; - - if target_username.domain_name().is_some() { - Ok(CredentialInjectionClientAcceptorProtocol::Kerberos) - } else { - Ok(CredentialInjectionClientAcceptorProtocol::Ntlm) - } - } - - pub(crate) fn server_kerberos_config(&self, client_addr: SocketAddr) -> anyhow::Result { - let user = sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8( - &self.session.acceptor.principal_name, - &self.session.realm, - self.session.acceptor.password.expose_secret(), - )); - - let kdc_url = self.in_process_kdc_url()?; - - // The SPN that the client puts on its AP-REQ ticket is the one for the target RDP - // server (`TERMSRV/`). Gateway-as-CredSSP-server is impersonating that target, - // so `ServerProperties` must claim the same SPN as the gateway listener or sspi-rs - // rejects the ticket. - Ok(sspi::KerberosServerConfig { - kerberos_config: sspi::KerberosConfig { - kdc_url: Some(kdc_url), - client_computer_name: client_addr.to_string(), - }, - server_properties: sspi::kerberos::ServerProperties::new( - &["TERMSRV", &self.target_hostname], - Some(user), - Duration::from_secs(300), - Some(sspi::Secret::new( - self.session.acceptor.long_term_key.expose_secret().clone(), - )), - )?, - }) - } - - pub(crate) fn intercept_network_request( - &self, - request: &NetworkRequest, - ) -> anyhow::Result { - if request.url.host_str() != Some(IN_PROCESS_KDC_HOST) { - return Ok(CredentialInjectionKdcInterception::NotInjectionRequest); - } - - let url_jti = request - .url - .path() - .trim_start_matches('/') - .parse::() - .context("malformed in-process KDC URL")?; - anyhow::ensure!( - url_jti == self.jti, - "in-process KDC URL JTI does not match current CredSSP session", - ); - - debug!( - jti = %self.jti, - scheme = %request.url.scheme(), - "Credential-injection KDC intercepted in-process request" - ); - - let kdc_message = KdcProxyMessage::from_raw(&request.data).context("malformed in-process KDC proxy payload")?; - self.handle_kdc_proxy_request(CredentialInjectionKdcRequest::in_process(kdc_message)) - } - - pub(crate) fn handle_kdc_proxy_request( - &self, - request: CredentialInjectionKdcRequest, - ) -> anyhow::Result { - let request_realm = self.resolve_message_realm(&request.message); - debug!( - jti = %self.jti, - resolved_realm = %request_realm, - "Credential-injection KDC realm resolved" - ); - - if let Some(mismatch) = realm_mismatch(&self.session.realm, &request_realm) { - return Ok(CredentialInjectionKdcInterception::NotInjectionRealm(mismatch)); - } - - let reply = self.handle_message(request.message)?; - Ok(CredentialInjectionKdcInterception::Intercepted(reply)) - } - - fn in_process_kdc_url(&self) -> anyhow::Result { - Url::parse(&format!("http://{}/{}", IN_PROCESS_KDC_HOST, self.jti)).context("build in-process KDC URL") - } - - fn resolve_message_realm(&self, kdc_proxy_message: &KdcProxyMessage) -> String { - kdc_proxy_message_realm(kdc_proxy_message).unwrap_or_else(|| self.session.realm.clone()) - } - - fn handle_message(&self, kdc_proxy_message: KdcProxyMessage) -> anyhow::Result> { - let reply = kdc::handle_kdc_proxy_message(kdc_proxy_message, &self.kdc_config, &self.target_hostname) - .context("handle credential-injection KDC message")?; - - reply.to_vec().context("encode credential-injection KDC reply") - } -} - -fn app_credential_username(credential: &AppCredential) -> &str { - match credential { - AppCredential::UsernamePassword { username, password: _ } => username, - } -} - -pub(crate) fn kdc_proxy_message_realm(kdc_proxy_message: &KdcProxyMessage) -> Option { - kdc_proxy_message - .target_domain - .0 - .as_ref() - .map(|realm| realm.0.to_string()) - .filter(|realm| !realm.is_empty()) -} - -fn realm_mismatch(expected: &str, actual: &str) -> Option { - if expected.eq_ignore_ascii_case(actual) { - return None; - } - - Some(RealmMismatch { - expected: expected.to_owned(), - actual: actual.to_owned(), - }) -} - -/// Per-session Kerberos material for proxy-based credential injection. -/// -/// The key material and the acceptor PA-ENC-TIMESTAMP password are wrapped in [`SecretBox`] / -/// [`SecretString`] so they cannot be accidentally written to logs through structured tracing. -/// Access requires an explicit `expose_secret()` call, which is greppable and reviewable. -struct CredentialInjectionKdcSession { - jti: Uuid, - realm: String, - kdc: CredentialInjectionKdcState, - acceptor: CredentialInjectionAcceptorState, -} - -struct CredentialInjectionKdcState { - krbtgt_key: SecretBox>, -} - -struct CredentialInjectionAcceptorState { - principal_name: String, - password: SecretString, - long_term_key: SecretBox>, -} - -impl fmt::Debug for CredentialInjectionKdcSession { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialInjectionKdcSession") - .field("jti", &self.jti) - .field("realm", &self.realm) - .field("kdc", &self.kdc) - .field("acceptor", &self.acceptor) - .finish() - } -} - -impl fmt::Debug for CredentialInjectionKdcState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialInjectionKdcState") - .field("krbtgt_key", &"<32 bytes redacted>") - .finish() - } -} - -impl fmt::Debug for CredentialInjectionAcceptorState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialInjectionAcceptorState") - .field("principal_name", &self.principal_name) - .field("password", &"") - .field("long_term_key", &"<32 bytes redacted>") - .finish() - } -} - -/// Derive per-session Kerberos material from the proxy username and the association token's JTI. -/// -/// The proxy username's optional `@realm` suffix selects the realm DVLS supplied; otherwise -/// fall back to a per-session synthetic realm derived from the JTI. The two sides agree -/// because DVLS derives the synthetic value the same way. -fn derive_credential_injection_kdc_session(proxy_username: &str, jti: Uuid) -> CredentialInjectionKdcSession { - let realm = proxy_username - .split_once('@') - .map(|(_, realm)| realm) - .filter(|realm| !realm.is_empty()) - .map(str::to_owned) - .unwrap_or_else(|| synthetic_realm(jti)); - - CredentialInjectionKdcSession { - jti, - realm, - kdc: CredentialInjectionKdcState { - krbtgt_key: SecretBox::new(Box::new(random_32_bytes())), - }, - acceptor: CredentialInjectionAcceptorState { - principal_name: "jet".to_owned(), - password: SecretString::from(hex::encode(random_32_bytes())), - long_term_key: SecretBox::new(Box::new(random_32_bytes())), - }, - } -} - -fn build_kdc_config( - session: &CredentialInjectionKdcSession, - proxy_credential: &AppCredential, -) -> anyhow::Result { - let realm = &session.realm; - let (proxy_user_name, proxy_password) = proxy_credential.decrypt_password()?; - let proxy_user_name = principal_for_realm(&proxy_user_name, realm); - let acceptor_principal_name = principal_for_realm(&session.acceptor.principal_name, realm); - - let acceptor_password = session.acceptor.password.expose_secret().to_owned(); - Ok(kdc::config::KerberosServer { - realm: realm.to_owned(), - users: vec![ - kdc::config::DomainUser { - username: proxy_user_name.clone(), - password: proxy_password.expose_secret().to_owned(), - salt: kerberos_salt(realm, &proxy_user_name), - }, - kdc::config::DomainUser { - username: acceptor_principal_name.clone(), - password: acceptor_password.clone(), - salt: kerberos_salt(realm, &acceptor_principal_name), - }, - ], - max_time_skew: 300, - krbtgt_key: session.kdc.krbtgt_key.expose_secret().clone(), - ticket_decryption_key: Some(session.acceptor.long_term_key.expose_secret().clone()), - service_user: Some(kdc::config::DomainUser { - username: acceptor_principal_name.clone(), - password: acceptor_password, - salt: kerberos_salt(realm, &acceptor_principal_name), - }), - }) -} - -fn principal_for_realm(user_name: &str, realm: &str) -> String { - if user_name.contains('@') { - user_name.to_owned() - } else { - format!("{user_name}@{realm}") - } -} - -fn kerberos_salt(realm: &str, principal: &str) -> String { - let local_name = principal.split('@').next().unwrap_or(principal); - format!("{}{local_name}", realm.to_ascii_uppercase()) -} - -fn synthetic_realm(jti: Uuid) -> String { - format!("CRED-{}.INVALID", jti.simple()).to_ascii_uppercase() -} - -fn random_32_bytes() -> Vec { - let mut bytes = vec![0u8; 32]; - OsRng.fill_bytes(&mut bytes); - bytes -} - -/// One-stop service for credential storage and credential-injection KDC state. -/// -/// Wraps the protocol-neutral [`ProvisioningStore`] and adds a Kerberos session cache keyed by -/// association-token JTI. The credential store remains the single source of truth for entry -/// lifetime; the session cache piggybacks on it (Arc-cloned credentials at lookup time, with stale -/// sessions evicted on insert-replacement and by a periodic sweep). -/// -/// All credential reads/writes — provision-credentials, RDP mode detection, KDC dispatch — go -/// through this service, so callers see one handle instead of coordinating a store and a registry. -#[derive(Clone)] -pub struct CredentialService { - // The `ConfHandle` is needed to resolve the hostname for the KDC config, which is used to - // build the SPN for the CredSSP acceptor. The hostname cannot not be a plain `String`, because - // the config can be reloaded at runtime. - conf_handle: ConfHandle, - credentials: ProvisioningStore, - sessions: Arc>>>, -} - -impl fmt::Debug for CredentialService { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialService") - .field("conf_handle", &"") - .field("credentials", &self.credentials) - .field("sessions", &self.sessions) - .finish() - } -} - -impl CredentialService { - pub fn new(conf_handle: ConfHandle) -> Self { - Self { - conf_handle, - credentials: ProvisioningStore::new(), - sessions: Arc::new(Mutex::new(HashMap::new())), - } - } - - /// Insert (or replace) the credentials half keyed by the token's JTI. - /// - /// Any previously-cached Kerberos session for the same JTI is dropped: it was derived from - /// the prior provisioning and is no longer valid for the new entry. We invalidate even when - /// the store reports no replacement, because the prior entry may have already been evicted by - /// `provisioning::CleanupTask` while its session cache entry was still awaiting the next - /// `sweep_orphans` tick. - pub(crate) fn insert_credentials( - &self, - token: String, - mapping: Option, - time_to_live: time::Duration, - ) -> Result { - // Snapshot the JTI from the new token so we can invalidate the matching session entry - // regardless of whether the credential store reports a replacement. `ProvisioningStore::insert_credentials` - // re-extracts internally; both calls go through the same code path, so an invalid token - // here will surface as the same `InvalidToken` error downstream. - let jti = crate::token::extract_jti(&token) - .context("failed to extract token ID") - .map_err(crate::provisioning::InsertError::InvalidToken)?; - let replaced = self.credentials.insert_credentials(token, mapping, time_to_live)?; - self.sessions.lock().remove(&jti); - Ok(replaced) - } - - /// Insert (or replace) the connection-options half. Drops any cached Kerberos session for the - /// JTI because `krb_kdc` is part of the session's routing inputs. - pub(crate) fn insert_connection_options( - &self, - jti: Uuid, - connection_options: TargetConnectionOptions, - time_to_live: time::Duration, - ) -> bool { - let replaced = self - .credentials - .insert_connection_options(jti, connection_options, time_to_live); - self.sessions.lock().remove(&jti); - replaced - } - - /// Look up a credential entry by its association-token JTI. - pub(crate) fn get(&self, jti: Uuid) -> Option { - self.credentials.get(jti) - } - - /// Borrow the inner [`ProvisioningStore`] for plumbing that genuinely needs the - /// protocol-neutral primitive (e.g. wiring the background expiry task). - pub fn credential_store(&self) -> &ProvisioningStore { - &self.credentials - } - - /// Resolve the credential-injection KDC bound to the given association-token JTI. - /// - /// Returns the per-call KDC view; the underlying Kerberos session (krbtgt key, acceptor - /// long-term key, acceptor password) is cached so the in-process KDC and the CredSSP acceptor - /// see identical key material for the lifetime of the provisioned credentials. - pub(crate) fn kdc_for(&self, jti: Uuid) -> Result { - let credential_entry = self.credentials.get(jti).ok_or_else(|| { - warn!(%jti, "KDC token references missing credential-injection state"); - CredentialInjectionKdcResolveError::MissingCredential { jti } - })?; - - let mapping = credential_entry.mapping.as_ref().ok_or_else(|| { - warn!(%jti, "KDC token references non-injection credential state"); - CredentialInjectionKdcResolveError::NonInjectionCredential { jti } - })?; - - // Validate association-token shape for credential injection (dst_hst present, etc.). - // SPN / acceptor hostname comes from gateway config below (#1856), not dst_hst. - crate::token::extract_credential_injection_target_hostname(&credential_entry.token).map_err(|source| { - warn!( - %jti, - error = format!("{source:#}"), - "KDC token references invalid credential-injection association token" - ); - CredentialInjectionKdcResolveError::InvalidAssociationToken { jti, source } - })?; - - let proxy_username = app_credential_username(&mapping.proxy).to_owned(); - // Atomic get-or-insert: holds the lock long enough to guarantee a single Arc - // wins for this JTI even under concurrent `kdc_for` calls. The derivation is fast (a few - // hundred bytes of OsRng) so doing it under the lock is acceptable. - let session = { - let mut sessions = self.sessions.lock(); - let session = sessions - .entry(jti) - .or_insert_with(|| Arc::new(derive_credential_injection_kdc_session(&proxy_username, jti))); - Arc::clone(session) - }; - - let hostname = self.conf_handle.get_conf().hostname.clone(); - - CredentialInjectionKdc::from_parts(jti, credential_entry, hostname, session) - .map_err(|source| CredentialInjectionKdcResolveError::BuildKdcConfig { jti, source }) - } - - fn sweep_orphans(&self) { - let stale_jtis: Vec = { - let sessions = self.sessions.lock(); - sessions - .keys() - .copied() - .filter(|jti| self.credentials.get(*jti).is_none()) - .collect() - }; - - if stale_jtis.is_empty() { - return; - } - - let mut sessions = self.sessions.lock(); - for jti in stale_jtis { - sessions.remove(&jti); - } - } -} - -pub struct CleanupTask { - pub service: CredentialService, -} - -#[async_trait] -impl Task for CleanupTask { - type Output = anyhow::Result<()>; - - const NAME: &'static str = "credential injection kdc cleanup"; - - async fn run(self, shutdown_signal: ShutdownSignal) -> Self::Output { - cleanup_task(self.service, shutdown_signal).await; - Ok(()) - } -} - -#[instrument(skip_all)] -async fn cleanup_task(service: CredentialService, mut shutdown_signal: ShutdownSignal) { - use tokio::time::{Duration, sleep}; - - const TASK_INTERVAL: Duration = Duration::from_secs(60 * 15); // 15 minutes - - debug!("Task started"); - - loop { - tokio::select! { - _ = sleep(TASK_INTERVAL) => {} - _ = shutdown_signal.wait() => { - break; - } - } - - service.sweep_orphans(); - } - - debug!("Task terminated"); -} - -#[cfg(test)] -mod tests { - use base64::Engine as _; - use ironrdp_connector::sspi::network_client::NetworkProtocol; - use secrecy::SecretString; - - use super::*; - use crate::config::ConfHandle; - use crate::credential::{CleartextAppCredential, CleartextAppCredentialMapping}; - - const TEST_CONFIG: &str = r#"{ - "Hostname": "dgateway.localhost.com", - "ProvisionerPublicKeyData": { - "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" - }, - "Listeners": [ - { "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" } - ], - "__debug__": { "disable_token_validation": true } - }"#; - - fn mock_conf_handle() -> ConfHandle { - ConfHandle::mock(TEST_CONFIG).expect("test config is valid") - } - - fn cleartext_mapping_with_target_username(target_username: &str) -> CleartextAppCredentialMapping { - CleartextAppCredentialMapping { - proxy: CleartextAppCredential::UsernamePassword { - username: "proxy@example.invalid".to_owned(), - password: SecretString::from("pwd"), - }, - target: CleartextAppCredential::UsernamePassword { - username: target_username.to_owned(), - password: SecretString::from("pwd"), - }, - } - } - - fn unsigned_jws(payload: serde_json::Value) -> String { - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header = engine.encode(r#"{"alg":"RS256"}"#); - let payload = engine.encode(serde_json::to_vec(&payload).expect("payload serializes")); - let signature = engine.encode(b"signature"); - format!("{header}.{payload}.{signature}") - } - - fn association_token(jti: Uuid) -> String { - unsigned_jws(serde_json::json!({ - "jti": jti, - "dst_hst": "target.example:3389" - })) - } - - fn dummy_entry_with_target_username(jti: Uuid, target_username: &str) -> ArcProvisioningEntry { - let store = ProvisioningStore::new(); - store - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username(target_username)), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - - store.get(jti).expect("credential entry is indexed by JTI") - } - - fn dummy_entry(jti: Uuid) -> ArcProvisioningEntry { - dummy_entry_with_target_username(jti, "target") - } - - fn dummy_kdc(jti: Uuid) -> CredentialInjectionKdc { - let entry = dummy_entry(jti); - let session = Arc::new(derive_credential_injection_kdc_session("proxy@example.invalid", jti)); - CredentialInjectionKdc::from_parts(jti, entry, "target.example".to_owned(), session) - .expect("valid credential-injection KDC") - } - - fn dummy_kdc_with_target_username(jti: Uuid, target_username: &str) -> CredentialInjectionKdc { - let entry = dummy_entry_with_target_username(jti, target_username); - let session = Arc::new(derive_credential_injection_kdc_session("proxy@example.invalid", jti)); - CredentialInjectionKdc::from_parts(jti, entry, "target.example".to_owned(), session) - .expect("valid credential-injection KDC") - } - - fn network_request(url: &str) -> NetworkRequest { - NetworkRequest { - protocol: NetworkProtocol::Http, - url: Url::parse(url).expect("test URL parses"), - data: Vec::new(), - } - } - - #[test] - fn proxy_user_at_realm_is_used_as_realm() { - let session = derive_credential_injection_kdc_session("proxy@example.invalid", Uuid::new_v4()); - assert_eq!(session.realm, "example.invalid"); - } - - #[test] - fn bare_proxy_username_yields_synthetic_realm() { - let jti = Uuid::new_v4(); - let session = derive_credential_injection_kdc_session("just-a-uuid", jti); - assert_eq!(session.realm, synthetic_realm(jti)); - assert!(!session.realm.is_empty()); - } - - #[test] - fn service_kdc_for_rejects_expired_credential_entry() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - // Negative TTL: entry is born already expired. `ProvisioningStore::get` does not - // filter on expiry, so the service's own check is what guarantees we never build a KDC - // over stale credentials. - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::seconds(-1), - ) - .expect("credential entry inserts"); - - assert!( - matches!( - service.kdc_for(jti), - Err(CredentialInjectionKdcResolveError::MissingCredential { .. }) - ), - "expired credentials must not yield a KDC" - ); - } - - #[test] - fn service_kdc_for_returns_same_session_under_concurrent_calls() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - - let first = service.kdc_for(jti).expect("first call resolves"); - let second = service.kdc_for(jti).expect("second call resolves"); - - // The Kerberos session is the piece that must be stable across calls; the per-call KDC - // view rebuilds the rest. Compare via the long-term acceptor key as a session-identity - // probe. - let first_key = first.session.acceptor.long_term_key.expose_secret().clone(); - let second_key = second.session.acceptor.long_term_key.expose_secret().clone(); - assert_eq!( - first_key, second_key, - "concurrent kdc_for must share one cached session per JTI" - ); - } - - #[test] - fn service_insert_drops_stale_session_even_without_credential_replacement() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - // Simulate the race called out by Codex: a previous provisioning's session is still - // cached, but the credential entry has already been evicted (e.g. by - // `provisioning::cleanup_task`) and `sweep_orphans` has not run yet. A fresh provisioning - // under the same JTI must drop the stale session regardless of whether - // `ProvisioningStore::insert_credentials` reports a replacement, otherwise the next `kdc_for` - // would reuse the old key material. - let stale_session = Arc::new(derive_credential_injection_kdc_session("proxy@example.invalid", jti)); - service.sessions.lock().insert(jti, Arc::clone(&stale_session)); - - let replaced = service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - assert!(!replaced, "test precondition: no credential replacement"); - - assert!( - !service.sessions.lock().contains_key(&jti), - "insert must drop stale session even when no credential replacement occurred" - ); - } - - #[test] - fn service_insert_replacement_drops_cached_kerberos_material() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - - let first = service.kdc_for(jti).expect("first call resolves"); - let first_key = first.session.acceptor.long_term_key.expose_secret().clone(); - - // Re-insert under the same JTI: the cached session for the previous entry must be evicted - // automatically, otherwise the new KDC would carry stale key material that the freshly - // provisioned credentials no longer match. - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry re-inserts"); - - let second = service.kdc_for(jti).expect("second call resolves with fresh session"); - let second_key = second.session.acceptor.long_term_key.expose_secret().clone(); - - assert_ne!( - first_key, second_key, - "insert-replacement must force a fresh session derivation" - ); - } - - #[test] - fn service_sweep_orphans_drops_sessions_with_no_credential_entry() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - - service.kdc_for(jti).expect("kdc_for populates session cache"); - assert!(service.sessions.lock().contains_key(&jti), "session cached"); - - // Simulate credential store eviction: build a parallel service whose credential store is - // empty but whose session cache is shared with the original. A more faithful test would - // drive `provisioning::cleanup_task` to expire the entry, but it sleeps for 15 minutes - // between ticks. Swapping the inner store is the deterministic equivalent. - let orphaned_service = CredentialService { - conf_handle: mock_conf_handle(), - credentials: ProvisioningStore::new(), - sessions: Arc::clone(&service.sessions), - }; - - orphaned_service.sweep_orphans(); - assert!( - !orphaned_service.sessions.lock().contains_key(&jti), - "sweep must drop sessions whose JTI is no longer in credential_store" - ); - } - - #[test] - fn client_acceptor_protocol_is_ntlm_for_domainless_target_credential() { - let kdc = dummy_kdc_with_target_username(Uuid::new_v4(), "Administrator"); - - assert_eq!( - kdc.client_acceptor_protocol().expect("protocol selected"), - CredentialInjectionClientAcceptorProtocol::Ntlm - ); - } - - #[test] - fn client_acceptor_protocol_is_kerberos_for_upn_target_credential() { - let kdc = dummy_kdc_with_target_username(Uuid::new_v4(), "administrator@example.invalid"); - - assert_eq!( - kdc.client_acceptor_protocol().expect("protocol selected"), - CredentialInjectionClientAcceptorProtocol::Kerberos - ); - } - - #[test] - fn client_acceptor_protocol_is_kerberos_for_downlevel_target_credential() { - let kdc = dummy_kdc_with_target_username(Uuid::new_v4(), "EXAMPLE\\Administrator"); - - assert_eq!( - kdc.client_acceptor_protocol().expect("protocol selected"), - CredentialInjectionClientAcceptorProtocol::Kerberos - ); - } - - #[test] - fn from_parts_rejects_mismatched_entry_and_session_jti() { - let entry_jti = Uuid::new_v4(); - let session_jti = Uuid::new_v4(); - assert_ne!(entry_jti, session_jti); - - let entry = dummy_entry(entry_jti); - let session = Arc::new(derive_credential_injection_kdc_session( - "proxy@example.invalid", - session_jti, - )); - - let err = CredentialInjectionKdc::from_parts(entry_jti, entry, "target.example".to_owned(), session) - .expect_err("mismatched entry/session JTI must fail closed"); - let msg = format!("{err:#}"); - assert!( - msg.contains("credential entry JTI does not match credential-injection KDC session JTI"), - "actual: {msg}" - ); - } - - #[test] - fn service_kdc_for_rejects_unknown_jti() { - let service = CredentialService::new(mock_conf_handle()); - - assert!( - matches!( - service.kdc_for(Uuid::new_v4()), - Err(CredentialInjectionKdcResolveError::MissingCredential { .. }) - ), - "KDC tokens with jet_cred_id must not fall back to real-KDC forwarding" - ); - } - - #[test] - fn service_kdc_for_rejects_non_injection_entry() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - service - .insert_credentials(association_token(jti), None, time::Duration::minutes(5)) - .expect("provision-token entry inserts"); - - assert!( - matches!( - service.kdc_for(jti), - Err(CredentialInjectionKdcResolveError::NonInjectionCredential { .. }) - ), - "KDC tokens with jet_cred_id must require provision-credentials state" - ); - } - - #[test] - fn service_kdc_for_uses_gateway_hostname_for_spn() { - // #1856: SPN / acceptor hostname is the Gateway hostname from config, not dst_hst. - // Token dst_hst is still validated (missing/invalid shape fails kdc_for). - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - - let kdc = service.kdc_for(jti).expect("credential-injection KDC resolves"); - - assert_eq!(kdc.target_hostname, "dgateway.localhost.com"); - } - - #[test] - fn intercept_ignores_non_loopback_host() { - let jti = Uuid::new_v4(); - let kdc = dummy_kdc(jti); - - let request = network_request("http://kdc.real.example/path"); - let result = kdc - .intercept_network_request(&request) - .expect("non-loopback request dispatches"); - - assert!(matches!( - result, - CredentialInjectionKdcInterception::NotInjectionRequest - )); - } - - #[test] - fn intercept_rejects_malformed_url_path() { - let jti = Uuid::new_v4(); - let kdc = dummy_kdc(jti); - - let request = network_request("http://cred.invalid/not-a-uuid"); - let err = kdc - .intercept_network_request(&request) - .expect_err("non-UUID path must fail"); - let msg = format!("{err:#}"); - assert!(msg.contains("malformed in-process KDC URL"), "actual: {msg}"); - } - - #[test] - fn intercept_rejects_mismatched_jti() { - let entry_jti = Uuid::new_v4(); - let other_jti = Uuid::new_v4(); - assert_ne!(entry_jti, other_jti); - - let kdc = dummy_kdc(entry_jti); - - let request = network_request(&format!("http://cred.invalid/{}", other_jti)); - let err = kdc - .intercept_network_request(&request) - .expect_err("JTI mismatch must fail"); - let msg = format!("{err:#}"); - assert!(msg.contains("does not match current CredSSP session"), "actual: {msg}"); - } - - #[test] - fn intercept_accepts_matching_url_path_before_payload_decode() { - let jti = Uuid::new_v4(); - let kdc = dummy_kdc(jti); - - let request = network_request(&format!("http://cred.invalid/{jti}")); - let err = kdc - .intercept_network_request(&request) - .expect_err("empty KDC payload must fail after URL/JTI validation"); - let msg = format!("{err:#}"); - assert!(msg.contains("malformed in-process KDC proxy payload"), "actual: {msg}"); - } - - #[test] - fn realm_mismatch_is_reported_as_not_injection_realm() { - let mismatch = - realm_mismatch("cred-session.invalid", "evil.example").expect("different realms produce a mismatch"); - assert_eq!(mismatch.expected, "cred-session.invalid"); - assert_eq!(mismatch.actual, "evil.example"); - } - - #[test] - fn missing_kdc_proxy_envelope_realm_falls_back_to_session_realm() { - let jti = Uuid::new_v4(); - let kdc = dummy_kdc(jti); - let message = KdcProxyMessage::from_raw_kerb_message(&[]).expect("KDC proxy wrapper builds"); - - assert_eq!(kdc.resolve_message_realm(&message), "example.invalid"); - } -} diff --git a/devolutions-gateway/src/generic_client.rs b/devolutions-gateway/src/generic_client.rs index dfc31df7f..d7287a21f 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -8,7 +8,8 @@ use tracing::field; use typed_builder::TypedBuilder; use crate::config::Conf; -use crate::credential_injection_kdc::CredentialService; +use crate::credential_injection::{CredentialInjection, SyntheticKdcRegistry}; +use crate::provisioning::ProvisioningStore; use crate::proxy::Proxy; use crate::rdp_pcb::{extract_association_claims, read_pcb}; use crate::recording::ActiveRecordings; @@ -27,7 +28,8 @@ pub struct GenericClient { sessions: SessionMessageSender, subscriber_tx: SubscriberSender, active_recordings: Arc, - credentials: CredentialService, + provisioning: ProvisioningStore, + synthetic_kdc_registry: SyntheticKdcRegistry, #[builder(default)] agent_tunnel_handle: Option>, } @@ -51,7 +53,8 @@ where sessions, subscriber_tx, active_recordings, - credentials, + provisioning, + synthetic_kdc_registry, agent_tunnel_handle, } = self; @@ -152,14 +155,18 @@ where // The credential store is keyed on the association token's JTI, so a direct // lookup by `claims.jti` is the primary path. if is_rdp - && let Some(entry) = credentials.get(claims.jti) + && let Some(entry) = provisioning.take(claims.jti) && entry.mapping.is_some() { anyhow::ensure!(token == entry.token, "token mismatch"); - let credential_injection_kdc = credentials.kdc_for(claims.jti)?; + let kerberos_enabled = conf.debug.enable_unstable && conf.debug.kerberos_credential_injection; + let credential_injection = + CredentialInjection::from_provisioned(claims.jti, entry, kerberos_enabled)? + .register_if_kerberos(&synthetic_kdc_registry); info!( - jti = %credential_injection_kdc.jti(), + jti = %credential_injection.jti(), + kerberos = credential_injection.uses_kerberos(), "RDP-TLS forwarding with credential injection" ); @@ -179,7 +186,7 @@ where .server_stream(server_stream) .sessions(sessions) .subscriber_tx(subscriber_tx) - .credential_injection_kdc(credential_injection_kdc) + .credential_injection(credential_injection) .client_stream_leftover_bytes(leftover_bytes) .server_dns_name(selected_target.host().to_owned()) .disconnect_interest(disconnect_interest) diff --git a/devolutions-gateway/src/lib.rs b/devolutions-gateway/src/lib.rs index ae5ef4190..1fb08ff85 100644 --- a/devolutions-gateway/src/lib.rs +++ b/devolutions-gateway/src/lib.rs @@ -16,7 +16,7 @@ pub mod api; pub mod cli; pub mod config; pub mod credential; -pub mod credential_injection_kdc; +pub mod credential_injection; pub mod extract; pub mod generic_client; pub mod http; @@ -62,7 +62,8 @@ pub struct DgwState { pub shutdown_signal: devolutions_gateway_task::ShutdownSignal, pub recordings: recording::RecordingMessageSender, pub job_queue_handle: job_queue::JobQueueHandle, - pub credentials: credential_injection_kdc::CredentialService, + pub provisioning: provisioning::ProvisioningStore, + pub synthetic_kdc_registry: credential_injection::SyntheticKdcRegistry, pub monitoring_state: Arc, pub traffic_audit_handle: traffic_audit::TrafficAuditHandle, pub agent_tunnel_handle: Option>, @@ -90,7 +91,8 @@ impl DgwState { let (shutdown_handle, shutdown_signal) = devolutions_gateway_task::ShutdownHandle::new(); let (job_queue_handle, job_queue_rx) = job_queue::JobQueueHandle::new(); let (traffic_audit_handle, traffic_audit_rx) = traffic_audit::TrafficAuditHandle::new(); - let credentials = credential_injection_kdc::CredentialService::new(conf_handle.clone()); + let provisioning = provisioning::ProvisioningStore::new(); + let synthetic_kdc_registry = credential_injection::SyntheticKdcRegistry::new(); let monitoring_state = Arc::new(network_monitor::State::new(Arc::new(MockMonitorsCache))?); let state = Self { @@ -103,7 +105,8 @@ impl DgwState { recordings: recording_manager_handle, job_queue_handle, traffic_audit_handle, - credentials, + provisioning, + synthetic_kdc_registry, monitoring_state, agent_tunnel_handle: None, }; diff --git a/devolutions-gateway/src/listener.rs b/devolutions-gateway/src/listener.rs index 5e23f5f8a..6dd0b179b 100644 --- a/devolutions-gateway/src/listener.rs +++ b/devolutions-gateway/src/listener.rs @@ -158,7 +158,8 @@ async fn handle_tcp_peer(stream: TcpStream, state: DgwState, peer_addr: SocketAd .sessions(state.sessions) .subscriber_tx(state.subscriber_tx) .active_recordings(state.recordings.active_recordings) - .credentials(state.credentials) + .provisioning(state.provisioning) + .synthetic_kdc_registry(state.synthetic_kdc_registry) .agent_tunnel_handle(state.agent_tunnel_handle) .build() .serve() diff --git a/devolutions-gateway/src/ngrok.rs b/devolutions-gateway/src/ngrok.rs index 9e2e846bd..9adb561a7 100644 --- a/devolutions-gateway/src/ngrok.rs +++ b/devolutions-gateway/src/ngrok.rs @@ -237,7 +237,8 @@ async fn run_tcp_tunnel(mut tunnel: ngrok::tunnel::TcpTunnel, state: DgwState) { .sessions(state.sessions) .subscriber_tx(state.subscriber_tx) .active_recordings(state.recordings.active_recordings) - .credentials(state.credentials) + .provisioning(state.provisioning) + .synthetic_kdc_registry(state.synthetic_kdc_registry) .agent_tunnel_handle(state.agent_tunnel_handle) .build() .serve() diff --git a/devolutions-gateway/src/provisioning.rs b/devolutions-gateway/src/provisioning.rs index 06b21c40b..76da7a671 100644 --- a/devolutions-gateway/src/provisioning.rs +++ b/devolutions-gateway/src/provisioning.rs @@ -44,8 +44,6 @@ pub struct ProvisioningEntry { pub(crate) connection_options: Option, } -pub type ArcProvisioningEntry = Arc; - #[derive(Debug, Clone)] struct CredentialsEntry { token: String, @@ -131,40 +129,41 @@ impl ProvisioningStore { self.connection_options.lock().insert(jti, entry).is_some() } - /// Assemble the provisioned view for a session. + /// Take the provisioned view for a session (one-shot). /// - /// Returns `None` unless the credentials half (token and/or mapping) is present and live. - /// Folds in connection options when that half is also present and live. - pub(crate) fn get(&self, jti: Uuid) -> Option { + /// Removes the credentials half (required) and any live connection-options half for `jti`. + /// Returns `None` if credentials are missing or expired. A second `take` for the same JTI + /// fails until preflight inserts again. + pub(crate) fn take(&self, jti: Uuid) -> Option { let now = time::OffsetDateTime::now_utc(); let (token, mapping) = { - let entries = self.credentials.lock(); - let entry = entries.get(&jti)?; + let mut entries = self.credentials.lock(); + let entry = entries.remove(&jti)?; if now >= entry.expires_at { warn!(%jti, "Provisioned credentials expired before the connection arrived"); return None; } - (entry.token.clone(), entry.mapping.clone()) + (entry.token, entry.mapping) }; - let connection_options = self.get_live_connection_options(jti, now); + let connection_options = { + let mut entries = self.connection_options.lock(); + match entries.remove(&jti) { + Some(entry) if now < entry.expires_at => Some(entry.connection_options), + Some(_) => { + warn!(%jti, "Provisioned connection options expired before the connection arrived"); + None + } + None => None, + } + }; - Some(Arc::new(ProvisioningEntry { + Some(ProvisioningEntry { token, mapping, connection_options, - })) - } - - fn get_live_connection_options(&self, jti: Uuid, now: time::OffsetDateTime) -> Option { - let entries = self.connection_options.lock(); - let entry = entries.get(&jti)?; - if now >= entry.expires_at { - warn!(%jti, "Provisioned connection options expired before the connection arrived"); - return None; - } - Some(entry.connection_options.clone()) + }) } } @@ -252,49 +251,54 @@ mod tests { } #[test] - fn get_returns_token_only_entry() { + fn take_returns_token_only_entry() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); store .insert_credentials(association_token(jti), None, time::Duration::minutes(5)) .expect("insert"); - let entry = store.get(jti).expect("live entry"); + let entry = store.take(jti).expect("live entry"); assert!(entry.mapping.is_none()); assert!(entry.connection_options.is_none()); + assert!(store.take(jti).is_none(), "second take is empty"); } #[test] - fn get_returns_live_credentials_without_options() { + fn take_returns_live_credentials_without_options() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); store .insert_credentials(association_token(jti), Some(mapping()), time::Duration::minutes(5)) .expect("insert"); - let entry = store.get(jti).expect("live entry"); + let entry = store.take(jti).expect("live entry"); assert!(entry.mapping.is_some()); assert!(entry.connection_options.is_none()); } #[test] - fn get_folds_in_live_connection_options() { + fn take_folds_in_and_consumes_connection_options() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); store .insert_credentials(association_token(jti), Some(mapping()), time::Duration::minutes(5)) .expect("insert credentials"); assert!(!store.insert_connection_options(jti, options(), time::Duration::minutes(5))); - let entry = store.get(jti).expect("live entry"); + let entry = store.take(jti).expect("live entry"); assert!(entry.connection_options.is_some()); + assert!(store.take(jti).is_none()); + // options half was removed with take; re-insert options alone does not revive credentials + assert!(!store.insert_connection_options(jti, options(), time::Duration::minutes(5))); + assert!(store.take(jti).is_none()); } #[test] - fn get_treats_expired_credentials_as_absent() { + fn take_treats_expired_credentials_as_absent() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); store .insert_credentials(association_token(jti), Some(mapping()), time::Duration::seconds(-1)) .expect("insert"); - assert!(store.get(jti).is_none()); + assert!(store.take(jti).is_none()); } #[test] diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index ca7376bd8..c4c104a7a 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -16,7 +16,8 @@ use tracing::field; const PCB_TRANSMIT_DEADLINE: Duration = Duration::from_secs(10); use crate::config::Conf; -use crate::credential_injection_kdc::{CredentialInjectionKdc, CredentialService}; +use crate::credential_injection::{CredentialInjection, SyntheticKdcRegistry}; +use crate::provisioning::ProvisioningStore; use crate::proxy::Proxy; use crate::recording::ActiveRecordings; use crate::session::{ConnectionModeDetails, DisconnectInterest, DisconnectedInfo, SessionInfo, SessionMessageSender}; @@ -437,7 +438,7 @@ async fn handle_with_credential_injection( subscriber_tx: SubscriberSender, active_recordings: &ActiveRecordings, cleanpath_pdu: RDCleanPathPdu, - credential_injection_kdc: CredentialInjectionKdc, + credential_injection: CredentialInjection, agent_tunnel_handle: Option>, ) -> anyhow::Result<()> { let tls_conf = conf.credssp_tls.get().context("CredSSP TLS configuration")?; @@ -538,24 +539,15 @@ 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_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()); let client_credssp_fut = crate::rdp_proxy::perform_credssp_as_server( &mut client_framed, - client_addr.ip(), + client_addr, gateway_public_key, client_security_protocol, - credential_injection_kdc.proxy_credential(), - krb_configs.server, - &credential_injection_kdc, + &credential_injection, &kdc_connector, ); @@ -564,8 +556,8 @@ async fn handle_with_credential_injection( destination.host().to_owned(), server_public_key, server_security_protocol, - credential_injection_kdc.target_credential(), - krb_configs.client, + &credential_injection, + &gateway_hostname, &kdc_connector, ); @@ -639,7 +631,8 @@ pub async fn handle( sessions: SessionMessageSender, subscriber_tx: SubscriberSender, active_recordings: &ActiveRecordings, - credentials: &CredentialService, + provisioning: &ProvisioningStore, + synthetic_kdc_registry: &SyntheticKdcRegistry, agent_tunnel_handle: Option>, ) -> anyhow::Result<()> { // Special handshake of our RDP extension @@ -660,7 +653,7 @@ pub async fn handle( // proxy-based credential injection mode. Otherwise, we continue the usual // clean path procedure. The credential store is keyed on the association token's JTI. if let Some(jti) = crate::token::extract_jti(token).ok() - && let Some(entry) = credentials.get(jti) + && let Some(entry) = provisioning.take(jti) && entry.mapping.is_some() { // VMConnect needs pre-X.224 CredSSP against the Hyper-V host cert on the client. @@ -671,10 +664,13 @@ pub async fn handle( anyhow::bail!("credential injection is not supported for VMConnect RDCleanPath"); } - let credential_injection_kdc = credentials.kdc_for(jti)?; - anyhow::ensure!(token == credential_injection_kdc.raw_token(), "token mismatch"); + anyhow::ensure!(token == entry.token, "token mismatch"); + let kerberos_enabled = conf.debug.enable_unstable && conf.debug.kerberos_credential_injection; + let credential_injection = CredentialInjection::from_provisioned(jti, entry, kerberos_enabled)? + .register_if_kerberos(synthetic_kdc_registry); debug!( - jti = %credential_injection_kdc.jti(), + jti = %credential_injection.jti(), + kerberos = credential_injection.uses_kerberos(), "Switching to RdpProxy for credential injection (WebSocket)" ); @@ -688,7 +684,7 @@ pub async fn handle( subscriber_tx, active_recordings, cleanpath_pdu, - credential_injection_kdc, + credential_injection, agent_tunnel_handle.clone(), ) .await; diff --git a/devolutions-gateway/src/rdp_proxy.rs b/devolutions-gateway/src/rdp_proxy.rs deleted file mode 100644 index 254a71df8..000000000 --- a/devolutions-gateway/src/rdp_proxy.rs +++ /dev/null @@ -1,846 +0,0 @@ -use std::net::{IpAddr, SocketAddr}; -use std::sync::Arc; - -use anyhow::Context as _; -use ironrdp_acceptor::credssp::CredsspProcessGenerator as CredsspServerProcessGenerator; -use ironrdp_connector::credssp::CredsspProcessGenerator as CredsspClientProcessGenerator; -use ironrdp_connector::sspi; -use ironrdp_connector::sspi::generator::GeneratorState; -use ironrdp_pdu::{mcs, nego, x224}; -use secrecy::ExposeSecret as _; -use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; -use typed_builder::TypedBuilder; - -use crate::config::Conf; -use crate::credential::AppCredential; -use crate::credential_injection_kdc::{ - CredentialInjectionClientAcceptorProtocol, CredentialInjectionKdc, CredentialInjectionKdcInterception, -}; -use crate::kdc_connector::KdcConnector; -use crate::proxy::Proxy; -use crate::session::{DisconnectInterest, SessionInfo, SessionMessageSender}; -use crate::subscriber::SubscriberSender; - -#[derive(TypedBuilder)] -pub struct RdpProxy { - conf: Arc, - session_info: SessionInfo, - client_stream: C, - client_addr: SocketAddr, - server_stream: S, - server_addr: SocketAddr, - credential_injection_kdc: CredentialInjectionKdc, - client_stream_leftover_bytes: bytes::BytesMut, - sessions: SessionMessageSender, - subscriber_tx: SubscriberSender, - server_dns_name: String, - disconnect_interest: Option, - /// Outbound dispatcher for CredSSP-originated KDC traffic. Encapsulates whether KDC - /// requests should attempt agent-tunnel routing (and any `jet_agent_id` pin from the - /// parent association token) or always go direct. - kdc_connector: KdcConnector, -} - -impl RdpProxy -where - A: AsyncWrite + AsyncRead + Unpin + Send, - B: AsyncWrite + AsyncRead + Unpin + Send, -{ - pub async fn run(self) -> anyhow::Result<()> { - handle(self).await - } -} - -#[instrument("rdp_proxy", skip_all, fields(session_id = proxy.session_info.id.to_string(), target = proxy.server_addr.to_string()))] -async fn handle(proxy: RdpProxy) -> anyhow::Result<()> -where - C: AsyncRead + AsyncWrite + Unpin + Send, - S: AsyncRead + AsyncWrite + Unpin + Send, -{ - let RdpProxy { - conf, - session_info, - client_stream, - client_addr, - server_stream, - server_addr, - credential_injection_kdc, - client_stream_leftover_bytes, - sessions, - subscriber_tx, - server_dns_name, - disconnect_interest, - kdc_connector, - } = proxy; - - let tls_conf = conf.credssp_tls.get().context("CredSSP TLS configuration")?; - let gateway_hostname = conf.hostname.clone(); - - // -- Retrieve the Gateway TLS public key that must be used for client-proxy CredSSP later on -- // - - let gateway_cert_chain_handle = tokio::spawn(crate::tls::get_cert_chain_for_acceptor_cached( - gateway_hostname.clone(), - tls_conf.acceptor.clone(), - )); - - // -- Dual handshake with the client and the server until the TLS security upgrade -- // - - let mut client_framed = - ironrdp_tokio::MovableTokioFramed::new_with_leftover(client_stream, client_stream_leftover_bytes); - let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); - - let handshake_result = dual_handshake_until_tls_upgrade( - &mut client_framed, - &mut server_framed, - credential_injection_kdc.target_credential(), - ) - .await?; - - let client_stream = client_framed.into_inner_no_leftover(); - let server_stream = server_framed.into_inner_no_leftover(); - - // -- Perform the TLS upgrading for both the client and the server, effectively acting as a man-in-the-middle -- // - - let client_tls_upgrade_fut = tls_conf.acceptor.accept(client_stream); - let server_tls_upgrade_fut = crate::tls::dangerous_connect(server_dns_name.clone(), server_stream); - - let (client_stream, server_stream) = tokio::join!(client_tls_upgrade_fut, server_tls_upgrade_fut); - - let client_stream = client_stream.context("TLS upgrade with client failed")?; - let server_stream = server_stream.context("TLS upgrade with server failed")?; - - let server_public_key = - crate::tls::extract_stream_peer_public_key(&server_stream).context("extract target server TLS public key")?; - - let gateway_cert_chain = gateway_cert_chain_handle.await??; - let gateway_public_key = crate::tls::extract_public_key(gateway_cert_chain.first().context("no leaf")?) - .context("extract Gateway public key")?; - - // -- Perform the CredSSP authentication with the client (acting as a server) and the server (acting as a client) -- // - - let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream); - let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); - - 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_configs.server, - &credential_injection_kdc, - &kdc_connector, - ); - - 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_configs.client, - &kdc_connector, - ); - - let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); - client_credssp_res.context("CredSSP with client")?; - server_credssp_res.context("CredSSP with server")?; - - // -- Intercept the Connect Confirm PDU, to override the server_security_protocol field -- // - - intercept_connect_confirm( - &mut client_framed, - &mut server_framed, - handshake_result.server_security_protocol, - ) - .await?; - - let (mut client_stream, client_leftover) = client_framed.into_inner(); - let (mut server_stream, server_leftover) = server_framed.into_inner(); - - // -- At this point, proceed to the usual two-way forwarding -- // - - info!("RDP-TLS forwarding (credential injection)"); - - client_stream - .write_all(&server_leftover) - .await - .context("write server leftover to client")?; - - server_stream - .write_all(&client_leftover) - .await - .context("write client leftover to server")?; - - Proxy::builder() - .conf(conf) - .session_info(session_info) - .address_a(client_addr) - .transport_a(client_stream) - .address_b(server_addr) - .transport_b(server_stream) - .sessions(sessions) - .subscriber_tx(subscriber_tx) - .disconnect_interest(disconnect_interest) - .build() - .select_dissector_and_forward() - .await - .context("RDP-TLS traffic proxying failed")?; - - Ok(()) -} - -#[derive(Debug)] -struct HandshakeResult { - client_security_protocol: nego::SecurityProtocol, - server_security_protocol: nego::SecurityProtocol, -} - -#[instrument(level = "debug", ret, skip_all)] -pub(crate) async fn intercept_connect_confirm( - client_framed: &mut ironrdp_tokio::MovableTokioFramed, - server_framed: &mut ironrdp_tokio::MovableTokioFramed, - server_security_protocol: nego::SecurityProtocol, -) -> anyhow::Result<()> -where - C: AsyncWrite + AsyncRead + Unpin + Send, - S: AsyncWrite + AsyncRead + Unpin + Send, -{ - let (_, received_frame) = client_framed - .read_pdu() - .await - .context("read MCS Connect Initial from client")?; - let received_connect_initial: x224::X224> = - ironrdp_core::decode(&received_frame).context("decode PDU from client")?; - let mut received_connect_initial: mcs::ConnectInitial = - ironrdp_core::decode(&received_connect_initial.0.data).context("decode Connect Initial PDU")?; - trace!(message = ?received_connect_initial, "Received Connect Initial PDU from client"); - - let mut gcc_blocks = received_connect_initial.conference_create_request.into_gcc_blocks(); - gcc_blocks.core.optional_data.server_selected_protocol = Some(server_security_protocol); - // Update the conference request with modified gcc_blocks. - received_connect_initial.conference_create_request = ironrdp_pdu::gcc::ConferenceCreateRequest::new(gcc_blocks)?; - trace!(message = ?received_connect_initial, "Send Connection Request PDU to server"); - let x224_msg_buf = ironrdp_core::encode_vec(&received_connect_initial)?; - let pdu = x224::X224Data { - data: std::borrow::Cow::Owned(x224_msg_buf), - }; - send_pdu(server_framed, &x224::X224(pdu)) - .await - .context("send connection request to server")?; - - Ok(()) -} - -#[instrument(name = "dual_handshake", level = "debug", ret, skip_all)] -async fn dual_handshake_until_tls_upgrade( - client_framed: &mut ironrdp_tokio::MovableTokioFramed, - server_framed: &mut ironrdp_tokio::MovableTokioFramed, - target_credential: &AppCredential, -) -> anyhow::Result -where - C: AsyncWrite + AsyncRead + Unpin + Send, - S: AsyncWrite + AsyncRead + Unpin + Send, -{ - let (_, received_frame) = client_framed.read_pdu().await.context("read PDU from client")?; - let received_connection_request: x224::X224 = - ironrdp_core::decode(&received_frame).context("decode PDU from client")?; - trace!(message = ?received_connection_request, "Received Connection Request PDU from client"); - - // Choose the security protocol to use with the client. - let received_connection_request_protocol = received_connection_request.0.protocol; - let client_security_protocol = if received_connection_request_protocol.contains(nego::SecurityProtocol::HYBRID_EX) { - nego::SecurityProtocol::HYBRID_EX - } else if received_connection_request - .0 - .protocol - .contains(nego::SecurityProtocol::HYBRID) - { - nego::SecurityProtocol::HYBRID - } else { - anyhow::bail!( - "client does not support CredSSP (received {})", - received_connection_request.0.protocol - ) - }; - - let connection_request_to_send = nego::ConnectionRequest { - nego_data: match target_credential { - AppCredential::UsernamePassword { username, .. } => { - Some(nego::NegoRequestData::cookie(username.to_owned())) - } - }, - flags: received_connection_request.0.flags, - // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/902b090b-9cb3-4efc-92bf-ee13373371e3 - // - // The spec states that `PROTOCOL_SSL` "SHOULD" also be set when using `PROTOCOL_HYBRID`: - // - // > PROTOCOL_HYBRID (0x00000002) - // > Credential Security Support Provider protocol (CredSSP) (section 5.4.5.2). - // > If this flag is set, then the PROTOCOL_SSL (0x00000001) flag SHOULD also be set - // > because Transport Layer Security (TLS) is a subset of CredSSP. - // - // However, in practice `mstsc` is picky about these flags: it expects the - // SupportedProtocol bits in the ConnectionRequestPDU that reach the target - // server to match what the client originally sent. If the proxy modifies - // them (for example, forcing HYBRID | HYBRID_EX and/or clearing SSL), - // the connection can fail with an authentication error (Code: 0x609). - // - // We therefore *do not* synthesize a new protocol bitmask here anymore. - // Instead, we forward the client's SupportedProtocol flags as-is and - // enforce our policy by validating them: if HYBRID / HYBRID_EX are not - // present (i.e. NLA is not negotiated), we fail the connection rather - // than trying to "fix" the flags ourselves. - // - // See also: https://serverfault.com/a/720161 - protocol: received_connection_request_protocol, - }; - trace!(?connection_request_to_send, "Send Connection Request PDU to server"); - send_pdu(server_framed, &x224::X224(connection_request_to_send)) - .await - .context("send connection request to server")?; - - let (_, received_frame) = server_framed.read_pdu().await.context("read PDU from server")?; - let received_connection_confirm: x224::X224 = - ironrdp_core::decode(&received_frame).context("decode PDU from server")?; - trace!(message = ?received_connection_confirm, "Received Connection Confirm PDU from server"); - - let (connection_confirm_to_send, handshake_result) = match &received_connection_confirm.0 { - nego::ConnectionConfirm::Response { - flags, - protocol: server_security_protocol, - } => { - debug!(?server_security_protocol, ?flags, "Server confirmed connection"); - - let result = if !server_security_protocol - .intersects(nego::SecurityProtocol::HYBRID | nego::SecurityProtocol::HYBRID_EX) - { - Err(anyhow::anyhow!( - "server selected security protocol {server_security_protocol}, which is not supported for credential injection" - )) - } else { - Ok(HandshakeResult { - client_security_protocol, - server_security_protocol: *server_security_protocol, - }) - }; - - ( - x224::X224(nego::ConnectionConfirm::Response { - flags: *flags, - protocol: client_security_protocol, - }), - result, - ) - } - nego::ConnectionConfirm::Failure { code } => ( - x224::X224(received_connection_confirm.0.clone()), - Err(anyhow::anyhow!("RDP session initiation failed with code {code}")), - ), - }; - - trace!(?connection_confirm_to_send, "Send Connection Request PDU to client"); - send_pdu(client_framed, &connection_confirm_to_send) - .await - .context("send connection confirm to client")?; - - handshake_result -} - -/// 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 { - 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, - }); - } - - let krb_kdc = credential_injection_kdc - .krb_kdc() - .context("kerberos credential injection requires the krb_kdc target connection option")?; - - Ok(CredentialInjectionKerberosConfigs { - server: Some(credential_injection_kdc.server_kerberos_config(client_addr)?), - client: Some(ironrdp_connector::credssp::KerberosConfig { - kdc_proxy_url: Some(krb_kdc.clone()), - hostname: gateway_hostname.to_owned(), - }), - }) -} - -#[instrument(name = "server_credssp", level = "debug", ret, skip_all)] -pub(crate) async fn perform_credssp_as_client( - framed: &mut ironrdp_tokio::Framed, - server_name: String, - server_public_key: Vec, - security_protocol: nego::SecurityProtocol, - credentials: &AppCredential, - kerberos_config: Option, - kdc_connector: &KdcConnector, -) -> anyhow::Result<()> -where - S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, -{ - use ironrdp_tokio::FramedWrite as _; - - // Decrypt password into short-lived buffer. - let (username, decrypted_password) = credentials - .decrypt_password() - .context("failed to decrypt credentials")?; - - let credentials = ironrdp_connector::Credentials::UsernamePassword { - username, - password: decrypted_password.expose_secret().to_owned(), - }; - // decrypted_password drops here, zeroizing its buffer; note: a copy of the plaintext - // remains in `credentials` above, which is a regular String (downstream API limitation). - - let (mut sequence, mut ts_request) = ironrdp_connector::credssp::CredsspSequence::init( - credentials, - None, - security_protocol, - ironrdp_connector::ServerName::new(server_name.clone()), - server_public_key, - kerberos_config, - )?; - - let mut buf = ironrdp_pdu::WriteBuf::new(); - - loop { - let client_state = { - let mut generator = sequence.process_ts_request(ts_request); - resolve_client_generator(&mut generator, kdc_connector).await? - }; // drop generator - - buf.clear(); - let written = sequence.handle_process_result(client_state, &mut buf)?; - - if let Some(response_len) = written.size() { - let response = &buf[..response_len]; - framed - .write_all(response) - .await - .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; - } - - let Some(next_pdu_hint) = sequence.next_pdu_hint() else { - break; - }; - - let pdu = framed.read_by_hint(next_pdu_hint).await.context("read frame by hint")?; - - if let Some(next_request) = sequence.decode_server_message(&pdu)? { - ts_request = next_request; - } else { - break; - } - } - - Ok(()) -} - -async fn resolve_server_generator( - generator: &mut CredsspServerProcessGenerator<'_>, - credential_injection_kdc: &CredentialInjectionKdc, - kdc_connector: &KdcConnector, -) -> Result { - let mut state = generator.start(); - - loop { - match state { - GeneratorState::Suspended(request) => { - let response = match credential_injection_kdc.intercept_network_request(&request) { - Ok(CredentialInjectionKdcInterception::Intercepted(response)) => Ok(response), - Ok(CredentialInjectionKdcInterception::NotInjectionRequest) => { - kdc_connector.send_network_request(&request).await - } - Ok(CredentialInjectionKdcInterception::NotInjectionRealm(mismatch)) => Err(anyhow::anyhow!( - "kdc request realm does not match credential-injection session realm: {mismatch}" - )), - Err(error) => Err(error), - } - .map_err(|err| sspi::credssp::ServerError { - ts_request: None, - error: sspi::Error::new(sspi::ErrorKind::InternalError, err), - })?; - - state = generator.resume(Ok(response)); - } - GeneratorState::Completed(client_state) => { - break client_state; - } - } - } -} - -async fn resolve_client_generator( - generator: &mut CredsspClientProcessGenerator<'_>, - kdc_connector: &KdcConnector, -) -> anyhow::Result { - let mut state = generator.start(); - - loop { - match state { - GeneratorState::Suspended(request) => { - let response = kdc_connector.send_network_request(&request).await?; - state = generator.resume(Ok(response)); - } - GeneratorState::Completed(client_state) => { - break Ok(client_state.map_err(|e| { - ironrdp_connector::ConnectorError::new("CredSSP", ironrdp_connector::ConnectorErrorKind::Credssp(e)) - })?); - } - }; - } -} - -#[expect(clippy::too_many_arguments)] -#[instrument(name = "client_credssp", level = "debug", ret, skip_all)] -pub(crate) async fn perform_credssp_as_server( - framed: &mut ironrdp_tokio::Framed, - client_addr: IpAddr, - gateway_public_key: Vec, - security_protocol: nego::SecurityProtocol, - credentials: &AppCredential, - kerberos_server_config: Option, - credential_injection_kdc: &CredentialInjectionKdc, - kdc_connector: &KdcConnector, -) -> anyhow::Result<()> -where - S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, -{ - use ironrdp_connector::sspi::credssp::EarlyUserAuthResult; - use ironrdp_tokio::FramedWrite as _; - - let mut buf = ironrdp_pdu::WriteBuf::new(); - - // Are we supposed to use the actual computer name of the client? - // But this does not seem to matter so far, so we stringify the IP address of the client instead. - let client_computer_name = ironrdp_connector::ServerName::new(client_addr.to_string()); - - let result = credssp_loop( - framed, - &mut buf, - client_computer_name, - gateway_public_key, - credentials, - kerberos_server_config, - credential_injection_kdc, - kdc_connector, - ) - .await; - - if security_protocol.intersects(nego::SecurityProtocol::HYBRID_EX) { - trace!(?result, "HYBRID_EX"); - - let result = if result.is_ok() { - EarlyUserAuthResult::Success - } else { - EarlyUserAuthResult::AccessDenied - }; - - buf.clear(); - result.to_buffer(&mut buf).context("write early user auth result")?; - let response = &buf[..result.buffer_len()]; - framed.write_all(response).await.context("write_all")?; - } - - return result; - - async fn credssp_loop( - framed: &mut ironrdp_tokio::Framed, - buf: &mut ironrdp_pdu::WriteBuf, - client_computer_name: ironrdp_connector::ServerName, - public_key: Vec, - credentials: &AppCredential, - kerberos_server_config: Option, - credential_injection_kdc: &CredentialInjectionKdc, - kdc_connector: &KdcConnector, - ) -> anyhow::Result<()> - where - S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, - { - // Decrypt password into short-lived buffer. - let (username, decrypted_password) = credentials - .decrypt_password() - .context("failed to decrypt credentials")?; - - let username = sspi::Username::parse(&username).context("invalid username")?; - - let identity = sspi::AuthIdentity { - username, - password: decrypted_password.expose_secret().to_owned().into(), - }; - // decrypted_password drops here, zeroizing its buffer; note: a copy of the plaintext - // remains in `identity` above (downstream API limitation). - - let mut sequence = ironrdp_acceptor::credssp::CredsspSequence::init( - &identity, - client_computer_name, - public_key, - kerberos_server_config, - )?; - - loop { - let Some(next_pdu_hint) = sequence.next_pdu_hint()? else { - break; - }; - - let pdu = framed - .read_by_hint(next_pdu_hint) - .await - .map_err(|e| ironrdp_connector::custom_err!("read frame by hint", e))?; - - let Some(ts_request) = sequence.decode_client_message(&pdu)? else { - break; - }; - - let result = { - let mut generator = sequence.process_ts_request(ts_request); - resolve_server_generator(&mut generator, credential_injection_kdc, kdc_connector).await - }; // drop generator - - buf.clear(); - let written = sequence.handle_process_result(result, buf)?; - - if let Some(response_len) = written.size() { - let response = &buf[..response_len]; - framed - .write_all(response) - .await - .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; - } - } - - Ok(()) - } -} - -async fn send_pdu(framed: &mut ironrdp_tokio::MovableTokioFramed, pdu: &P) -> anyhow::Result<()> -where - S: AsyncWrite + Unpin + Send, - P: ironrdp_core::Encode, -{ - use ironrdp_tokio::FramedWrite as _; - - let payload = ironrdp_core::encode_vec(pdu).context("failed to encode PDU")?; - framed.write_all(&payload).await.context("failed to write PDU")?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::net::{Ipv4Addr, SocketAddr}; - use std::sync::Arc; - - use base64::Engine as _; - use secrecy::SecretString; - use uuid::Uuid; - - use super::*; - use crate::config::ConfHandle; - use crate::credential::{CleartextAppCredential, CleartextAppCredentialMapping}; - use crate::credential_injection_kdc::CredentialService; - use crate::target_connection_options::TargetConnectionOptions; - - const TEST_CONFIG: &str = r#"{ - "Hostname": "dgateway.localhost.com", - "ProvisionerPublicKeyData": { - "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" - }, - "Listeners": [ - { "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" } - ], - "__debug__": { "disable_token_validation": true } - }"#; - - const KERBEROS_CONFIG: &str = r#"{ - "Hostname": "dgateway.localhost.com", - "ProvisionerPublicKeyData": { - "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" - }, - "Listeners": [ - { "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" } - ], - "__debug__": { - "disable_token_validation": true, - "enable_unstable": true, - "kerberos_credential_injection": true - } - }"#; - - fn conf(json: &str) -> Arc { - ConfHandle::mock(json).expect("test config is valid").get_conf() - } - - fn client_addr() -> SocketAddr { - SocketAddr::from((Ipv4Addr::LOCALHOST, 33_889)) - } - - fn association_token(jti: Uuid) -> String { - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header = engine.encode(r#"{"alg":"RS256"}"#); - let payload = engine.encode( - serde_json::to_vec(&serde_json::json!({ - "jti": jti, - "dst_hst": "target.example:3389" - })) - .expect("payload serializes"), - ); - let signature = engine.encode(b"signature"); - format!("{header}.{payload}.{signature}") - } - - fn mapping(target_username: &str) -> CleartextAppCredentialMapping { - CleartextAppCredentialMapping { - proxy: CleartextAppCredential::UsernamePassword { - username: "proxy@example.invalid".to_owned(), - password: SecretString::from("pwd"), - }, - target: CleartextAppCredential::UsernamePassword { - username: target_username.to_owned(), - password: SecretString::from("pwd"), - }, - } - } - - /// Provision credentials (and optional `krb_kdc`) then resolve the injection KDC — the - /// in-process path RDP takes before building CredSSP Kerberos configs. - fn provisioned_kdc(target_username: &str, krb_kdc: Option<&str>) -> CredentialInjectionKdc { - let service = CredentialService::new(ConfHandle::mock(TEST_CONFIG).expect("test config is valid")); - let jti = Uuid::new_v4(); - service - .insert_credentials( - association_token(jti), - Some(mapping(target_username)), - time::Duration::minutes(5), - ) - .expect("credentials insert"); - if let Some(krb_kdc) = krb_kdc { - let options = TargetConnectionOptions::new(Some(krb_kdc)).expect("valid krb_kdc"); - service.insert_connection_options(jti, options, time::Duration::minutes(5)); - } - service.kdc_for(jti).expect("kdc_for resolves provisioned state") - } - - // 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)); - } - - #[test] - fn provisioned_krb_kdc_becomes_client_kdc_proxy_url() { - let conf = conf(KERBEROS_CONFIG); - let kdc = provisioned_kdc("administrator@example.invalid", Some("tcp://dc.example.com:88")); - - let configs = - credential_injection_kerberos_configs(conf.as_ref(), client_addr(), "dgateway.localhost.com", &kdc) - .expect("kerberos configs build when krb_kdc is provisioned"); - - let client = configs.client.expect("client leg speaks Kerberos"); - assert_eq!( - client.kdc_proxy_url.as_ref().map(url::Url::as_str), - Some("tcp://dc.example.com:88"), - "target-side CredSSP must use the provisioned KDC URL", - ); - assert_eq!(client.hostname, "dgateway.localhost.com"); - assert!(configs.server.is_some(), "both CredSSP legs must agree on Kerberos"); - } - - #[test] - fn kerberos_path_requires_provisioned_krb_kdc() { - let conf = conf(KERBEROS_CONFIG); - let kdc = provisioned_kdc("administrator@example.invalid", None); - - let error = - match credential_injection_kerberos_configs(conf.as_ref(), client_addr(), "dgateway.localhost.com", &kdc) { - Ok(_) => panic!("Kerberos without krb_kdc must fail before CredSSP starts"), - Err(error) => error, - }; - - assert!( - format!("{error:#}").contains("krb_kdc"), - "error should name the missing connection option, got: {error:#}", - ); - } - - #[test] - fn ntlm_path_does_not_require_krb_kdc() { - // Domainless target → NTLM decision even with Kerberos feature flags on. - let conf = conf(KERBEROS_CONFIG); - let kdc = provisioned_kdc("Administrator", None); - - let configs = - credential_injection_kerberos_configs(conf.as_ref(), client_addr(), "dgateway.localhost.com", &kdc) - .expect("NTLM path succeeds without connection options"); - - assert!(configs.client.is_none()); - assert!(configs.server.is_none()); - } - - #[test] - fn kerberos_flags_off_does_not_require_krb_kdc() { - // Domain-qualified target but feature flags off → NTLM on both legs. - let conf = conf(TEST_CONFIG); - let kdc = provisioned_kdc("administrator@example.invalid", None); - - let configs = - credential_injection_kerberos_configs(conf.as_ref(), client_addr(), "dgateway.localhost.com", &kdc) - .expect("flags off means NTLM without needing krb_kdc"); - - assert!(configs.client.is_none()); - assert!(configs.server.is_none()); - } -} diff --git a/devolutions-gateway/src/rdp_proxy/credssp.rs b/devolutions-gateway/src/rdp_proxy/credssp.rs new file mode 100644 index 000000000..db47beb87 --- /dev/null +++ b/devolutions-gateway/src/rdp_proxy/credssp.rs @@ -0,0 +1,571 @@ +//! CredSSP MITM for proxy-based RDP credential injection. +//! +//! Enclosed here so [`super::RdpProxy`] only orchestrates handshake and TLS upgrade. +//! The dual CredSSP legs, Kerberos config derivation, Connect Confirm intercept, and the +//! post-auth forward all live in [`CredsspSession::run`]. + +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::Context as _; +use ironrdp_acceptor::credssp::CredsspProcessGenerator as CredsspServerProcessGenerator; +use ironrdp_connector::credssp::CredsspProcessGenerator as CredsspClientProcessGenerator; +use ironrdp_connector::sspi; +use ironrdp_connector::sspi::generator::GeneratorState; +use ironrdp_pdu::{mcs, nego, x224}; +use secrecy::ExposeSecret as _; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _}; +use typed_builder::TypedBuilder; + +use super::send_pdu; +use crate::config::Conf; +use crate::credential::AppCredential; +use crate::credential_injection::{CredentialInjection, CredentialInjectionKdc, CredentialInjectionKdcInterception}; +use crate::kdc_connector::KdcConnector; +use crate::proxy::Proxy; +use crate::session::{DisconnectInterest, SessionInfo, SessionMessageSender}; +use crate::subscriber::SubscriberSender; + +/// Long-lived inputs for the CredSSP MITM + forward phase. +#[derive(TypedBuilder)] +pub(crate) struct CredsspSession { + conf: Arc, + session_info: SessionInfo, + client_addr: SocketAddr, + server_addr: SocketAddr, + credential_injection: CredentialInjection, + sessions: SessionMessageSender, + subscriber_tx: SubscriberSender, + server_dns_name: String, + disconnect_interest: Option, + kdc_connector: KdcConnector, +} + +/// Streams and keys collected after TLS upgrade, ready for CredSSP. +#[derive(TypedBuilder)] +pub(crate) struct PreparedCredssp { + client_stream: C, + server_stream: S, + gateway_public_key: Vec, + server_public_key: Vec, + client_security_protocol: nego::SecurityProtocol, + server_security_protocol: nego::SecurityProtocol, +} + +impl CredsspSession { + pub(super) fn conf(&self) -> &Conf { + &self.conf + } + + pub(super) fn server_dns_name(&self) -> &str { + &self.server_dns_name + } + + pub(super) fn target_credential(&self) -> &AppCredential { + self.credential_injection.target_credential() + } + + /// Run both CredSSP legs, fix Connect Confirm, then forward RDP-TLS. + pub(crate) async fn run(self, prepared: PreparedCredssp) -> anyhow::Result<()> + where + C: AsyncRead + AsyncWrite + Unpin + Send, + S: AsyncRead + AsyncWrite + Unpin + Send, + { + let Self { + conf, + session_info, + client_addr, + server_addr, + credential_injection, + sessions, + subscriber_tx, + server_dns_name, + disconnect_interest, + kdc_connector, + } = self; + let PreparedCredssp { + client_stream, + server_stream, + gateway_public_key, + server_public_key, + client_security_protocol, + server_security_protocol, + } = prepared; + + let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream); + let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); + + let client_credssp_fut = perform_credssp_as_server( + &mut client_framed, + client_addr, + gateway_public_key, + client_security_protocol, + &credential_injection, + &kdc_connector, + ); + + let server_credssp_fut = perform_credssp_as_client( + &mut server_framed, + server_dns_name, + server_public_key, + server_security_protocol, + &credential_injection, + &conf.hostname, + &kdc_connector, + ); + + let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); + client_credssp_res.context("CredSSP with client")?; + server_credssp_res.context("CredSSP with server")?; + + intercept_connect_confirm(&mut client_framed, &mut server_framed, server_security_protocol).await?; + + let (mut client_stream, client_leftover) = client_framed.into_inner(); + let (mut server_stream, server_leftover) = server_framed.into_inner(); + + info!("RDP-TLS forwarding (credential injection)"); + + client_stream + .write_all(&server_leftover) + .await + .context("write server leftover to client")?; + + server_stream + .write_all(&client_leftover) + .await + .context("write client leftover to server")?; + + Proxy::builder() + .conf(conf) + .session_info(session_info) + .address_a(client_addr) + .transport_a(client_stream) + .address_b(server_addr) + .transport_b(server_stream) + .sessions(sessions) + .subscriber_tx(subscriber_tx) + .disconnect_interest(disconnect_interest) + .build() + .select_dissector_and_forward() + .await + .context("RDP-TLS traffic proxying failed")?; + + Ok(()) + } +} + +pub(crate) async fn intercept_connect_confirm( + client_framed: &mut ironrdp_tokio::MovableTokioFramed, + server_framed: &mut ironrdp_tokio::MovableTokioFramed, + server_security_protocol: nego::SecurityProtocol, +) -> anyhow::Result<()> +where + C: AsyncWrite + AsyncRead + Unpin + Send, + S: AsyncWrite + AsyncRead + Unpin + Send, +{ + let (_, received_frame) = client_framed + .read_pdu() + .await + .context("read MCS Connect Initial from client")?; + let received_connect_initial: x224::X224> = + ironrdp_core::decode(&received_frame).context("decode PDU from client")?; + let mut received_connect_initial: mcs::ConnectInitial = + ironrdp_core::decode(&received_connect_initial.0.data).context("decode Connect Initial PDU")?; + trace!(message = ?received_connect_initial, "Received Connect Initial PDU from client"); + + let mut gcc_blocks = received_connect_initial.conference_create_request.into_gcc_blocks(); + gcc_blocks.core.optional_data.server_selected_protocol = Some(server_security_protocol); + // Update the conference request with modified gcc_blocks. + received_connect_initial.conference_create_request = ironrdp_pdu::gcc::ConferenceCreateRequest::new(gcc_blocks)?; + trace!(message = ?received_connect_initial, "Send Connection Request PDU to server"); + let x224_msg_buf = ironrdp_core::encode_vec(&received_connect_initial)?; + let pdu = x224::X224Data { + data: std::borrow::Cow::Owned(x224_msg_buf), + }; + send_pdu(server_framed, &x224::X224(pdu)) + .await + .context("send connection request to server")?; + + Ok(()) +} + +fn server_kerberos_setup( + client_addr: SocketAddr, + injection: &CredentialInjection, +) -> anyhow::Result<(Option, Option<&CredentialInjectionKdc>)> { + let Some(kerberos) = injection.as_kerberos() else { + return Ok((None, None)); + }; + let synthetic = kerberos.synthetic_kdc(); + Ok((Some(synthetic.server_kerberos_config(client_addr)?), Some(synthetic))) +} + +fn client_kerberos_config( + gateway_hostname: &str, + injection: &CredentialInjection, +) -> anyhow::Result> { + let Some(kerberos) = injection.as_kerberos() else { + return Ok(None); + }; + Ok(Some(ironrdp_connector::credssp::KerberosConfig { + kdc_proxy_url: Some(kerberos.target_kdc().clone()), + hostname: gateway_hostname.to_owned(), + })) +} + +#[instrument(name = "server_credssp", level = "debug", ret, skip_all)] +pub(crate) async fn perform_credssp_as_client( + framed: &mut ironrdp_tokio::Framed, + server_name: String, + server_public_key: Vec, + security_protocol: nego::SecurityProtocol, + injection: &CredentialInjection, + gateway_hostname: &str, + kdc_connector: &KdcConnector, +) -> anyhow::Result<()> +where + S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, +{ + use ironrdp_tokio::FramedWrite as _; + + let credentials = injection.target_credential(); + let kerberos_config = client_kerberos_config(gateway_hostname, injection)?; + + // Decrypt password into short-lived buffer. + let (username, decrypted_password) = credentials + .decrypt_password() + .context("failed to decrypt credentials")?; + + let credentials = ironrdp_connector::Credentials::UsernamePassword { + username, + password: decrypted_password.expose_secret().to_owned(), + }; + // decrypted_password drops here, zeroizing its buffer; note: a copy of the plaintext + // remains in `credentials` above, which is a regular String (downstream API limitation). + + let (mut sequence, mut ts_request) = ironrdp_connector::credssp::CredsspSequence::init( + credentials, + None, + security_protocol, + ironrdp_connector::ServerName::new(server_name.clone()), + server_public_key, + kerberos_config, + )?; + + let mut buf = ironrdp_pdu::WriteBuf::new(); + + loop { + let client_state = { + let mut generator = sequence.process_ts_request(ts_request); + resolve_client_generator(&mut generator, kdc_connector).await? + }; // drop generator + + buf.clear(); + let written = sequence.handle_process_result(client_state, &mut buf)?; + + if let Some(response_len) = written.size() { + let response = &buf[..response_len]; + framed + .write_all(response) + .await + .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; + } + + let Some(next_pdu_hint) = sequence.next_pdu_hint() else { + break; + }; + + let pdu = framed.read_by_hint(next_pdu_hint).await.context("read frame by hint")?; + + if let Some(next_request) = sequence.decode_server_message(&pdu)? { + ts_request = next_request; + } else { + break; + } + } + + Ok(()) +} + +async fn resolve_server_generator( + generator: &mut CredsspServerProcessGenerator<'_>, + credential_injection_kdc: Option<&CredentialInjectionKdc>, + kdc_connector: &KdcConnector, +) -> Result { + let mut state = generator.start(); + + loop { + match state { + GeneratorState::Suspended(request) => { + let kdc = credential_injection_kdc.ok_or_else(|| sspi::credssp::ServerError { + ts_request: None, + error: sspi::Error::new( + sspi::ErrorKind::InternalError, + "Kerberos CredSSP generator requires a synthetic KDC", + ), + })?; + let response = match kdc.intercept_network_request(&request) { + Ok(CredentialInjectionKdcInterception::Intercepted(response)) => Ok(response), + Ok(CredentialInjectionKdcInterception::NotInjectionRequest) => { + kdc_connector.send_network_request(&request).await + } + Ok(CredentialInjectionKdcInterception::NotInjectionRealm(mismatch)) => Err(anyhow::anyhow!( + "kdc request realm does not match credential-injection session realm: {mismatch}" + )), + Err(error) => Err(error), + } + .map_err(|err| sspi::credssp::ServerError { + ts_request: None, + error: sspi::Error::new(sspi::ErrorKind::InternalError, err), + })?; + + state = generator.resume(Ok(response)); + } + GeneratorState::Completed(client_state) => { + break client_state; + } + } + } +} + +async fn resolve_client_generator( + generator: &mut CredsspClientProcessGenerator<'_>, + kdc_connector: &KdcConnector, +) -> anyhow::Result { + let mut state = generator.start(); + + loop { + match state { + GeneratorState::Suspended(request) => { + let response = kdc_connector.send_network_request(&request).await?; + state = generator.resume(Ok(response)); + } + GeneratorState::Completed(client_state) => { + break Ok(client_state.map_err(|e| { + ironrdp_connector::ConnectorError::new("CredSSP", ironrdp_connector::ConnectorErrorKind::Credssp(e)) + })?); + } + }; + } +} + +#[instrument(name = "client_credssp", level = "debug", ret, skip_all)] +pub(crate) async fn perform_credssp_as_server( + framed: &mut ironrdp_tokio::Framed, + client_addr: SocketAddr, + gateway_public_key: Vec, + security_protocol: nego::SecurityProtocol, + injection: &CredentialInjection, + kdc_connector: &KdcConnector, +) -> anyhow::Result<()> +where + S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, +{ + use ironrdp_connector::sspi::credssp::EarlyUserAuthResult; + use ironrdp_tokio::FramedWrite as _; + + let mut buf = ironrdp_pdu::WriteBuf::new(); + + // Are we supposed to use the actual computer name of the client? + // But this does not seem to matter so far, so we stringify the IP address of the client instead. + let client_computer_name = ironrdp_connector::ServerName::new(client_addr.ip().to_string()); + + let (kerberos_server_config, synthetic_kdc) = server_kerberos_setup(client_addr, injection)?; + let credentials = injection.proxy_credential(); + + let result = credssp_loop( + framed, + &mut buf, + client_computer_name, + gateway_public_key, + credentials, + kerberos_server_config, + synthetic_kdc, + kdc_connector, + ) + .await; + + if security_protocol.intersects(nego::SecurityProtocol::HYBRID_EX) { + trace!(?result, "HYBRID_EX"); + + let result = if result.is_ok() { + EarlyUserAuthResult::Success + } else { + EarlyUserAuthResult::AccessDenied + }; + + buf.clear(); + result.to_buffer(&mut buf).context("write early user auth result")?; + let response = &buf[..result.buffer_len()]; + framed.write_all(response).await.context("write_all")?; + } + + return result; + + #[allow(clippy::too_many_arguments)] + async fn credssp_loop( + framed: &mut ironrdp_tokio::Framed, + buf: &mut ironrdp_pdu::WriteBuf, + client_computer_name: ironrdp_connector::ServerName, + public_key: Vec, + credentials: &AppCredential, + kerberos_server_config: Option, + credential_injection_kdc: Option<&CredentialInjectionKdc>, + kdc_connector: &KdcConnector, + ) -> anyhow::Result<()> + where + S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, + { + // Decrypt password into short-lived buffer. + let (username, decrypted_password) = credentials + .decrypt_password() + .context("failed to decrypt credentials")?; + + let username = sspi::Username::parse(&username).context("invalid username")?; + + let identity = sspi::AuthIdentity { + username, + password: decrypted_password.expose_secret().to_owned().into(), + }; + // decrypted_password drops here, zeroizing its buffer; note: a copy of the plaintext + // remains in `identity` above (downstream API limitation). + + let mut sequence = ironrdp_acceptor::credssp::CredsspSequence::init( + &identity, + client_computer_name, + public_key, + kerberos_server_config, + )?; + + loop { + let Some(next_pdu_hint) = sequence.next_pdu_hint()? else { + break; + }; + + let pdu = framed + .read_by_hint(next_pdu_hint) + .await + .map_err(|e| ironrdp_connector::custom_err!("read frame by hint", e))?; + + let Some(ts_request) = sequence.decode_client_message(&pdu)? else { + break; + }; + + let result = { + let mut generator = sequence.process_ts_request(ts_request); + resolve_server_generator(&mut generator, credential_injection_kdc, kdc_connector).await + }; // drop generator + + buf.clear(); + let written = sequence.handle_process_result(result, buf)?; + + if let Some(response_len) = written.size() { + let response = &buf[..response_len]; + framed + .write_all(response) + .await + .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use secrecy::SecretString; + use uuid::Uuid; + + use super::*; + use crate::credential::{CleartextAppCredential, CleartextAppCredentialMapping}; + use crate::credential_injection::{CredentialInjection, SyntheticKdcRegistry}; + use crate::provisioning::ProvisioningStore; + use crate::target_connection_options::TargetConnectionOptions; + + fn association_token(jti: Uuid) -> String { + use base64::Engine as _; + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode(r#"{"alg":"RS256"}"#); + let payload = engine.encode( + serde_json::to_vec(&serde_json::json!({ + "jti": jti, + "dst_hst": "target.example:3389" + })) + .expect("payload serializes"), + ); + let signature = engine.encode(b"signature"); + format!("{header}.{payload}.{signature}") + } + + fn kerberos_injection() -> CredentialInjection { + let jti = Uuid::new_v4(); + let store = ProvisioningStore::new(); + store + .insert_credentials( + 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"), + }, + }), + time::Duration::minutes(5), + ) + .expect("credentials"); + let options = TargetConnectionOptions::new(Some("tcp://dc.example.com:88")).expect("kdc"); + store.insert_connection_options(jti, options, time::Duration::minutes(5)); + let entry = store.take(jti).expect("entry"); + CredentialInjection::from_provisioned(jti, entry, true) + .expect("prepared") + .register_if_kerberos(&SyntheticKdcRegistry::new()) + } + + #[test] + fn client_kerberos_config_uses_provisioned_target_kdc_url() { + let injection = kerberos_injection(); + let config = client_kerberos_config("dgateway.localhost.com", &injection) + .expect("config builds") + .expect("kerberos client leg"); + + assert_eq!( + config.kdc_proxy_url.as_ref().map(url::Url::as_str), + Some("tcp://dc.example.com:88"), + "CredSSP kdc_proxy_url must be the provisioned krb_kdc", + ); + assert_eq!(config.hostname, "dgateway.localhost.com"); + } + + #[test] + fn client_kerberos_config_is_none_for_ntlm() { + let jti = Uuid::new_v4(); + let store = ProvisioningStore::new(); + store + .insert_credentials( + association_token(jti), + Some(CleartextAppCredentialMapping { + proxy: CleartextAppCredential::UsernamePassword { + username: "proxy@example.invalid".to_owned(), + password: SecretString::from("pwd"), + }, + target: CleartextAppCredential::UsernamePassword { + username: "Administrator".to_owned(), + password: SecretString::from("pwd"), + }, + }), + time::Duration::minutes(5), + ) + .expect("credentials"); + let entry = store.take(jti).expect("entry"); + let injection = CredentialInjection::from_provisioned(jti, entry, true) + .expect("ntlm prepared") + .register_if_kerberos(&SyntheticKdcRegistry::new()); + + let config = client_kerberos_config("dgateway.localhost.com", &injection).expect("ntlm ok"); + assert!(config.is_none()); + } +} diff --git a/devolutions-gateway/src/rdp_proxy/mod.rs b/devolutions-gateway/src/rdp_proxy/mod.rs new file mode 100644 index 000000000..85ee1af48 --- /dev/null +++ b/devolutions-gateway/src/rdp_proxy/mod.rs @@ -0,0 +1,278 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::Context as _; +use ironrdp_pdu::{nego, x224}; +use tokio::io::{AsyncRead, AsyncWrite}; +use typed_builder::TypedBuilder; + +mod credssp; + +use credssp::CredsspSession; +pub(crate) use credssp::{ + PreparedCredssp, intercept_connect_confirm, perform_credssp_as_client, perform_credssp_as_server, +}; + +use crate::config::Conf; +use crate::credential::AppCredential; +use crate::credential_injection::CredentialInjection; +use crate::kdc_connector::KdcConnector; +use crate::session::{DisconnectInterest, SessionInfo, SessionMessageSender}; +use crate::subscriber::SubscriberSender; + +/// RDP proxy for credential-injection sessions. +/// +/// The main path only orchestrates handshake and TLS upgrade. CredSSP MITM and the subsequent +/// forward live in [`CredsspSession`] / [`PreparedCredssp`]. +#[derive(TypedBuilder)] +pub struct RdpProxy { + conf: Arc, + session_info: SessionInfo, + client_stream: C, + client_addr: SocketAddr, + server_stream: S, + server_addr: SocketAddr, + credential_injection: CredentialInjection, + client_stream_leftover_bytes: bytes::BytesMut, + sessions: SessionMessageSender, + subscriber_tx: SubscriberSender, + server_dns_name: String, + disconnect_interest: Option, + kdc_connector: KdcConnector, +} + +impl RdpProxy +where + A: AsyncWrite + AsyncRead + Unpin + Send, + B: AsyncWrite + AsyncRead + Unpin + Send, +{ + pub async fn run(self) -> anyhow::Result<()> { + handle(self).await + } +} + +#[instrument("rdp_proxy", skip_all, fields(session_id = proxy.session_info.id.to_string(), target = proxy.server_addr.to_string()))] +async fn handle(proxy: RdpProxy) -> anyhow::Result<()> +where + C: AsyncRead + AsyncWrite + Unpin + Send, + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + let RdpProxy { + conf, + session_info, + client_stream, + client_addr, + server_stream, + server_addr, + credential_injection, + client_stream_leftover_bytes, + sessions, + subscriber_tx, + server_dns_name, + disconnect_interest, + kdc_connector, + } = proxy; + + let session = CredsspSession::builder() + .conf(conf) + .session_info(session_info) + .client_addr(client_addr) + .server_addr(server_addr) + .credential_injection(credential_injection) + .sessions(sessions) + .subscriber_tx(subscriber_tx) + .server_dns_name(server_dns_name.clone()) + .disconnect_interest(disconnect_interest) + .kdc_connector(kdc_connector) + .build(); + + let tls_conf = session.conf().credssp_tls.get().context("CredSSP TLS configuration")?; + let gateway_hostname = session.conf().hostname.clone(); + + // -- Retrieve the Gateway TLS public key that must be used for client-proxy CredSSP later on -- // + + let gateway_cert_chain_handle = tokio::spawn(crate::tls::get_cert_chain_for_acceptor_cached( + gateway_hostname.clone(), + tls_conf.acceptor.clone(), + )); + + // -- Dual handshake with the client and the server until the TLS security upgrade -- // + + let mut client_framed = + ironrdp_tokio::MovableTokioFramed::new_with_leftover(client_stream, client_stream_leftover_bytes); + let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); + + let handshake_result = + dual_handshake_until_tls_upgrade(&mut client_framed, &mut server_framed, session.target_credential()).await?; + + let client_stream = client_framed.into_inner_no_leftover(); + let server_stream = server_framed.into_inner_no_leftover(); + + // -- Perform the TLS upgrading for both the client and the server, effectively acting as a man-in-the-middle -- // + + let client_tls_upgrade_fut = tls_conf.acceptor.accept(client_stream); + let server_tls_upgrade_fut = crate::tls::dangerous_connect(session.server_dns_name().to_owned(), server_stream); + + let (client_stream, server_stream) = tokio::join!(client_tls_upgrade_fut, server_tls_upgrade_fut); + + let client_stream = client_stream.context("TLS upgrade with client failed")?; + let server_stream = server_stream.context("TLS upgrade with server failed")?; + + let server_public_key = + crate::tls::extract_stream_peer_public_key(&server_stream).context("extract target server TLS public key")?; + + let gateway_cert_chain = gateway_cert_chain_handle.await??; + let gateway_public_key = crate::tls::extract_public_key(gateway_cert_chain.first().context("no leaf")?) + .context("extract Gateway public key")?; + + let prepared = PreparedCredssp::builder() + .client_stream(client_stream) + .server_stream(server_stream) + .gateway_public_key(gateway_public_key) + .server_public_key(server_public_key) + .client_security_protocol(handshake_result.client_security_protocol) + .server_security_protocol(handshake_result.server_security_protocol) + .build(); + + // CredSSP MITM + Connect Confirm intercept + bidirectional forward: owned by CredsspSession. + session.run(prepared).await +} + +#[derive(Debug)] +struct HandshakeResult { + client_security_protocol: nego::SecurityProtocol, + server_security_protocol: nego::SecurityProtocol, +} + +#[instrument(name = "dual_handshake", level = "debug", ret, skip_all)] +async fn dual_handshake_until_tls_upgrade( + client_framed: &mut ironrdp_tokio::MovableTokioFramed, + server_framed: &mut ironrdp_tokio::MovableTokioFramed, + target_credential: &AppCredential, +) -> anyhow::Result +where + C: AsyncWrite + AsyncRead + Unpin + Send, + S: AsyncWrite + AsyncRead + Unpin + Send, +{ + let (_, received_frame) = client_framed.read_pdu().await.context("read PDU from client")?; + let received_connection_request: x224::X224 = + ironrdp_core::decode(&received_frame).context("decode PDU from client")?; + trace!(message = ?received_connection_request, "Received Connection Request PDU from client"); + + // Choose the security protocol to use with the client. + let received_connection_request_protocol = received_connection_request.0.protocol; + let client_security_protocol = if received_connection_request_protocol.contains(nego::SecurityProtocol::HYBRID_EX) { + nego::SecurityProtocol::HYBRID_EX + } else if received_connection_request + .0 + .protocol + .contains(nego::SecurityProtocol::HYBRID) + { + nego::SecurityProtocol::HYBRID + } else { + anyhow::bail!( + "client does not support CredSSP (received {})", + received_connection_request.0.protocol + ) + }; + + let connection_request_to_send = nego::ConnectionRequest { + nego_data: match target_credential { + AppCredential::UsernamePassword { username, .. } => { + Some(nego::NegoRequestData::cookie(username.to_owned())) + } + }, + flags: received_connection_request.0.flags, + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/902b090b-9cb3-4efc-92bf-ee13373371e3 + // + // The spec states that `PROTOCOL_SSL` "SHOULD" also be set when using `PROTOCOL_HYBRID`: + // + // > PROTOCOL_HYBRID (0x00000002) + // > Credential Security Support Provider protocol (CredSSP) (section 5.4.5.2). + // > If this flag is set, then the PROTOCOL_SSL (0x00000001) flag SHOULD also be set + // > because Transport Layer Security (TLS) is a subset of CredSSP. + // + // However, in practice `mstsc` is picky about these flags: it expects the + // SupportedProtocol bits in the ConnectionRequestPDU that reach the target + // server to match what the client originally sent. If the proxy modifies + // them (for example, forcing HYBRID | HYBRID_EX and/or clearing SSL), + // the connection can fail with an authentication error (Code: 0x609). + // + // We therefore *do not* synthesize a new protocol bitmask here anymore. + // Instead, we forward the client's SupportedProtocol flags as-is and + // enforce our policy by validating them: if HYBRID / HYBRID_EX are not + // present (i.e. NLA is not negotiated), we fail the connection rather + // than trying to "fix" the flags ourselves. + // + // See also: https://serverfault.com/a/720161 + protocol: received_connection_request_protocol, + }; + trace!(?connection_request_to_send, "Send Connection Request PDU to server"); + send_pdu(server_framed, &x224::X224(connection_request_to_send)) + .await + .context("send connection request to server")?; + + let (_, received_frame) = server_framed.read_pdu().await.context("read PDU from server")?; + let received_connection_confirm: x224::X224 = + ironrdp_core::decode(&received_frame).context("decode PDU from server")?; + trace!(message = ?received_connection_confirm, "Received Connection Confirm PDU from server"); + + let (connection_confirm_to_send, handshake_result) = match &received_connection_confirm.0 { + nego::ConnectionConfirm::Response { + flags, + protocol: server_security_protocol, + } => { + debug!(?server_security_protocol, ?flags, "Server confirmed connection"); + + let result = if !server_security_protocol + .intersects(nego::SecurityProtocol::HYBRID | nego::SecurityProtocol::HYBRID_EX) + { + Err(anyhow::anyhow!( + "server selected security protocol {server_security_protocol}, which is not supported for credential injection" + )) + } else { + Ok(HandshakeResult { + client_security_protocol, + server_security_protocol: *server_security_protocol, + }) + }; + + ( + x224::X224(nego::ConnectionConfirm::Response { + flags: *flags, + protocol: client_security_protocol, + }), + result, + ) + } + nego::ConnectionConfirm::Failure { code } => ( + x224::X224(received_connection_confirm.0.clone()), + Err(anyhow::anyhow!("RDP session initiation failed with code {code}")), + ), + }; + + trace!(?connection_confirm_to_send, "Send Connection Request PDU to client"); + send_pdu(client_framed, &connection_confirm_to_send) + .await + .context("send connection confirm to client")?; + + handshake_result +} + +pub(super) async fn send_pdu(framed: &mut ironrdp_tokio::MovableTokioFramed, pdu: &P) -> anyhow::Result<()> +where + S: AsyncWrite + Unpin + Send, + P: ironrdp_core::Encode, +{ + use ironrdp_tokio::FramedWrite as _; + + let payload = ironrdp_core::encode_vec(pdu).context("failed to encode PDU")?; + framed.write_all(&payload).await.context("failed to write PDU")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + // Protocol selection is owned by CredentialInjection (from_provisioned + register_if_kerberos). + // See credential_injection tests for Kerberos-vs-NTLM decision coverage. +} diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index 6602c9746..fa2f8b6c1 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -267,7 +267,8 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { .await .context("failed to initialize traffic audit manager")?; - let credentials = devolutions_gateway::credential_injection_kdc::CredentialService::new(conf_handle.clone()); + let provisioning = devolutions_gateway::provisioning::ProvisioningStore::new(); + let synthetic_kdc_registry = devolutions_gateway::credential_injection::SyntheticKdcRegistry::new(); let filesystem_monitor_config_cache = devolutions_gateway::api::monitoring::FilesystemConfigCache::new( config::get_data_dir().join("monitors_cache.json"), @@ -315,7 +316,8 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { shutdown_signal: tasks.shutdown_signal.clone(), recordings: recording_manager_handle.clone(), job_queue_handle: job_queue_ctx.job_queue_handle.clone(), - credentials: credentials.clone(), + provisioning: provisioning.clone(), + synthetic_kdc_registry: synthetic_kdc_registry.clone(), monitoring_state, traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, @@ -350,11 +352,7 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { tasks.register(devolutions_gateway::token::CleanupTask { token_cache }); - tasks.register(devolutions_gateway::provisioning::CleanupTask { - handle: credentials.credential_store().clone(), - }); - - tasks.register(devolutions_gateway::credential_injection_kdc::CleanupTask { service: credentials }); + tasks.register(devolutions_gateway::provisioning::CleanupTask { handle: provisioning }); tasks.register(devolutions_log::LogDeleterTask::::new( conf.log_file.clone(), From 2a863542769d219c196fa051098d310671ed13cc Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 31 Jul 2026 10:50:36 -0400 Subject: [PATCH 02/36] fix(dgw): address Copilot review on CredSSP/provisioning refactor Authorize CleanPath tokens before one-shot take, use a registry-wide generation counter, and replace bare clippy allow with expect. SPN remains association-token dst_hst for client-facing CredSSP. --- .../src/credential_injection.rs | 12 +++---- devolutions-gateway/src/provisioning.rs | 13 +++++++ devolutions-gateway/src/rd_clean_path.rs | 35 +++++++++++-------- devolutions-gateway/src/rdp_proxy/credssp.rs | 5 ++- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/devolutions-gateway/src/credential_injection.rs b/devolutions-gateway/src/credential_injection.rs index 7a2341a7f..be7f19067 100644 --- a/devolutions-gateway/src/credential_injection.rs +++ b/devolutions-gateway/src/credential_injection.rs @@ -530,7 +530,8 @@ pub struct SyntheticKdcRegistry { #[derive(Debug, Default)] struct RegistryInner { live: HashMap, - next_generation: HashMap, + /// Registry-wide monotonic counter (not per-JTI) so generations stay unique without leaking map entries. + next_generation: u64, } #[derive(Debug, Clone)] @@ -572,16 +573,15 @@ impl SyntheticKdcRegistry { } } - fn allocate_generation(inner: &mut RegistryInner, jti: Uuid) -> u64 { - let slot = inner.next_generation.entry(jti).or_insert(0); - *slot = slot.wrapping_add(1); - *slot + fn allocate_generation(inner: &mut RegistryInner) -> u64 { + inner.next_generation = inner.next_generation.wrapping_add(1); + inner.next_generation } pub(crate) fn register(&self, kdc: Arc) -> SyntheticKdcRegistration { let jti = kdc.jti(); let mut inner = self.inner.lock(); - let generation = Self::allocate_generation(&mut inner, jti); + let generation = Self::allocate_generation(&mut inner); inner.live.insert(jti, PublishedSyntheticKdc { generation, kdc }); debug!(%jti, generation, "published synthetic KDC"); SyntheticKdcRegistration { diff --git a/devolutions-gateway/src/provisioning.rs b/devolutions-gateway/src/provisioning.rs index 76da7a671..e7cb50223 100644 --- a/devolutions-gateway/src/provisioning.rs +++ b/devolutions-gateway/src/provisioning.rs @@ -129,6 +129,19 @@ impl ProvisioningStore { self.connection_options.lock().insert(jti, entry).is_some() } + /// True when the credentials half is live and carries an injection mapping. + /// + /// Does not consume the entry — use before auth when deciding whether to take the + /// credential-injection path. + pub(crate) fn has_mapping(&self, jti: Uuid) -> bool { + let now = time::OffsetDateTime::now_utc(); + let entries = self.credentials.lock(); + match entries.get(&jti) { + Some(entry) if now < entry.expires_at => entry.mapping.is_some(), + _ => false, + } + } + /// Take the provisioned view for a session (one-shot). /// /// Removes the credentials half (required) and any live connection-options half for `jti`. diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index c4c104a7a..86e8e8f2d 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -438,7 +438,8 @@ async fn handle_with_credential_injection( subscriber_tx: SubscriberSender, active_recordings: &ActiveRecordings, cleanpath_pdu: RDCleanPathPdu, - credential_injection: CredentialInjection, + provisioning: &ProvisioningStore, + synthetic_kdc_registry: &SyntheticKdcRegistry, agent_tunnel_handle: Option>, ) -> anyhow::Result<()> { let tls_conf = conf.credssp_tls.get().context("CredSSP TLS configuration")?; @@ -482,6 +483,19 @@ async fn handle_with_credential_injection( .await .context("RDCleanPath authorization failed")?; + let token = cleanpath_pdu + .proxy_auth + .as_deref() + .context("missing token in RDCleanPath PDU")?; + let entry = provisioning + .take(claims.jti) + .context("provisioned credentials missing after authorization")?; + anyhow::ensure!(entry.mapping.is_some(), "provisioned entry has no credential mapping"); + anyhow::ensure!(token == entry.token, "token mismatch"); + let kerberos_enabled = conf.debug.enable_unstable && conf.debug.kerberos_credential_injection; + let credential_injection = CredentialInjection::from_provisioned(claims.jti, entry, kerberos_enabled)? + .register_if_kerberos(synthetic_kdc_registry); + let ConnectedRdpServer { tls_stream: server_stream, server_addr, @@ -651,10 +665,10 @@ pub async fn handle( // If a credential mapping has been pushed, we automatically switch to // proxy-based credential injection mode. Otherwise, we continue the usual - // clean path procedure. The credential store is keyed on the association token's JTI. + // clean path procedure. Peek only here — take after authorize_cleanpath so an + // unverified token cannot burn a victim JTI's one-shot groceries. if let Some(jti) = crate::token::extract_jti(token).ok() - && let Some(entry) = provisioning.take(jti) - && entry.mapping.is_some() + && provisioning.has_mapping(jti) { // VMConnect needs pre-X.224 CredSSP against the Hyper-V host cert on the client. // Proxy CredSSP MITM is X.224-first and is not supported for this ordering. @@ -664,15 +678,7 @@ pub async fn handle( anyhow::bail!("credential injection is not supported for VMConnect RDCleanPath"); } - anyhow::ensure!(token == entry.token, "token mismatch"); - let kerberos_enabled = conf.debug.enable_unstable && conf.debug.kerberos_credential_injection; - let credential_injection = CredentialInjection::from_provisioned(jti, entry, kerberos_enabled)? - .register_if_kerberos(synthetic_kdc_registry); - debug!( - jti = %credential_injection.jti(), - kerberos = credential_injection.uses_kerberos(), - "Switching to RdpProxy for credential injection (WebSocket)" - ); + debug!(%jti, "Switching to RdpProxy for credential injection (WebSocket)"); return handle_with_credential_injection( client_stream, @@ -684,7 +690,8 @@ pub async fn handle( subscriber_tx, active_recordings, cleanpath_pdu, - credential_injection, + provisioning, + synthetic_kdc_registry, agent_tunnel_handle.clone(), ) .await; diff --git a/devolutions-gateway/src/rdp_proxy/credssp.rs b/devolutions-gateway/src/rdp_proxy/credssp.rs index db47beb87..49d986652 100644 --- a/devolutions-gateway/src/rdp_proxy/credssp.rs +++ b/devolutions-gateway/src/rdp_proxy/credssp.rs @@ -402,7 +402,10 @@ where return result; - #[allow(clippy::too_many_arguments)] + #[expect( + clippy::too_many_arguments, + reason = "CredSSP loop needs framed IO, identity, optional synthetic KDC, and KdcConnector together", + )] async fn credssp_loop( framed: &mut ironrdp_tokio::Framed, buf: &mut ironrdp_pdu::WriteBuf, From dc7c48219d569d1a2981e1a07f0ca130f62ede9b Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 31 Jul 2026 10:58:21 -0400 Subject: [PATCH 03/36] style(dgw): rustfmt expect attribute --- devolutions-gateway/src/rdp_proxy/credssp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devolutions-gateway/src/rdp_proxy/credssp.rs b/devolutions-gateway/src/rdp_proxy/credssp.rs index 49d986652..5295b6e75 100644 --- a/devolutions-gateway/src/rdp_proxy/credssp.rs +++ b/devolutions-gateway/src/rdp_proxy/credssp.rs @@ -404,7 +404,7 @@ where #[expect( clippy::too_many_arguments, - reason = "CredSSP loop needs framed IO, identity, optional synthetic KDC, and KdcConnector together", + reason = "CredSSP loop needs framed IO, identity, optional synthetic KDC, and KdcConnector together" )] async fn credssp_loop( framed: &mut ironrdp_tokio::Framed, From b34472ff06c7ef602df1f8e790009efc44584961 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 14 Aug 2026 17:17:49 -0400 Subject: [PATCH 04/36] fix(dgw): pin injection destination to dst_hst Use association dst_hst for synthetic KDC SPN and target-leg Kerberos hostname instead of conf.hostname. Route RDCleanPath through CredsspSession, peek before one-shot take, and document checkout TTL. Issue: DGW review #1900 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/credential_injection.rs | 124 ++++++++++++----- devolutions-gateway/src/generic_client.rs | 21 ++- devolutions-gateway/src/openapi.rs | 8 +- devolutions-gateway/src/provisioning.rs | 11 +- devolutions-gateway/src/rd_clean_path.rs | 128 ++++++------------ devolutions-gateway/src/rdp_proxy/credssp.rs | 26 ++-- devolutions-gateway/src/rdp_proxy/mod.rs | 11 +- 7 files changed, 168 insertions(+), 161 deletions(-) diff --git a/devolutions-gateway/src/credential_injection.rs b/devolutions-gateway/src/credential_injection.rs index be7f19067..770d88886 100644 --- a/devolutions-gateway/src/credential_injection.rs +++ b/devolutions-gateway/src/credential_injection.rs @@ -1,8 +1,9 @@ -//! Credential-injection runtime: groceries → dish → synthetic KDC pass window. +//! Credential-injection runtime for RDP. //! -//! - Provisioned data lives in [`crate::provisioning::ProvisioningStore`] (supermarket). -//! - [`CredentialInjection`] is built by the RDP path from those groceries (chef). -//! - [`SyntheticKdcRegistry`] is the pass window: RDP publishes, `/jet/KdcProxy` looks up only. +//! - Provisioned material lives in [`crate::provisioning::ProvisioningStore`] until checkout. +//! - [`CredentialInjection::from_provisioned`] builds a session-scoped injection plan. +//! - Kerberos sessions publish a [`CredentialInjectionKdc`] into [`SyntheticKdcRegistry`]; +//! `/jet/KdcProxy` resolves only that registry (not the provisioning store). use std::collections::HashMap; use std::fmt; @@ -97,14 +98,14 @@ pub(crate) enum CredentialInjection { Ntlm(NtlmCredentialInjection), } -/// Kerberos dish: credentials + real KDC address + shared synthetic KDC. +/// Kerberos injection: credentials, target KDC URL, and the session synthetic KDC. pub(crate) struct KerberosCredentialInjection { credential_mapping: AppCredentialMapping, target_kdc: Url, synthetic: Arc, } -/// Chef output: protocol chosen; synthetic KDC built if needed, not yet published. +/// Protocol chosen; synthetic KDC built when Kerberos, not yet published to the registry. #[derive(Debug)] pub(crate) enum PreparedCredentialInjection { Kerberos(KerberosCredentialInjection), @@ -119,7 +120,7 @@ impl PreparedCredentialInjection { let registration = registry.register(Arc::clone(&injection.synthetic)); debug!( jti = %injection.synthetic.jti(), - "registered synthetic KDC for credential-injection session" + "Registered synthetic KDC for credential-injection session" ); CredentialInjection::Kerberos(injection, registration) } @@ -201,7 +202,10 @@ impl CredentialInjection { matches!(self, Self::Kerberos(_, _)) } - /// RDP chef: owned groceries → prepared dish. Does not touch the registry. + /// Build a session injection plan from a checked-out provisioning entry. + /// + /// Does not publish to [`SyntheticKdcRegistry`]; call + /// [`PreparedCredentialInjection::register_if_kerberos`] next. pub(crate) fn from_provisioned( jti: Uuid, credential_entry: ProvisioningEntry, @@ -214,7 +218,7 @@ impl CredentialInjection { } = credential_entry; let mapping = mapping.ok_or_else(|| { - warn!(%jti, "credential-injection state has no mapping"); + warn!(%jti, "Credential-injection state has no mapping"); CredentialInjectionKdcResolveError::NonInjectionCredential { jti } })?; @@ -222,30 +226,28 @@ impl CredentialInjection { warn!( %jti, error = format!("{source:#}"), - "invalid credential-injection association token" + "Invalid credential-injection association token" ); CredentialInjectionKdcResolveError::InvalidAssociationToken { jti, source } })?; - let target_username = match sspi::Username::parse(app_credential_username(&mapping.target)) { - Ok(u) => u, - Err(error) => { - warn!(%jti, error = format!("{error:#}"), "invalid target credential username"); - return Err(CredentialInjectionKdcResolveError::BuildKdcConfig { - jti, - source: anyhow::anyhow!("invalid target credential username: {error}"), - }); - } - }; - - let wants_kerberos = kerberos_enabled && target_username.domain_name().is_some(); - if !wants_kerberos { + let target_username = app_credential_username(&mapping.target); + if !select_kerberos_for_target(kerberos_enabled, target_username) { return Ok(PreparedCredentialInjection::Ntlm(NtlmCredentialInjection { jti, credential_mapping: mapping, })); } + // Kerberos path: username must parse (select_kerberos_for_target already required a domain). + if let Err(error) = sspi::Username::parse(target_username) { + warn!(%jti, error = format!("{error:#}"), "Invalid target credential username"); + return Err(CredentialInjectionKdcResolveError::BuildKdcConfig { + jti, + source: anyhow::anyhow!("invalid target credential username: {error}"), + }); + } + let target_kdc = connection_options .as_ref() .and_then(|o| o.krb_kdc()) @@ -267,6 +269,21 @@ impl CredentialInjection { } } +/// Unstable debug opt-in for Kerberos credential injection (both legs). +pub(crate) fn kerberos_injection_opt_in(conf: &crate::config::Conf) -> bool { + conf.debug.enable_unstable && conf.debug.kerberos_credential_injection +} + +/// Whether target username + opt-in select Kerberos injection (otherwise NTLM). +pub(crate) fn select_kerberos_for_target(kerberos_enabled: bool, target_username: &str) -> bool { + if !kerberos_enabled { + return false; + } + sspi::Username::parse(target_username) + .ok() + .is_some_and(|username| username.domain_name().is_some()) +} + pub(crate) struct CredentialInjectionKdcRequest { message: KdcProxyMessage, } @@ -329,6 +346,11 @@ impl CredentialInjectionKdc { self.jti } + /// Session destination host from association `dst_hst` (not Gateway `conf.hostname`). + pub(crate) fn target_hostname(&self) -> &str { + &self.target_hostname + } + pub(crate) fn server_kerberos_config(&self, client_addr: SocketAddr) -> anyhow::Result { let user = sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8( &self.acceptor_principal_name, @@ -338,9 +360,8 @@ impl CredentialInjectionKdc { let kdc_url = self.in_process_kdc_url()?; - // The SPN that the client puts on its AP-REQ ticket is the one for the target RDP - // server (`TERMSRV/`). Gateway-as-CredSSP-server is impersonating that target, - // so ServerProperties must claim the same SPN or sspi-rs rejects the ticket. + // Client AP-REQ SPN is TERMSRV/. Gateway-as-CredSSP-server impersonates that + // session destination, so ServerProperties must claim the same SPN. Ok(sspi::KerberosServerConfig { kerberos_config: sspi::KerberosConfig { kdc_url: Some(kdc_url), @@ -516,9 +537,9 @@ fn random_32_bytes() -> Vec { /// Live synthetic KDCs published by active RDP credential-injection sessions. /// -/// Pass window between handlers: -/// - RDP path publishes when it starts a Kerberos injection -/// - `/jet/KdcProxy` only looks up; it never builds a KDC from provisioned groceries +/// - The RDP path registers when a Kerberos injection session starts. +/// - `/jet/KdcProxy` only looks up published entries; it never builds a KDC from +/// [`crate::provisioning::ProvisioningStore`]. /// /// Entries are connection-scoped via [`SyntheticKdcRegistration`]. Reconnects `register` again /// (replace + bump generation); a late drop of an older registration is a no-op. @@ -674,6 +695,15 @@ mod tests { } } + #[test] + fn select_kerberos_for_target_matrix() { + assert!(!select_kerberos_for_target(false, "user@CORP.EXAMPLE")); + assert!(!select_kerberos_for_target(true, "Administrator")); + assert!(!select_kerberos_for_target(true, "")); + assert!(select_kerberos_for_target(true, "user@CORP.EXAMPLE")); + assert!(select_kerberos_for_target(true, r"CORP\user")); + } + #[test] fn proxy_user_at_realm_is_used_as_realm() { assert_eq!( @@ -725,7 +755,7 @@ mod tests { let store = stock_with_mapping(jti, "administrator@example.invalid"); store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); let entry = store.take(jti).expect("entry"); - assert!(store.take(jti).is_none(), "take consumes groceries"); + assert!(store.take(jti).is_none(), "take is one-shot"); let registry = SyntheticKdcRegistry::new(); let prepared = CredentialInjection::from_provisioned(jti, entry, true).expect("prepared"); assert!(registry.get(jti).is_none(), "not published until register_if_kerberos"); @@ -756,6 +786,38 @@ mod tests { ); } + #[test] + fn from_provisioned_uses_association_dst_hst_for_synthetic_kdc() { + // Destination is dynamic per token. conf.hostname is Gateway identity only and must not + // drive synthetic KDC SPN / service host (deliberate correction of #1856). + let jti = Uuid::new_v4(); + let store = ProvisioningStore::new(); + store + .insert_credentials( + unsigned_jws(serde_json::json!({ + "jti": jti, + "dst_hst": "it-help-dc.corp.example:3389" + })), + Some(cleartext_mapping_with_target_username("administrator@example.invalid")), + time::Duration::minutes(5), + ) + .expect("insert"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let entry = store.take(jti).expect("entry"); + let injection = CredentialInjection::from_provisioned(jti, entry, true) + .expect("prepared") + .register_if_kerberos(&SyntheticKdcRegistry::new()); + + assert_eq!( + injection + .as_kerberos() + .expect("kerberos") + .synthetic_kdc() + .target_hostname(), + "it-help-dc.corp.example", + ); + } + #[test] fn registry_replace_and_guarded_drop_keeps_successor() { let registry = SyntheticKdcRegistry::new(); @@ -773,7 +835,7 @@ mod tests { } #[test] - fn kdc_proxy_cannot_invent_from_groceries() { + fn kdc_proxy_cannot_invent_from_provisioning_store() { let jti = Uuid::new_v4(); let _store = stock_with_mapping(jti, "administrator@example.invalid"); let registry = SyntheticKdcRegistry::new(); diff --git a/devolutions-gateway/src/generic_client.rs b/devolutions-gateway/src/generic_client.rs index d7287a21f..a824ceb17 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -146,20 +146,15 @@ where let disconnect_interest = DisconnectInterest::from_reconnection_policy(claims.jet_reuse); - // We support proxy-based credential injection for RDP. - // If a credential mapping has been pushed, we automatically switch to this mode. - // Otherwise, we continue the generic procedure. - // - // RdpProxy is generic over the server stream, so credential injection works - // regardless of whether the upstream is direct TCP or tunnelled via an agent. - // The credential store is keyed on the association token's JTI, so a direct - // lookup by `claims.jti` is the primary path. - if is_rdp - && let Some(entry) = provisioning.take(claims.jti) - && entry.mapping.is_some() - { + // RDP credential injection: peek for a mapping first so token-only provision rows are not + // consumed. take() is one-shot after the injection path is chosen. + if is_rdp && provisioning.has_mapping(claims.jti) { + let entry = provisioning + .take(claims.jti) + .context("provisioned credentials missing after has_mapping")?; + anyhow::ensure!(entry.mapping.is_some(), "provisioned entry has no credential mapping"); anyhow::ensure!(token == entry.token, "token mismatch"); - let kerberos_enabled = conf.debug.enable_unstable && conf.debug.kerberos_credential_injection; + let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in(&conf); let credential_injection = CredentialInjection::from_provisioned(claims.jti, entry, kerberos_enabled)? .register_if_kerberos(&synthetic_kdc_registry); diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 73b22bf60..25590af34 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -393,10 +393,12 @@ struct PreflightOperation { /// /// Required for "resolve-host" kind. host_to_resolve: Option, - /// Minimum persistence duration in seconds for the data provisioned via this operation. + /// How long provisioned data may wait for first use, in seconds. /// - /// Optional parameter for "provision-token", "provision-credentials", and - /// "provision-connection-options" kinds. + /// Optional for "provision-token", "provision-credentials", and + /// "provision-connection-options". For credential-injection mappings this is the maximum + /// time until checkout: the injection path consumes the entry once (one-shot) when a + /// session starts, and does not put it back after a failed attempt. Re-provision to retry. time_to_live: Option, } diff --git a/devolutions-gateway/src/provisioning.rs b/devolutions-gateway/src/provisioning.rs index e7cb50223..48a3689e6 100644 --- a/devolutions-gateway/src/provisioning.rs +++ b/devolutions-gateway/src/provisioning.rs @@ -142,11 +142,16 @@ impl ProvisioningStore { } } - /// Take the provisioned view for a session (one-shot). + /// Take the provisioned view for a session (one-shot checkout). /// /// Removes the credentials half (required) and any live connection-options half for `jti`. - /// Returns `None` if credentials are missing or expired. A second `take` for the same JTI - /// fails until preflight inserts again. + /// Returns `None` if credentials are missing or expired. + /// + /// **Contract:** injection mappings are consumed when the injection path checks them out. + /// They are not restored after a failed TLS/CredSSP attempt. `time_to_live` is how long the + /// entry may wait for that first checkout, not a retry budget. Re-provision to try again. + /// Callers must [`Self::has_mapping`] (or equivalent) before taking so token-only rows are + /// not destroyed by unrelated RDP connections. pub(crate) fn take(&self, jti: Uuid) -> Option { let now = time::OffsetDateTime::now_utc(); diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index 86e8e8f2d..c5bb4314c 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -426,7 +426,7 @@ async fn connect_rdp_server( }) } -/// Handle RDP connection with credential injection via CredSSP MITM +/// Handle RDP connection with credential injection via CredSSP MITM. #[expect(clippy::too_many_arguments)] async fn handle_with_credential_injection( mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send, @@ -443,7 +443,6 @@ async fn handle_with_credential_injection( agent_tunnel_handle: Option>, ) -> anyhow::Result<()> { let tls_conf = conf.credssp_tls.get().context("CredSSP TLS configuration")?; - let gateway_hostname = conf.hostname.clone(); let x224_req = cleanpath_pdu @@ -453,7 +452,6 @@ async fn handle_with_credential_injection( let received_connection_request: ironrdp_pdu::x224::X224 = ironrdp_core::decode(x224_req.as_bytes()).context("decode X224 connection request PDU from client")?; - // Choose the security protocol to use with the client. let received_connection_request_protocol = received_connection_request.0.protocol; let client_security_protocol = if received_connection_request_protocol.contains(nego::SecurityProtocol::HYBRID_EX) { nego::SecurityProtocol::HYBRID_EX @@ -470,7 +468,6 @@ async fn handle_with_credential_injection( ) }; - // Authorize and connect to the RDP server. let CleanPathAuth { claims } = authorize_cleanpath( &cleanpath_pdu, client_addr, @@ -485,17 +482,10 @@ async fn handle_with_credential_injection( let token = cleanpath_pdu .proxy_auth - .as_deref() + .clone() .context("missing token in RDCleanPath PDU")?; - let entry = provisioning - .take(claims.jti) - .context("provisioned credentials missing after authorization")?; - anyhow::ensure!(entry.mapping.is_some(), "provisioned entry has no credential mapping"); - anyhow::ensure!(token == entry.token, "token mismatch"); - let kerberos_enabled = conf.debug.enable_unstable && conf.debug.kerberos_credential_injection; - let credential_injection = CredentialInjection::from_provisioned(claims.jti, entry, kerberos_enabled)? - .register_if_kerberos(synthetic_kdc_registry); + // Connect before checkout so a target connect failure does not consume one-shot credentials. let ConnectedRdpServer { tls_stream: server_stream, server_addr, @@ -506,13 +496,21 @@ async fn handle_with_credential_injection( .context("RDCleanPath connection failed")?; let x224_rsp = x224_rsp.context("RDCleanPath credential injection requires X.224")?; - // Retrieve the Gateway TLS public key that must be used for client-proxy CredSSP later on. + let entry = provisioning + .take(claims.jti) + .context("provisioned credentials missing after authorization")?; + anyhow::ensure!(entry.mapping.is_some(), "provisioned entry has no credential mapping"); + anyhow::ensure!(token == entry.token, "token mismatch"); + + let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in(&conf); + let credential_injection = CredentialInjection::from_provisioned(claims.jti, entry, kerberos_enabled)? + .register_if_kerberos(synthetic_kdc_registry); + let gateway_cert_chain_handle = tokio::spawn(crate::tls::get_cert_chain_for_acceptor_cached( - gateway_hostname.clone(), + gateway_hostname, tls_conf.acceptor.clone(), )); - // Extract server security protocol from X224 response (before x224_rsp is moved). let x224_confirm: ironrdp_pdu::x224::X224 = ironrdp_core::decode(&x224_rsp).context("decode X224 connection confirm")?; let server_security_protocol = match &x224_confirm.0 { @@ -536,8 +534,7 @@ async fn handle_with_credential_injection( let gateway_public_key = crate::tls::extract_public_key(gateway_cert_chain.first().context("no leaf")?) .context("extract Gateway public key")?; - // Send RDCleanPath response to client using Devolutions Gateway certification chain. - // (When performing credential injection, the client performs CredSSP against the Devolutions Gateway.) + // Client CredSSP runs against the Gateway certificate chain. trace!("Sending RDCleanPath response"); let rd_clean_path_rsp = RDCleanPathPdu::new_response( server_addr.to_string(), @@ -546,64 +543,8 @@ async fn handle_with_credential_injection( ) .context("couldn't build RDCleanPath response")?; send_clean_path_response(&mut client_stream, &rd_clean_path_rsp).await?; - debug!("RDCleanPath response sent, now performing CredSSP MITM"); + debug!("RDCleanPath response sent, starting CredSSP MITM"); - // -- Perform the CredSSP authentication with the client (acting as a server) and the server (acting as a client) -- // - - let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream); - let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); - - let kdc_connector = - crate::kdc_connector::KdcConnector::new(claims.jet_aid, claims.jet_agent_id, agent_tunnel_handle.clone()); - - let client_credssp_fut = crate::rdp_proxy::perform_credssp_as_server( - &mut client_framed, - client_addr, - gateway_public_key, - client_security_protocol, - &credential_injection, - &kdc_connector, - ); - - 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, - &gateway_hostname, - &kdc_connector, - ); - - let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); - client_credssp_res.context("CredSSP with client")?; - server_credssp_res.context("CredSSP with server")?; - - debug!("CredSSP MITM completed successfully"); - - // -- Intercept the Connect Confirm PDU, to override the server_security_protocol field -- // - - crate::rdp_proxy::intercept_connect_confirm(&mut client_framed, &mut server_framed, server_security_protocol) - .await?; - - let (mut client_stream, client_leftover) = client_framed.into_inner(); - let (mut server_stream, server_leftover) = server_framed.into_inner(); - - // -- At this point, proceed to the usual two-way forwarding -- // - - info!("RDP-TLS forwarding (credential injection)"); - - client_stream - .write_all(&server_leftover) - .await - .context("write server leftover to client")?; - - server_stream - .write_all(&client_leftover) - .await - .context("write client leftover to server")?; - - // Build SessionInfo for forwarding let info = SessionInfo::builder() .id(claims.jet_aid) .application_protocol(claims.jet_ap) @@ -616,22 +557,32 @@ async fn handle_with_credential_injection( .build(); let disconnect_interest = DisconnectInterest::from_reconnection_policy(claims.jet_reuse); + let kdc_connector = + crate::kdc_connector::KdcConnector::new(claims.jet_aid, claims.jet_agent_id, agent_tunnel_handle.clone()); - // Plain forwarding for now - Proxy::builder() + let session = crate::rdp_proxy::CredsspSession::builder() .conf(conf) .session_info(info) - .address_a(client_addr) - .transport_a(client_stream) - .address_b(server_addr) - .transport_b(server_stream) + .client_addr(client_addr) + .server_addr(server_addr) + .credential_injection(credential_injection) .sessions(sessions) .subscriber_tx(subscriber_tx) + .server_dns_name(destination.host().to_owned()) .disconnect_interest(disconnect_interest) - .build() - .select_dissector_and_forward() - .await - .context("proxy failed") + .kdc_connector(kdc_connector) + .build(); + + let prepared = crate::rdp_proxy::PreparedCredssp::builder() + .client_stream(client_stream) + .server_stream(server_stream) + .gateway_public_key(gateway_public_key) + .server_public_key(server_public_key) + .client_security_protocol(client_security_protocol) + .server_security_protocol(server_security_protocol) + .build(); + + session.run(prepared).await } #[expect(clippy::too_many_arguments)] @@ -663,10 +614,9 @@ pub async fn handle( .as_deref() .context("missing token in RDCleanPath PDU")?; - // If a credential mapping has been pushed, we automatically switch to - // proxy-based credential injection mode. Otherwise, we continue the usual - // clean path procedure. Peek only here — take after authorize_cleanpath so an - // unverified token cannot burn a victim JTI's one-shot groceries. + // If a credential mapping has been pushed, switch to proxy-based credential injection. + // Peek only here — take after authorize + server connect so an unverified token cannot + // burn a victim JTI, and a failed target connect does not consume the one-shot entry. if let Some(jti) = crate::token::extract_jti(token).ok() && provisioning.has_mapping(jti) { diff --git a/devolutions-gateway/src/rdp_proxy/credssp.rs b/devolutions-gateway/src/rdp_proxy/credssp.rs index 5295b6e75..a49a0b155 100644 --- a/devolutions-gateway/src/rdp_proxy/credssp.rs +++ b/devolutions-gateway/src/rdp_proxy/credssp.rs @@ -110,7 +110,6 @@ impl CredsspSession { server_public_key, server_security_protocol, &credential_injection, - &conf.hostname, &kdc_connector, ); @@ -154,7 +153,7 @@ impl CredsspSession { } } -pub(crate) async fn intercept_connect_confirm( +async fn intercept_connect_confirm( client_framed: &mut ironrdp_tokio::MovableTokioFramed, server_framed: &mut ironrdp_tokio::MovableTokioFramed, server_security_protocol: nego::SecurityProtocol, @@ -201,26 +200,26 @@ fn server_kerberos_setup( } fn client_kerberos_config( - gateway_hostname: &str, injection: &CredentialInjection, ) -> anyhow::Result> { let Some(kerberos) = injection.as_kerberos() else { return Ok(None); }; + // Target-leg Kerberos uses the same session destination as the synthetic KDC (association + // `dst_hst`). conf.hostname is Gateway identity only and is not the RDP destination. Ok(Some(ironrdp_connector::credssp::KerberosConfig { kdc_proxy_url: Some(kerberos.target_kdc().clone()), - hostname: gateway_hostname.to_owned(), + hostname: kerberos.synthetic_kdc().target_hostname().to_owned(), })) } #[instrument(name = "server_credssp", level = "debug", ret, skip_all)] -pub(crate) async fn perform_credssp_as_client( +async fn perform_credssp_as_client( framed: &mut ironrdp_tokio::Framed, server_name: String, server_public_key: Vec, security_protocol: nego::SecurityProtocol, injection: &CredentialInjection, - gateway_hostname: &str, kdc_connector: &KdcConnector, ) -> anyhow::Result<()> where @@ -229,7 +228,7 @@ where use ironrdp_tokio::FramedWrite as _; let credentials = injection.target_credential(); - let kerberos_config = client_kerberos_config(gateway_hostname, injection)?; + let kerberos_config = client_kerberos_config(injection)?; // Decrypt password into short-lived buffer. let (username, decrypted_password) = credentials @@ -350,7 +349,7 @@ async fn resolve_client_generator( } #[instrument(name = "client_credssp", level = "debug", ret, skip_all)] -pub(crate) async fn perform_credssp_as_server( +async fn perform_credssp_as_server( framed: &mut ironrdp_tokio::Framed, client_addr: SocketAddr, gateway_public_key: Vec, @@ -529,9 +528,9 @@ mod tests { } #[test] - fn client_kerberos_config_uses_provisioned_target_kdc_url() { + fn client_kerberos_config_uses_provisioned_target_kdc_url_and_dst_hst() { let injection = kerberos_injection(); - let config = client_kerberos_config("dgateway.localhost.com", &injection) + let config = client_kerberos_config(&injection) .expect("config builds") .expect("kerberos client leg"); @@ -540,7 +539,10 @@ mod tests { Some("tcp://dc.example.com:88"), "CredSSP kdc_proxy_url must be the provisioned krb_kdc", ); - assert_eq!(config.hostname, "dgateway.localhost.com"); + assert_eq!( + config.hostname, "target.example", + "target-leg Kerberos hostname is association dst_hst, not conf.hostname", + ); } #[test] @@ -568,7 +570,7 @@ mod tests { .expect("ntlm prepared") .register_if_kerberos(&SyntheticKdcRegistry::new()); - let config = client_kerberos_config("dgateway.localhost.com", &injection).expect("ntlm ok"); + let config = client_kerberos_config(&injection).expect("ntlm ok"); assert!(config.is_none()); } } diff --git a/devolutions-gateway/src/rdp_proxy/mod.rs b/devolutions-gateway/src/rdp_proxy/mod.rs index 85ee1af48..3eae2e84e 100644 --- a/devolutions-gateway/src/rdp_proxy/mod.rs +++ b/devolutions-gateway/src/rdp_proxy/mod.rs @@ -8,10 +8,7 @@ use typed_builder::TypedBuilder; mod credssp; -use credssp::CredsspSession; -pub(crate) use credssp::{ - PreparedCredssp, intercept_connect_confirm, perform_credssp_as_client, perform_credssp_as_server, -}; +pub(crate) use credssp::{CredsspSession, PreparedCredssp}; use crate::config::Conf; use crate::credential::AppCredential; @@ -270,9 +267,3 @@ where framed.write_all(&payload).await.context("failed to write PDU")?; Ok(()) } - -#[cfg(test)] -mod tests { - // Protocol selection is owned by CredentialInjection (from_provisioned + register_if_kerberos). - // See credential_injection tests for Kerberos-vs-NTLM decision coverage. -} From 038ca3dc738efd905c2129567de7016525d07e0e Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Mon, 17 Aug 2026 11:21:32 -0400 Subject: [PATCH 05/36] ci(package): retry Windows tool installs Keep the preinstalled WiX toolset until Chocolatey successfully installs the pinned version. Retry transient feed failures, validate candle.exe, and expose WIXSHARP_WIXDIR so installer builds cannot continue with an empty WiX path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 36 ++++++++++++++++++++----------- .github/workflows/package.yml | 10 ++++++--- ci/install-chocolatey-package.ps1 | 34 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 16 deletions(-) create mode 100644 ci/install-chocolatey-package.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 113ebd31d..946746f73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -646,15 +646,20 @@ jobs: - name: Configure Windows runner if: ${{ matrix.os == 'windows' }} run: | - # https://github.com/actions/runner-images/issues/9667 - choco uninstall wixtoolset - choco install wixtoolset --version 3.14.0 --allow-downgrade --no-progress --force - - # WiX is installed on Windows runners but not in the PATH - Write-Output "C:\Program Files (x86)\WiX Toolset v3.14\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + ./ci/install-chocolatey-package.ps1 ` + -Package wixtoolset ` + -Version 3.14.0 ` + -AdditionalArguments @('--allow-downgrade', '--force') + + $WixBinPath = "C:\Program Files (x86)\WiX Toolset v3.14\bin" + if (-not (Test-Path (Join-Path $WixBinPath 'candle.exe'))) { + throw "WiX installation is missing candle.exe at $WixBinPath" + } + Write-Output $WixBinPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + Write-Output "WIXSHARP_WIXDIR=$WixBinPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # NASM is required by aws-lc-rs (used as rustls crypto backend) - choco install nasm + ./ci/install-chocolatey-package.ps1 -Package nasm # Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell Install-Module VsDevShell -Force @@ -909,18 +914,23 @@ jobs: - name: Configure Windows runner if: ${{ matrix.os == 'windows' }} run: | - # https://github.com/actions/runner-images/issues/9667 - choco uninstall wixtoolset - choco install wixtoolset --version 3.14.0 --allow-downgrade --force + ./ci/install-chocolatey-package.ps1 ` + -Package wixtoolset ` + -Version 3.14.0 ` + -AdditionalArguments @('--allow-downgrade', '--force') # Devolutions PEDM needs MakeAppx.exe Write-Output "C:\Program Files (x86)\Windows Kits\10\bin\10.0.17763.0\x64" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - # WiX is installed on Windows runners but not in the PATH - Write-Output "C:\Program Files (x86)\WiX Toolset v3.14\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + $WixBinPath = "C:\Program Files (x86)\WiX Toolset v3.14\bin" + if (-not (Test-Path (Join-Path $WixBinPath 'candle.exe'))) { + throw "WiX installation is missing candle.exe at $WixBinPath" + } + Write-Output $WixBinPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + Write-Output "WIXSHARP_WIXDIR=$WixBinPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # NASM is required by aws-lc-rs (used as rustls crypto backend) - choco install nasm + ./ci/install-chocolatey-package.ps1 -Package nasm # We need to add the NASM binary folder to the PATH manually. Write-Output "$Env:ProgramFiles\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 27ee014f9..4d4fe71da 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -249,10 +249,14 @@ jobs: run: | echo "C:\Program Files (x86)\Windows Kits\10\bin\10.0.17763.0\x64" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - # https://github.com/actions/runner-images/issues/9667 - choco uninstall wixtoolset - choco install wixtoolset --version 3.14.0 --allow-downgrade --no-progress --force + ./ci/install-chocolatey-package.ps1 ` + -Package wixtoolset ` + -Version 3.14.0 ` + -AdditionalArguments @('--allow-downgrade', '--force') $WixBinPath = "C:\Program Files (x86)\WiX Toolset v3.14\bin" + if (-not (Test-Path (Join-Path $WixBinPath 'candle.exe'))) { + throw "WiX installation is missing candle.exe at $WixBinPath" + } echo $WixBinPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append # WixSharp reads WIXSHARP_WIXDIR as a fallback when WixSharpBinPath MSBuild property # is empty (happens when /t:restore,build runs on a fresh runner without cached NuGet diff --git a/ci/install-chocolatey-package.ps1 b/ci/install-chocolatey-package.ps1 new file mode 100644 index 000000000..16a67f7aa --- /dev/null +++ b/ci/install-chocolatey-package.ps1 @@ -0,0 +1,34 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Package, + + [string]$Version, + + [string[]]$AdditionalArguments = @(), + + [ValidateRange(1, 10)] + [int]$Attempts = 3 +) + +$ErrorActionPreference = 'Stop' + +$arguments = @('install', $Package, '--yes', '--no-progress') +if ($Version) { + $arguments += @('--version', $Version) +} +$arguments += $AdditionalArguments + +for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + & choco @arguments + if ($LASTEXITCODE -eq 0) { + exit 0 + } + + if ($attempt -eq $Attempts) { + throw "Chocolatey failed to install $Package after $Attempts attempts" + } + + $delaySeconds = 10 * $attempt + Write-Warning "Chocolatey failed to install $Package (attempt $attempt/$Attempts); retrying in $delaySeconds seconds" + Start-Sleep -Seconds $delaySeconds +} From 731ce31706e932d7ddabda6a0853ede410d74dd3 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Mon, 17 Aug 2026 15:17:13 -0400 Subject: [PATCH 06/36] chore: remove unrelated package CI changes Keep PR #1900 scoped to the Gateway provisioning and CredSSP refactor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 36 +++++++++++-------------------- .github/workflows/package.yml | 10 +++------ ci/install-chocolatey-package.ps1 | 34 ----------------------------- 3 files changed, 16 insertions(+), 64 deletions(-) delete mode 100644 ci/install-chocolatey-package.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 946746f73..113ebd31d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -646,20 +646,15 @@ jobs: - name: Configure Windows runner if: ${{ matrix.os == 'windows' }} run: | - ./ci/install-chocolatey-package.ps1 ` - -Package wixtoolset ` - -Version 3.14.0 ` - -AdditionalArguments @('--allow-downgrade', '--force') - - $WixBinPath = "C:\Program Files (x86)\WiX Toolset v3.14\bin" - if (-not (Test-Path (Join-Path $WixBinPath 'candle.exe'))) { - throw "WiX installation is missing candle.exe at $WixBinPath" - } - Write-Output $WixBinPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - Write-Output "WIXSHARP_WIXDIR=$WixBinPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # https://github.com/actions/runner-images/issues/9667 + choco uninstall wixtoolset + choco install wixtoolset --version 3.14.0 --allow-downgrade --no-progress --force + + # WiX is installed on Windows runners but not in the PATH + Write-Output "C:\Program Files (x86)\WiX Toolset v3.14\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append # NASM is required by aws-lc-rs (used as rustls crypto backend) - ./ci/install-chocolatey-package.ps1 -Package nasm + choco install nasm # Install Visual Studio Developer PowerShell Module for cmdlets such as Enter-VsDevShell Install-Module VsDevShell -Force @@ -914,23 +909,18 @@ jobs: - name: Configure Windows runner if: ${{ matrix.os == 'windows' }} run: | - ./ci/install-chocolatey-package.ps1 ` - -Package wixtoolset ` - -Version 3.14.0 ` - -AdditionalArguments @('--allow-downgrade', '--force') + # https://github.com/actions/runner-images/issues/9667 + choco uninstall wixtoolset + choco install wixtoolset --version 3.14.0 --allow-downgrade --force # Devolutions PEDM needs MakeAppx.exe Write-Output "C:\Program Files (x86)\Windows Kits\10\bin\10.0.17763.0\x64" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - $WixBinPath = "C:\Program Files (x86)\WiX Toolset v3.14\bin" - if (-not (Test-Path (Join-Path $WixBinPath 'candle.exe'))) { - throw "WiX installation is missing candle.exe at $WixBinPath" - } - Write-Output $WixBinPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - Write-Output "WIXSHARP_WIXDIR=$WixBinPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # WiX is installed on Windows runners but not in the PATH + Write-Output "C:\Program Files (x86)\WiX Toolset v3.14\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append # NASM is required by aws-lc-rs (used as rustls crypto backend) - ./ci/install-chocolatey-package.ps1 -Package nasm + choco install nasm # We need to add the NASM binary folder to the PATH manually. Write-Output "$Env:ProgramFiles\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 4d4fe71da..27ee014f9 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -249,14 +249,10 @@ jobs: run: | echo "C:\Program Files (x86)\Windows Kits\10\bin\10.0.17763.0\x64" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - ./ci/install-chocolatey-package.ps1 ` - -Package wixtoolset ` - -Version 3.14.0 ` - -AdditionalArguments @('--allow-downgrade', '--force') + # https://github.com/actions/runner-images/issues/9667 + choco uninstall wixtoolset + choco install wixtoolset --version 3.14.0 --allow-downgrade --no-progress --force $WixBinPath = "C:\Program Files (x86)\WiX Toolset v3.14\bin" - if (-not (Test-Path (Join-Path $WixBinPath 'candle.exe'))) { - throw "WiX installation is missing candle.exe at $WixBinPath" - } echo $WixBinPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append # WixSharp reads WIXSHARP_WIXDIR as a fallback when WixSharpBinPath MSBuild property # is empty (happens when /t:restore,build runs on a fresh runner without cached NuGet diff --git a/ci/install-chocolatey-package.ps1 b/ci/install-chocolatey-package.ps1 deleted file mode 100644 index 16a67f7aa..000000000 --- a/ci/install-chocolatey-package.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -param( - [Parameter(Mandatory = $true)] - [string]$Package, - - [string]$Version, - - [string[]]$AdditionalArguments = @(), - - [ValidateRange(1, 10)] - [int]$Attempts = 3 -) - -$ErrorActionPreference = 'Stop' - -$arguments = @('install', $Package, '--yes', '--no-progress') -if ($Version) { - $arguments += @('--version', $Version) -} -$arguments += $AdditionalArguments - -for ($attempt = 1; $attempt -le $Attempts; $attempt++) { - & choco @arguments - if ($LASTEXITCODE -eq 0) { - exit 0 - } - - if ($attempt -eq $Attempts) { - throw "Chocolatey failed to install $Package after $Attempts attempts" - } - - $delaySeconds = 10 * $attempt - Write-Warning "Chocolatey failed to install $Package (attempt $attempt/$Attempts); retrying in $delaySeconds seconds" - Start-Sleep -Seconds $delaySeconds -} From 2e10e60ab92e6c34f9fb336ca5afb2e129fdd19d Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Mon, 17 Aug 2026 15:17:27 -0400 Subject: [PATCH 07/36] fix(dgw): make injection checkout fail closed Keep consumed credential mappings visible until their original expiry so a reused JTI fails explicitly instead of silently falling back to ordinary forwarding. Centralize atomic checkout and CredSSP orchestration, preserve token-only provisioning, and simplify KDC error handling. Record the one-shot contract in PR history without regenerating unchanged OpenAPI artifacts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/credential_injection.rs | 121 +++++------ devolutions-gateway/src/generic_client.rs | 106 ++++++---- devolutions-gateway/src/openapi.rs | 8 +- devolutions-gateway/src/provisioning.rs | 198 +++++++++++++++--- devolutions-gateway/src/rd_clean_path.rs | 36 ++-- 5 files changed, 312 insertions(+), 157 deletions(-) diff --git a/devolutions-gateway/src/credential_injection.rs b/devolutions-gateway/src/credential_injection.rs index 770d88886..d21ae0a06 100644 --- a/devolutions-gateway/src/credential_injection.rs +++ b/devolutions-gateway/src/credential_injection.rs @@ -24,9 +24,7 @@ use url::Url; use uuid::Uuid; use crate::credential::{AppCredential, AppCredentialMapping}; -use crate::provisioning::ProvisioningEntry; -#[cfg(test)] -use crate::provisioning::ProvisioningStore; +use crate::provisioning::{ProvisioningEntry, ProvisioningStore}; // The reserved `.invalid` TLD (RFC 6761) lets sspi-rs CredSSP server emit "KDC requests" that // never leave the process: `intercept_network_request` recognises this hostname and dispatches @@ -51,26 +49,6 @@ pub(crate) struct CredentialInjectionKdc { kdc_config: kdc::config::KerberosServer, } -#[derive(Debug, Error)] -pub(crate) enum CredentialInjectionKdcResolveError { - #[error("credential-injection state is not available for {jti}")] - NonInjectionCredential { jti: Uuid }, - #[error("association token for {jti} is not valid for credential injection")] - InvalidAssociationToken { - jti: Uuid, - #[source] - source: anyhow::Error, - }, - #[error("credential-injection KDC config could not be initialized for {jti}")] - BuildKdcConfig { - jti: Uuid, - #[source] - source: anyhow::Error, - }, - #[error("Kerberos credential injection requires target connection option krb_kdc for {jti}")] - MissingKrbKdc { jti: Uuid }, -} - #[derive(Debug, Clone, PartialEq, Eq, Error)] #[error("expected: {expected}, got: {actual}")] pub(crate) struct RealmMismatch { @@ -170,6 +148,19 @@ impl NtlmCredentialInjection { } impl CredentialInjection { + pub(crate) fn checkout( + provisioning: &ProvisioningStore, + registry: &SyntheticKdcRegistry, + jti: Uuid, + token: &str, + kerberos_enabled: bool, + ) -> anyhow::Result { + let entry = provisioning + .take_mapping(jti, token) + .with_context(|| format!("checkout credential-injection material for {jti}"))?; + Ok(Self::from_provisioned(jti, entry, kerberos_enabled)?.register_if_kerberos(registry)) + } + pub(crate) fn jti(&self) -> Uuid { match self { Self::Kerberos(k, _) => k.synthetic.jti(), @@ -210,26 +201,17 @@ impl CredentialInjection { jti: Uuid, credential_entry: ProvisioningEntry, kerberos_enabled: bool, - ) -> Result { + ) -> anyhow::Result { let ProvisioningEntry { token, mapping, connection_options, } = credential_entry; - let mapping = mapping.ok_or_else(|| { - warn!(%jti, "Credential-injection state has no mapping"); - CredentialInjectionKdcResolveError::NonInjectionCredential { jti } - })?; + let mapping = mapping.context("credential-injection state has no mapping")?; - let target_hostname = crate::token::extract_credential_injection_target_hostname(&token).map_err(|source| { - warn!( - %jti, - error = format!("{source:#}"), - "Invalid credential-injection association token" - ); - CredentialInjectionKdcResolveError::InvalidAssociationToken { jti, source } - })?; + let target_hostname = crate::token::extract_credential_injection_target_hostname(&token) + .with_context(|| format!("association token for {jti} is not valid for credential injection"))?; let target_username = app_credential_username(&mapping.target); if !select_kerberos_for_target(kerberos_enabled, target_username) { @@ -240,26 +222,20 @@ impl CredentialInjection { } // Kerberos path: username must parse (select_kerberos_for_target already required a domain). - if let Err(error) = sspi::Username::parse(target_username) { - warn!(%jti, error = format!("{error:#}"), "Invalid target credential username"); - return Err(CredentialInjectionKdcResolveError::BuildKdcConfig { - jti, - source: anyhow::anyhow!("invalid target credential username: {error}"), - }); - } + sspi::Username::parse(target_username) + .with_context(|| format!("invalid target credential username for credential-injection session {jti}"))?; let target_kdc = connection_options .as_ref() .and_then(|o| o.krb_kdc()) .cloned() - .ok_or_else(|| { - warn!(%jti, "Kerberos credential injection requires krb_kdc"); - CredentialInjectionKdcResolveError::MissingKrbKdc { jti } + .with_context(|| { + format!("Kerberos credential injection requires target connection option krb_kdc for {jti}") })?; let proxy_username = app_credential_username(&mapping.proxy).to_owned(); let synthetic = CredentialInjectionKdc::new(jti, target_hostname, &proxy_username, &mapping.proxy) - .map_err(|source| CredentialInjectionKdcResolveError::BuildKdcConfig { jti, source })?; + .with_context(|| format!("credential-injection KDC config could not be initialized for {jti}"))?; Ok(PreparedCredentialInjection::Kerberos(KerberosCredentialInjection { credential_mapping: mapping, @@ -270,8 +246,8 @@ impl CredentialInjection { } /// Unstable debug opt-in for Kerberos credential injection (both legs). -pub(crate) fn kerberos_injection_opt_in(conf: &crate::config::Conf) -> bool { - conf.debug.enable_unstable && conf.debug.kerberos_credential_injection +pub(crate) fn kerberos_injection_opt_in(enable_unstable: bool, kerberos_credential_injection: bool) -> bool { + enable_unstable && kerberos_credential_injection } /// Whether target username + opt-in select Kerberos injection (otherwise NTLM). @@ -541,8 +517,9 @@ fn random_32_bytes() -> Vec { /// - `/jet/KdcProxy` only looks up published entries; it never builds a KDC from /// [`crate::provisioning::ProvisioningStore`]. /// -/// Entries are connection-scoped via [`SyntheticKdcRegistration`]. Reconnects `register` again -/// (replace + bump generation); a late drop of an older registration is a no-op. +/// Entries are connection-scoped via [`SyntheticKdcRegistration`] and removed when the owning session ends. +/// Generations prevent an older session from unpublishing a replacement. +/// Re-provisioning the same JTI can register that replacement before the older session ends. #[derive(Debug, Clone)] pub struct SyntheticKdcRegistry { inner: Arc>, @@ -551,7 +528,6 @@ pub struct SyntheticKdcRegistry { #[derive(Debug, Default)] struct RegistryInner { live: HashMap, - /// Registry-wide monotonic counter (not per-JTI) so generations stay unique without leaking map entries. next_generation: u64, } @@ -561,7 +537,7 @@ struct PublishedSyntheticKdc { kdc: Arc, } -/// RAII lease for a published synthetic KDC. Dropping it unpublishes only this generation. +/// RAII lease for a published synthetic KDC. pub(crate) struct SyntheticKdcRegistration { registry: SyntheticKdcRegistry, jti: Uuid, @@ -576,7 +552,7 @@ impl Drop for SyntheticKdcRegistration { }; if current.generation == self.generation { inner.live.remove(&self.jti); - debug!(jti = %self.jti, generation = self.generation, "unpublished synthetic KDC"); + debug!(jti = %self.jti, generation = self.generation, "Unpublished synthetic KDC"); } } } @@ -594,17 +570,13 @@ impl SyntheticKdcRegistry { } } - fn allocate_generation(inner: &mut RegistryInner) -> u64 { - inner.next_generation = inner.next_generation.wrapping_add(1); - inner.next_generation - } - pub(crate) fn register(&self, kdc: Arc) -> SyntheticKdcRegistration { let jti = kdc.jti(); let mut inner = self.inner.lock(); - let generation = Self::allocate_generation(&mut inner); + inner.next_generation = inner.next_generation.wrapping_add(1); + let generation = inner.next_generation; inner.live.insert(jti, PublishedSyntheticKdc { generation, kdc }); - debug!(%jti, generation, "published synthetic KDC"); + debug!(%jti, generation, "Published synthetic KDC"); SyntheticKdcRegistration { registry: self.clone(), jti, @@ -613,7 +585,7 @@ impl SyntheticKdcRegistry { } pub(crate) fn get(&self, jti: Uuid) -> Option> { - self.inner.lock().live.get(&jti).map(|e| Arc::clone(&e.kdc)) + self.inner.lock().live.get(&jti).map(|entry| Arc::clone(&entry.kdc)) } } @@ -695,6 +667,14 @@ mod tests { } } + #[test] + fn kerberos_injection_opt_in_requires_both_flags() { + assert!(!kerberos_injection_opt_in(false, false)); + assert!(!kerberos_injection_opt_in(false, true)); + assert!(!kerberos_injection_opt_in(true, false)); + assert!(kerberos_injection_opt_in(true, true)); + } + #[test] fn select_kerberos_for_target_matrix() { assert!(!select_kerberos_for_target(false, "user@CORP.EXAMPLE")); @@ -746,7 +726,7 @@ mod tests { let jti = Uuid::new_v4(); let entry = dummy_entry(jti, "administrator@example.invalid"); let err = CredentialInjection::from_provisioned(jti, entry, true).expect_err("kdc"); - assert!(matches!(err, CredentialInjectionKdcResolveError::MissingKrbKdc { .. })); + assert!(format!("{err:#}").contains("requires target connection option krb_kdc")); } #[test] @@ -819,18 +799,19 @@ mod tests { } #[test] - fn registry_replace_and_guarded_drop_keeps_successor() { + fn older_registration_drop_keeps_reprovisioned_successor() { let registry = SyntheticKdcRegistry::new(); let jti = Uuid::new_v4(); let first = Arc::new(dummy_kdc(jti)); - let first_reg = registry.register(Arc::clone(&first)); + let first_registration = registry.register(Arc::clone(&first)); assert!(Arc::ptr_eq(®istry.get(jti).expect("first"), &first)); + let second = Arc::new(dummy_kdc(jti)); - let second_reg = registry.register(Arc::clone(&second)); - assert!(Arc::ptr_eq(®istry.get(jti).expect("second"), &second)); - drop(first_reg); - assert!(Arc::ptr_eq(®istry.get(jti).expect("still second"), &second)); - drop(second_reg); + let second_registration = registry.register(Arc::clone(&second)); + drop(first_registration); + assert!(Arc::ptr_eq(®istry.get(jti).expect("successor"), &second)); + + drop(second_registration); assert!(registry.get(jti).is_none()); } diff --git a/devolutions-gateway/src/generic_client.rs b/devolutions-gateway/src/generic_client.rs index a824ceb17..ef9121ec4 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -9,7 +9,7 @@ use typed_builder::TypedBuilder; use crate::config::Conf; use crate::credential_injection::{CredentialInjection, SyntheticKdcRegistry}; -use crate::provisioning::ProvisioningStore; +use crate::provisioning::{MappingStatus, ProvisioningStore}; use crate::proxy::Proxy; use crate::rdp_pcb::{extract_association_claims, read_pcb}; use crate::recording::ActiveRecordings; @@ -146,50 +146,66 @@ where let disconnect_interest = DisconnectInterest::from_reconnection_policy(claims.jet_reuse); - // RDP credential injection: peek for a mapping first so token-only provision rows are not - // consumed. take() is one-shot after the injection path is chosen. - if is_rdp && provisioning.has_mapping(claims.jti) { - let entry = provisioning - .take(claims.jti) - .context("provisioned credentials missing after has_mapping")?; - anyhow::ensure!(entry.mapping.is_some(), "provisioned entry has no credential mapping"); - anyhow::ensure!(token == entry.token, "token mismatch"); - let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in(&conf); - let credential_injection = - CredentialInjection::from_provisioned(claims.jti, entry, kerberos_enabled)? - .register_if_kerberos(&synthetic_kdc_registry); - - info!( - jti = %credential_injection.jti(), - kerberos = credential_injection.uses_kerberos(), - "RDP-TLS forwarding with credential injection" - ); - - let kdc_connector = crate::kdc_connector::KdcConnector::new( - claims.jet_aid, - claims.jet_agent_id, - agent_tunnel_handle.clone(), - ); - - // NOTE: In the future, we could imagine performing proxy-based recording as well using RdpProxy. - return crate::rdp_proxy::RdpProxy::builder() - .conf(conf) - .session_info(info) - .client_addr(client_addr) - .client_stream(client_stream) - .server_addr(server_addr) - .server_stream(server_stream) - .sessions(sessions) - .subscriber_tx(subscriber_tx) - .credential_injection(credential_injection) - .client_stream_leftover_bytes(leftover_bytes) - .server_dns_name(selected_target.host().to_owned()) - .disconnect_interest(disconnect_interest) - .kdc_connector(kdc_connector) - .build() - .run() - .await - .context("encountered a failure during RDP proxying (credential injection)"); + // Peek first so token-only provision rows are not consumed. + // Fail explicitly for consumed mappings instead of silently downgrading. + let mapping_status = if is_rdp { + provisioning.mapping_status(claims.jti) + } else { + MappingStatus::Absent + }; + match mapping_status { + MappingStatus::Consumed => { + anyhow::bail!( + "credential-injection material for {} was already consumed; re-provision to retry", + claims.jti + ); + } + MappingStatus::Absent => {} + MappingStatus::Available => { + let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in( + conf.debug.enable_unstable, + conf.debug.kerberos_credential_injection, + ); + let credential_injection = CredentialInjection::checkout( + &provisioning, + &synthetic_kdc_registry, + claims.jti, + token, + kerberos_enabled, + )?; + + info!( + jti = %credential_injection.jti(), + kerberos = credential_injection.uses_kerberos(), + "RDP-TLS forwarding with credential injection" + ); + + let kdc_connector = crate::kdc_connector::KdcConnector::new( + claims.jet_aid, + claims.jet_agent_id, + agent_tunnel_handle.clone(), + ); + + // NOTE: In the future, we could imagine performing proxy-based recording as well using RdpProxy. + return crate::rdp_proxy::RdpProxy::builder() + .conf(conf) + .session_info(info) + .client_addr(client_addr) + .client_stream(client_stream) + .server_addr(server_addr) + .server_stream(server_stream) + .sessions(sessions) + .subscriber_tx(subscriber_tx) + .credential_injection(credential_injection) + .client_stream_leftover_bytes(leftover_bytes) + .server_dns_name(selected_target.host().to_owned()) + .disconnect_interest(disconnect_interest) + .kdc_connector(kdc_connector) + .build() + .run() + .await + .context("encountered a failure during RDP proxying (credential injection)"); + } } info!("Upstream forwarding"); diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 25590af34..73b22bf60 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -393,12 +393,10 @@ struct PreflightOperation { /// /// Required for "resolve-host" kind. host_to_resolve: Option, - /// How long provisioned data may wait for first use, in seconds. + /// Minimum persistence duration in seconds for the data provisioned via this operation. /// - /// Optional for "provision-token", "provision-credentials", and - /// "provision-connection-options". For credential-injection mappings this is the maximum - /// time until checkout: the injection path consumes the entry once (one-shot) when a - /// session starts, and does not put it back after a failed attempt. Re-provision to retry. + /// Optional parameter for "provision-token", "provision-credentials", and + /// "provision-connection-options" kinds. time_to_live: Option, } diff --git a/devolutions-gateway/src/provisioning.rs b/devolutions-gateway/src/provisioning.rs index 48a3689e6..bc72b877b 100644 --- a/devolutions-gateway/src/provisioning.rs +++ b/devolutions-gateway/src/provisioning.rs @@ -51,6 +51,19 @@ struct CredentialsEntry { expires_at: time::OffsetDateTime, } +#[derive(Debug, Default)] +struct CredentialsState { + entries: HashMap, + consumed: HashMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MappingStatus { + Available, + Consumed, + Absent, +} + #[derive(Debug, Clone)] struct ConnectionOptionsEntry { connection_options: TargetConnectionOptions, @@ -68,7 +81,7 @@ struct ConnectionOptionsEntry { /// operations and may arrive, expire, or be replaced independently. #[derive(Debug, Clone)] pub struct ProvisioningStore { - credentials: Arc>>, + credentials: Arc>, connection_options: Arc>>, } @@ -81,7 +94,7 @@ impl Default for ProvisioningStore { impl ProvisioningStore { pub fn new() -> Self { Self { - credentials: Arc::new(Mutex::new(HashMap::new())), + credentials: Arc::new(Mutex::new(CredentialsState::default())), connection_options: Arc::new(Mutex::new(HashMap::new())), } } @@ -111,7 +124,9 @@ impl ProvisioningStore { expires_at: time::OffsetDateTime::now_utc() + time_to_live, }; - Ok(self.credentials.lock().insert(jti, entry).is_some()) + let mut credentials = self.credentials.lock(); + credentials.consumed.remove(&jti); + Ok(credentials.entries.insert(jti, entry).is_some()) } /// Insert or replace the connection-options half. Returns whether a prior entry was replaced. @@ -129,39 +144,45 @@ impl ProvisioningStore { self.connection_options.lock().insert(jti, entry).is_some() } - /// True when the credentials half is live and carries an injection mapping. + /// State of the credential-injection mapping for `jti`. /// - /// Does not consume the entry — use before auth when deciding whether to take the - /// credential-injection path. - pub(crate) fn has_mapping(&self, jti: Uuid) -> bool { + /// Does not consume the entry. + /// A consumed tombstone remains until the original provisioning expiry. + /// This makes reconnects fail explicitly instead of silently falling back to non-injected forwarding. + pub(crate) fn mapping_status(&self, jti: Uuid) -> MappingStatus { let now = time::OffsetDateTime::now_utc(); - let entries = self.credentials.lock(); - match entries.get(&jti) { - Some(entry) if now < entry.expires_at => entry.mapping.is_some(), - _ => false, + let mut credentials = self.credentials.lock(); + + if credentials + .consumed + .get(&jti) + .is_some_and(|expires_at| now < *expires_at) + { + return MappingStatus::Consumed; + } + credentials.consumed.remove(&jti); + + match credentials.entries.get(&jti) { + Some(entry) if now < entry.expires_at && entry.mapping.is_some() => MappingStatus::Available, + _ => MappingStatus::Absent, } } - /// Take the provisioned view for a session (one-shot checkout). - /// - /// Removes the credentials half (required) and any live connection-options half for `jti`. - /// Returns `None` if credentials are missing or expired. - /// - /// **Contract:** injection mappings are consumed when the injection path checks them out. - /// They are not restored after a failed TLS/CredSSP attempt. `time_to_live` is how long the - /// entry may wait for that first checkout, not a retry budget. Re-provision to try again. - /// Callers must [`Self::has_mapping`] (or equivalent) before taking so token-only rows are - /// not destroyed by unrelated RDP connections. + /// Test helper that takes either a token-only or mapped entry. + #[cfg(test)] pub(crate) fn take(&self, jti: Uuid) -> Option { let now = time::OffsetDateTime::now_utc(); let (token, mapping) = { - let mut entries = self.credentials.lock(); - let entry = entries.remove(&jti)?; + let mut credentials = self.credentials.lock(); + let entry = credentials.entries.remove(&jti)?; if now >= entry.expires_at { warn!(%jti, "Provisioned credentials expired before the connection arrived"); return None; } + if entry.mapping.is_some() { + credentials.consumed.insert(jti, entry.expires_at); + } (entry.token, entry.mapping) }; @@ -183,6 +204,64 @@ impl ProvisioningStore { connection_options, }) } + + /// Atomically validate and consume an injection mapping (one-shot checkout). + /// + /// The mapping is not restored after a failed TLS/CredSSP attempt. + /// `time_to_live` is how long it may wait for first checkout, not a retry budget. + /// A consumed tombstone makes subsequent attempts fail explicitly until expiry or re-provisioning. + pub(crate) fn take_mapping(&self, jti: Uuid, token: &str) -> anyhow::Result { + let now = time::OffsetDateTime::now_utc(); + + let (token, mapping) = { + let mut credentials = self.credentials.lock(); + + if credentials + .consumed + .get(&jti) + .is_some_and(|expires_at| now < *expires_at) + { + anyhow::bail!("credential-injection material for {jti} was already consumed; re-provision to retry"); + } + credentials.consumed.remove(&jti); + + let entry = credentials + .entries + .get(&jti) + .context("provisioned credential-injection material is missing")?; + anyhow::ensure!( + now < entry.expires_at, + "provisioned credential-injection material expired" + ); + anyhow::ensure!(entry.mapping.is_some(), "provisioned entry has no credential mapping"); + anyhow::ensure!(token == entry.token, "token mismatch"); + + let entry = credentials + .entries + .remove(&jti) + .expect("entry exists while credential state lock is held"); + credentials.consumed.insert(jti, entry.expires_at); + (entry.token, entry.mapping) + }; + + let connection_options = { + let mut entries = self.connection_options.lock(); + match entries.remove(&jti) { + Some(entry) if now < entry.expires_at => Some(entry.connection_options), + Some(_) => { + warn!(%jti, "Provisioned connection options expired before the connection arrived"); + None + } + None => None, + } + }; + + Ok(ProvisioningEntry { + token, + mapping, + connection_options, + }) + } } pub struct CleanupTask { @@ -218,7 +297,10 @@ async fn cleanup_task(handle: ProvisioningStore, mut shutdown_signal: ShutdownSi } let now = time::OffsetDateTime::now_utc(); - handle.credentials.lock().retain(|_, entry| now < entry.expires_at); + let mut credentials = handle.credentials.lock(); + credentials.entries.retain(|_, entry| now < entry.expires_at); + credentials.consumed.retain(|_, expires_at| now < *expires_at); + drop(credentials); handle .connection_options .lock() @@ -337,4 +419,72 @@ mod tests { assert!(!store.insert_connection_options(jti, options(), time::Duration::minutes(5))); assert!(store.insert_connection_options(jti, options(), time::Duration::minutes(5))); } + + #[test] + fn consumed_mapping_is_explicit_until_reprovisioned() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) + .expect("insert"); + + assert_eq!(store.mapping_status(jti), MappingStatus::Available); + store.take_mapping(jti, &token).expect("first checkout"); + assert_eq!(store.mapping_status(jti), MappingStatus::Consumed); + let error = store.take_mapping(jti, &token).expect_err("second checkout fails"); + assert!(format!("{error:#}").contains("already consumed")); + + store + .insert_credentials(token, Some(mapping()), time::Duration::minutes(5)) + .expect("re-provision"); + assert_eq!(store.mapping_status(jti), MappingStatus::Available); + } + + #[test] + fn token_mismatch_does_not_consume_mapping() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) + .expect("insert"); + + let error = store.take_mapping(jti, "different token").expect_err("mismatch"); + assert!(format!("{error:#}").contains("token mismatch")); + assert_eq!(store.mapping_status(jti), MappingStatus::Available); + store.take_mapping(jti, &token).expect("valid checkout"); + } + + #[test] + fn concurrent_mapping_checkout_has_one_winner() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) + .expect("insert"); + + let barrier = Arc::new(std::sync::Barrier::new(3)); + let handles: Vec<_> = (0..2) + .map(|_| { + let store = store.clone(); + let token = token.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + store.take_mapping(jti, &token) + }) + }) + .collect(); + barrier.wait(); + + let results: Vec<_> = handles + .into_iter() + .map(|handle| handle.join().expect("thread")) + .collect(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + let error = results.into_iter().find_map(Result::err).expect("one failure"); + assert!(format!("{error:#}").contains("already consumed")); + } } diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index c5bb4314c..e30d635cc 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -17,7 +17,7 @@ const PCB_TRANSMIT_DEADLINE: Duration = Duration::from_secs(10); use crate::config::Conf; use crate::credential_injection::{CredentialInjection, SyntheticKdcRegistry}; -use crate::provisioning::ProvisioningStore; +use crate::provisioning::{MappingStatus, ProvisioningStore}; use crate::proxy::Proxy; use crate::recording::ActiveRecordings; use crate::session::{ConnectionModeDetails, DisconnectInterest, DisconnectedInfo, SessionInfo, SessionMessageSender}; @@ -484,6 +484,11 @@ async fn handle_with_credential_injection( .proxy_auth .clone() .context("missing token in RDCleanPath PDU")?; + anyhow::ensure!( + provisioning.mapping_status(claims.jti) != MappingStatus::Consumed, + "credential-injection material for {} was already consumed; re-provision to retry", + claims.jti, + ); // Connect before checkout so a target connect failure does not consume one-shot credentials. let ConnectedRdpServer { @@ -496,15 +501,17 @@ async fn handle_with_credential_injection( .context("RDCleanPath connection failed")?; let x224_rsp = x224_rsp.context("RDCleanPath credential injection requires X.224")?; - let entry = provisioning - .take(claims.jti) - .context("provisioned credentials missing after authorization")?; - anyhow::ensure!(entry.mapping.is_some(), "provisioned entry has no credential mapping"); - anyhow::ensure!(token == entry.token, "token mismatch"); - - let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in(&conf); - let credential_injection = CredentialInjection::from_provisioned(claims.jti, entry, kerberos_enabled)? - .register_if_kerberos(synthetic_kdc_registry); + let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in( + conf.debug.enable_unstable, + conf.debug.kerberos_credential_injection, + ); + let credential_injection = CredentialInjection::checkout( + provisioning, + synthetic_kdc_registry, + claims.jti, + &token, + kerberos_enabled, + )?; let gateway_cert_chain_handle = tokio::spawn(crate::tls::get_cert_chain_for_acceptor_cached( gateway_hostname, @@ -615,10 +622,13 @@ pub async fn handle( .context("missing token in RDCleanPath PDU")?; // If a credential mapping has been pushed, switch to proxy-based credential injection. - // Peek only here — take after authorize + server connect so an unverified token cannot - // burn a victim JTI, and a failed target connect does not consume the one-shot entry. + // Peek here without consuming. + // Checkout happens after authorization and target connection to protect the JTI from invalid requests and failures. if let Some(jti) = crate::token::extract_jti(token).ok() - && provisioning.has_mapping(jti) + && matches!( + provisioning.mapping_status(jti), + MappingStatus::Available | MappingStatus::Consumed + ) { // VMConnect needs pre-X.224 CredSSP against the Hyper-V host cert on the client. // Proxy CredSSP MITM is X.224-first and is not supported for this ordering. From e855268ffa4c3d02b35e6e94f2c865e11ef710e3 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Mon, 17 Aug 2026 15:31:24 -0400 Subject: [PATCH 08/36] docs(openapi): document one-shot provisioning TTL Describe time_to_live as the first-checkout window for credential-injection mappings and state that failed attempts require re-provisioning. Regenerate the published specification and clients. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/openapi/doc/index.adoc | 2 +- .../openapi/dotnet-client/docs/PreflightOperation.md | 3 +-- .../Model/PreflightOperation.cs | 6 +++--- devolutions-gateway/openapi/gateway-api.yaml | 9 ++++++--- .../ts-angular-client/model/preflightOperation.ts | 2 +- devolutions-gateway/src/openapi.rs | 9 ++++++--- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index 6910ffa17..42bd35a87 100644 --- a/devolutions-gateway/openapi/doc/index.adoc +++ b/devolutions-gateway/openapi/doc/index.adoc @@ -4445,7 +4445,7 @@ Current auto-update schedule for Devolutions Agent. | | X | Integer -| Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. +| How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry. | int32 | token diff --git a/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md b/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md index 4c16436b1..aadff24ba 100644 --- a/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md +++ b/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md @@ -10,8 +10,7 @@ Name | Type | Description | Notes **Kind** | **PreflightOperationKind** | | **ProxyCredential** | [**AppCredential**](AppCredential.md) | | [optional] **TargetCredential** | [**AppCredential**](AppCredential.md) | | [optional] -**TimeToLive** | **int?** | Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | [optional] +**TimeToLive** | **int?** | How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry. | [optional] **Token** | **string** | The token to be stored on the proxy-side. Required for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - 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 59212a095..6b90ff714 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 @@ -53,7 +53,7 @@ protected PreflightOperation() { } /// kind (required). /// proxyCredential. /// targetCredential. - /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds.. + /// How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry.. /// The token to be stored on the proxy-side. Required for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds.. public PreflightOperation(TargetConnectionOptions connectionOptions = default(TargetConnectionOptions), 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)) { @@ -100,9 +100,9 @@ protected PreflightOperation() { } public AppCredential TargetCredential { get; set; } /// - /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. + /// How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry. /// - /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. + /// How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry. [DataMember(Name = "time_to_live", EmitDefaultValue = true)] public int? TimeToLive { get; set; } diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 851fdd6af..53cfbd9c6 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -1837,10 +1837,13 @@ components: type: integer format: int32 description: |- - Minimum persistence duration in seconds for the data provisioned via this operation. + How long provisioned data may wait for first use, in seconds. - Optional parameter for "provision-token", "provision-credentials", and - "provision-connection-options" kinds. + Optional for "provision-token", "provision-credentials", and + "provision-connection-options". + Credential-injection mappings are consumed once when a session starts and are not restored + after a failed attempt. + Re-provision to retry. nullable: true minimum: 0 token: diff --git a/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts b/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts index 76d51046a..7a115b294 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts @@ -26,7 +26,7 @@ export interface PreflightOperation { proxy_credential?: AppCredential | null; target_credential?: AppCredential | null; /** - * Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. + * How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry. */ time_to_live?: number | null; /** diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 73b22bf60..506ac8589 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -393,10 +393,13 @@ struct PreflightOperation { /// /// Required for "resolve-host" kind. host_to_resolve: Option, - /// Minimum persistence duration in seconds for the data provisioned via this operation. + /// How long provisioned data may wait for first use, in seconds. /// - /// Optional parameter for "provision-token", "provision-credentials", and - /// "provision-connection-options" kinds. + /// Optional for "provision-token", "provision-credentials", and + /// "provision-connection-options". + /// Credential-injection mappings are consumed once when a session starts and are not restored + /// after a failed attempt. + /// Re-provision to retry. time_to_live: Option, } From 69910210e29680341782fb036560e8ca4fafdd8a Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Mon, 17 Aug 2026 15:46:45 -0400 Subject: [PATCH 09/36] docs(dgw): clarify Kerberos SPN contract State that supported credential-injection clients retain association dst_hst as their logical TERMSRV service name even when the transport endpoint is a Gateway listener. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/src/api/kdc_proxy.rs | 7 +++---- devolutions-gateway/src/credential_injection.rs | 5 +++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/devolutions-gateway/src/api/kdc_proxy.rs b/devolutions-gateway/src/api/kdc_proxy.rs index ad6aebd8c..f90eee2ae 100644 --- a/devolutions-gateway/src/api/kdc_proxy.rs +++ b/devolutions-gateway/src/api/kdc_proxy.rs @@ -183,10 +183,9 @@ mod tests { #[test] fn enforce_realm_mismatch_passes_under_bypass() { - // `bypass=true` is the `__debug__.disable_token_validation` downgrade. CBenoit asked - // for explicit coverage of this branch because it is the only place the realm - // authorization is intentionally weakened, and slipping the gate (e.g. by inverting the - // condition) would only surface in production. + // `bypass=true` is the `__debug__.disable_token_validation` downgrade. + // This is the only branch where realm authorization is intentionally weakened, so pin it + // explicitly to catch an inverted gate. assert!(enforce_realm_token_match("ad.example", "evil.example", true).is_ok()); } diff --git a/devolutions-gateway/src/credential_injection.rs b/devolutions-gateway/src/credential_injection.rs index d21ae0a06..45c1dfe75 100644 --- a/devolutions-gateway/src/credential_injection.rs +++ b/devolutions-gateway/src/credential_injection.rs @@ -323,6 +323,11 @@ impl CredentialInjectionKdc { } /// Session destination host from association `dst_hst` (not Gateway `conf.hostname`). + /// + /// Supported clients retain this logical destination when forming their `TERMSRV` SPN, even + /// when the transport endpoint is a Gateway listener. + /// Clients that derive the SPN from the Gateway transport hostname are not supported by the + /// unstable Kerberos credential-injection path. pub(crate) fn target_hostname(&self) -> &str { &self.target_hostname } From 180aa0c4810ff40ca14412e1945f48eaf50491be Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Mon, 17 Aug 2026 17:03:59 -0400 Subject: [PATCH 10/36] revert: remove one-shot OpenAPI documentation Restore the pre-existing provisioning TTL wording and generated artifacts. The OpenAPI documentation update was outside the requested PR scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/openapi/doc/index.adoc | 2 +- .../openapi/dotnet-client/docs/PreflightOperation.md | 3 ++- .../Model/PreflightOperation.cs | 6 +++--- devolutions-gateway/openapi/gateway-api.yaml | 9 +++------ .../ts-angular-client/model/preflightOperation.ts | 2 +- devolutions-gateway/src/openapi.rs | 9 +++------ 6 files changed, 13 insertions(+), 18 deletions(-) diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index 42bd35a87..6910ffa17 100644 --- a/devolutions-gateway/openapi/doc/index.adoc +++ b/devolutions-gateway/openapi/doc/index.adoc @@ -4445,7 +4445,7 @@ Current auto-update schedule for Devolutions Agent. | | X | Integer -| How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry. +| Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | int32 | token diff --git a/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md b/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md index aadff24ba..4c16436b1 100644 --- a/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md +++ b/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md @@ -10,7 +10,8 @@ Name | Type | Description | Notes **Kind** | **PreflightOperationKind** | | **ProxyCredential** | [**AppCredential**](AppCredential.md) | | [optional] **TargetCredential** | [**AppCredential**](AppCredential.md) | | [optional] -**TimeToLive** | **int?** | How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry. | [optional] +**TimeToLive** | **int?** | Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | [optional] **Token** | **string** | The token to be stored on the proxy-side. Required for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + 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 6b90ff714..59212a095 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 @@ -53,7 +53,7 @@ protected PreflightOperation() { } /// kind (required). /// proxyCredential. /// targetCredential. - /// How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry.. + /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds.. /// The token to be stored on the proxy-side. Required for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds.. public PreflightOperation(TargetConnectionOptions connectionOptions = default(TargetConnectionOptions), 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)) { @@ -100,9 +100,9 @@ protected PreflightOperation() { } public AppCredential TargetCredential { get; set; } /// - /// How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry. + /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. /// - /// How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry. + /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. [DataMember(Name = "time_to_live", EmitDefaultValue = true)] public int? TimeToLive { get; set; } diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 53cfbd9c6..851fdd6af 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -1837,13 +1837,10 @@ components: type: integer format: int32 description: |- - How long provisioned data may wait for first use, in seconds. + Minimum persistence duration in seconds for the data provisioned via this operation. - Optional for "provision-token", "provision-credentials", and - "provision-connection-options". - Credential-injection mappings are consumed once when a session starts and are not restored - after a failed attempt. - Re-provision to retry. + Optional parameter for "provision-token", "provision-credentials", and + "provision-connection-options" kinds. nullable: true minimum: 0 token: diff --git a/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts b/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts index 7a115b294..76d51046a 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts @@ -26,7 +26,7 @@ export interface PreflightOperation { proxy_credential?: AppCredential | null; target_credential?: AppCredential | null; /** - * How long provisioned data may wait for first use, in seconds. Optional for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\". Credential-injection mappings are consumed once when a session starts and are not restored after a failed attempt. Re-provision to retry. + * Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. */ time_to_live?: number | null; /** diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 506ac8589..73b22bf60 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -393,13 +393,10 @@ struct PreflightOperation { /// /// Required for "resolve-host" kind. host_to_resolve: Option, - /// How long provisioned data may wait for first use, in seconds. + /// Minimum persistence duration in seconds for the data provisioned via this operation. /// - /// Optional for "provision-token", "provision-credentials", and - /// "provision-connection-options". - /// Credential-injection mappings are consumed once when a session starts and are not restored - /// after a failed attempt. - /// Re-provision to retry. + /// Optional parameter for "provision-token", "provision-credentials", and + /// "provision-connection-options" kinds. time_to_live: Option, } From 8eb632d0b2cde2a69a83b3dd07b277057a646b14 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Thu, 20 Aug 2026 14:38:28 -0400 Subject: [PATCH 11/36] fix(dgw): keep injection mappings across reconnects Token validation already accepts the same association JWT inside jet_reuse, but checkout consumed the mapping on first use. Native RDM reconnects reuse that JWT without DVLS, so injection failed or silently forwarded. Keep encrypted mappings until the token acceptance deadline, authorize before choosing injection, fail closed when required material is gone, and reuse one synthetic KDC per provisioning generation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/credential_injection.rs | 297 +++++++++++++++-- devolutions-gateway/src/generic_client.rs | 125 +++---- devolutions-gateway/src/provisioning.rs | 304 ++++++++++++------ devolutions-gateway/src/rd_clean_path.rs | 133 ++++---- devolutions-gateway/src/rdp_proxy/credssp.rs | 7 +- devolutions-gateway/src/token.rs | 17 + 6 files changed, 621 insertions(+), 262 deletions(-) diff --git a/devolutions-gateway/src/credential_injection.rs b/devolutions-gateway/src/credential_injection.rs index 45c1dfe75..825e187c1 100644 --- a/devolutions-gateway/src/credential_injection.rs +++ b/devolutions-gateway/src/credential_injection.rs @@ -1,9 +1,10 @@ //! Credential-injection runtime for RDP. //! -//! - Provisioned material lives in [`crate::provisioning::ProvisioningStore`] until checkout. +//! - Provisioned mappings live in [`crate::provisioning::ProvisioningStore`]. //! - [`CredentialInjection::from_provisioned`] builds a session-scoped injection plan. -//! - Kerberos sessions publish a [`CredentialInjectionKdc`] into [`SyntheticKdcRegistry`]; -//! `/jet/KdcProxy` resolves only that registry (not the provisioning store). +//! - Kerberos sessions reuse one synthetic KDC per provisioning generation, then publish a +//! [`CredentialInjectionKdc`] into [`SyntheticKdcRegistry`] for the connection. +//! - `/jet/KdcProxy` resolves only that registry (not the provisioning store). use std::collections::HashMap; use std::fmt; @@ -92,10 +93,14 @@ pub(crate) enum PreparedCredentialInjection { impl PreparedCredentialInjection { /// Publish the synthetic KDC when this is Kerberos; NTLM is a no-op pass-through. - pub(crate) fn register_if_kerberos(self, registry: &SyntheticKdcRegistry) -> CredentialInjection { + pub(crate) fn register_if_kerberos( + self, + registry: &SyntheticKdcRegistry, + provision_generation: u64, + ) -> CredentialInjection { match self { Self::Kerberos(injection) => { - let registration = registry.register(Arc::clone(&injection.synthetic)); + let registration = registry.register(Arc::clone(&injection.synthetic), provision_generation); debug!( jti = %injection.synthetic.jti(), "Registered synthetic KDC for credential-injection session" @@ -156,9 +161,21 @@ impl CredentialInjection { kerberos_enabled: bool, ) -> anyhow::Result { let entry = provisioning - .take_mapping(jti, token) + .get_mapping(jti, token) .with_context(|| format!("checkout credential-injection material for {jti}"))?; - Ok(Self::from_provisioned(jti, entry, kerberos_enabled)?.register_if_kerberos(registry)) + let generation = entry.generation; + let kdc_expires_at = entry.kdc_expires_at; + registry.discard_stale_session_kdc(jti, generation); + let prepared = Self::from_provisioned(jti, entry, kerberos_enabled)?; + let prepared = match prepared { + PreparedCredentialInjection::Kerberos(mut injection) => { + let expires_at = kdc_expires_at.context("mapped Kerberos row has no token deadline")?; + injection.synthetic = registry.intern_session_kdc(jti, generation, expires_at, injection.synthetic); + PreparedCredentialInjection::Kerberos(injection) + } + ntlm @ PreparedCredentialInjection::Ntlm(_) => ntlm, + }; + Ok(prepared.register_if_kerberos(registry, generation)) } pub(crate) fn jti(&self) -> Uuid { @@ -206,6 +223,8 @@ impl CredentialInjection { token, mapping, connection_options, + generation: _, + kdc_expires_at: _, } = credential_entry; let mapping = mapping.context("credential-injection state has no mapping")?; @@ -522,9 +541,9 @@ fn random_32_bytes() -> Vec { /// - `/jet/KdcProxy` only looks up published entries; it never builds a KDC from /// [`crate::provisioning::ProvisioningStore`]. /// -/// Entries are connection-scoped via [`SyntheticKdcRegistration`] and removed when the owning session ends. -/// Generations prevent an older session from unpublishing a replacement. -/// Re-provisioning the same JTI can register that replacement before the older session ends. +/// Connection leases publish to `/jet/KdcProxy`. The same provisioning generation is +/// reference-counted; a newer generation replaces an older one. An older lease cannot unpublish +/// or overwrite a newer generation. #[derive(Debug, Clone)] pub struct SyntheticKdcRegistry { inner: Arc>, @@ -533,31 +552,51 @@ pub struct SyntheticKdcRegistry { #[derive(Debug, Default)] struct RegistryInner { live: HashMap, - next_generation: u64, + session: HashMap, } #[derive(Debug, Clone)] struct PublishedSyntheticKdc { - generation: u64, + provision_generation: u64, + leases: u32, + kdc: Arc, +} + +#[derive(Debug, Clone)] +struct SessionSyntheticKdc { + provision_generation: u64, + expires_at: time::OffsetDateTime, kdc: Arc, } +fn generation_is_newer(candidate: u64, than: u64) -> bool { + candidate != than && candidate.wrapping_sub(than) < than.wrapping_sub(candidate) +} + /// RAII lease for a published synthetic KDC. pub(crate) struct SyntheticKdcRegistration { registry: SyntheticKdcRegistry, jti: Uuid, - generation: u64, + provision_generation: u64, } impl Drop for SyntheticKdcRegistration { fn drop(&mut self) { let mut inner = self.registry.inner.lock(); - let Some(current) = inner.live.get(&self.jti) else { + let Some(current) = inner.live.get_mut(&self.jti) else { return; }; - if current.generation == self.generation { + if current.provision_generation != self.provision_generation { + return; + } + current.leases = current.leases.saturating_sub(1); + if current.leases == 0 { inner.live.remove(&self.jti); - debug!(jti = %self.jti, generation = self.generation, "Unpublished synthetic KDC"); + debug!( + jti = %self.jti, + provision_generation = self.provision_generation, + "Unpublished synthetic KDC" + ); } } } @@ -575,23 +614,112 @@ impl SyntheticKdcRegistry { } } - pub(crate) fn register(&self, kdc: Arc) -> SyntheticKdcRegistration { + pub(crate) fn register( + &self, + kdc: Arc, + provision_generation: u64, + ) -> SyntheticKdcRegistration { let jti = kdc.jti(); let mut inner = self.inner.lock(); - inner.next_generation = inner.next_generation.wrapping_add(1); - let generation = inner.next_generation; - inner.live.insert(jti, PublishedSyntheticKdc { generation, kdc }); - debug!(%jti, generation, "Published synthetic KDC"); + match inner.live.get_mut(&jti) { + Some(current) if current.provision_generation == provision_generation => { + current.leases = current.leases.saturating_add(1); + } + Some(current) if generation_is_newer(current.provision_generation, provision_generation) => {} + _ => { + inner.live.insert( + jti, + PublishedSyntheticKdc { + provision_generation, + leases: 1, + kdc, + }, + ); + debug!(%jti, provision_generation, "Published synthetic KDC"); + } + } SyntheticKdcRegistration { registry: self.clone(), jti, - generation, + provision_generation, } } pub(crate) fn get(&self, jti: Uuid) -> Option> { self.inner.lock().live.get(&jti).map(|entry| Arc::clone(&entry.kdc)) } + + /// Drop interned KDCs that are expired or older than this provisioning generation. + pub(crate) fn discard_stale_session_kdc(&self, jti: Uuid, provision_generation: u64) { + let now = time::OffsetDateTime::now_utc(); + let mut inner = self.inner.lock(); + inner.session.retain(|_, entry| now < entry.expires_at); + if inner + .session + .get(&jti) + .is_some_and(|entry| generation_is_newer(provision_generation, entry.provision_generation)) + { + inner.session.remove(&jti); + } + } + + /// Reuse the synthetic KDC for this provisioning generation until `expires_at`. + /// + /// A later `provision-credentials` bumps the generation and replaces the cached KDC. + /// An older generation never overwrites a newer interned KDC. + pub(crate) fn intern_session_kdc( + &self, + jti: Uuid, + provision_generation: u64, + expires_at: time::OffsetDateTime, + kdc: Arc, + ) -> Arc { + let now = time::OffsetDateTime::now_utc(); + let mut inner = self.inner.lock(); + inner.session.retain(|_, entry| now < entry.expires_at); + if now >= expires_at { + if inner + .session + .get(&jti) + .is_some_and(|entry| entry.provision_generation == provision_generation) + { + inner.session.remove(&jti); + } + return kdc; + } + if let Some(existing) = inner.session.get(&jti) { + if existing.provision_generation == provision_generation { + return Arc::clone(&existing.kdc); + } + if generation_is_newer(existing.provision_generation, provision_generation) { + return kdc; + } + } + inner.session.insert( + jti, + SessionSyntheticKdc { + provision_generation, + expires_at, + kdc: Arc::clone(&kdc), + }, + ); + kdc + } + + #[cfg(test)] + pub(crate) fn session_kdc_live(&self, jti: Uuid) -> bool { + self.interned_kdc(jti).is_some() + } + + #[cfg(test)] + fn interned_kdc(&self, jti: Uuid) -> Option> { + let now = time::OffsetDateTime::now_utc(); + self.inner + .lock() + .session + .get(&jti) + .and_then(|entry| (now < entry.expires_at).then(|| Arc::clone(&entry.kdc))) + } } #[cfg(test)] @@ -628,7 +756,8 @@ mod tests { fn association_token(jti: Uuid) -> String { unsigned_jws(serde_json::json!({ "jti": jti, - "dst_hst": "target.example:3389" + "dst_hst": "target.example:3389", + "exp": time::OffsetDateTime::now_utc().unix_timestamp() + 3600 })) } @@ -710,7 +839,7 @@ mod tests { let registry = SyntheticKdcRegistry::new(); let injection = CredentialInjection::from_provisioned(jti, entry, false) .expect("prepared") - .register_if_kerberos(®istry); + .register_if_kerberos(®istry, 1); assert!(!injection.uses_kerberos()); assert!(registry.get(jti).is_none()); } @@ -722,7 +851,7 @@ mod tests { let registry = SyntheticKdcRegistry::new(); let injection = CredentialInjection::from_provisioned(jti, entry, true) .expect("prepared") - .register_if_kerberos(®istry); + .register_if_kerberos(®istry, 1); assert!(!injection.uses_kerberos()); } @@ -740,11 +869,11 @@ mod tests { let store = stock_with_mapping(jti, "administrator@example.invalid"); store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); let entry = store.take(jti).expect("entry"); - assert!(store.take(jti).is_none(), "take is one-shot"); + assert!(store.take(jti).is_none(), "test helper take removes the row"); let registry = SyntheticKdcRegistry::new(); let prepared = CredentialInjection::from_provisioned(jti, entry, true).expect("prepared"); assert!(registry.get(jti).is_none(), "not published until register_if_kerberos"); - let injection = prepared.register_if_kerberos(®istry); + let injection = prepared.register_if_kerberos(®istry, 1); assert!(injection.uses_kerberos()); assert!(registry.get(jti).is_some()); assert_eq!( @@ -753,6 +882,80 @@ mod tests { ); } + #[test] + fn checkout_reuses_synthetic_kdc_for_the_same_generation() { + let jti = Uuid::new_v4(); + let token = association_token(jti); + let store = ProvisioningStore::new(); + store + .insert_credentials( + token.clone(), + Some(cleartext_mapping_with_target_username("administrator@example.invalid")), + time::Duration::minutes(5), + ) + .expect("insert"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let registry = SyntheticKdcRegistry::new(); + + let first = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("first"); + let first_ptr = std::ptr::from_ref(first.as_kerberos().expect("kerberos").synthetic_kdc()); + drop(first); + + let second = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("second"); + let second_ptr = std::ptr::from_ref(second.as_kerberos().expect("kerberos").synthetic_kdc()); + assert_eq!(first_ptr, second_ptr); + + store + .insert_credentials( + token.clone(), + Some(cleartext_mapping_with_target_username("administrator@example.invalid")), + time::Duration::minutes(5), + ) + .expect("re-provision"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let third = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("third"); + let third_ptr = std::ptr::from_ref(third.as_kerberos().expect("kerberos").synthetic_kdc()); + assert_ne!(first_ptr, third_ptr); + } + + #[test] + fn interned_kdc_is_not_kept_past_deadline() { + let jti = Uuid::new_v4(); + let registry = SyntheticKdcRegistry::new(); + let kdc = Arc::new(dummy_kdc(jti)); + let expired = time::OffsetDateTime::now_utc() - time::Duration::seconds(1); + registry.intern_session_kdc(jti, 1, expired, kdc); + assert!(!registry.session_kdc_live(jti)); + } + + #[test] + fn ntlm_checkout_discards_previous_generation_kdc() { + let jti = Uuid::new_v4(); + let token = association_token(jti); + let store = ProvisioningStore::new(); + store + .insert_credentials( + token.clone(), + Some(cleartext_mapping_with_target_username("administrator@example.invalid")), + time::Duration::minutes(5), + ) + .expect("insert"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let registry = SyntheticKdcRegistry::new(); + let _kerberos = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("kerberos"); + assert!(registry.session_kdc_live(jti)); + + store + .insert_credentials( + token.clone(), + Some(cleartext_mapping_with_target_username("Administrator")), + time::Duration::minutes(5), + ) + .expect("ntlm re-provision"); + let _ntlm = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("ntlm"); + assert!(!registry.session_kdc_live(jti)); + } + #[test] fn provisioned_krb_kdc_is_carried_on_kerberos_injection() { // Pins provision → from_provisioned → target_kdc for the CredSSP client leg. @@ -762,7 +965,7 @@ mod tests { let entry = store.take(jti).expect("entry"); let injection = CredentialInjection::from_provisioned(jti, entry, true) .expect("prepared") - .register_if_kerberos(&SyntheticKdcRegistry::new()); + .register_if_kerberos(&SyntheticKdcRegistry::new(), 1); assert_eq!( injection.as_kerberos().expect("kerberos").target_kdc().as_str(), @@ -781,7 +984,8 @@ mod tests { .insert_credentials( unsigned_jws(serde_json::json!({ "jti": jti, - "dst_hst": "it-help-dc.corp.example:3389" + "dst_hst": "it-help-dc.corp.example:3389", + "exp": time::OffsetDateTime::now_utc().unix_timestamp() + 3600 })), Some(cleartext_mapping_with_target_username("administrator@example.invalid")), time::Duration::minutes(5), @@ -791,7 +995,7 @@ mod tests { let entry = store.take(jti).expect("entry"); let injection = CredentialInjection::from_provisioned(jti, entry, true) .expect("prepared") - .register_if_kerberos(&SyntheticKdcRegistry::new()); + .register_if_kerberos(&SyntheticKdcRegistry::new(), 1); assert_eq!( injection @@ -808,11 +1012,11 @@ mod tests { let registry = SyntheticKdcRegistry::new(); let jti = Uuid::new_v4(); let first = Arc::new(dummy_kdc(jti)); - let first_registration = registry.register(Arc::clone(&first)); + let first_registration = registry.register(Arc::clone(&first), 1); assert!(Arc::ptr_eq(®istry.get(jti).expect("first"), &first)); let second = Arc::new(dummy_kdc(jti)); - let second_registration = registry.register(Arc::clone(&second)); + let second_registration = registry.register(Arc::clone(&second), 2); drop(first_registration); assert!(Arc::ptr_eq(®istry.get(jti).expect("successor"), &second)); @@ -820,6 +1024,35 @@ mod tests { assert!(registry.get(jti).is_none()); } + #[test] + fn same_generation_leases_unpublish_on_last_drop() { + let registry = SyntheticKdcRegistry::new(); + let jti = Uuid::new_v4(); + let kdc = Arc::new(dummy_kdc(jti)); + let first = registry.register(Arc::clone(&kdc), 1); + let second = registry.register(Arc::clone(&kdc), 1); + drop(first); + assert!(registry.get(jti).is_some()); + drop(second); + assert!(registry.get(jti).is_none()); + } + + #[test] + fn stale_generation_does_not_replace_interned_kdc() { + let jti = Uuid::new_v4(); + let registry = SyntheticKdcRegistry::new(); + let newer = Arc::new(dummy_kdc(jti)); + let older = Arc::new(dummy_kdc(jti)); + let deadline = time::OffsetDateTime::now_utc() + time::Duration::minutes(5); + let interned = registry.intern_session_kdc(jti, 2, deadline, Arc::clone(&newer)); + assert!(Arc::ptr_eq(&interned, &newer)); + let rejected = registry.intern_session_kdc(jti, 1, deadline, Arc::clone(&older)); + assert!(Arc::ptr_eq(&rejected, &older)); + assert!(Arc::ptr_eq(®istry.interned_kdc(jti).expect("kept"), &newer)); + registry.discard_stale_session_kdc(jti, 1); + assert!(Arc::ptr_eq(®istry.interned_kdc(jti).expect("still kept"), &newer)); + } + #[test] fn kdc_proxy_cannot_invent_from_provisioning_store() { let jti = Uuid::new_v4(); diff --git a/devolutions-gateway/src/generic_client.rs b/devolutions-gateway/src/generic_client.rs index ef9121ec4..cd5646006 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -116,6 +116,21 @@ where RecordingPolicy::Proxy => anyhow::bail!("can't meet recording policy"), } + let is_rdp = claims.jet_ap == token::ApplicationProtocol::Known(token::Protocol::Rdp); + let mapping_status = if is_rdp { + provisioning.mapping_status(claims.jti) + } else { + MappingStatus::Absent + }; + let inject = match mapping_status { + MappingStatus::RequiredMissing => anyhow::bail!( + "credential-injection material for {} is missing or expired; re-provision to retry", + claims.jti + ), + MappingStatus::Available => true, + MappingStatus::Absent => false, + }; + let ConnectedUpstream { leg: mut server_stream, server_addr, @@ -131,8 +146,6 @@ where span.record("target", selected_target.to_string()); - let is_rdp = claims.jet_ap == token::ApplicationProtocol::Known(token::Protocol::Rdp); - let info = SessionInfo::builder() .id(claims.jet_aid) .application_protocol(claims.jet_ap) @@ -146,66 +159,56 @@ where let disconnect_interest = DisconnectInterest::from_reconnection_policy(claims.jet_reuse); - // Peek first so token-only provision rows are not consumed. - // Fail explicitly for consumed mappings instead of silently downgrading. - let mapping_status = if is_rdp { - provisioning.mapping_status(claims.jti) - } else { - MappingStatus::Absent - }; - match mapping_status { - MappingStatus::Consumed => { - anyhow::bail!( - "credential-injection material for {} was already consumed; re-provision to retry", + if inject { + let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in( + conf.debug.enable_unstable, + conf.debug.kerberos_credential_injection, + ); + let credential_injection = CredentialInjection::checkout( + &provisioning, + &synthetic_kdc_registry, + claims.jti, + token, + kerberos_enabled, + ) + .with_context(|| { + format!( + "credential-injection material for {} is missing or expired; re-provision to retry", claims.jti - ); - } - MappingStatus::Absent => {} - MappingStatus::Available => { - let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in( - conf.debug.enable_unstable, - conf.debug.kerberos_credential_injection, - ); - let credential_injection = CredentialInjection::checkout( - &provisioning, - &synthetic_kdc_registry, - claims.jti, - token, - kerberos_enabled, - )?; - - info!( - jti = %credential_injection.jti(), - kerberos = credential_injection.uses_kerberos(), - "RDP-TLS forwarding with credential injection" - ); - - let kdc_connector = crate::kdc_connector::KdcConnector::new( - claims.jet_aid, - claims.jet_agent_id, - agent_tunnel_handle.clone(), - ); - - // NOTE: In the future, we could imagine performing proxy-based recording as well using RdpProxy. - return crate::rdp_proxy::RdpProxy::builder() - .conf(conf) - .session_info(info) - .client_addr(client_addr) - .client_stream(client_stream) - .server_addr(server_addr) - .server_stream(server_stream) - .sessions(sessions) - .subscriber_tx(subscriber_tx) - .credential_injection(credential_injection) - .client_stream_leftover_bytes(leftover_bytes) - .server_dns_name(selected_target.host().to_owned()) - .disconnect_interest(disconnect_interest) - .kdc_connector(kdc_connector) - .build() - .run() - .await - .context("encountered a failure during RDP proxying (credential injection)"); - } + ) + })?; + + info!( + jti = %credential_injection.jti(), + kerberos = credential_injection.uses_kerberos(), + "RDP-TLS forwarding with credential injection" + ); + + let kdc_connector = crate::kdc_connector::KdcConnector::new( + claims.jet_aid, + claims.jet_agent_id, + agent_tunnel_handle.clone(), + ); + + // NOTE: In the future, we could imagine performing proxy-based recording as well using RdpProxy. + return crate::rdp_proxy::RdpProxy::builder() + .conf(conf) + .session_info(info) + .client_addr(client_addr) + .client_stream(client_stream) + .server_addr(server_addr) + .server_stream(server_stream) + .sessions(sessions) + .subscriber_tx(subscriber_tx) + .credential_injection(credential_injection) + .client_stream_leftover_bytes(leftover_bytes) + .server_dns_name(selected_target.host().to_owned()) + .disconnect_interest(disconnect_interest) + .kdc_connector(kdc_connector) + .build() + .run() + .await + .context("encountered a failure during RDP proxying (credential injection)"); } info!("Upstream forwarding"); diff --git a/devolutions-gateway/src/provisioning.rs b/devolutions-gateway/src/provisioning.rs index bc72b877b..991badf5b 100644 --- a/devolutions-gateway/src/provisioning.rs +++ b/devolutions-gateway/src/provisioning.rs @@ -42,6 +42,8 @@ pub struct ProvisioningEntry { pub(crate) token: String, pub(crate) mapping: Option, pub(crate) connection_options: Option, + pub(crate) generation: u64, + pub(crate) kdc_expires_at: Option, } #[derive(Debug, Clone)] @@ -49,18 +51,15 @@ struct CredentialsEntry { token: String, mapping: Option, expires_at: time::OffsetDateTime, -} - -#[derive(Debug, Default)] -struct CredentialsState { - entries: HashMap, - consumed: HashMap, + /// `Some` for `provision-credentials`: fail closed until this JWT acceptance deadline. + required_until: Option, + generation: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum MappingStatus { Available, - Consumed, + RequiredMissing, Absent, } @@ -81,7 +80,7 @@ struct ConnectionOptionsEntry { /// operations and may arrive, expire, or be replaced independently. #[derive(Debug, Clone)] pub struct ProvisioningStore { - credentials: Arc>, + credentials: Arc>>, connection_options: Arc>>, } @@ -94,15 +93,18 @@ impl Default for ProvisioningStore { impl ProvisioningStore { pub fn new() -> Self { Self { - credentials: Arc::new(Mutex::new(CredentialsState::default())), + credentials: Arc::new(Mutex::new(HashMap::new())), connection_options: Arc::new(Mutex::new(HashMap::new())), } } /// Insert or replace the credentials half (token-only or with a mapping). /// - /// Same contract as master: `provision-token` passes `mapping = None`; - /// `provision-credentials` passes `Some(mapping)`. + /// `provision-token` passes `mapping = None`; `provision-credentials` passes `Some(mapping)`. + /// + /// For mapped rows, `time_to_live` is a staging wait for first checkout, capped to the token + /// acceptance deadline. The first successful [`Self::get_mapping`] then keeps the mapping until + /// that deadline. pub(crate) fn insert_credentials( &self, token: String, @@ -112,21 +114,48 @@ impl ProvisioningStore { let jti = crate::token::extract_jti(&token) .context("failed to extract token ID") .map_err(InsertError::InvalidToken)?; + let now = time::OffsetDateTime::now_utc(); + let staging_expires = now + time_to_live; + let required_until = if mapping.is_some() { + let exp = crate::token::extract_exp(&token) + .context("failed to extract token expiration") + .map_err(InsertError::InvalidToken)?; + Some(crate::token::token_acceptance_deadline(exp)) + } else { + None + }; + let expires_at = required_until.map_or(staging_expires, |deadline| staging_expires.min(deadline)); let mapping = mapping .map(CleartextAppCredentialMapping::encrypt) .transpose() .context("encrypt provisioned credentials") .map_err(InsertError::CredentialEncryption)?; - let entry = CredentialsEntry { - token, - mapping, - expires_at: time::OffsetDateTime::now_utc() + time_to_live, - }; - let mut credentials = self.credentials.lock(); - credentials.consumed.remove(&jti); - Ok(credentials.entries.insert(jti, entry).is_some()) + let generation = credentials + .get(&jti) + .map_or(1, |entry| entry.generation.wrapping_add(1)); + let replaced = credentials + .insert( + jti, + CredentialsEntry { + token, + mapping, + expires_at, + required_until, + generation, + }, + ) + .is_some(); + + if let Some(deadline) = required_until + && let Some(options) = self.connection_options.lock().get_mut(&jti) + && options.expires_at > deadline + { + options.expires_at = deadline; + } + + Ok(replaced) } /// Insert or replace the connection-options half. Returns whether a prior entry was replaced. @@ -136,9 +165,15 @@ impl ProvisioningStore { connection_options: TargetConnectionOptions, time_to_live: time::Duration, ) -> bool { + let now = time::OffsetDateTime::now_utc(); + let mut expires_at = now + time_to_live; + if let Some(deadline) = self.credentials.lock().get(&jti).and_then(|entry| entry.required_until) { + expires_at = expires_at.min(deadline); + } + let entry = ConnectionOptionsEntry { connection_options, - expires_at: time::OffsetDateTime::now_utc() + time_to_live, + expires_at, }; self.connection_options.lock().insert(jti, entry).is_some() @@ -146,25 +181,24 @@ impl ProvisioningStore { /// State of the credential-injection mapping for `jti`. /// - /// Does not consume the entry. - /// A consumed tombstone remains until the original provisioning expiry. - /// This makes reconnects fail explicitly instead of silently falling back to non-injected forwarding. + /// `RequiredMissing` means injection was provisioned but the mapping is gone or expired while + /// the token could still be accepted. Callers must fail closed instead of ordinary forwarding. pub(crate) fn mapping_status(&self, jti: Uuid) -> MappingStatus { let now = time::OffsetDateTime::now_utc(); - let mut credentials = self.credentials.lock(); + let credentials = self.credentials.lock(); - if credentials - .consumed - .get(&jti) - .is_some_and(|expires_at| now < *expires_at) - { - return MappingStatus::Consumed; - } - credentials.consumed.remove(&jti); - - match credentials.entries.get(&jti) { - Some(entry) if now < entry.expires_at && entry.mapping.is_some() => MappingStatus::Available, - _ => MappingStatus::Absent, + let Some(entry) = credentials.get(&jti) else { + return MappingStatus::Absent; + }; + let Some(deadline) = entry.required_until else { + return MappingStatus::Absent; + }; + if now >= deadline { + MappingStatus::Absent + } else if entry.mapping.is_some() && now < entry.expires_at { + MappingStatus::Available + } else { + MappingStatus::RequiredMissing } } @@ -173,17 +207,14 @@ impl ProvisioningStore { pub(crate) fn take(&self, jti: Uuid) -> Option { let now = time::OffsetDateTime::now_utc(); - let (token, mapping) = { + let (token, mapping, generation, kdc_expires_at) = { let mut credentials = self.credentials.lock(); - let entry = credentials.entries.remove(&jti)?; + let entry = credentials.remove(&jti)?; if now >= entry.expires_at { warn!(%jti, "Provisioned credentials expired before the connection arrived"); return None; } - if entry.mapping.is_some() { - credentials.consumed.insert(jti, entry.expires_at); - } - (entry.token, entry.mapping) + (entry.token, entry.mapping, entry.generation, entry.required_until) }; let connection_options = { @@ -202,52 +233,55 @@ impl ProvisioningStore { token, mapping, connection_options, + generation, + kdc_expires_at, }) } - /// Atomically validate and consume an injection mapping (one-shot checkout). + /// Clone injection material for this `jti`. /// - /// The mapping is not restored after a failed TLS/CredSSP attempt. - /// `time_to_live` is how long it may wait for first checkout, not a retry budget. - /// A consumed tombstone makes subsequent attempts fail explicitly until expiry or re-provisioning. - pub(crate) fn take_mapping(&self, jti: Uuid, token: &str) -> anyhow::Result { + /// The first successful lookup extends retention to the token acceptance deadline so reconnects + /// authorized by `jet_reuse` can still inject. + pub(crate) fn get_mapping(&self, jti: Uuid, token: &str) -> anyhow::Result { let now = time::OffsetDateTime::now_utc(); - let (token, mapping) = { + let (token, mapping, generation, required_until) = { let mut credentials = self.credentials.lock(); - - if credentials - .consumed - .get(&jti) - .is_some_and(|expires_at| now < *expires_at) - { - anyhow::bail!("credential-injection material for {jti} was already consumed; re-provision to retry"); - } - credentials.consumed.remove(&jti); - let entry = credentials - .entries - .get(&jti) + .get_mut(&jti) .context("provisioned credential-injection material is missing")?; - anyhow::ensure!( - now < entry.expires_at, - "provisioned credential-injection material expired" - ); - anyhow::ensure!(entry.mapping.is_some(), "provisioned entry has no credential mapping"); + anyhow::ensure!(token == entry.token, "token mismatch"); + let Some(deadline) = entry.required_until else { + anyhow::bail!("provisioned entry has no credential mapping"); + }; + anyhow::ensure!(entry.mapping.is_some(), "provisioned entry has no credential mapping"); - let entry = credentials - .entries - .remove(&jti) - .expect("entry exists while credential state lock is held"); - credentials.consumed.insert(jti, entry.expires_at); - (entry.token, entry.mapping) + if now >= deadline || now >= entry.expires_at { + anyhow::bail!("credential-injection material for {jti} is missing or expired; re-provision to retry"); + } + + entry.expires_at = deadline; + + ( + entry.token.clone(), + entry.mapping.clone(), + entry.generation, + entry.required_until, + ) }; let connection_options = { let mut entries = self.connection_options.lock(); - match entries.remove(&jti) { - Some(entry) if now < entry.expires_at => Some(entry.connection_options), + match entries.get_mut(&jti) { + Some(entry) if now < entry.expires_at => { + if let Some(deadline) = required_until + && entry.expires_at < deadline + { + entry.expires_at = deadline; + } + Some(entry.connection_options.clone()) + } Some(_) => { warn!(%jti, "Provisioned connection options expired before the connection arrived"); None @@ -260,8 +294,15 @@ impl ProvisioningStore { token, mapping, connection_options, + generation, + kdc_expires_at: required_until, }) } + + #[cfg(test)] + pub(crate) fn credentials_expires_at(&self, jti: Uuid) -> Option { + self.credentials.lock().get(&jti).map(|entry| entry.expires_at) + } } pub struct CleanupTask { @@ -298,8 +339,13 @@ async fn cleanup_task(handle: ProvisioningStore, mut shutdown_signal: ShutdownSi let now = time::OffsetDateTime::now_utc(); let mut credentials = handle.credentials.lock(); - credentials.entries.retain(|_, entry| now < entry.expires_at); - credentials.consumed.retain(|_, expires_at| now < *expires_at); + for entry in credentials.values_mut() { + if now >= entry.expires_at { + entry.mapping = None; + } + } + credentials + .retain(|_, entry| now < entry.expires_at || entry.required_until.is_some_and(|deadline| now < deadline)); drop(credentials); handle .connection_options @@ -331,14 +377,15 @@ mod tests { } } - fn association_token(jti: Uuid) -> String { + fn association_token_with_exp(jti: Uuid, exp: i64) -> String { use base64::Engine as _; let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; let header = engine.encode(r#"{"alg":"RS256"}"#); let payload = engine.encode( serde_json::to_vec(&serde_json::json!({ "jti": jti, - "dst_hst": "target.example:3389" + "dst_hst": "target.example:3389", + "exp": exp })) .expect("payload serializes"), ); @@ -346,6 +393,10 @@ mod tests { format!("{header}.{payload}.{signature}") } + fn association_token(jti: Uuid) -> String { + association_token_with_exp(jti, time::OffsetDateTime::now_utc().unix_timestamp() + 3600) + } + fn options() -> TargetConnectionOptions { serde_json::from_value(serde_json::json!({ "krb_kdc": "tcp://dc.example:88" })).expect("options") } @@ -421,7 +472,7 @@ mod tests { } #[test] - fn consumed_mapping_is_explicit_until_reprovisioned() { + fn get_mapping_is_reusable_until_reprovisioned() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); let token = association_token(jti); @@ -430,19 +481,19 @@ mod tests { .expect("insert"); assert_eq!(store.mapping_status(jti), MappingStatus::Available); - store.take_mapping(jti, &token).expect("first checkout"); - assert_eq!(store.mapping_status(jti), MappingStatus::Consumed); - let error = store.take_mapping(jti, &token).expect_err("second checkout fails"); - assert!(format!("{error:#}").contains("already consumed")); + store.get_mapping(jti, &token).expect("first checkout"); + assert_eq!(store.mapping_status(jti), MappingStatus::Available); + store.get_mapping(jti, &token).expect("second checkout"); store - .insert_credentials(token, Some(mapping()), time::Duration::minutes(5)) + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) .expect("re-provision"); - assert_eq!(store.mapping_status(jti), MappingStatus::Available); + let first = store.get_mapping(jti, &token).expect("after replace"); + assert_eq!(first.generation, 2); } #[test] - fn token_mismatch_does_not_consume_mapping() { + fn token_mismatch_does_not_drop_mapping() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); let token = association_token(jti); @@ -450,14 +501,14 @@ mod tests { .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) .expect("insert"); - let error = store.take_mapping(jti, "different token").expect_err("mismatch"); + let error = store.get_mapping(jti, "different token").expect_err("mismatch"); assert!(format!("{error:#}").contains("token mismatch")); assert_eq!(store.mapping_status(jti), MappingStatus::Available); - store.take_mapping(jti, &token).expect("valid checkout"); + store.get_mapping(jti, &token).expect("valid checkout"); } #[test] - fn concurrent_mapping_checkout_has_one_winner() { + fn concurrent_mapping_checkout_all_succeed() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); let token = association_token(jti); @@ -473,7 +524,7 @@ mod tests { let barrier = Arc::clone(&barrier); std::thread::spawn(move || { barrier.wait(); - store.take_mapping(jti, &token) + store.get_mapping(jti, &token) }) }) .collect(); @@ -483,8 +534,77 @@ mod tests { .into_iter() .map(|handle| handle.join().expect("thread")) .collect(); - assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); - let error = results.into_iter().find_map(Result::err).expect("one failure"); - assert!(format!("{error:#}").contains("already consumed")); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 2); + } + + #[test] + fn staging_expiry_before_first_use_is_required_missing() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::seconds(-1)) + .expect("insert"); + + assert_eq!(store.mapping_status(jti), MappingStatus::RequiredMissing); + let error = store.get_mapping(jti, &token).expect_err("expired staging"); + assert!(format!("{error:#}").contains("missing or expired")); + } + + #[test] + fn first_get_extends_expiry_to_token_deadline() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let exp = time::OffsetDateTime::now_utc().unix_timestamp() + 3600; + let token = association_token_with_exp(jti, exp); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::seconds(30)) + .expect("insert"); + + let before = store.credentials_expires_at(jti).expect("inserted"); + store.get_mapping(jti, &token).expect("activate"); + let after = store.credentials_expires_at(jti).expect("activated"); + assert!(after > before); + assert_eq!(after, crate::token::token_acceptance_deadline(exp)); + } + + #[test] + fn insert_caps_caller_ttl_to_token_acceptance_deadline() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let exp = time::OffsetDateTime::now_utc().unix_timestamp(); + let token = association_token_with_exp(jti, exp); + store + .insert_credentials(token, Some(mapping()), time::Duration::hours(2)) + .expect("insert"); + + let expires_at = store.credentials_expires_at(jti).expect("inserted"); + let deadline = crate::token::token_acceptance_deadline(exp); + let delta = (expires_at - deadline).abs(); + assert!(delta <= time::Duration::seconds(1)); + } + + #[test] + fn mapped_insert_requires_exp() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + use base64::Engine as _; + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let token = format!( + "{}.{}.{}", + engine.encode(r#"{"alg":"RS256"}"#), + engine.encode( + serde_json::to_vec(&serde_json::json!({ + "jti": jti, + "dst_hst": "target.example:3389" + })) + .expect("payload") + ), + engine.encode(b"signature") + ); + let error = store + .insert_credentials(token, Some(mapping()), time::Duration::minutes(5)) + .expect_err("missing exp"); + assert!(format!("{error:#}").contains("exp")); } } diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index e30d635cc..7cd4cce25 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -432,12 +432,10 @@ async fn handle_with_credential_injection( mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send, client_addr: SocketAddr, conf: Arc, - token_cache: &TokenCache, - jrl: &CurrentJrl, sessions: SessionMessageSender, subscriber_tx: SubscriberSender, - active_recordings: &ActiveRecordings, cleanpath_pdu: RDCleanPathPdu, + claims: AssociationTokenClaims, provisioning: &ProvisioningStore, synthetic_kdc_registry: &SyntheticKdcRegistry, agent_tunnel_handle: Option>, @@ -468,29 +466,11 @@ async fn handle_with_credential_injection( ) }; - let CleanPathAuth { claims } = authorize_cleanpath( - &cleanpath_pdu, - client_addr, - &conf, - token_cache, - jrl, - active_recordings, - &sessions, - ) - .await - .context("RDCleanPath authorization failed")?; - let token = cleanpath_pdu .proxy_auth .clone() .context("missing token in RDCleanPath PDU")?; - anyhow::ensure!( - provisioning.mapping_status(claims.jti) != MappingStatus::Consumed, - "credential-injection material for {} was already consumed; re-provision to retry", - claims.jti, - ); - // Connect before checkout so a target connect failure does not consume one-shot credentials. let ConnectedRdpServer { tls_stream: server_stream, server_addr, @@ -615,69 +595,74 @@ pub async fn handle( .await .context("couldn't read cleanpath PDU")?; - // Early credential detection: check if we should use RdpProxy instead. - let token = cleanpath_pdu - .proxy_auth - .as_deref() - .context("missing token in RDCleanPath PDU")?; + let auth = match authorize_cleanpath( + &cleanpath_pdu, + client_addr, + &conf, + token_cache, + jrl, + active_recordings, + &sessions, + ) + .await + { + Ok(auth) => auth, + Err(error) => { + let response = RDCleanPathPdu::from(&error); + send_clean_path_response(&mut client_stream, &response).await?; + return anyhow::Error::new(error) + .context("an error occurred when processing cleanpath PDU") + .pipe(Err)?; + } + }; - // If a credential mapping has been pushed, switch to proxy-based credential injection. - // Peek here without consuming. - // Checkout happens after authorization and target connection to protect the JTI from invalid requests and failures. - if let Some(jti) = crate::token::extract_jti(token).ok() + let mapping_status = provisioning.mapping_status(auth.claims.jti); + if is_vmconnect_request(&cleanpath_pdu) && matches!( - provisioning.mapping_status(jti), - MappingStatus::Available | MappingStatus::Consumed + mapping_status, + MappingStatus::Available | MappingStatus::RequiredMissing ) { - // VMConnect needs pre-X.224 CredSSP against the Hyper-V host cert on the client. - // Proxy CredSSP MITM is X.224-first and is not supported for this ordering. - if is_vmconnect_request(&cleanpath_pdu) { - let response = RDCleanPathPdu::new_http_error(400); + let response = RDCleanPathPdu::new_http_error(400); + send_clean_path_response(&mut client_stream, &response).await?; + anyhow::bail!("credential injection is not supported for VMConnect RDCleanPath"); + } + + match mapping_status { + MappingStatus::Available => { + debug!(jti = %auth.claims.jti, "Switching to RdpProxy for credential injection (WebSocket)"); + return handle_with_credential_injection( + client_stream, + client_addr, + conf, + sessions, + subscriber_tx, + cleanpath_pdu, + auth.claims, + provisioning, + synthetic_kdc_registry, + agent_tunnel_handle.clone(), + ) + .await; + } + MappingStatus::RequiredMissing => { + let error = CleanPathError::BadRequest(anyhow::anyhow!( + "credential-injection material for {} is missing or expired; re-provision to retry", + auth.claims.jti + )); + let response = RDCleanPathPdu::from(&error); send_clean_path_response(&mut client_stream, &response).await?; - anyhow::bail!("credential injection is not supported for VMConnect RDCleanPath"); + return anyhow::Error::new(error) + .context("an error occurred when processing cleanpath PDU") + .pipe(Err)?; } - - debug!(%jti, "Switching to RdpProxy for credential injection (WebSocket)"); - - return handle_with_credential_injection( - client_stream, - client_addr, - conf, - token_cache, - jrl, - sessions, - subscriber_tx, - active_recordings, - cleanpath_pdu, - provisioning, - synthetic_kdc_registry, - agent_tunnel_handle.clone(), - ) - .await; + MappingStatus::Absent => {} } trace!("Processing RDCleanPath"); - let (auth, connected) = match async { - let auth = authorize_cleanpath( - &cleanpath_pdu, - client_addr, - &conf, - token_cache, - jrl, - active_recordings, - &sessions, - ) - .await?; - - let connected = connect_rdp_server(&auth.claims, cleanpath_pdu, agent_tunnel_handle.as_ref()).await?; - - Ok::<_, CleanPathError>((auth, connected)) - } - .await - { - Ok(result) => result, + let connected = match connect_rdp_server(&auth.claims, cleanpath_pdu, agent_tunnel_handle.as_ref()).await { + Ok(connected) => connected, Err(error) => { let response = RDCleanPathPdu::from(&error); send_clean_path_response(&mut client_stream, &response).await?; diff --git a/devolutions-gateway/src/rdp_proxy/credssp.rs b/devolutions-gateway/src/rdp_proxy/credssp.rs index a49a0b155..5a7053664 100644 --- a/devolutions-gateway/src/rdp_proxy/credssp.rs +++ b/devolutions-gateway/src/rdp_proxy/credssp.rs @@ -492,7 +492,8 @@ mod tests { let payload = engine.encode( serde_json::to_vec(&serde_json::json!({ "jti": jti, - "dst_hst": "target.example:3389" + "dst_hst": "target.example:3389", + "exp": time::OffsetDateTime::now_utc().unix_timestamp() + 3600 })) .expect("payload serializes"), ); @@ -524,7 +525,7 @@ mod tests { let entry = store.take(jti).expect("entry"); CredentialInjection::from_provisioned(jti, entry, true) .expect("prepared") - .register_if_kerberos(&SyntheticKdcRegistry::new()) + .register_if_kerberos(&SyntheticKdcRegistry::new(), 1) } #[test] @@ -568,7 +569,7 @@ mod tests { let entry = store.take(jti).expect("entry"); let injection = CredentialInjection::from_provisioned(jti, entry, true) .expect("ntlm prepared") - .register_if_kerberos(&SyntheticKdcRegistry::new()); + .register_if_kerberos(&SyntheticKdcRegistry::new(), 1); let config = client_kerberos_config(&injection).expect("ntlm ok"); assert!(config.is_none()); diff --git a/devolutions-gateway/src/token.rs b/devolutions-gateway/src/token.rs index a3f6e0e7f..18e6e08cb 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -1279,6 +1279,23 @@ pub fn extract_jti(token: &str) -> anyhow::Result { extract_uuid(token, "jti").context("extract jti") } +/// Extract the JWT `exp` claim without verifying the signature. +pub fn extract_exp(token: &str) -> anyhow::Result { + let payload = extract_payload(token)?; + let exp = payload.get("exp").context("exp is missing from the token")?; + exp.as_i64() + .or_else(|| exp.as_u64().and_then(|value| i64::try_from(value).ok())) + .context("exp is malformed") +} + +/// Latest instant at which Gateway will still accept a token with this `exp`. +/// +/// Includes the hardcoded JWT clock-skew leeway. +pub(crate) fn token_acceptance_deadline(exp: i64) -> time::OffsetDateTime { + let timestamp = exp.saturating_add(i64::from(LEEWAY_SECS)); + time::OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(time::OffsetDateTime::UNIX_EPOCH) +} + pub fn extract_session_id(token: &str) -> anyhow::Result { extract_uuid(token, "jet_aid").context("extract jet_aid") } From bdc338241573143a91bbfdbd418f9257cb4ce3f9 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Thu, 20 Aug 2026 15:33:32 -0400 Subject: [PATCH 12/36] test(dgw): require exp on provision-credentials fixtures Mapped insert now caps retention to the association token acceptance deadline, so unsigned preflight fixtures without exp fail as invalid-parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/tests/preflight.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/devolutions-gateway/tests/preflight.rs b/devolutions-gateway/tests/preflight.rs index 1d0de8866..1cbcf8480 100644 --- a/devolutions-gateway/tests/preflight.rs +++ b/devolutions-gateway/tests/preflight.rs @@ -46,6 +46,10 @@ fn preflight_request(operations: serde_json::Value) -> anyhow::Result i64 { + time::OffsetDateTime::now_utc().unix_timestamp() + 3600 +} + fn unsigned_jws(payload: serde_json::Value) -> anyhow::Result { let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; let header = engine.encode(r#"{"alg":"RS256"}"#); @@ -84,7 +88,8 @@ async fn test_provision_credentials_success() -> anyhow::Result<()> { let jti = Uuid::new_v4(); let token = unsigned_jws(json!({ "jti": jti, - "dst_hst": "target.example:3389" + "dst_hst": "target.example:3389", + "exp": token_exp() }))?; let op_id = Uuid::new_v4(); @@ -124,7 +129,8 @@ async fn test_provision_credentials_success_when_unstable_disabled() -> anyhow:: let jti = Uuid::new_v4(); let token = unsigned_jws(json!({ "jti": jti, - "dst_hst": "target.example:3389" + "dst_hst": "target.example:3389", + "exp": token_exp() }))?; let op_id = Uuid::new_v4(); @@ -318,7 +324,8 @@ async fn test_provision_credentials_and_connection_options_fold() -> anyhow::Res let jti = Uuid::new_v4(); let token = unsigned_jws(json!({ "jti": jti, - "dst_hst": "target.example:3389" + "dst_hst": "target.example:3389", + "exp": token_exp() }))?; let ops = json!([ From e8261332f78e9937a60838c2e616adccf71e52aa Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 21 Aug 2026 13:49:13 -0400 Subject: [PATCH 13/36] fix(dgw): checkout injection before connecting upstream Missing Kerberos krb_kdc must fail closed without dialing the target. Issue: DGW-1900 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/src/generic_client.rs | 45 +++++++++++++---------- devolutions-gateway/src/rd_clean_path.rs | 23 ++++++------ 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/devolutions-gateway/src/generic_client.rs b/devolutions-gateway/src/generic_client.rs index cd5646006..ad68d0e7f 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -131,6 +131,31 @@ where MappingStatus::Absent => false, }; + // Checkout before dialing so missing Kerberos material cannot open an upstream socket. + let credential_injection = if inject { + let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in( + conf.debug.enable_unstable, + conf.debug.kerberos_credential_injection, + ); + Some( + CredentialInjection::checkout( + &provisioning, + &synthetic_kdc_registry, + claims.jti, + token, + kerberos_enabled, + ) + .with_context(|| { + format!( + "credential-injection material for {} is missing or expired; re-provision to retry", + claims.jti + ) + })?, + ) + } else { + None + }; + let ConnectedUpstream { leg: mut server_stream, server_addr, @@ -159,25 +184,7 @@ where let disconnect_interest = DisconnectInterest::from_reconnection_policy(claims.jet_reuse); - if inject { - let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in( - conf.debug.enable_unstable, - conf.debug.kerberos_credential_injection, - ); - let credential_injection = CredentialInjection::checkout( - &provisioning, - &synthetic_kdc_registry, - claims.jti, - token, - kerberos_enabled, - ) - .with_context(|| { - format!( - "credential-injection material for {} is missing or expired; re-provision to retry", - claims.jti - ) - })?; - + if let Some(credential_injection) = credential_injection { info!( jti = %credential_injection.jti(), kerberos = credential_injection.uses_kerberos(), diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index 7cd4cce25..edafebb7d 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -471,16 +471,6 @@ async fn handle_with_credential_injection( .clone() .context("missing token in RDCleanPath PDU")?; - let ConnectedRdpServer { - tls_stream: server_stream, - server_addr, - selected_target: destination, - x224_rsp, - } = connect_rdp_server(&claims, cleanpath_pdu, agent_tunnel_handle.as_ref()) - .await - .context("RDCleanPath connection failed")?; - let x224_rsp = x224_rsp.context("RDCleanPath credential injection requires X.224")?; - let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in( conf.debug.enable_unstable, conf.debug.kerberos_credential_injection, @@ -491,7 +481,18 @@ async fn handle_with_credential_injection( claims.jti, &token, kerberos_enabled, - )?; + ) + .context("checkout credential-injection material before connecting upstream")?; + + let ConnectedRdpServer { + tls_stream: server_stream, + server_addr, + selected_target: destination, + x224_rsp, + } = connect_rdp_server(&claims, cleanpath_pdu, agent_tunnel_handle.as_ref()) + .await + .context("RDCleanPath connection failed")?; + let x224_rsp = x224_rsp.context("RDCleanPath credential injection requires X.224")?; let gateway_cert_chain_handle = tokio::spawn(crate::tls::get_cert_chain_for_acceptor_cached( gateway_hostname, From a2c9dd6a1c1064794214a8e88ee1ad846eefce4c Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 25 Aug 2026 14:23:37 -0400 Subject: [PATCH 14/36] docs(dgw): document credential injection intent Capture the lifecycle invariants that guide the credential injection refactor before further implementation changes. --- devolutions-gateway/src/credential/INTENT.md | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 devolutions-gateway/src/credential/INTENT.md diff --git a/devolutions-gateway/src/credential/INTENT.md b/devolutions-gateway/src/credential/INTENT.md new file mode 100644 index 000000000..3144aebe7 --- /dev/null +++ b/devolutions-gateway/src/credential/INTENT.md @@ -0,0 +1,50 @@ +# Intention: + + +## Context and terminology + +Logical Session: a logical session is defined when a connection reaches Gateway and is authenticated with the association token. + +```rust +pub struct AssociationTokenClaims { + pub jet_aid: Uuid, + + .. + pub jet_ttl: SessionTtl, + + pub jet_reuse: ReconnectionPolicy, + pub exp: i64, + .. + pub jti: Uuid, +} +``` + +Injected credentials: injected credentials are the actual credentials sent by the provisioner (DVLS) to Gateway, which are used later by a logical session to serve the purpose of granting access to a client without exposing the actual credentials. + +## Decisions +1. Credential-injection support must follow the lifecycle of its logical session. + +A logical session is established when Gateway accepts its association token for the initial connection. + +As long as Gateway would authorize an initial connection or reconnect for that logical session, the same connection must remain possible with credential injection. + +Association-token expiry does not terminate a reconnect window that belongs to an already established logical session. + +When Gateway can no longer authorize any connection or reconnect for that logical session, it must immediately remove all credential-injection material owned by the session. + +This DOES NOT mean that the injected credentials should live as long as the logical session continues. +A session's lifetime is defined by `jet_ttl`, but whether it can establish a connection or reconnection is defined by `jet_reuse` and `exp`. +The injected credentials should be removed when the session can no longer establish a connection or reconnection. + +2. Provisioning for the same JTI should be permitted, but the policy for different kinds of provisioning should be defined on a per-kind basis. +For credential injection, the policy is that provisioning for the same JTI should be rejected. + +3. If a connection requires credential injection but its required credentials are not available, the connection should fail immediately. +The connection should not continue without the required injection support. + +4. The injected credentials naturally arrive earlier than the connection that uses them. +The second half of the lifetime of the injected credentials is defined in 1); we define the first half of the lifetime of the injected credentials here: + +When injected credentials arrive through provisioning by the `provision-credentials` operation, we will have a `time_to_live` (TTL) for the injected credentials, which is defined by the caller of the `provision-credentials` operation. +The TTL here specifically defines how long the injected credentials should be kept in memory before they are checked out by a logical session. +Once a logical session checks out the injected credentials, their lifetime is defined by the logical session's lifetime, and the TTL no longer applies. From 8951d37f4f7fe5a0227c49fbe0cfd4a904611002 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 25 Aug 2026 15:01:08 -0400 Subject: [PATCH 15/36] docs(dgw): clarify credential injection intent Define checkout, staging, credential replacement, cleanup, and synthetic KDC lifetime expectations. --- devolutions-gateway/src/credential/INTENT.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/devolutions-gateway/src/credential/INTENT.md b/devolutions-gateway/src/credential/INTENT.md index 3144aebe7..7d09e5904 100644 --- a/devolutions-gateway/src/credential/INTENT.md +++ b/devolutions-gateway/src/credential/INTENT.md @@ -21,6 +21,12 @@ pub struct AssociationTokenClaims { Injected credentials: injected credentials are the actual credentials sent by the provisioner (DVLS) to Gateway, which are used later by a logical session to serve the purpose of granting access to a client without exposing the actual credentials. +Checkout: When an injected credential has arrived and is sitting in memory, and the association token arrives at Gateway and the lookup of the injected credential is successful, we consider the injected credential checked out by the logical session. + +Staging/Stage: when an injected credential arrives at Gateway but checkout has not happened yet, we consider the injected credential to be in staging. + +Remove/Eject: remove and eject here specifically mean actively removing the injected credentials/materials from memory and making sure they will not be accessible on a best-effort basis. + ## Decisions 1. Credential-injection support must follow the lifecycle of its logical session. @@ -28,8 +34,6 @@ A logical session is established when Gateway accepts its association token for As long as Gateway would authorize an initial connection or reconnect for that logical session, the same connection must remain possible with credential injection. -Association-token expiry does not terminate a reconnect window that belongs to an already established logical session. - When Gateway can no longer authorize any connection or reconnect for that logical session, it must immediately remove all credential-injection material owned by the session. This DOES NOT mean that the injected credentials should live as long as the logical session continues. @@ -37,14 +41,16 @@ A session's lifetime is defined by `jet_ttl`, but whether it can establish a con The injected credentials should be removed when the session can no longer establish a connection or reconnection. 2. Provisioning for the same JTI should be permitted, but the policy for different kinds of provisioning should be defined on a per-kind basis. -For credential injection, the policy is that provisioning for the same JTI should be rejected. +For credential injection, the policy is that the old injected credentials should be removed when new injected credentials are provisioned for the same JTI. 3. If a connection requires credential injection but its required credentials are not available, the connection should fail immediately. The connection should not continue without the required injection support. +An association token does not identify whether credential injection is required, so this rule only applies while Gateway still has credential-injection state for the JTI. 4. The injected credentials naturally arrive earlier than the connection that uses them. -The second half of the lifetime of the injected credentials is defined in 1); we define the first half of the lifetime of the injected credentials here: +The second half (checked out) of the lifetime of the injected credentials is defined in 1); we define the staging lifetime of the injected credentials here: + +The amount of time that the injected credentials can stay in staging is defined by the provisioning TTL, which is supplied by the provisioner through the preflight provisioning operation. +When the provisioning TTL expires, Gateway must actively remove the staged material from memory. -When injected credentials arrive through provisioning by the `provision-credentials` operation, we will have a `time_to_live` (TTL) for the injected credentials, which is defined by the caller of the `provision-credentials` operation. -The TTL here specifically defines how long the injected credentials should be kept in memory before they are checked out by a logical session. -Once a logical session checks out the injected credentials, their lifetime is defined by the logical session's lifetime, and the TTL no longer applies. +5. A synthetic KDC should have only one instance per JTI at all times. From bda124d70027c87f11d8ea5b7f32464553f9dc0f Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 25 Aug 2026 16:56:11 -0400 Subject: [PATCH 16/36] fix(dgw): align credential injection lifecycle Keep checked-out credentials and Kerberos session material available for every association-token use that Gateway accepts. Use provisioning TTL only for staging, remove expired material at its deadline, and keep connection-option retention independent. Issue: DVLS-14697 --- devolutions-gateway/openapi/doc/index.adoc | 2 +- .../dotnet-client/docs/PreflightOperation.md | 3 +- .../Model/PreflightOperation.cs | 6 +- devolutions-gateway/openapi/gateway-api.yaml | 6 +- .../model/preflightOperation.ts | 2 +- .../src/credential_injection.rs | 281 +++++++++++++++--- devolutions-gateway/src/generic_client.rs | 4 - devolutions-gateway/src/openapi.rs | 6 +- devolutions-gateway/src/provisioning.rs | 209 ++++++++----- devolutions-gateway/src/rd_clean_path.rs | 18 +- devolutions-gateway/src/rdp_proxy/credssp.rs | 5 +- devolutions-gateway/src/service.rs | 4 + devolutions-gateway/src/token.rs | 5 +- 13 files changed, 398 insertions(+), 153 deletions(-) diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index 6910ffa17..b40865063 100644 --- a/devolutions-gateway/openapi/doc/index.adoc +++ b/devolutions-gateway/openapi/doc/index.adoc @@ -4445,7 +4445,7 @@ Current auto-update schedule for Devolutions Agent. | | X | Integer -| Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. +| Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | int32 | token diff --git a/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md b/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md index 4c16436b1..ba5977cc9 100644 --- a/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md +++ b/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md @@ -10,8 +10,7 @@ Name | Type | Description | Notes **Kind** | **PreflightOperationKind** | | **ProxyCredential** | [**AppCredential**](AppCredential.md) | | [optional] **TargetCredential** | [**AppCredential**](AppCredential.md) | | [optional] -**TimeToLive** | **int?** | Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | [optional] +**TimeToLive** | **int?** | Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | [optional] **Token** | **string** | The token to be stored on the proxy-side. Required for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - 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 59212a095..e0fbcd573 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 @@ -53,7 +53,7 @@ protected PreflightOperation() { } /// kind (required). /// proxyCredential. /// targetCredential. - /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds.. + /// Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds.. /// The token to be stored on the proxy-side. Required for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds.. public PreflightOperation(TargetConnectionOptions connectionOptions = default(TargetConnectionOptions), 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)) { @@ -100,9 +100,9 @@ protected PreflightOperation() { } public AppCredential TargetCredential { get; set; } /// - /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. + /// Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. /// - /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. + /// Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. [DataMember(Name = "time_to_live", EmitDefaultValue = true)] public int? TimeToLive { get; set; } diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 851fdd6af..008e0d910 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -1837,7 +1837,11 @@ components: type: integer format: int32 description: |- - Minimum persistence duration in seconds for the data provisioned via this operation. + Retention duration in seconds for data provisioned by this operation. + + For "provision-credentials", this is the maximum staging time before the first credential + checkout. After checkout, Gateway retains the credentials for later connections authorized + for the same association. Optional parameter for "provision-token", "provision-credentials", and "provision-connection-options" kinds. diff --git a/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts b/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts index 76d51046a..b06048ed3 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts @@ -26,7 +26,7 @@ export interface PreflightOperation { proxy_credential?: AppCredential | null; target_credential?: AppCredential | null; /** - * Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. + * Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. */ time_to_live?: number | null; /** diff --git a/devolutions-gateway/src/credential_injection.rs b/devolutions-gateway/src/credential_injection.rs index 825e187c1..06e2b04b9 100644 --- a/devolutions-gateway/src/credential_injection.rs +++ b/devolutions-gateway/src/credential_injection.rs @@ -13,16 +13,20 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Context as _; +use async_trait::async_trait; use chacha20poly1305::aead::OsRng; use chacha20poly1305::aead::rand_core::RngCore as _; +use devolutions_gateway_task::{ShutdownSignal, Task}; use ironrdp_connector::sspi; use ironrdp_connector::sspi::generator::NetworkRequest; use parking_lot::Mutex; use picky_krb::messages::KdcProxyMessage; use secrecy::{ExposeSecret as _, SecretBox, SecretString}; use thiserror::Error; +use tokio::sync::Notify; use url::Url; use uuid::Uuid; +use zeroize::Zeroize as _; use crate::credential::{AppCredential, AppCredentialMapping}; use crate::provisioning::{ProvisioningEntry, ProvisioningStore}; @@ -80,6 +84,11 @@ pub(crate) enum CredentialInjection { /// Kerberos injection: credentials, target KDC URL, and the session synthetic KDC. pub(crate) struct KerberosCredentialInjection { credential_mapping: AppCredentialMapping, + session: KerberosSessionMaterial, +} + +#[derive(Debug, Clone)] +struct KerberosSessionMaterial { target_kdc: Url, synthetic: Arc, } @@ -100,9 +109,9 @@ impl PreparedCredentialInjection { ) -> CredentialInjection { match self { Self::Kerberos(injection) => { - let registration = registry.register(Arc::clone(&injection.synthetic), provision_generation); + let registration = registry.register(Arc::clone(&injection.session.synthetic), provision_generation); debug!( - jti = %injection.synthetic.jti(), + jti = %injection.session.synthetic.jti(), "Registered synthetic KDC for credential-injection session" ); CredentialInjection::Kerberos(injection, registration) @@ -114,19 +123,19 @@ impl PreparedCredentialInjection { impl KerberosCredentialInjection { pub(crate) fn synthetic_kdc(&self) -> &CredentialInjectionKdc { - &self.synthetic + &self.session.synthetic } pub(crate) fn target_kdc(&self) -> &Url { - &self.target_kdc + &self.session.target_kdc } } impl fmt::Debug for KerberosCredentialInjection { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("KerberosCredentialInjection") - .field("target_kdc", &self.target_kdc) - .field("synthetic", &self.synthetic) + .field("target_kdc", &self.session.target_kdc) + .field("synthetic", &self.session.synthetic) .finish_non_exhaustive() } } @@ -166,11 +175,12 @@ impl CredentialInjection { let generation = entry.generation; let kdc_expires_at = entry.kdc_expires_at; registry.discard_stale_session_kdc(jti, generation); - let prepared = Self::from_provisioned(jti, entry, kerberos_enabled)?; + let session = registry.session_kerberos_material(jti, generation); + let prepared = Self::from_provisioned_with_session(jti, entry, kerberos_enabled, session)?; let prepared = match prepared { PreparedCredentialInjection::Kerberos(mut injection) => { let expires_at = kdc_expires_at.context("mapped Kerberos row has no token deadline")?; - injection.synthetic = registry.intern_session_kdc(jti, generation, expires_at, injection.synthetic); + injection.session = registry.intern_session_kerberos(jti, generation, expires_at, injection.session); PreparedCredentialInjection::Kerberos(injection) } ntlm @ PreparedCredentialInjection::Ntlm(_) => ntlm, @@ -180,7 +190,7 @@ impl CredentialInjection { pub(crate) fn jti(&self) -> Uuid { match self { - Self::Kerberos(k, _) => k.synthetic.jti(), + Self::Kerberos(k, _) => k.session.synthetic.jti(), Self::Ntlm(ntlm) => ntlm.jti(), } } @@ -214,10 +224,20 @@ impl CredentialInjection { /// /// Does not publish to [`SyntheticKdcRegistry`]; call /// [`PreparedCredentialInjection::register_if_kerberos`] next. + #[cfg(test)] pub(crate) fn from_provisioned( jti: Uuid, credential_entry: ProvisioningEntry, kerberos_enabled: bool, + ) -> anyhow::Result { + Self::from_provisioned_with_session(jti, credential_entry, kerberos_enabled, None) + } + + fn from_provisioned_with_session( + jti: Uuid, + credential_entry: ProvisioningEntry, + kerberos_enabled: bool, + session: Option, ) -> anyhow::Result { let ProvisioningEntry { token, @@ -240,6 +260,13 @@ impl CredentialInjection { })); } + if let Some(session) = session { + return Ok(PreparedCredentialInjection::Kerberos(KerberosCredentialInjection { + credential_mapping: mapping, + session, + })); + } + // Kerberos path: username must parse (select_kerberos_for_target already required a domain). sspi::Username::parse(target_username) .with_context(|| format!("invalid target credential username for credential-injection session {jti}"))?; @@ -258,8 +285,10 @@ impl CredentialInjection { Ok(PreparedCredentialInjection::Kerberos(KerberosCredentialInjection { credential_mapping: mapping, - target_kdc, - synthetic: Arc::new(synthetic), + session: KerberosSessionMaterial { + target_kdc, + synthetic: Arc::new(synthetic), + }, })) } } @@ -304,6 +333,12 @@ impl fmt::Debug for CredentialInjectionKdc { } } +impl Drop for CredentialInjectionKdc { + fn drop(&mut self) { + zeroize_kdc_config(&mut self.kdc_config); + } +} + impl CredentialInjectionKdc { fn new( jti: Uuid, @@ -315,14 +350,14 @@ impl CredentialInjectionKdc { let acceptor_principal_name = "jet".to_owned(); let acceptor_password = SecretString::from(hex::encode(random_32_bytes())); let acceptor_long_term_key = SecretBox::new(Box::new(random_32_bytes())); - let krbtgt_key = random_32_bytes(); + let krbtgt_key = SecretBox::new(Box::new(random_32_bytes())); let kdc_config = build_kdc_config( &realm, proxy_credential, &acceptor_principal_name, acceptor_password.expose_secret(), - &krbtgt_key, + krbtgt_key.expose_secret(), acceptor_long_term_key.expose_secret(), )?; @@ -512,6 +547,19 @@ fn build_kdc_config( }) } +fn zeroize_kdc_config(config: &mut kdc::config::KerberosServer) { + for user in &mut config.users { + user.password.zeroize(); + } + config.krbtgt_key.zeroize(); + if let Some(key) = &mut config.ticket_decryption_key { + key.zeroize(); + } + if let Some(user) = &mut config.service_user { + user.password.zeroize(); + } +} + fn principal_for_realm(user_name: &str, realm: &str) -> String { if user_name.contains('@') { user_name.to_owned() @@ -547,12 +595,13 @@ fn random_32_bytes() -> Vec { #[derive(Debug, Clone)] pub struct SyntheticKdcRegistry { inner: Arc>, + cleanup_notify: Arc, } #[derive(Debug, Default)] struct RegistryInner { live: HashMap, - session: HashMap, + session: HashMap, } #[derive(Debug, Clone)] @@ -563,10 +612,10 @@ struct PublishedSyntheticKdc { } #[derive(Debug, Clone)] -struct SessionSyntheticKdc { +struct SessionKerberosEntry { provision_generation: u64, expires_at: time::OffsetDateTime, - kdc: Arc, + material: KerberosSessionMaterial, } fn generation_is_newer(candidate: u64, than: u64) -> bool { @@ -611,6 +660,7 @@ impl SyntheticKdcRegistry { pub fn new() -> Self { Self { inner: Arc::new(Mutex::new(RegistryInner::default())), + cleanup_notify: Arc::new(Notify::new()), } } @@ -649,34 +699,47 @@ impl SyntheticKdcRegistry { self.inner.lock().live.get(&jti).map(|entry| Arc::clone(&entry.kdc)) } - /// Drop interned KDCs that are expired or older than this provisioning generation. + /// Drop an interned KDC older than this provisioning generation. pub(crate) fn discard_stale_session_kdc(&self, jti: Uuid, provision_generation: u64) { - let now = time::OffsetDateTime::now_utc(); let mut inner = self.inner.lock(); - inner.session.retain(|_, entry| now < entry.expires_at); if inner .session .get(&jti) .is_some_and(|entry| generation_is_newer(provision_generation, entry.provision_generation)) { inner.session.remove(&jti); + self.cleanup_notify.notify_one(); + } + } + + fn session_kerberos_material(&self, jti: Uuid, provision_generation: u64) -> Option { + let now = time::OffsetDateTime::now_utc(); + let mut inner = self.inner.lock(); + let entry = inner.session.get(&jti)?; + if now >= entry.expires_at { + inner.session.remove(&jti); + self.cleanup_notify.notify_one(); + return None; } + (entry.provision_generation == provision_generation).then(|| entry.material.clone()) } - /// Reuse the synthetic KDC for this provisioning generation until `expires_at`. + /// Reuse the Kerberos session material for this provisioning generation until `expires_at`. /// /// A later `provision-credentials` bumps the generation and replaces the cached KDC. /// An older generation never overwrites a newer interned KDC. - pub(crate) fn intern_session_kdc( + fn intern_session_kerberos( &self, jti: Uuid, provision_generation: u64, expires_at: time::OffsetDateTime, - kdc: Arc, - ) -> Arc { + material: KerberosSessionMaterial, + ) -> KerberosSessionMaterial { let now = time::OffsetDateTime::now_utc(); let mut inner = self.inner.lock(); - inner.session.retain(|_, entry| now < entry.expires_at); + if inner.session.get(&jti).is_some_and(|entry| now >= entry.expires_at) { + inner.session.remove(&jti); + } if now >= expires_at { if inner .session @@ -684,26 +747,36 @@ impl SyntheticKdcRegistry { .is_some_and(|entry| entry.provision_generation == provision_generation) { inner.session.remove(&jti); + self.cleanup_notify.notify_one(); } - return kdc; + return material; } if let Some(existing) = inner.session.get(&jti) { if existing.provision_generation == provision_generation { - return Arc::clone(&existing.kdc); + return existing.material.clone(); } if generation_is_newer(existing.provision_generation, provision_generation) { - return kdc; + return material; } } inner.session.insert( jti, - SessionSyntheticKdc { + SessionKerberosEntry { provision_generation, expires_at, - kdc: Arc::clone(&kdc), + material: material.clone(), }, ); - kdc + self.cleanup_notify.notify_one(); + material + } + + fn remove_expired_session_kdcs(&self, now: time::OffsetDateTime) { + self.inner.lock().session.retain(|_, entry| now < entry.expires_at); + } + + fn next_session_expiry(&self) -> Option { + self.inner.lock().session.values().map(|entry| entry.expires_at).min() } #[cfg(test)] @@ -718,8 +791,53 @@ impl SyntheticKdcRegistry { .lock() .session .get(&jti) - .and_then(|entry| (now < entry.expires_at).then(|| Arc::clone(&entry.kdc))) + .and_then(|entry| (now < entry.expires_at).then(|| Arc::clone(&entry.material.synthetic))) + } +} + +pub struct CleanupTask { + pub handle: SyntheticKdcRegistry, +} + +#[async_trait] +impl Task for CleanupTask { + type Output = anyhow::Result<()>; + + const NAME: &'static str = "synthetic KDC cleanup"; + + async fn run(self, shutdown_signal: ShutdownSignal) -> Self::Output { + cleanup_task(self.handle, shutdown_signal).await; + Ok(()) + } +} + +#[tracing::instrument(skip_all)] +async fn cleanup_task(handle: SyntheticKdcRegistry, mut shutdown_signal: ShutdownSignal) { + tracing::debug!("Task started"); + + loop { + let now = time::OffsetDateTime::now_utc(); + handle.remove_expired_session_kdcs(now); + + match handle.next_session_expiry() { + Some(deadline) => { + let delay = (deadline - now).try_into().unwrap_or_default(); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = handle.cleanup_notify.notified() => {} + _ = shutdown_signal.wait() => break, + } + } + None => { + tokio::select! { + _ = handle.cleanup_notify.notified() => {} + _ = shutdown_signal.wait() => break, + } + } + } } + + tracing::debug!("Task terminated"); } #[cfg(test)] @@ -793,6 +911,13 @@ mod tests { .expect("valid KDC") } + fn kerberos_material(kdc: Arc) -> KerberosSessionMaterial { + KerberosSessionMaterial { + target_kdc: Url::parse("tcp://dc.example:88").expect("url"), + synthetic: kdc, + } + } + fn network_request(url: &str) -> NetworkRequest { NetworkRequest { protocol: NetworkProtocol::Http, @@ -918,14 +1043,96 @@ mod tests { assert_ne!(first_ptr, third_ptr); } + #[test] + fn checkout_reuses_kerberos_session_after_connection_options_expire() { + let jti = Uuid::new_v4(); + let token = association_token(jti); + let store = ProvisioningStore::new(); + store + .insert_credentials( + token.clone(), + Some(cleartext_mapping_with_target_username("administrator@example.invalid")), + time::Duration::minutes(5), + ) + .expect("insert"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let registry = SyntheticKdcRegistry::new(); + + let first_injection = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("first"); + let first = first_injection.as_kerberos().expect("kerberos"); + let first_kdc = std::ptr::from_ref(first.synthetic_kdc()); + let first_target_kdc = first.target_kdc().clone(); + drop(first_injection); + + store.insert_connection_options( + jti, + TargetConnectionOptions::new(Some("tcp://replacement.example:88")).expect("options"), + time::Duration::seconds(-1), + ); + + let second = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("reconnect"); + let second = second.as_kerberos().expect("kerberos"); + assert_eq!(std::ptr::from_ref(second.synthetic_kdc()), first_kdc); + assert_eq!(second.target_kdc(), &first_target_kdc); + } + #[test] fn interned_kdc_is_not_kept_past_deadline() { let jti = Uuid::new_v4(); let registry = SyntheticKdcRegistry::new(); let kdc = Arc::new(dummy_kdc(jti)); - let expired = time::OffsetDateTime::now_utc() - time::Duration::seconds(1); - registry.intern_session_kdc(jti, 1, expired, kdc); + let deadline = time::OffsetDateTime::now_utc() + time::Duration::minutes(5); + registry.intern_session_kerberos(jti, 1, deadline, kerberos_material(kdc)); + registry.remove_expired_session_kdcs(deadline + time::Duration::seconds(1)); + assert!(!registry.session_kdc_live(jti)); + } + + #[test] + fn session_cache_expiry_keeps_active_registration() { + let jti = Uuid::new_v4(); + let registry = SyntheticKdcRegistry::new(); + let kdc = Arc::new(dummy_kdc(jti)); + let deadline = time::OffsetDateTime::now_utc() + time::Duration::minutes(5); + registry.intern_session_kerberos(jti, 1, deadline, kerberos_material(Arc::clone(&kdc))); + let registration = registry.register(Arc::clone(&kdc), 1); + + registry.remove_expired_session_kdcs(deadline + time::Duration::seconds(1)); + assert!(!registry.session_kdc_live(jti)); + assert!(Arc::ptr_eq(®istry.get(jti).expect("active KDC"), &kdc)); + drop(registration); + assert!(registry.get(jti).is_none()); + } + + #[test] + fn zeroize_kdc_config_clears_secret_copies() { + let mut kdc = dummy_kdc(Uuid::new_v4()); + assert!(kdc.kdc_config.users.iter().any(|user| !user.password.is_empty())); + assert!(!kdc.kdc_config.krbtgt_key.is_empty()); + assert!( + kdc.kdc_config + .ticket_decryption_key + .as_ref() + .is_some_and(|key| !key.is_empty()) + ); + assert!( + kdc.kdc_config + .service_user + .as_ref() + .is_some_and(|user| !user.password.is_empty()) + ); + + zeroize_kdc_config(&mut kdc.kdc_config); + + assert!(kdc.kdc_config.users.iter().all(|user| user.password.is_empty())); + assert!(kdc.kdc_config.krbtgt_key.is_empty()); + assert!(kdc.kdc_config.ticket_decryption_key.as_ref().is_some_and(Vec::is_empty)); + assert!( + kdc.kdc_config + .service_user + .as_ref() + .is_some_and(|user| user.password.is_empty()) + ); } #[test] @@ -1044,10 +1251,10 @@ mod tests { let newer = Arc::new(dummy_kdc(jti)); let older = Arc::new(dummy_kdc(jti)); let deadline = time::OffsetDateTime::now_utc() + time::Duration::minutes(5); - let interned = registry.intern_session_kdc(jti, 2, deadline, Arc::clone(&newer)); - assert!(Arc::ptr_eq(&interned, &newer)); - let rejected = registry.intern_session_kdc(jti, 1, deadline, Arc::clone(&older)); - assert!(Arc::ptr_eq(&rejected, &older)); + let interned = registry.intern_session_kerberos(jti, 2, deadline, kerberos_material(Arc::clone(&newer))); + assert!(Arc::ptr_eq(&interned.synthetic, &newer)); + let rejected = registry.intern_session_kerberos(jti, 1, deadline, kerberos_material(Arc::clone(&older))); + assert!(Arc::ptr_eq(&rejected.synthetic, &older)); assert!(Arc::ptr_eq(®istry.interned_kdc(jti).expect("kept"), &newer)); registry.discard_stale_session_kdc(jti, 1); assert!(Arc::ptr_eq(®istry.interned_kdc(jti).expect("still kept"), &newer)); diff --git a/devolutions-gateway/src/generic_client.rs b/devolutions-gateway/src/generic_client.rs index ad68d0e7f..a96ff04ee 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -123,10 +123,6 @@ where MappingStatus::Absent }; let inject = match mapping_status { - MappingStatus::RequiredMissing => anyhow::bail!( - "credential-injection material for {} is missing or expired; re-provision to retry", - claims.jti - ), MappingStatus::Available => true, MappingStatus::Absent => false, }; diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 73b22bf60..151804a9e 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -393,7 +393,11 @@ struct PreflightOperation { /// /// Required for "resolve-host" kind. host_to_resolve: Option, - /// Minimum persistence duration in seconds for the data provisioned via this operation. + /// Retention duration in seconds for data provisioned by this operation. + /// + /// For "provision-credentials", this is the maximum staging time before the first credential + /// checkout. After checkout, Gateway retains the credentials for later connections authorized + /// for the same association. /// /// Optional parameter for "provision-token", "provision-credentials", and /// "provision-connection-options" kinds. diff --git a/devolutions-gateway/src/provisioning.rs b/devolutions-gateway/src/provisioning.rs index 991badf5b..e581b6719 100644 --- a/devolutions-gateway/src/provisioning.rs +++ b/devolutions-gateway/src/provisioning.rs @@ -6,6 +6,7 @@ use anyhow::Context as _; use async_trait::async_trait; use devolutions_gateway_task::{ShutdownSignal, Task}; use parking_lot::Mutex; +use tokio::sync::Notify; use tracing::{debug, instrument, warn}; use uuid::Uuid; @@ -51,7 +52,6 @@ struct CredentialsEntry { token: String, mapping: Option, expires_at: time::OffsetDateTime, - /// `Some` for `provision-credentials`: fail closed until this JWT acceptance deadline. required_until: Option, generation: u64, } @@ -59,7 +59,6 @@ struct CredentialsEntry { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum MappingStatus { Available, - RequiredMissing, Absent, } @@ -82,6 +81,7 @@ struct ConnectionOptionsEntry { pub struct ProvisioningStore { credentials: Arc>>, connection_options: Arc>>, + cleanup_notify: Arc, } impl Default for ProvisioningStore { @@ -95,6 +95,7 @@ impl ProvisioningStore { Self { credentials: Arc::new(Mutex::new(HashMap::new())), connection_options: Arc::new(Mutex::new(HashMap::new())), + cleanup_notify: Arc::new(Notify::new()), } } @@ -102,9 +103,9 @@ impl ProvisioningStore { /// /// `provision-token` passes `mapping = None`; `provision-credentials` passes `Some(mapping)`. /// - /// For mapped rows, `time_to_live` is a staging wait for first checkout, capped to the token - /// acceptance deadline. The first successful [`Self::get_mapping`] then keeps the mapping until - /// that deadline. + /// For mapped rows, `time_to_live` is the staging wait for first checkout. + /// The first successful [`Self::get_mapping`] then keeps the mapping until the token acceptance + /// deadline. pub(crate) fn insert_credentials( &self, token: String, @@ -120,11 +121,14 @@ impl ProvisioningStore { let exp = crate::token::extract_exp(&token) .context("failed to extract token expiration") .map_err(InsertError::InvalidToken)?; - Some(crate::token::token_acceptance_deadline(exp)) + Some( + crate::token::token_acceptance_deadline(exp) + .context("invalid token expiration") + .map_err(InsertError::InvalidToken)?, + ) } else { None }; - let expires_at = required_until.map_or(staging_expires, |deadline| staging_expires.min(deadline)); let mapping = mapping .map(CleartextAppCredentialMapping::encrypt) .transpose() @@ -141,19 +145,14 @@ impl ProvisioningStore { CredentialsEntry { token, mapping, - expires_at, + expires_at: staging_expires, required_until, generation, }, ) .is_some(); - if let Some(deadline) = required_until - && let Some(options) = self.connection_options.lock().get_mut(&jti) - && options.expires_at > deadline - { - options.expires_at = deadline; - } + self.cleanup_notify.notify_one(); Ok(replaced) } @@ -166,39 +165,33 @@ impl ProvisioningStore { time_to_live: time::Duration, ) -> bool { let now = time::OffsetDateTime::now_utc(); - let mut expires_at = now + time_to_live; - if let Some(deadline) = self.credentials.lock().get(&jti).and_then(|entry| entry.required_until) { - expires_at = expires_at.min(deadline); - } - let entry = ConnectionOptionsEntry { connection_options, - expires_at, + expires_at: now + time_to_live, }; - self.connection_options.lock().insert(jti, entry).is_some() + let replaced = self.connection_options.lock().insert(jti, entry).is_some(); + self.cleanup_notify.notify_one(); + replaced } /// State of the credential-injection mapping for `jti`. - /// - /// `RequiredMissing` means injection was provisioned but the mapping is gone or expired while - /// the token could still be accepted. Callers must fail closed instead of ordinary forwarding. pub(crate) fn mapping_status(&self, jti: Uuid) -> MappingStatus { let now = time::OffsetDateTime::now_utc(); - let credentials = self.credentials.lock(); + let mut credentials = self.credentials.lock(); let Some(entry) = credentials.get(&jti) else { return MappingStatus::Absent; }; - let Some(deadline) = entry.required_until else { + if now >= entry.expires_at { + credentials.remove(&jti); return MappingStatus::Absent; - }; - if now >= deadline { - MappingStatus::Absent - } else if entry.mapping.is_some() && now < entry.expires_at { + } + + if entry.mapping.is_some() { MappingStatus::Available } else { - MappingStatus::RequiredMissing + MappingStatus::Absent } } @@ -245,7 +238,7 @@ impl ProvisioningStore { pub(crate) fn get_mapping(&self, jti: Uuid, token: &str) -> anyhow::Result { let now = time::OffsetDateTime::now_utc(); - let (token, mapping, generation, required_until) = { + let (token, mapping, generation, required_until, expiry_changed) = { let mut credentials = self.credentials.lock(); let entry = credentials .get_mut(&jti) @@ -257,10 +250,12 @@ impl ProvisioningStore { }; anyhow::ensure!(entry.mapping.is_some(), "provisioned entry has no credential mapping"); - if now >= deadline || now >= entry.expires_at { + if now >= entry.expires_at { + credentials.remove(&jti); anyhow::bail!("credential-injection material for {jti} is missing or expired; re-provision to retry"); } + let expiry_changed = entry.expires_at != deadline; entry.expires_at = deadline; ( @@ -268,20 +263,18 @@ impl ProvisioningStore { entry.mapping.clone(), entry.generation, entry.required_until, + expiry_changed, ) }; + if expiry_changed { + self.cleanup_notify.notify_one(); + } + let connection_options = { - let mut entries = self.connection_options.lock(); - match entries.get_mut(&jti) { - Some(entry) if now < entry.expires_at => { - if let Some(deadline) = required_until - && entry.expires_at < deadline - { - entry.expires_at = deadline; - } - Some(entry.connection_options.clone()) - } + let entries = self.connection_options.lock(); + match entries.get(&jti) { + Some(entry) if now < entry.expires_at => Some(entry.connection_options.clone()), Some(_) => { warn!(%jti, "Provisioned connection options expired before the connection arrived"); None @@ -303,6 +296,27 @@ impl ProvisioningStore { pub(crate) fn credentials_expires_at(&self, jti: Uuid) -> Option { self.credentials.lock().get(&jti).map(|entry| entry.expires_at) } + + #[cfg(test)] + pub(crate) fn connection_options_expires_at(&self, jti: Uuid) -> Option { + self.connection_options.lock().get(&jti).map(|entry| entry.expires_at) + } + + fn remove_expired(&self, now: time::OffsetDateTime) { + self.credentials.lock().retain(|_, entry| now < entry.expires_at); + self.connection_options.lock().retain(|_, entry| now < entry.expires_at); + } + + fn next_expiry(&self) -> Option { + let credentials_expiry = self.credentials.lock().values().map(|entry| entry.expires_at).min(); + let options_expiry = self + .connection_options + .lock() + .values() + .map(|entry| entry.expires_at) + .min(); + credentials_expiry.into_iter().chain(options_expiry).min() + } } pub struct CleanupTask { @@ -323,34 +337,28 @@ impl Task for CleanupTask { #[instrument(skip_all)] async fn cleanup_task(handle: ProvisioningStore, mut shutdown_signal: ShutdownSignal) { - use tokio::time::{Duration, sleep}; - - const TASK_INTERVAL: Duration = Duration::from_secs(60 * 15); - debug!("Task started"); loop { - tokio::select! { - _ = sleep(TASK_INTERVAL) => {} - _ = shutdown_signal.wait() => { - break; - } - } - let now = time::OffsetDateTime::now_utc(); - let mut credentials = handle.credentials.lock(); - for entry in credentials.values_mut() { - if now >= entry.expires_at { - entry.mapping = None; + handle.remove_expired(now); + + match handle.next_expiry() { + Some(deadline) => { + let delay = (deadline - now).try_into().unwrap_or_default(); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = handle.cleanup_notify.notified() => {} + _ = shutdown_signal.wait() => break, + } + } + None => { + tokio::select! { + _ = handle.cleanup_notify.notified() => {} + _ = shutdown_signal.wait() => break, + } } } - credentials - .retain(|_, entry| now < entry.expires_at || entry.required_until.is_some_and(|deadline| now < deadline)); - drop(credentials); - handle - .connection_options - .lock() - .retain(|_, entry| now < entry.expires_at); } debug!("Task terminated"); @@ -437,7 +445,6 @@ mod tests { let entry = store.take(jti).expect("live entry"); assert!(entry.connection_options.is_some()); assert!(store.take(jti).is_none()); - // options half was removed with take; re-insert options alone does not revive credentials assert!(!store.insert_connection_options(jti, options(), time::Duration::minutes(5))); assert!(store.take(jti).is_none()); } @@ -472,7 +479,7 @@ mod tests { } #[test] - fn get_mapping_is_reusable_until_reprovisioned() { + fn reprovision_before_checkout_replaces_staged_mapping() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); let token = association_token(jti); @@ -480,18 +487,27 @@ mod tests { .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) .expect("insert"); - assert_eq!(store.mapping_status(jti), MappingStatus::Available); - store.get_mapping(jti, &token).expect("first checkout"); - assert_eq!(store.mapping_status(jti), MappingStatus::Available); - store.get_mapping(jti, &token).expect("second checkout"); - store .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) .expect("re-provision"); - let first = store.get_mapping(jti, &token).expect("after replace"); + let first = store.get_mapping(jti, &token).expect("checkout replacement"); assert_eq!(first.generation, 2); } + #[test] + fn checked_out_mapping_is_reusable() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) + .expect("insert"); + + let first = store.get_mapping(jti, &token).expect("first checkout"); + let second = store.get_mapping(jti, &token).expect("second checkout"); + assert_eq!(first.generation, second.generation); + } + #[test] fn token_mismatch_does_not_drop_mapping() { let store = ProvisioningStore::new(); @@ -538,7 +554,7 @@ mod tests { } #[test] - fn staging_expiry_before_first_use_is_required_missing() { + fn staging_expiry_before_first_use_removes_mapping() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); let token = association_token(jti); @@ -546,9 +562,10 @@ mod tests { .insert_credentials(token.clone(), Some(mapping()), time::Duration::seconds(-1)) .expect("insert"); - assert_eq!(store.mapping_status(jti), MappingStatus::RequiredMissing); + assert_eq!(store.mapping_status(jti), MappingStatus::Absent); + assert!(store.credentials_expires_at(jti).is_none()); let error = store.get_mapping(jti, &token).expect_err("expired staging"); - assert!(format!("{error:#}").contains("missing or expired")); + assert!(format!("{error:#}").contains("missing")); } #[test] @@ -565,11 +582,11 @@ mod tests { store.get_mapping(jti, &token).expect("activate"); let after = store.credentials_expires_at(jti).expect("activated"); assert!(after > before); - assert_eq!(after, crate::token::token_acceptance_deadline(exp)); + assert_eq!(after, crate::token::token_acceptance_deadline(exp).expect("deadline")); } #[test] - fn insert_caps_caller_ttl_to_token_acceptance_deadline() { + fn staging_lifetime_uses_provisioning_ttl() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); let exp = time::OffsetDateTime::now_utc().unix_timestamp(); @@ -579,9 +596,39 @@ mod tests { .expect("insert"); let expires_at = store.credentials_expires_at(jti).expect("inserted"); - let deadline = crate::token::token_acceptance_deadline(exp); - let delta = (expires_at - deadline).abs(); - assert!(delta <= time::Duration::seconds(1)); + let deadline = crate::token::token_acceptance_deadline(exp).expect("deadline"); + assert!(expires_at > deadline); + } + + #[test] + fn credential_lifetime_does_not_change_connection_options_lifetime() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store.insert_connection_options(jti, options(), time::Duration::minutes(5)); + let options_expires_at = store.connection_options_expires_at(jti).expect("options"); + + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(1)) + .expect("insert"); + assert_eq!(store.connection_options_expires_at(jti), Some(options_expires_at)); + + store.get_mapping(jti, &token).expect("checkout"); + assert_eq!(store.connection_options_expires_at(jti), Some(options_expires_at)); + } + + #[test] + fn mapped_insert_rejects_out_of_range_expiration() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token_with_exp(jti, i64::MAX); + + let error = store + .insert_credentials(token, Some(mapping()), time::Duration::minutes(5)) + .expect_err("invalid expiration"); + + assert!(format!("{error:#}").contains("supported timestamp range")); + assert_eq!(store.mapping_status(jti), MappingStatus::Absent); } #[test] diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index edafebb7d..9193c730a 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -618,12 +618,7 @@ pub async fn handle( }; let mapping_status = provisioning.mapping_status(auth.claims.jti); - if is_vmconnect_request(&cleanpath_pdu) - && matches!( - mapping_status, - MappingStatus::Available | MappingStatus::RequiredMissing - ) - { + if is_vmconnect_request(&cleanpath_pdu) && mapping_status == MappingStatus::Available { let response = RDCleanPathPdu::new_http_error(400); send_clean_path_response(&mut client_stream, &response).await?; anyhow::bail!("credential injection is not supported for VMConnect RDCleanPath"); @@ -646,17 +641,6 @@ pub async fn handle( ) .await; } - MappingStatus::RequiredMissing => { - let error = CleanPathError::BadRequest(anyhow::anyhow!( - "credential-injection material for {} is missing or expired; re-provision to retry", - auth.claims.jti - )); - let response = RDCleanPathPdu::from(&error); - send_clean_path_response(&mut client_stream, &response).await?; - return anyhow::Error::new(error) - .context("an error occurred when processing cleanpath PDU") - .pipe(Err)?; - } MappingStatus::Absent => {} } diff --git a/devolutions-gateway/src/rdp_proxy/credssp.rs b/devolutions-gateway/src/rdp_proxy/credssp.rs index 5a7053664..da2a72dbe 100644 --- a/devolutions-gateway/src/rdp_proxy/credssp.rs +++ b/devolutions-gateway/src/rdp_proxy/credssp.rs @@ -230,17 +230,16 @@ where let credentials = injection.target_credential(); let kerberos_config = client_kerberos_config(injection)?; - // Decrypt password into short-lived buffer. let (username, decrypted_password) = credentials .decrypt_password() .context("failed to decrypt credentials")?; + // TODO: Pass a zeroizing password type once ironrdp-connector accepts one, so this temporary + // plaintext allocation is cleared before release. let credentials = ironrdp_connector::Credentials::UsernamePassword { username, password: decrypted_password.expose_secret().to_owned(), }; - // decrypted_password drops here, zeroizing its buffer; note: a copy of the plaintext - // remains in `credentials` above, which is a regular String (downstream API limitation). let (mut sequence, mut ts_request) = ironrdp_connector::credssp::CredsspSequence::init( credentials, diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index fa2f8b6c1..e4c9a2d7d 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -354,6 +354,10 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { tasks.register(devolutions_gateway::provisioning::CleanupTask { handle: provisioning }); + tasks.register(devolutions_gateway::credential_injection::CleanupTask { + handle: synthetic_kdc_registry, + }); + tasks.register(devolutions_log::LogDeleterTask::::new( conf.log_file.clone(), )); diff --git a/devolutions-gateway/src/token.rs b/devolutions-gateway/src/token.rs index 18e6e08cb..10f7beb91 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -1291,9 +1291,10 @@ pub fn extract_exp(token: &str) -> anyhow::Result { /// Latest instant at which Gateway will still accept a token with this `exp`. /// /// Includes the hardcoded JWT clock-skew leeway. -pub(crate) fn token_acceptance_deadline(exp: i64) -> time::OffsetDateTime { +pub(crate) fn token_acceptance_deadline(exp: i64) -> anyhow::Result { let timestamp = exp.saturating_add(i64::from(LEEWAY_SECS)); - time::OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(time::OffsetDateTime::UNIX_EPOCH) + time::OffsetDateTime::from_unix_timestamp(timestamp) + .context("token expiration is outside the supported timestamp range") } pub fn extract_session_id(token: &str) -> anyhow::Result { From 85545af64a3f911d0a7e3d3d68ecb8a105a1121f Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 25 Aug 2026 18:14:34 -0400 Subject: [PATCH 17/36] fix(dgw): release credentials after CredSSP Drop the per-connection credential mapping and synthetic KDC lease as soon as both CredSSP legs finish. RDP forwarding no longer retains secret material for the full session. Issue: DVLS-14697 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- devolutions-gateway/src/rdp_proxy/credssp.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/devolutions-gateway/src/rdp_proxy/credssp.rs b/devolutions-gateway/src/rdp_proxy/credssp.rs index da2a72dbe..94e36c6f1 100644 --- a/devolutions-gateway/src/rdp_proxy/credssp.rs +++ b/devolutions-gateway/src/rdp_proxy/credssp.rs @@ -116,6 +116,7 @@ impl CredsspSession { let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); client_credssp_res.context("CredSSP with client")?; server_credssp_res.context("CredSSP with server")?; + drop(credential_injection); intercept_connect_confirm(&mut client_framed, &mut server_framed, server_security_protocol).await?; From 6ff1270f4ee971c806f62f6114aeca0993864660 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Thu, 20 Aug 2026 15:49:56 -0400 Subject: [PATCH 18/36] test(dgw): cover credential injection reconnect Add process-level tests for DVLS-like preflight, first inject, jet_reuse reconnect, fail-closed missing mappings, and synthetic KDC generation reuse. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 + testsuite/Cargo.toml | 2 + testsuite/src/dgw_config.rs | 7 +- testsuite/tests/cli/dgw/cred_injection.rs | 611 ++++++++++++++++++++++ testsuite/tests/cli/dgw/mod.rs | 1 + 5 files changed, 622 insertions(+), 1 deletion(-) create mode 100644 testsuite/tests/cli/dgw/cred_injection.rs diff --git a/Cargo.lock b/Cargo.lock index f2fa1f931..04dc60798 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7516,6 +7516,8 @@ dependencies = [ "escargot", "expect-test", "fastrand", + "ironrdp-core 0.2.1", + "ironrdp-pdu", "libsql", "mcp-proxy", "network-scanner", diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 895e12531..74ea00174 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -32,6 +32,8 @@ tokio-tungstenite = { version = "0.29", features = ["rustls-tls-native-roots"] } [dev-dependencies] base64 = "0.23" +ironrdp-core = { version = "0.2", features = ["std"] } +ironrdp-pdu = { version = "0.9", features = ["std"] } proxy-socks = { path = "../crates/proxy-socks" } libsql = { version = "0.9", default-features = false, features = ["core"] } mcp-proxy.path = "../crates/mcp-proxy" diff --git a/testsuite/src/dgw_config.rs b/testsuite/src/dgw_config.rs index f5fc7a441..885e71e48 100644 --- a/testsuite/src/dgw_config.rs +++ b/testsuite/src/dgw_config.rs @@ -45,6 +45,9 @@ pub struct DgwConfig { /// Enable unstable features. #[builder(default = false)] enable_unstable: bool, + /// Enable Kerberos credential injection (also requires `enable_unstable`). + #[builder(default = false)] + kerberos_credential_injection: bool, /// Override the recording path in the gateway config. /// /// When `None`, the gateway uses its default (`/recordings`). @@ -84,6 +87,7 @@ impl DgwConfigHandle { disable_token_validation, verbosity_profile, enable_unstable, + kerberos_credential_injection, recording_path, agent_tunnel, } = config; @@ -137,7 +141,8 @@ impl DgwConfigHandle { "VerbosityProfile": "{verbosity_profile}", "__debug__": {{ "disable_token_validation": {disable_token_validation}, - "enable_unstable": {enable_unstable} + "enable_unstable": {enable_unstable}, + "kerberos_credential_injection": {kerberos_credential_injection} }}{recording_path_json}{agent_tunnel_json} }}"# ); diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs new file mode 100644 index 000000000..01bcfed94 --- /dev/null +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -0,0 +1,611 @@ +//! Process-level tests for RDP credential injection, reconnect, and fail-closed routing. +//! +//! These tests start a real Gateway, provision credentials over `/jet/preflight` the way DVLS +//! does, then connect to the TCP listener with an RDP preconnection blob. A loopback peer stands +//! in for the destination RDP server and records the X.224 Connection Request the proxy forwards. +//! Injection is observed from Gateway logs and from the rewritten mstshash cookie. CredSSP is not +//! completed: the contract under test is checkout, reconnect, and fail-closed routing. + +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use base64::Engine as _; +use testsuite::cli::{dgw_tokio_cmd, wait_for_tcp_port}; +use testsuite::dgw_config::{DgwConfig, DgwConfigHandle, VerbosityProfile}; +use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::process::Child; + +const CLIENT_COOKIE: &str = "client-cookie-user"; +const TARGET_USER: &str = "injected-target-user"; +const PROXY_USER: &str = "injected-proxy-user"; +const KERBEROS_TARGET_USER: &str = "administrator@example.invalid"; +const INJECT_LOG: &str = "RDP-TLS forwarding with credential injection"; +const FORWARD_LOG: &str = "Upstream forwarding"; +const MISSING_LOG: &str = "missing or expired; re-provision to retry"; +const PUBLISHED_KDC_LOG: &str = "Published synthetic KDC"; +const REGISTERED_KDC_LOG: &str = "Registered synthetic KDC for credential-injection session"; + +fn next_id() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(1); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("00000000-0000-4000-a000-{n:012x}") +} + +fn unsigned_jws(header: serde_json::Value, payload: serde_json::Value) -> anyhow::Result { + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode(serde_json::to_vec(&header).context("serialize JWT header")?); + let payload = engine.encode(serde_json::to_vec(&payload).context("serialize JWT payload")?); + Ok(format!("{header}.{payload}.ZHVtbXlfc2lnbmF0dXJl")) +} + +fn preflight_scope_token() -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"SCOPE"}), + serde_json::json!({ + "scope": "gateway.preflight", + "exp": 9_999_999_999_i64, + "jti": next_id(), + }), + ) +} + +fn association_token(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"ASSOCIATION"}), + serde_json::json!({ + "dst_hst": format!("127.0.0.1:{dest_port}"), + "exp": 9_999_999_999_i64, + "jet_aid": jet_aid, + "jet_ap": "rdp", + "jet_cm": "fwd", + "jet_rec": "none", + "jet_reuse": jet_reuse, + "jti": jti, + "nbf": 0, + }), + ) +} + +fn encode_pcb(token: &str) -> anyhow::Result> { + let pcb = ironrdp_pdu::pcb::PreconnectionBlob { + version: ironrdp_pdu::pcb::PcbVersion::V2, + id: 0, + v2_payload: Some(token.to_owned()), + }; + ironrdp_core::encode_vec(&pcb).context("encode preconnection blob") +} + +fn encode_connection_request(cookie: &str) -> anyhow::Result> { + use ironrdp_pdu::nego::{ConnectionRequest, NegoRequestData, RequestFlags, SecurityProtocol}; + use ironrdp_pdu::x224::X224; + + let pdu = X224(ConnectionRequest { + nego_data: Some(NegoRequestData::cookie(cookie.to_owned())), + flags: RequestFlags::empty(), + protocol: SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX | SecurityProtocol::SSL, + }); + ironrdp_core::encode_vec(&pdu).context("encode X.224 connection request") +} + +fn strip_ansi(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' && chars.peek() == Some(&'[') { + chars.next(); + for next in chars.by_ref() { + if next.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out +} + +struct LogBuffer(Arc>); + +impl LogBuffer { + fn new() -> Self { + Self(Arc::new(Mutex::new(String::new()))) + } + + fn snapshot(&self) -> String { + strip_ansi(&self.0.lock().expect("log mutex")) + } + + async fn wait_contains(&self, needle: &str) -> anyhow::Result { + self.wait_count(needle, 1).await + } + + async fn wait_count(&self, needle: &str, count: usize) -> anyhow::Result { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let snapshot = self.snapshot(); + if snapshot.matches(needle).count() >= count { + return Ok(snapshot); + } + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for {count} occurrence(s) of {needle:?}; logs:\n{snapshot}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +struct FakeRdpTarget { + port: u16, + accepted: Arc, + payloads: Arc>>>, +} + +impl FakeRdpTarget { + async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind fake RDP target")?; + let port = listener.local_addr().context("fake RDP local_addr")?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let payloads = Arc::new(Mutex::new(Vec::new())); + let accepted_task = Arc::clone(&accepted); + let payloads_task = Arc::clone(&payloads); + + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + accepted_task.fetch_add(1, Ordering::SeqCst); + let payloads = Arc::clone(&payloads_task); + tokio::spawn(async move { + let mut buf = vec![0_u8; 4096]; + // CredSSP cert generation can delay the rewritten X.224 CR. + if let Ok(Ok(n)) = tokio::time::timeout(Duration::from_secs(30), stream.read(&mut buf)).await + && n > 0 + { + payloads.lock().expect("payload mutex").push(buf[..n].to_vec()); + } + // Keep the accepted socket open so the proxy can finish writing the CR. + tokio::time::sleep(Duration::from_secs(30)).await; + }); + } + }); + + Ok(Self { + port, + accepted, + payloads, + }) + } + + fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) + } + + async fn wait_payloads(&self, count: usize) -> anyhow::Result>> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + { + let payloads = self.payloads.lock().expect("payload mutex"); + if payloads.len() >= count { + return Ok(payloads.clone()); + } + } + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for {count} target payload(s); accepted={}", + self.accepted() + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +struct GatewayProc { + config: DgwConfigHandle, + process: Child, + logs: LogBuffer, +} + +impl GatewayProc { + async fn start(kerberos: bool) -> anyhow::Result { + let config = DgwConfig::builder() + .disable_token_validation(true) + .verbosity_profile(VerbosityProfile::DEBUG) + .enable_unstable(kerberos) + .kerberos_credential_injection(kerberos) + .build() + .init() + .context("init gateway config")?; + + let mut process = dgw_tokio_cmd() + .env("DGATEWAY_CONFIG_PATH", config.config_dir()) + .env("RUST_LOG", "devolutions_gateway=debug") + .env("NO_COLOR", "1") + .kill_on_drop(true) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("start Devolutions Gateway")?; + + let logs = LogBuffer::new(); + spawn_stdio_collector(process.stdout.take(), Arc::clone(&logs.0)); + spawn_stdio_collector(process.stderr.take(), Arc::clone(&logs.0)); + + wait_for_tcp_port(config.http_port()) + .await + .context("wait for gateway HTTP port")?; + + Ok(Self { config, process, logs }) + } +} + +fn spawn_stdio_collector(stream: Option, logs: Arc>) +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, +{ + let Some(stream) = stream else { + return; + }; + tokio::spawn(async move { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => logs.lock().expect("log mutex").push_str(&line), + } + } + }); +} + +async fn post_preflight(http_port: u16, operations: serde_json::Value) -> anyhow::Result { + let bearer = preflight_scope_token()?; + let body = serde_json::to_string(&operations).context("serialize preflight body")?; + let request = format!( + "POST /jet/preflight HTTP/1.1\r\n\ + Host: 127.0.0.1:{http_port}\r\n\ + Content-Type: application/json\r\n\ + Authorization: Bearer {bearer}\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len() + ); + + let mut stream = TcpStream::connect(("127.0.0.1", http_port)) + .await + .context("connect to gateway HTTP")?; + stream.write_all(request.as_bytes()).await.context("write preflight")?; + stream.flush().await.context("flush preflight")?; + + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + reader + .read_line(&mut status_line) + .await + .context("read preflight status")?; + anyhow::ensure!(status_line.contains("200"), "preflight HTTP status was {status_line:?}"); + + let mut content_length = None; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.context("read preflight header")?; + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().to_owned()) + { + content_length = Some(value.parse::().context("parse Content-Length")?); + } + } + + let response_body = if let Some(len) = content_length { + let mut buf = vec![0_u8; len]; + reader.read_exact(&mut buf).await.context("read preflight body")?; + String::from_utf8(buf).context("preflight body utf-8")? + } else { + let mut buf = String::new(); + reader + .read_to_string(&mut buf) + .await + .context("read preflight eof body")?; + buf + }; + + let json: serde_json::Value = + serde_json::from_str(&response_body).with_context(|| format!("parse preflight JSON: {response_body}"))?; + let outputs = json.as_array().context("preflight response is not an array")?; + // Re-provisioning the same JTI emits an info alert, then still acks. + let mut acked = 0_usize; + for output in outputs { + match output["kind"].as_str() { + Some("ack") => acked += 1, + Some("alert") if output["alert_status"] == "info" => {} + _ => anyhow::bail!("preflight operation was not ack: {output}"), + } + } + anyhow::ensure!(acked > 0, "preflight returned no ack: {json}"); + Ok(json) +} + +async fn provision_credentials( + http_port: u16, + token: &str, + target_username: &str, + time_to_live: u32, + krb_kdc: Option<&str>, +) -> anyhow::Result<()> { + let mut operations = vec![serde_json::json!({ + "id": next_id(), + "kind": "provision-credentials", + "token": token, + "proxy_credential": { + "kind": "username-password", + "username": PROXY_USER, + "password": "proxy-secret" + }, + "target_credential": { + "kind": "username-password", + "username": target_username, + "password": "target-secret" + }, + "time_to_live": time_to_live + })]; + + if let Some(krb_kdc) = krb_kdc { + operations.push(serde_json::json!({ + "id": next_id(), + "kind": "provision-connection-options", + "token": token, + "connection_options": { "krb_kdc": krb_kdc }, + "time_to_live": time_to_live + })); + } + + post_preflight(http_port, serde_json::Value::Array(operations)).await?; + Ok(()) +} + +async fn connect_rdp_client(gateway_tcp: u16, association_jwt: &str) -> anyhow::Result { + let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) + .await + .context("connect to gateway TCP")?; + stream + .write_all(&encode_pcb(association_jwt)?) + .await + .context("write preconnection blob")?; + stream + .write_all(&encode_connection_request(CLIENT_COOKIE)?) + .await + .context("write connection request")?; + stream.flush().await.context("flush RDP client")?; + Ok(stream) +} + +fn cookie_line(username: &str) -> String { + format!("Cookie: mstshash={username}") +} + +fn payloads_contain(payloads: &[Vec], needle: &str) -> bool { + payloads + .iter() + .any(|payload| String::from_utf8_lossy(payload).contains(needle)) +} + +#[tokio::test] +async fn first_rdp_connection_injects_ntlm() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start(false).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + assert!( + logs.contains("kerberos=false"), + "expected NTLM injection; logs:\n{logs}" + ); + assert!( + !logs.contains(FORWARD_LOG), + "injection must not fall back to ordinary forward; logs:\n{logs}" + ); + + let payloads = target.wait_payloads(1).await?; + assert!( + payloads_contain(&payloads, &cookie_line(TARGET_USER)), + "target should see injected cookie; payloads={payloads:?}" + ); + assert!( + !payloads_contain(&payloads, &cookie_line(CLIENT_COOKIE)), + "target must not see the client cookie; payloads={payloads:?}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn reconnect_same_jwt_still_injects() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start(false).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + + let first = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + gateway.logs.wait_count(INJECT_LOG, 1).await?; + target.wait_payloads(1).await?; + drop(first); + + let _second = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 2).await?; + assert!( + !logs.contains(FORWARD_LOG), + "reconnect must keep injecting, not ordinary-forward; logs:\n{logs}" + ); + + let payloads = target.wait_payloads(2).await?; + assert_eq!(payloads.len(), 2, "both connections should reach the fake RDP target"); + assert!( + payloads + .iter() + .all(|payload| String::from_utf8_lossy(payload).contains(&cookie_line(TARGET_USER))), + "both reconnects should inject the target cookie; payloads={payloads:?}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn required_missing_fails_closed() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start(false).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 1, None).await?; + tokio::time::sleep(Duration::from_secs(2)).await; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_contains(MISSING_LOG).await?; + assert!( + !logs.contains(INJECT_LOG), + "expired mapping must not inject; logs:\n{logs}" + ); + assert!( + !logs.contains(FORWARD_LOG), + "expired mapping must fail closed, never silent ordinary forward; logs:\n{logs}" + ); + + tokio::time::sleep(Duration::from_millis(500)).await; + assert_eq!(target.accepted(), 0, "fail-closed routing must not connect upstream"); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn unprovisioned_rdp_uses_ordinary_forward() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start(false).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_contains(FORWARD_LOG).await?; + assert!( + !logs.contains(INJECT_LOG), + "absent mapping should ordinary-forward; logs:\n{logs}" + ); + + let payloads = target.wait_payloads(1).await?; + assert!( + payloads_contain(&payloads, &cookie_line(CLIENT_COOKIE)), + "ordinary forward should keep the client cookie; payloads={payloads:?}" + ); + assert!( + !payloads_contain(&payloads, &cookie_line(TARGET_USER)), + "ordinary forward must not invent an injection cookie; payloads={payloads:?}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start(true).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + // Keep overlapping reconnect sockets so the same-generation KDC lease stays live. + let _first = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 1).await?; + assert!( + logs.contains("kerberos=true"), + "expected Kerberos injection; logs:\n{logs}" + ); + gateway.logs.wait_count(PUBLISHED_KDC_LOG, 1).await?; + + let _second = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 2).await?; + assert_eq!( + logs.matches("kerberos=true").count(), + 2, + "same generation reconnect should still inject Kerberos; logs:\n{logs}" + ); + gateway.logs.wait_count(REGISTERED_KDC_LOG, 2).await?; + assert_eq!( + logs.matches(PUBLISHED_KDC_LOG).count(), + 1, + "same provisioning generation must reuse the interned synthetic KDC; logs:\n{logs}" + ); + + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + let _third = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 3).await?; + assert_eq!( + logs.matches("kerberos=true").count(), + 3, + "newer provisioning generation should replace and still inject; logs:\n{logs}" + ); + let logs = gateway.logs.wait_count(PUBLISHED_KDC_LOG, 2).await?; + assert_eq!( + logs.matches(REGISTERED_KDC_LOG).count(), + 3, + "each connection should register a synthetic KDC lease; logs:\n{logs}" + ); + assert!( + !logs.contains(FORWARD_LOG), + "Kerberos injection must not ordinary-forward; logs:\n{logs}" + ); + + let payloads = target.wait_payloads(3).await?; + assert!( + payloads + .iter() + .all(|payload| String::from_utf8_lossy(payload).contains(&cookie_line(KERBEROS_TARGET_USER))), + "each generation should inject the Kerberos target username; payloads={payloads:?}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} diff --git a/testsuite/tests/cli/dgw/mod.rs b/testsuite/tests/cli/dgw/mod.rs index c6cc1226b..f6e88737a 100644 --- a/testsuite/tests/cli/dgw/mod.rs +++ b/testsuite/tests/cli/dgw/mod.rs @@ -1,5 +1,6 @@ mod benign_disconnect; mod cli_args; +mod cred_injection; mod heartbeat; mod preflight; mod tls_anchoring; From 920df3663df8c92a84b57d611cd6ac44acb4e086 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Thu, 20 Aug 2026 15:57:07 -0400 Subject: [PATCH 19/36] style(dgw): fix clippy literal suffixes in injection e2e Clippy separated_literal_suffix failed CI lints on the stacked reconnect tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testsuite/tests/cli/dgw/cred_injection.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs index 01bcfed94..b58403f98 100644 --- a/testsuite/tests/cli/dgw/cred_injection.rs +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -46,7 +46,7 @@ fn preflight_scope_token() -> anyhow::Result { serde_json::json!({"alg":"RS256","typ":"JWT","cty":"SCOPE"}), serde_json::json!({ "scope": "gateway.preflight", - "exp": 9_999_999_999_i64, + "exp": 9_999_999_999i64, "jti": next_id(), }), ) @@ -57,7 +57,7 @@ fn association_token(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) - serde_json::json!({"alg":"RS256","typ":"JWT","cty":"ASSOCIATION"}), serde_json::json!({ "dst_hst": format!("127.0.0.1:{dest_port}"), - "exp": 9_999_999_999_i64, + "exp": 9_999_999_999i64, "jet_aid": jet_aid, "jet_ap": "rdp", "jet_cm": "fwd", @@ -161,7 +161,7 @@ impl FakeRdpTarget { accepted_task.fetch_add(1, Ordering::SeqCst); let payloads = Arc::clone(&payloads_task); tokio::spawn(async move { - let mut buf = vec![0_u8; 4096]; + let mut buf = vec![0u8; 4096]; // CredSSP cert generation can delay the rewritten X.224 CR. if let Ok(Ok(n)) = tokio::time::timeout(Duration::from_secs(30), stream.read(&mut buf)).await && n > 0 @@ -310,7 +310,7 @@ async fn post_preflight(http_port: u16, operations: serde_json::Value) -> anyhow } let response_body = if let Some(len) = content_length { - let mut buf = vec![0_u8; len]; + let mut buf = vec![0u8; len]; reader.read_exact(&mut buf).await.context("read preflight body")?; String::from_utf8(buf).context("preflight body utf-8")? } else { @@ -326,7 +326,7 @@ async fn post_preflight(http_port: u16, operations: serde_json::Value) -> anyhow serde_json::from_str(&response_body).with_context(|| format!("parse preflight JSON: {response_body}"))?; let outputs = json.as_array().context("preflight response is not an array")?; // Re-provisioning the same JTI emits an info alert, then still acks. - let mut acked = 0_usize; + let mut acked = 0usize; for output in outputs { match output["kind"].as_str() { Some("ack") => acked += 1, From 78a204a07f9e5c2083cbc8830d31095c4c8b1a1e Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Thu, 20 Aug 2026 17:06:47 -0400 Subject: [PATCH 20/36] test(dgw): complete Kerberos injection against a mock KDC Drive CredSSP through a TCP kdc crate and IronRDP acceptor so target-leg Kerberos injection is proven, not just log-matched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 6 + testsuite/Cargo.toml | 6 + testsuite/tests/cli/dgw/cred_injection.rs | 44 +- testsuite/tests/cli/dgw/cred_injection_kdc.rs | 634 ++++++++++++++++++ testsuite/tests/cli/dgw/mod.rs | 1 + 5 files changed, 670 insertions(+), 21 deletions(-) create mode 100644 testsuite/tests/cli/dgw/cred_injection_kdc.rs diff --git a/Cargo.lock b/Cargo.lock index 04dc60798..b3f239ccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7516,12 +7516,17 @@ dependencies = [ "escargot", "expect-test", "fastrand", + "ironrdp-acceptor", + "ironrdp-connector", "ironrdp-core 0.2.1", "ironrdp-pdu", + "ironrdp-tokio", + "kdc", "libsql", "mcp-proxy", "network-scanner", "network-scanner-proto", + "picky-krb", "proxy-socks", "rstest", "serde", @@ -7536,6 +7541,7 @@ dependencies = [ "tokio-tungstenite", "tokio-util", "typed-builder", + "x509-cert 0.3.0", ] [[package]] diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 74ea00174..5c023c919 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -32,8 +32,13 @@ tokio-tungstenite = { version = "0.29", features = ["rustls-tls-native-roots"] } [dev-dependencies] base64 = "0.23" +ironrdp-acceptor = "0.10" +ironrdp-connector = "0.10" ironrdp-core = { version = "0.2", features = ["std"] } ironrdp-pdu = { version = "0.9", features = ["std"] } +ironrdp-tokio = "0.10" +kdc = "0.1" +picky-krb = "0.12" proxy-socks = { path = "../crates/proxy-socks" } libsql = { version = "0.9", default-features = false, features = ["core"] } mcp-proxy.path = "../crates/mcp-proxy" @@ -45,6 +50,7 @@ sysevent.path = "../crates/sysevent" tempfile = "3" test-utils.path = "../crates/test-utils" tokio-rustls = { version = "0.26", features = ["ring"] } +x509-cert = { version = "0.3", default-features = false, features = ["std"] } [target.'cfg(unix)'.dev-dependencies] sysevent-syslog.path = "../crates/sysevent-syslog" diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs index b58403f98..11fafd805 100644 --- a/testsuite/tests/cli/dgw/cred_injection.rs +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -18,23 +18,25 @@ use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, Buf use tokio::net::{TcpListener, TcpStream}; use tokio::process::Child; -const CLIENT_COOKIE: &str = "client-cookie-user"; -const TARGET_USER: &str = "injected-target-user"; -const PROXY_USER: &str = "injected-proxy-user"; -const KERBEROS_TARGET_USER: &str = "administrator@example.invalid"; -const INJECT_LOG: &str = "RDP-TLS forwarding with credential injection"; +pub(crate) const CLIENT_COOKIE: &str = "client-cookie-user"; +pub(crate) const TARGET_USER: &str = "injected-target-user"; +pub(crate) const PROXY_USER: &str = "injected-proxy-user"; +pub(crate) const PROXY_PASSWORD: &str = "proxy-secret"; +pub(crate) const TARGET_PASSWORD: &str = "target-secret"; +pub(crate) const KERBEROS_TARGET_USER: &str = "administrator@example.invalid"; +pub(crate) const INJECT_LOG: &str = "RDP-TLS forwarding with credential injection"; const FORWARD_LOG: &str = "Upstream forwarding"; const MISSING_LOG: &str = "missing or expired; re-provision to retry"; const PUBLISHED_KDC_LOG: &str = "Published synthetic KDC"; const REGISTERED_KDC_LOG: &str = "Registered synthetic KDC for credential-injection session"; -fn next_id() -> String { +pub(crate) fn next_id() -> String { static COUNTER: AtomicU64 = AtomicU64::new(1); let n = COUNTER.fetch_add(1, Ordering::Relaxed); format!("00000000-0000-4000-a000-{n:012x}") } -fn unsigned_jws(header: serde_json::Value, payload: serde_json::Value) -> anyhow::Result { +pub(crate) fn unsigned_jws(header: serde_json::Value, payload: serde_json::Value) -> anyhow::Result { let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; let header = engine.encode(serde_json::to_vec(&header).context("serialize JWT header")?); let payload = engine.encode(serde_json::to_vec(&payload).context("serialize JWT payload")?); @@ -52,7 +54,7 @@ fn preflight_scope_token() -> anyhow::Result { ) } -fn association_token(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { +pub(crate) fn association_token(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { unsigned_jws( serde_json::json!({"alg":"RS256","typ":"JWT","cty":"ASSOCIATION"}), serde_json::json!({ @@ -69,7 +71,7 @@ fn association_token(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) - ) } -fn encode_pcb(token: &str) -> anyhow::Result> { +pub(crate) fn encode_pcb(token: &str) -> anyhow::Result> { let pcb = ironrdp_pdu::pcb::PreconnectionBlob { version: ironrdp_pdu::pcb::PcbVersion::V2, id: 0, @@ -108,22 +110,22 @@ fn strip_ansi(input: &str) -> String { out } -struct LogBuffer(Arc>); +pub(crate) struct LogBuffer(Arc>); impl LogBuffer { fn new() -> Self { Self(Arc::new(Mutex::new(String::new()))) } - fn snapshot(&self) -> String { + pub(crate) fn snapshot(&self) -> String { strip_ansi(&self.0.lock().expect("log mutex")) } - async fn wait_contains(&self, needle: &str) -> anyhow::Result { + pub(crate) async fn wait_contains(&self, needle: &str) -> anyhow::Result { self.wait_count(needle, 1).await } - async fn wait_count(&self, needle: &str, count: usize) -> anyhow::Result { + pub(crate) async fn wait_count(&self, needle: &str, count: usize) -> anyhow::Result { let deadline = Instant::now() + Duration::from_secs(15); loop { let snapshot = self.snapshot(); @@ -205,14 +207,14 @@ impl FakeRdpTarget { } } -struct GatewayProc { - config: DgwConfigHandle, - process: Child, - logs: LogBuffer, +pub(crate) struct GatewayProc { + pub(crate) config: DgwConfigHandle, + pub(crate) process: Child, + pub(crate) logs: LogBuffer, } impl GatewayProc { - async fn start(kerberos: bool) -> anyhow::Result { + pub(crate) async fn start(kerberos: bool) -> anyhow::Result { let config = DgwConfig::builder() .disable_token_validation(true) .verbosity_profile(VerbosityProfile::DEBUG) @@ -338,7 +340,7 @@ async fn post_preflight(http_port: u16, operations: serde_json::Value) -> anyhow Ok(json) } -async fn provision_credentials( +pub(crate) async fn provision_credentials( http_port: u16, token: &str, target_username: &str, @@ -352,12 +354,12 @@ async fn provision_credentials( "proxy_credential": { "kind": "username-password", "username": PROXY_USER, - "password": "proxy-secret" + "password": PROXY_PASSWORD }, "target_credential": { "kind": "username-password", "username": target_username, - "password": "target-secret" + "password": TARGET_PASSWORD }, "time_to_live": time_to_live })]; diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs new file mode 100644 index 000000000..38846da56 --- /dev/null +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -0,0 +1,634 @@ +//! Kerberos credential injection against a mock KDC and IronRDP CredSSP server. +//! +//! Proves the target-leg path: Gateway fetches tickets from a TCP KDC (`kdc` crate from +//! sspi-rs) and completes CredSSP with a fake RDP acceptor. The Gateway-facing client uses +//! NTLM so the test does not depend on the in-process synthetic KDC. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use ironrdp_connector::sspi; +use ironrdp_connector::sspi::generator::GeneratorState; +use ironrdp_pdu::nego::{ + ConnectionConfirm, ConnectionRequest, NegoRequestData, RequestFlags, ResponseFlags, SecurityProtocol, +}; +use ironrdp_pdu::x224::X224; +use ironrdp_tokio::{FramedWrite as _, TokioFramed}; +use picky_krb::messages::KdcProxyMessage; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_rustls::rustls::pki_types::pem::PemObject as _; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use tokio_rustls::rustls::{ClientConfig, ServerConfig}; +use x509_cert::der::Decode as _; + +use super::cred_injection::{ + GatewayProc, KERBEROS_TARGET_USER, PROXY_PASSWORD, PROXY_USER, TARGET_PASSWORD, encode_pcb, next_id, + provision_credentials, unsigned_jws, +}; + +const REALM: &str = "EXAMPLE.INVALID"; +// sspi-rs downgrades Negotiate to NTLM when the SPN host is an IP address. +const SERVICE_HOST: &str = "localhost"; +const KRBTGT_KEY: [u8; 32] = [0x11; 32]; +const TERMSRV_KEY: [u8; 32] = [0x22; 32]; + +struct MockKdc { + port: u16, + exchanges: Arc, +} + +impl MockKdc { + async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind mock KDC")?; + let port = listener.local_addr().context("mock KDC local_addr")?.port(); + let exchanges = Arc::new(AtomicUsize::new(0)); + let exchanges_task = Arc::clone(&exchanges); + let config = kdc_config(); + + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let config = config.clone(); + let exchanges = Arc::clone(&exchanges_task); + tokio::spawn(async move { + match serve_kdc_exchange(stream, &config).await { + Ok(()) => { + exchanges.fetch_add(1, Ordering::SeqCst); + } + Err(error) => eprintln!("mock KDC exchange failed: {error:#}"), + } + }); + } + }); + + Ok(Self { port, exchanges }) + } + + fn url(&self) -> String { + format!("tcp://127.0.0.1:{}", self.port) + } + + fn exchanges(&self) -> usize { + self.exchanges.load(Ordering::SeqCst) + } +} + +fn kdc_config() -> kdc::config::KerberosServer { + let username = format!("administrator@{REALM}"); + kdc::config::KerberosServer { + realm: REALM.to_owned(), + users: vec![kdc::config::DomainUser { + username, + password: TARGET_PASSWORD.to_owned(), + salt: format!("{}administrator", REALM.to_ascii_uppercase()), + }], + max_time_skew: 300, + krbtgt_key: KRBTGT_KEY.to_vec(), + ticket_decryption_key: Some(TERMSRV_KEY.to_vec()), + service_user: None, + } +} + +async fn serve_kdc_exchange(mut stream: TcpStream, config: &kdc::config::KerberosServer) -> anyhow::Result<()> { + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.context("read KDC length")?; + let len = usize::try_from(u32::from_be_bytes(len_buf)).context("KDC length")?; + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.context("read KDC body")?; + + let mut raw = Vec::with_capacity(4 + len); + raw.extend_from_slice(&len_buf); + raw.extend_from_slice(&body); + + let request = KdcProxyMessage::from_raw_kerb_message(&raw).context("wrap KDC TCP payload")?; + let reply = kdc::handle_kdc_proxy_message(request, config, SERVICE_HOST).context("handle KDC message")?; + stream + .write_all(&reply.kerb_message.0.0) + .await + .context("write KDC reply")?; + Ok(()) +} + +struct MockRdp { + port: u16, + credssp_ok: Arc, +} + +impl MockRdp { + async fn start(kdc_url: String) -> anyhow::Result { + install_crypto_provider(); + // Dual-stack so Windows `localhost` (IPv6 first) still hits the fake server. + let listener = match TcpListener::bind("[::]:0").await { + Ok(listener) => listener, + Err(_) => TcpListener::bind("127.0.0.1:0").await.context("bind mock RDP")?, + }; + let port = listener.local_addr().context("mock RDP local_addr")?.port(); + let credssp_ok = Arc::new(AtomicBool::new(false)); + let credssp_ok_task = Arc::clone(&credssp_ok); + let acceptor = tls_acceptor()?; + let public_key = server_public_key()?; + + tokio::spawn(async move { + loop { + let Ok((stream, peer)) = listener.accept().await else { + break; + }; + let acceptor = acceptor.clone(); + let public_key = public_key.clone(); + let credssp_ok = Arc::clone(&credssp_ok_task); + let kdc_url = kdc_url.clone(); + tokio::spawn(async move { + match accept_kerberos_rdp(stream, peer, acceptor, public_key, &kdc_url).await { + Ok(()) => credssp_ok.store(true, Ordering::SeqCst), + Err(error) => eprintln!("mock RDP CredSSP failed: {error:#}"), + } + }); + } + }); + + Ok(Self { port, credssp_ok }) + } + + fn credssp_ok(&self) -> bool { + self.credssp_ok.load(Ordering::SeqCst) + } + + async fn wait_credssp(&self) -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if self.credssp_ok() { + return Ok(()); + } + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for Kerberos CredSSP on mock RDP"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +async fn accept_kerberos_rdp( + stream: TcpStream, + peer: std::net::SocketAddr, + acceptor: tokio_rustls::TlsAcceptor, + public_key: Vec, + kdc_url: &str, +) -> anyhow::Result<()> { + let mut framed = TokioFramed::new(stream); + let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; + let _: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + + let confirm = X224(ConnectionConfirm::Response { + flags: ResponseFlags::empty(), + protocol: SecurityProtocol::HYBRID, + }); + framed + .write_all(&ironrdp_core::encode_vec(&confirm).context("encode X.224 CC")?) + .await + .context("write X.224 CC")?; + + let tcp = framed.into_inner_no_leftover(); + let tls = acceptor.accept(tcp).await.context("TLS accept")?; + let mut framed = TokioFramed::new(tls); + + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(KERBEROS_TARGET_USER).context("parse target username")?, + password: TARGET_PASSWORD.to_owned().into(), + }; + let kerberos_config = sspi::KerberosServerConfig { + kerberos_config: sspi::KerberosConfig { + kdc_url: Some(kdc_url.parse().context("parse mock KDC URL")?), + client_computer_name: peer.to_string(), + }, + server_properties: sspi::kerberos::ServerProperties::new( + &["TERMSRV", SERVICE_HOST], + Some(sspi::CredentialsBuffers::AuthIdentity( + sspi::AuthIdentityBuffers::from_utf8(identity.username.account_name(), REALM, TARGET_PASSWORD), + )), + Duration::from_secs(300), + Some(sspi::Secret::new(TERMSRV_KEY.to_vec())), + ) + .context("Kerberos server properties")?, + }; + + let mut server = sspi::credssp::CredSspServer::new( + public_key, + IdentityProxy(identity), + sspi::credssp::ServerMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(kerberos_config), + Some("kerberos,!ntlm".to_owned()), + peer.to_string(), + )), + ) + .context("init Kerberos-only CredSSP server")?; + + let hint = TsRequestHint; + let mut buf = ironrdp_pdu::WriteBuf::new(); + for _ in 0..6 { + let pdu = framed.read_by_hint(&hint).await.context("read CredSSP TSRequest")?; + let ts_request = sspi::credssp::TsRequest::from_buffer(&pdu).context("decode CredSSP")?; + let result = { + let mut generator = server.process(ts_request); + resolve_sspi_server(&mut generator) + .await + .map_err(|error| anyhow::anyhow!("mock RDP CredSSP: {error:?}"))? + }; + match result { + sspi::credssp::ServerState::ReplyNeeded(outbound) => { + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode server TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write CredSSP")?; + } + sspi::credssp::ServerState::Finished(_) => return Ok(()), + } + } + anyhow::bail!("mock RDP CredSSP exceeded 6 round trips") +} + +async fn resolve_sspi_server( + generator: &mut sspi::generator::Generator< + '_, + sspi::generator::NetworkRequest, + sspi::Result>, + Result, + >, +) -> Result { + let mut state = generator.start(); + loop { + match state { + GeneratorState::Suspended(request) => { + let reply = send_kdc_tcp(&request) + .await + .map_err(|error| sspi::credssp::ServerError { + ts_request: None, + error: sspi::Error::new(sspi::ErrorKind::NoAuthenticatingAuthority, error), + })?; + state = generator.resume(Ok(reply)); + } + GeneratorState::Completed(result) => break result, + } + } +} + +async fn send_kdc_tcp(request: &sspi::generator::NetworkRequest) -> anyhow::Result> { + let host = request.url.host_str().context("KDC host")?; + let port = request.url.port().unwrap_or(88); + let mut stream = TcpStream::connect((host, port)).await.context("connect mock KDC")?; + stream.write_all(&request.data).await.context("write KDC request")?; + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.context("read KDC length")?; + let len = usize::try_from(u32::from_be_bytes(len_buf)).context("KDC length")?; + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.context("read KDC body")?; + let mut reply = Vec::with_capacity(4 + len); + reply.extend_from_slice(&len_buf); + reply.extend_from_slice(&body); + Ok(reply) +} + +struct IdentityProxy(sspi::AuthIdentity); + +impl sspi::credssp::CredentialsProxy for IdentityProxy { + type AuthenticationData = sspi::AuthIdentity; + + fn auth_data_by_user(&mut self, username: &sspi::Username) -> std::io::Result { + if username.account_name() != self.0.username.account_name() { + return Err(std::io::Error::other("invalid username")); + } + let mut data = self.0.clone(); + data.username = username.clone(); + Ok(data) + } + + fn auth_data(&mut self) -> Result, std::io::Error> { + Ok(vec![self.0.clone()]) + } +} + +async fn connect_ntlm_client( + gateway_tcp: u16, + association_jwt: &str, +) -> anyhow::Result> { + let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) + .await + .context("connect gateway TCP")?; + stream + .write_all(&encode_pcb(association_jwt)?) + .await + .context("write PCB")?; + stream.write_all(&encode_hybrid_cr()?).await.context("write X.224 CR")?; + stream.flush().await.context("flush CR")?; + + let mut framed = TokioFramed::new(stream); + let (_, confirm) = framed.read_pdu().await.context("read X.224 CC")?; + let confirm: X224 = ironrdp_core::decode(&confirm).context("decode X.224 CC")?; + anyhow::ensure!( + matches!(confirm.0, ConnectionConfirm::Response { protocol, .. } if protocol.contains(SecurityProtocol::HYBRID)), + "gateway did not confirm CredSSP: {confirm:?}" + ); + + let tcp = framed.into_inner_no_leftover(); + let connector = dangerous_tls_connector(); + let server_name = ServerName::try_from("localhost").map_err(|error| anyhow::anyhow!("{error}"))?; + connector.connect(server_name, tcp).await.context("TLS to gateway") +} + +async fn complete_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { + use sspi::credssp::{ClientMode, ClientState, CredSspClient, CredSspMode, TsRequest}; + use sspi::ntlm::NtlmConfig; + + let public_key = peer_public_key(&tls)?; + let mut framed = TokioFramed::new(tls); + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(PROXY_USER).context("parse proxy username")?, + password: PROXY_PASSWORD.to_owned().into(), + }; + let mut client = CredSspClient::new( + public_key, + identity.into(), + CredSspMode::WithCredentials, + // Gateway's CredSSP server is Negotiate (Kerberos+NTLM). A raw NTLM client is rejected. + ClientMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(NtlmConfig { + client_computer_name: Some("cred-injection-e2e".to_owned()), + }), + Some("ntlm,!kerberos,!pku2u".to_owned()), + "cred-injection-e2e".to_owned(), + )), + format!("TERMSRV/{SERVICE_HOST}"), + ) + .context("init Negotiate-NTLM CredSSP client")?; + + let mut ts_request = TsRequest::default(); + let mut buf = ironrdp_pdu::WriteBuf::new(); + let hint = TsRequestHint; + + for _ in 0..6 { + let client_state = { + let mut generator = client.process(std::mem::take(&mut ts_request)); + resolve_sspi_client(&mut generator)? + }; + let (outbound, finished) = match client_state { + ClientState::ReplyNeeded(request) => (request, false), + ClientState::FinalMessage(request) => (request, true), + }; + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode client TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write client CredSSP")?; + if finished { + return Ok(()); + } + let pdu = framed.read_by_hint(&hint).await.context("read server CredSSP")?; + ts_request = TsRequest::from_buffer(&pdu).context("decode server TSRequest")?; + } + + anyhow::bail!("CredSSP exceeded 6 round trips") +} + +fn resolve_sspi_client( + generator: &mut sspi::generator::Generator< + '_, + sspi::generator::NetworkRequest, + sspi::Result>, + sspi::Result, + >, +) -> anyhow::Result { + let state = generator.start(); + match state { + GeneratorState::Suspended(request) => { + anyhow::bail!("NTLM CredSSP client issued a network request: {}", request.url); + } + GeneratorState::Completed(result) => result.map_err(|error| anyhow::anyhow!("client CredSSP: {error}")), + } +} + +#[derive(Debug)] +struct TsRequestHint; + +impl ironrdp_pdu::PduHint for TsRequestHint { + fn find_size(&self, bytes: &[u8]) -> ironrdp_core::DecodeResult> { + match sspi::credssp::TsRequest::read_length(bytes) { + Ok(length) => Ok(Some((true, length))), + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(None), + Err(error) => Err(ironrdp_core::other_err!("TsRequestHint", source: error)), + } + } +} + +fn association_token_for_host(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"ASSOCIATION"}), + serde_json::json!({ + "dst_hst": format!("{SERVICE_HOST}:{dest_port}"), + "exp": 9_999_999_999i64, + "jet_aid": jet_aid, + "jet_ap": "rdp", + "jet_cm": "fwd", + "jet_rec": "none", + "jet_reuse": jet_reuse, + "jti": jti, + "nbf": 0, + }), + ) +} + +fn encode_hybrid_cr() -> anyhow::Result> { + let pdu = X224(ConnectionRequest { + nego_data: Some(NegoRequestData::cookie(super::cred_injection::CLIENT_COOKIE.to_owned())), + flags: RequestFlags::empty(), + protocol: SecurityProtocol::HYBRID | SecurityProtocol::SSL, + }); + ironrdp_core::encode_vec(&pdu).context("encode hybrid CR") +} + +fn peer_public_key(tls: &tokio_rustls::client::TlsStream) -> anyhow::Result> { + let cert = tls + .get_ref() + .1 + .peer_certificates() + .and_then(|certs| certs.first()) + .context("gateway TLS certificate missing")?; + extract_public_key(cert) +} + +fn server_public_key() -> anyhow::Result> { + let cert = CertificateDer::from_pem_slice(CERT_PEM.as_bytes()).context("parse mock RDP cert")?; + extract_public_key(&cert) +} + +fn extract_public_key(cert: &CertificateDer<'_>) -> anyhow::Result> { + let cert = x509_cert::Certificate::from_der(cert.as_ref()).context("parse X509")?; + let public_key = cert + .tbs_certificate() + .subject_public_key_info() + .subject_public_key + .as_bytes() + .context("unaligned subject public key")? + .to_owned(); + Ok(public_key) +} + +fn tls_acceptor() -> anyhow::Result { + let cert = CertificateDer::from_pem_slice(CERT_PEM.as_bytes()).context("parse cert PEM")?; + let key = PrivateKeyDer::from_pem_slice(KEY_PEM.as_bytes()).context("parse key PEM")?; + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert], key) + .context("TLS server config")?; + Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config))) +} + +fn dangerous_tls_connector() -> tokio_rustls::TlsConnector { + let mut config = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) + .with_no_client_auth(); + config.resumption = tokio_rustls::rustls::client::Resumption::disabled(); + tokio_rustls::TlsConnector::from(Arc::new(config)) +} + +fn install_crypto_provider() { + let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default(); +} + +#[derive(Debug)] +struct NoCertificateVerification; + +impl tokio_rustls::rustls::client::danger::ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _: &CertificateDer<'_>, + _: &[CertificateDer<'_>], + _: &ServerName<'_>, + _: &[u8], + _: tokio_rustls::rustls::pki_types::UnixTime, + ) -> Result { + Ok(tokio_rustls::rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &tokio_rustls::rustls::DigitallySignedStruct, + ) -> Result { + Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &tokio_rustls::rustls::DigitallySignedStruct, + ) -> Result { + Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA256, + tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA256, + tokio_rustls::rustls::SignatureScheme::ED25519, + ] + } +} + +#[tokio::test] +async fn kerberos_injection_completes_credssp_against_mock_kdc() -> anyhow::Result<()> { + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start(kdc.url()).await?; + let mut gateway = GatewayProc::start(true).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some(&kdc.url()), + ) + .await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + complete_ntlm_credssp(tls) + .await + .context("Gateway-facing NTLM CredSSP")?; + rdp.wait_credssp() + .await + .with_context(|| format!("gateway logs:\n{}", gateway.logs.snapshot()))?; + anyhow::ensure!( + kdc.exchanges() >= 2, + "expected AS-REQ and TGS-REQ against the mock KDC; exchanges={}; gateway logs:\n{}", + kdc.exchanges(), + gateway.logs.snapshot() + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- +MIIDCzCCAfOgAwIBAgIUPRJa8i280unV3/kW6TE2fSUw8PwwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTEyNTA5NDAzMFoYDzIxMjUx +MTAxMDk0MDMwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDHpBlyRgUx/V9cQGw/eqDFc6odxB2hvnbudi67LvEj +cNIWOU79R1e/NswME4oecqT9W05n4UyxkABfm2qjODO0nDf47W0DsgbEA87qE715 +RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgWa8rhPc7E6In1uDRak0lWKQPQSBbc +34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEvXp+g9WsuMaU913ecacWDjyWDkf80 +pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsgUbK+TMWvkF9bcxAQDnJsrZRL7Jfh +ofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1jrU3cPA7dAgMBAAGjUzBRMB0GA1Ud +DgQWBBTT+m6dyc/c3mXF3JAsZr9OqUwgWTAfBgNVHSMEGDAWgBTT+m6dyc/c3mXF +3JAsZr9OqUwgWTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBB +i/yonZY3ztaeGElzD8xkI+rJ+daJ5WzdfKnzudJllg/Ht8m7wO5SdQnMt2T44gbH +05uekc1zXnXb7fJKqs3R6DacctG0nQ3acuI+IMtTaBbbAcf3PJJlo0Pap0ypVC0R +IUiUhJGFNi4cCBOvJqsly0d3T5xqOXU1Q5j3mIwRBY68+m9btwwuZWvASRADtCyZ +RpisBzS4a6jSeHXa4iG/VhskbiZkcnfHNTw7yNJJdv125y2zQkWWF9wlLbYwWr40 +x9Ba6YbssOz6epATKhvt80yclO34AzUyimssvViIUpgFEyaPhZZTw46Q/6X3ixK4 +/v4eYM0cCHN0h+rynSor +-----END CERTIFICATE-----"#; + +const KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHpBlyRgUx/V9c +QGw/eqDFc6odxB2hvnbudi67LvEjcNIWOU79R1e/NswME4oecqT9W05n4UyxkABf +m2qjODO0nDf47W0DsgbEA87qE715RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgW +a8rhPc7E6In1uDRak0lWKQPQSBbc34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEv +Xp+g9WsuMaU913ecacWDjyWDkf80pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsg +UbK+TMWvkF9bcxAQDnJsrZRL7JfhofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1j +rU3cPA7dAgMBAAECggEAKh7KK5zwTaq6atlAvWfe8anEk4EkC1MG/qq6k02FHMgZ +2wx+SNu7fKFQDaA1vNTNUJLqCOq05qWOHp3IsuURq6JmAMP/Aw+Vc9el2ScPC74E +Dt09MmlZKl77H3fxPYwoFx5RHrbIuvoSH/DgHgOPU2YIbWpOyWlXyLDgmBoNkM3N +fXYLXJONpStPHeQLhh7LcHO3CZgn6kycJyByEO2NtcchS5zITiJuwL+qR5/QIlvD +Yo7jdCjelJat38MZ9dE1us8xlIjQtsYF/acZZtcpYho+7ZpDCNcb+xF8KStKei+B +MMpWISsa+Zh9g7lPYTnG/i1dSMMT100XCEw8o4rBoQKBgQDnptz8acp7DB2wJH4L +c0xuw8IlrSl3BGUEj8H+RyFlpH3+//i6/fE9MrtF8b4FSYUp5AG4NVFGcRbwJVGW +jeL13YwIKMdXjmx8fDIylCgBB1tzBS9T/0ws3HS8avxhKvjgoXIZm6D3XDcBslrH +c9/LojT8YGI1wx7jWI2qKj8yeQKBgQDcn+kQ1QjzgIz6bAVWY3t1jr5uHHyaS+5G +ihY/mx4Mn3DURgPXZHz/HrN9rZkax0zuq9wuIlqgZ2KI37iCF49M4aZxC788LyDo +Hp0Cak3wt3g0Tj6J7SJiQe8h/6VBS4R5dRD2vhEc3xPAOf7WIFdlLYBOOvE/LmOt +N6ChkfgGhQKBgQDSiDqLRPJ7BjXtIh1T9sPeXxeR+mCXBG1yydx7ZtYZdHf2S1kZ +STX4cqT1GpGiaIEX41sUuZBWPu2j76bI98bvwRxFRhp1nsFGGfHdOf1pgfBBBtNO +udXXZ7zIiUs6XD24mcIDOAgBB9QOPLR4VP1uKsuRG1/mkKD/6jlGEANDsQKBgQDC +AoEygxQnBVFz2c/rwvnLS+Zb8AMGsGTtdPrRnjeThBX1JUi1fbGJq1bN2v27Fa2q +aEjr7NvjGGcG1C1tgQhL5Fa4LEtTwmHenSUW/aJiXwR+gpvuMDC/VRnTvPp2a9En ++XEcedGUoPq+XIGjjLctyxB8Osrw83tF1JgV3MXN/QKBgQC83B54rYDd4QmVH5nL +WLw834fgr+Z1hA6UqJIaahlD/bDwzbbJEv0pHCBxe01ywQFivqWBdVbuoy9YSeLS +KKEklzh+L0SorrYoBA5F63qx0zy05bba0ASplgDUEUNZn7oIFi7x5pVsNNaNxZpR +bQGM8UrNQvWQ+tutRmp7PM6VuQ== +-----END PRIVATE KEY-----"#; diff --git a/testsuite/tests/cli/dgw/mod.rs b/testsuite/tests/cli/dgw/mod.rs index f6e88737a..acb5a50a6 100644 --- a/testsuite/tests/cli/dgw/mod.rs +++ b/testsuite/tests/cli/dgw/mod.rs @@ -1,6 +1,7 @@ mod benign_disconnect; mod cli_args; mod cred_injection; +mod cred_injection_kdc; mod heartbeat; mod preflight; mod tls_anchoring; From 6667a0ac6a7386b76334ede374cdf2bee036a742 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 25 Aug 2026 12:27:21 -0400 Subject: [PATCH 21/36] feat(dgw): enable Kerberos injection without debug flags Kerberos credential injection no longer requires __debug__.enable_unstable or kerberos_credential_injection. Those keys still parse so existing configs keep loading. Issue: DVLS-14697 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config_schema.json | 2 +- devolutions-gateway/src/api/kdc_proxy.rs | 25 ------------------- devolutions-gateway/src/config.rs | 7 +----- .../src/credential_injection.rs | 14 +---------- devolutions-gateway/src/generic_client.rs | 24 ++++++------------ devolutions-gateway/src/rd_clean_path.rs | 15 +++-------- 6 files changed, 13 insertions(+), 74 deletions(-) diff --git a/config_schema.json b/config_schema.json index 0ffad23f4..14f98cad8 100644 --- a/config_schema.json +++ b/config_schema.json @@ -540,7 +540,7 @@ "kerberos_credential_injection": { "type": "boolean", "default": false, - "description": "Whether to enable proxy-based RDP credential injection against Kerberos-enforced targets." + "description": "Ignored. Kerberos credential injection is always available when provisioned." }, "enable_unstable": { "type": "boolean", diff --git a/devolutions-gateway/src/api/kdc_proxy.rs b/devolutions-gateway/src/api/kdc_proxy.rs index f90eee2ae..5b3c07df0 100644 --- a/devolutions-gateway/src/api/kdc_proxy.rs +++ b/devolutions-gateway/src/api/kdc_proxy.rs @@ -2,7 +2,6 @@ use axum::Router; use axum::extract::State; use axum::routing::post; use picky_krb::messages::KdcProxyMessage; -use uuid::Uuid; use crate::DgwState; use crate::credential_injection::{ @@ -44,8 +43,6 @@ async fn kdc_proxy( match destination { KdcDestination::Inject { jti } => { - enforce_credential_injection_enabled(jti, conf.debug.enable_unstable)?; - let kdc = synthetic_kdc_registry .get(jti) .ok_or_else(|| HttpError::bad_request().msg("no live synthetic KDC published for this session"))?; @@ -132,18 +129,6 @@ async fn forward_to_real_kdc( reply.to_vec().map_err(HttpError::internal().err()) } -fn enforce_credential_injection_enabled(jet_cred_id: Uuid, enable_unstable: bool) -> Result<(), HttpError> { - if enable_unstable { - return Ok(()); - } - - warn!( - %jet_cred_id, - "Credential-injection KDC token rejected because unstable Kerberos injection is disabled" - ); - Err(HttpError::bad_request().msg("credential-injection KDC proxy is not enabled")) -} - /// Refuses to forward a KDC request whose realm disagrees with the realm the token was issued for. /// /// `bypass=true` (only when `__debug__.disable_token_validation` is on) downgrades the mismatch @@ -188,14 +173,4 @@ mod tests { // explicitly to catch an inverted gate. assert!(enforce_realm_token_match("ad.example", "evil.example", true).is_ok()); } - - #[test] - fn credential_injection_gate_allows_jet_cred_id_when_enabled() { - assert!(enforce_credential_injection_enabled(Uuid::new_v4(), true).is_ok()); - } - - #[test] - fn credential_injection_gate_rejects_jet_cred_id_when_disabled() { - assert!(enforce_credential_injection_enabled(Uuid::new_v4(), false).is_err()); - } } diff --git a/devolutions-gateway/src/config.rs b/devolutions-gateway/src/config.rs index f5d0de0a1..7faae0212 100644 --- a/devolutions-gateway/src/config.rs +++ b/devolutions-gateway/src/config.rs @@ -1418,12 +1418,7 @@ pub mod dto { #[serde(default = "ws_keep_alive_interval_default_value")] pub ws_keep_alive_interval: u64, - /// Enable proxy-based RDP credential injection against Kerberos-enforced targets - /// - /// 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`. + /// Ignored. Kerberos credential injection is stable and no longer gated here. #[serde(default)] pub kerberos_credential_injection: bool, diff --git a/devolutions-gateway/src/credential_injection.rs b/devolutions-gateway/src/credential_injection.rs index 06e2b04b9..1b3f8deb2 100644 --- a/devolutions-gateway/src/credential_injection.rs +++ b/devolutions-gateway/src/credential_injection.rs @@ -294,11 +294,7 @@ impl CredentialInjection { } /// Unstable debug opt-in for Kerberos credential injection (both legs). -pub(crate) fn kerberos_injection_opt_in(enable_unstable: bool, kerberos_credential_injection: bool) -> bool { - enable_unstable && kerberos_credential_injection -} - -/// Whether target username + opt-in select Kerberos injection (otherwise NTLM). +/// Whether the target username should use Kerberos injection (otherwise NTLM). pub(crate) fn select_kerberos_for_target(kerberos_enabled: bool, target_username: &str) -> bool { if !kerberos_enabled { return false; @@ -926,14 +922,6 @@ mod tests { } } - #[test] - fn kerberos_injection_opt_in_requires_both_flags() { - assert!(!kerberos_injection_opt_in(false, false)); - assert!(!kerberos_injection_opt_in(false, true)); - assert!(!kerberos_injection_opt_in(true, false)); - assert!(kerberos_injection_opt_in(true, true)); - } - #[test] fn select_kerberos_for_target_matrix() { assert!(!select_kerberos_for_target(false, "user@CORP.EXAMPLE")); diff --git a/devolutions-gateway/src/generic_client.rs b/devolutions-gateway/src/generic_client.rs index a96ff04ee..752897114 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -129,24 +129,14 @@ where // Checkout before dialing so missing Kerberos material cannot open an upstream socket. let credential_injection = if inject { - let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in( - conf.debug.enable_unstable, - conf.debug.kerberos_credential_injection, - ); Some( - CredentialInjection::checkout( - &provisioning, - &synthetic_kdc_registry, - claims.jti, - token, - kerberos_enabled, - ) - .with_context(|| { - format!( - "credential-injection material for {} is missing or expired; re-provision to retry", - claims.jti - ) - })?, + CredentialInjection::checkout(&provisioning, &synthetic_kdc_registry, claims.jti, token, true) + .with_context(|| { + format!( + "credential-injection material for {} is missing or expired; re-provision to retry", + claims.jti + ) + })?, ) } else { None diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index 9193c730a..e0e4eba44 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -471,18 +471,9 @@ async fn handle_with_credential_injection( .clone() .context("missing token in RDCleanPath PDU")?; - let kerberos_enabled = crate::credential_injection::kerberos_injection_opt_in( - conf.debug.enable_unstable, - conf.debug.kerberos_credential_injection, - ); - let credential_injection = CredentialInjection::checkout( - provisioning, - synthetic_kdc_registry, - claims.jti, - &token, - kerberos_enabled, - ) - .context("checkout credential-injection material before connecting upstream")?; + let credential_injection = + CredentialInjection::checkout(provisioning, synthetic_kdc_registry, claims.jti, &token, true) + .context("checkout credential-injection material before connecting upstream")?; let ConnectedRdpServer { tls_stream: server_stream, From 0517ecacd4a305e380041c3e1f89fd6267d5c8e4 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Thu, 20 Aug 2026 18:02:50 -0400 Subject: [PATCH 22/36] test(dgw): cover Kerberos client leg and fail-closed paths Prove both CredSSP hops against mock KDC/RDP, NTLM CredSSP both legs, and Kerberos fail-closed when the password, KDC, or krb_kdc is wrong. Token-cache jet_reuse still needs signed JWTs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testsuite/tests/cli/dgw/cred_injection.rs | 86 +++- testsuite/tests/cli/dgw/cred_injection_kdc.rs | 446 +++++++++++++++++- 2 files changed, 504 insertions(+), 28 deletions(-) diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs index 11fafd805..6c5d236d4 100644 --- a/testsuite/tests/cli/dgw/cred_injection.rs +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -25,8 +25,9 @@ pub(crate) const PROXY_PASSWORD: &str = "proxy-secret"; pub(crate) const TARGET_PASSWORD: &str = "target-secret"; pub(crate) const KERBEROS_TARGET_USER: &str = "administrator@example.invalid"; pub(crate) const INJECT_LOG: &str = "RDP-TLS forwarding with credential injection"; -const FORWARD_LOG: &str = "Upstream forwarding"; -const MISSING_LOG: &str = "missing or expired; re-provision to retry"; +pub(crate) const FORWARD_LOG: &str = "Upstream forwarding"; +pub(crate) const MISSING_LOG: &str = "missing or expired; re-provision to retry"; +pub(crate) const PROXY_KERBEROS_USER: &str = "injected-proxy-user@example.invalid"; const PUBLISHED_KDC_LOG: &str = "Published synthetic KDC"; const REGISTERED_KDC_LOG: &str = "Registered synthetic KDC for credential-injection session"; @@ -346,6 +347,27 @@ pub(crate) async fn provision_credentials( target_username: &str, time_to_live: u32, krb_kdc: Option<&str>, +) -> anyhow::Result<()> { + provision_mapping( + http_port, + token, + PROXY_USER, + target_username, + TARGET_PASSWORD, + time_to_live, + krb_kdc, + ) + .await +} + +pub(crate) async fn provision_mapping( + http_port: u16, + token: &str, + proxy_username: &str, + target_username: &str, + target_password: &str, + time_to_live: u32, + krb_kdc: Option<&str>, ) -> anyhow::Result<()> { let mut operations = vec![serde_json::json!({ "id": next_id(), @@ -353,13 +375,13 @@ pub(crate) async fn provision_credentials( "token": token, "proxy_credential": { "kind": "username-password", - "username": PROXY_USER, + "username": proxy_username, "password": PROXY_PASSWORD }, "target_credential": { "kind": "username-password", "username": target_username, - "password": TARGET_PASSWORD + "password": target_password }, "time_to_live": time_to_live })]; @@ -611,3 +633,59 @@ async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Res let _ = gateway.process.start_kill(); Ok(()) } + +#[tokio::test] +async fn domainless_target_stays_ntlm_even_with_krb_kdc() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start(true).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + assert!( + logs.contains("kerberos=false"), + "username without a realm must stay NTLM even if krb_kdc is provisioned; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_opt_out_uses_ntlm_for_domain_user() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start(false).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + assert!( + logs.contains("kerberos=false"), + "Kerberos injection opt-out must NTLM even with a domain username; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index 38846da56..364097ca0 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -17,7 +17,7 @@ use ironrdp_pdu::nego::{ use ironrdp_pdu::x224::X224; use ironrdp_tokio::{FramedWrite as _, TokioFramed}; use picky_krb::messages::KdcProxyMessage; -use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio_rustls::rustls::pki_types::pem::PemObject as _; use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; @@ -25,8 +25,9 @@ use tokio_rustls::rustls::{ClientConfig, ServerConfig}; use x509_cert::der::Decode as _; use super::cred_injection::{ - GatewayProc, KERBEROS_TARGET_USER, PROXY_PASSWORD, PROXY_USER, TARGET_PASSWORD, encode_pcb, next_id, - provision_credentials, unsigned_jws, + FORWARD_LOG, GatewayProc, INJECT_LOG, KERBEROS_TARGET_USER, MISSING_LOG, PROXY_KERBEROS_USER, PROXY_PASSWORD, + PROXY_USER, TARGET_PASSWORD, TARGET_USER, encode_pcb, next_id, provision_credentials, provision_mapping, + unsigned_jws, }; const REALM: &str = "EXAMPLE.INVALID"; @@ -114,13 +115,27 @@ async fn serve_kdc_exchange(mut stream: TcpStream, config: &kdc::config::Kerbero Ok(()) } +#[derive(Clone)] +enum MockRdpMode { + Kerberos { kdc_url: String }, + Ntlm, +} + struct MockRdp { port: u16, credssp_ok: Arc, } impl MockRdp { - async fn start(kdc_url: String) -> anyhow::Result { + async fn start_kerberos(kdc_url: String) -> anyhow::Result { + Self::start(MockRdpMode::Kerberos { kdc_url }).await + } + + async fn start_ntlm() -> anyhow::Result { + Self::start(MockRdpMode::Ntlm).await + } + + async fn start(mode: MockRdpMode) -> anyhow::Result { install_crypto_provider(); // Dual-stack so Windows `localhost` (IPv6 first) still hits the fake server. let listener = match TcpListener::bind("[::]:0").await { @@ -141,9 +156,15 @@ impl MockRdp { let acceptor = acceptor.clone(); let public_key = public_key.clone(); let credssp_ok = Arc::clone(&credssp_ok_task); - let kdc_url = kdc_url.clone(); + let mode = mode.clone(); tokio::spawn(async move { - match accept_kerberos_rdp(stream, peer, acceptor, public_key, &kdc_url).await { + let result = match &mode { + MockRdpMode::Kerberos { kdc_url } => { + accept_kerberos_rdp(stream, peer, acceptor, public_key, kdc_url).await + } + MockRdpMode::Ntlm => accept_ntlm_rdp(stream, peer, acceptor, public_key).await, + }; + match result { Ok(()) => credssp_ok.store(true, Ordering::SeqCst), Err(error) => eprintln!("mock RDP CredSSP failed: {error:#}"), } @@ -254,6 +275,69 @@ async fn accept_kerberos_rdp( anyhow::bail!("mock RDP CredSSP exceeded 6 round trips") } +async fn accept_ntlm_rdp( + stream: TcpStream, + peer: std::net::SocketAddr, + acceptor: tokio_rustls::TlsAcceptor, + public_key: Vec, +) -> anyhow::Result<()> { + let mut framed = TokioFramed::new(stream); + let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; + let _: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + + let confirm = X224(ConnectionConfirm::Response { + flags: ResponseFlags::empty(), + protocol: SecurityProtocol::HYBRID, + }); + framed + .write_all(&ironrdp_core::encode_vec(&confirm).context("encode X.224 CC")?) + .await + .context("write X.224 CC")?; + + let tcp = framed.into_inner_no_leftover(); + let tls = acceptor.accept(tcp).await.context("TLS accept")?; + let mut framed = TokioFramed::new(tls); + + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(TARGET_USER).context("parse NTLM target username")?, + password: TARGET_PASSWORD.to_owned().into(), + }; + let mut server = sspi::credssp::CredSspServer::new( + public_key, + IdentityProxy(identity), + sspi::credssp::ServerMode::Ntlm(sspi::ntlm::NtlmConfig { + client_computer_name: Some(peer.to_string()), + }), + ) + .context("init NTLM CredSSP server")?; + + let hint = TsRequestHint; + let mut buf = ironrdp_pdu::WriteBuf::new(); + for _ in 0..6 { + let pdu = framed.read_by_hint(&hint).await.context("read CredSSP TSRequest")?; + let ts_request = sspi::credssp::TsRequest::from_buffer(&pdu).context("decode CredSSP")?; + let result = { + let mut generator = server.process(ts_request); + resolve_sspi_server(&mut generator) + .await + .map_err(|error| anyhow::anyhow!("mock RDP NTLM CredSSP: {error:?}"))? + }; + match result { + sspi::credssp::ServerState::ReplyNeeded(outbound) => { + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode server TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write CredSSP")?; + } + sspi::credssp::ServerState::Finished(_) => return Ok(()), + } + } + anyhow::bail!("mock RDP NTLM CredSSP exceeded 6 round trips") +} + async fn resolve_sspi_server( generator: &mut sspi::generator::Generator< '_, @@ -343,39 +427,68 @@ async fn connect_ntlm_client( } async fn complete_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { + complete_client_credssp(tls, PROXY_USER, None, false).await +} + +async fn complete_raw_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { + complete_client_credssp(tls, PROXY_USER, None, true).await +} + +async fn complete_client_credssp( + tls: tokio_rustls::client::TlsStream, + username: &str, + kdc_proxy_url: Option<&str>, + raw_ntlm: bool, +) -> anyhow::Result<()> { use sspi::credssp::{ClientMode, ClientState, CredSspClient, CredSspMode, TsRequest}; use sspi::ntlm::NtlmConfig; let public_key = peer_public_key(&tls)?; let mut framed = TokioFramed::new(tls); let identity = sspi::AuthIdentity { - username: sspi::Username::parse(PROXY_USER).context("parse proxy username")?, + username: sspi::Username::parse(username).context("parse client username")?, password: PROXY_PASSWORD.to_owned().into(), }; - let mut client = CredSspClient::new( - public_key, - identity.into(), - CredSspMode::WithCredentials, - // Gateway's CredSSP server is Negotiate (Kerberos+NTLM). A raw NTLM client is rejected. + let client_mode = if let Some(kdc_url) = kdc_proxy_url { + ClientMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(sspi::KerberosConfig { + kdc_url: Some(kdc_url.parse().context("parse KDC proxy URL")?), + client_computer_name: "cred-injection-e2e".to_owned(), + }), + Some("kerberos,!ntlm".to_owned()), + "cred-injection-e2e".to_owned(), + )) + } else if raw_ntlm { + // Gateway NTLM injection uses ServerMode::Ntlm, which rejects SPNEGO. + ClientMode::Ntlm(NtlmConfig { + client_computer_name: Some("cred-injection-e2e".to_owned()), + }) + } else { ClientMode::Negotiate(sspi::NegotiateConfig::new( Box::new(NtlmConfig { client_computer_name: Some("cred-injection-e2e".to_owned()), }), Some("ntlm,!kerberos,!pku2u".to_owned()), "cred-injection-e2e".to_owned(), - )), + )) + }; + let mut client = CredSspClient::new( + public_key, + identity.into(), + CredSspMode::WithCredentials, + client_mode, format!("TERMSRV/{SERVICE_HOST}"), ) - .context("init Negotiate-NTLM CredSSP client")?; + .context("init CredSSP client")?; let mut ts_request = TsRequest::default(); let mut buf = ironrdp_pdu::WriteBuf::new(); let hint = TsRequestHint; - for _ in 0..6 { + for _ in 0..8 { let client_state = { let mut generator = client.process(std::mem::take(&mut ts_request)); - resolve_sspi_client(&mut generator)? + resolve_sspi_client(&mut generator).await? }; let (outbound, finished) = match client_state { ClientState::ReplyNeeded(request) => (request, false), @@ -395,10 +508,10 @@ async fn complete_ntlm_credssp(tls: tokio_rustls::client::TlsStream) ts_request = TsRequest::from_buffer(&pdu).context("decode server TSRequest")?; } - anyhow::bail!("CredSSP exceeded 6 round trips") + anyhow::bail!("CredSSP exceeded 8 round trips") } -fn resolve_sspi_client( +async fn resolve_sspi_client( generator: &mut sspi::generator::Generator< '_, sspi::generator::NetworkRequest, @@ -406,15 +519,104 @@ fn resolve_sspi_client( sspi::Result, >, ) -> anyhow::Result { - let state = generator.start(); - match state { - GeneratorState::Suspended(request) => { - anyhow::bail!("NTLM CredSSP client issued a network request: {}", request.url); + let mut state = generator.start(); + loop { + match state { + GeneratorState::Suspended(request) => { + let reply = match request.url.scheme() { + "tcp" | "udp" => send_kdc_tcp(&request).await?, + "http" | "https" => send_kdc_http(&request).await?, + other => anyhow::bail!("unsupported KDC scheme {other}: {}", request.url), + }; + state = generator.resume(Ok(reply)); + } + GeneratorState::Completed(result) => { + break result.map_err(|error| anyhow::anyhow!("client CredSSP: {error}")); + } } - GeneratorState::Completed(result) => result.map_err(|error| anyhow::anyhow!("client CredSSP: {error}")), } } +async fn send_kdc_http(request: &sspi::generator::NetworkRequest) -> anyhow::Result> { + let host = request.url.host_str().context("KDC proxy host")?; + let port = request.url.port_or_known_default().unwrap_or(80); + let path = if request.url.query().is_some() { + format!("{}?{}", request.url.path(), request.url.query().unwrap_or_default()) + } else { + request.url.path().to_owned() + }; + let mut stream = TcpStream::connect((host, port)).await.context("connect KDC proxy")?; + let header = format!( + "POST {path} HTTP/1.1\r\n\ + Host: {host}:{port}\r\n\ + Content-Type: application/octet-stream\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + request.data.len() + ); + stream + .write_all(header.as_bytes()) + .await + .context("write KDC proxy headers")?; + stream.write_all(&request.data).await.context("write KDC proxy body")?; + stream.flush().await.context("flush KDC proxy")?; + + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + reader + .read_line(&mut status_line) + .await + .context("read KDC proxy status")?; + anyhow::ensure!(status_line.contains("200"), "KDC proxy HTTP status was {status_line:?}"); + + let mut content_length = None; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.context("read KDC proxy header")?; + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().to_owned()) + { + content_length = Some(value.parse::().context("parse KDC proxy Content-Length")?); + } + } + + if let Some(len) = content_length { + let mut buf = vec![0u8; len]; + tokio::io::AsyncReadExt::read_exact(&mut reader, &mut buf) + .await + .context("read KDC proxy body")?; + Ok(buf) + } else { + let mut buf = Vec::new(); + tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buf) + .await + .context("read KDC proxy eof body")?; + Ok(buf) + } +} + +fn kdc_inject_token(association_jti: &str) -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"KDC"}), + serde_json::json!({ + "exp": 9_999_999_999i64, + "jet_cred_id": association_jti, + "jti": next_id(), + }), + ) +} + +fn kdc_proxy_url(http_port: u16, association_jti: &str) -> anyhow::Result { + let token = kdc_inject_token(association_jti)?; + Ok(format!("http://127.0.0.1:{http_port}/jet/KdcProxy/{token}")) +} + #[derive(Debug)] struct TsRequestHint; @@ -551,7 +753,7 @@ impl tokio_rustls::rustls::client::danger::ServerCertVerifier for NoCertificateV async fn kerberos_injection_completes_credssp_against_mock_kdc() -> anyhow::Result<()> { install_crypto_provider(); let kdc = MockKdc::start().await?; - let rdp = MockRdp::start(kdc.url()).await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; let mut gateway = GatewayProc::start(true).await?; let jti = next_id(); @@ -584,6 +786,202 @@ async fn kerberos_injection_completes_credssp_against_mock_kdc() -> anyhow::Resu Ok(()) } +#[tokio::test] +async fn kerberos_client_and_target_legs_complete_credssp() -> anyhow::Result<()> { + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; + let mut gateway = GatewayProc::start(true).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_mapping( + gateway.config.http_port(), + &token, + PROXY_KERBEROS_USER, + KERBEROS_TARGET_USER, + TARGET_PASSWORD, + 300, + Some(&kdc.url()), + ) + .await?; + + let kdc_proxy = kdc_proxy_url(gateway.config.http_port(), &jti)?; + let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + complete_client_credssp(tls, PROXY_KERBEROS_USER, Some(&kdc_proxy), false) + .await + .with_context(|| { + format!( + "client-leg Kerberos CredSSP; gateway logs:\n{}", + gateway.logs.snapshot() + ) + })?; + rdp.wait_credssp().await.with_context(|| { + format!( + "target-leg Kerberos CredSSP; gateway logs:\n{}", + gateway.logs.snapshot() + ) + })?; + anyhow::ensure!( + kdc.exchanges() >= 2, + "target-leg must talk to the mock KDC; exchanges={}; logs:\n{}", + kdc.exchanges(), + gateway.logs.snapshot() + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; + let mut gateway = GatewayProc::start(true).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_mapping( + gateway.config.http_port(), + &token, + PROXY_USER, + KERBEROS_TARGET_USER, + "wrong-target-password", + 300, + Some(&kdc.url()), + ) + .await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + let _ = complete_ntlm_credssp(tls).await; + tokio::time::sleep(Duration::from_secs(2)).await; + anyhow::ensure!( + !rdp.credssp_ok(), + "wrong target password must not complete Kerberos CredSSP; logs:\n{}", + gateway.logs.snapshot() + ); + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + !logs.contains(FORWARD_LOG), + "wrong password must not ordinary-forward; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { + install_crypto_provider(); + let rdp = MockRdp::start_kerberos("tcp://127.0.0.1:1".to_owned()).await?; + let mut gateway = GatewayProc::start(true).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some("tcp://127.0.0.1:1"), + ) + .await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + let _ = complete_ntlm_credssp(tls).await; + tokio::time::sleep(Duration::from_secs(2)).await; + anyhow::ensure!( + !rdp.credssp_ok(), + "unreachable KDC must not complete CredSSP; logs:\n{}", + gateway.logs.snapshot() + ); + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + !logs.contains(FORWARD_LOG), + "KDC down must not ordinary-forward; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_missing_krb_kdc_fails_closed() -> anyhow::Result<()> { + let rdp = FakeClosedTarget::start().await?; + let mut gateway = GatewayProc::start(true).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, KERBEROS_TARGET_USER, 300, None).await?; + + let mut stream = TcpStream::connect(("127.0.0.1", gateway.config.tcp_port())) + .await + .context("connect gateway TCP")?; + stream.write_all(&encode_pcb(&token)?).await.context("write PCB")?; + stream.write_all(&encode_hybrid_cr()?).await.context("write CR")?; + stream.flush().await.context("flush CR")?; + let logs = gateway.logs.wait_contains(MISSING_LOG).await?; + anyhow::ensure!( + !logs.contains(FORWARD_LOG), + "missing krb_kdc must fail closed; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn ntlm_injection_completes_credssp_both_legs() -> anyhow::Result<()> { + install_crypto_provider(); + let rdp = MockRdp::start_ntlm().await?; + let mut gateway = GatewayProc::start(false).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + complete_raw_ntlm_credssp(tls) + .await + .with_context(|| format!("client-leg NTLM CredSSP; logs:\n{}", gateway.logs.snapshot()))?; + rdp.wait_credssp() + .await + .with_context(|| format!("target-leg NTLM CredSSP; logs:\n{}", gateway.logs.snapshot()))?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + anyhow::ensure!( + logs.contains("kerberos=false"), + "expected NTLM injection; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +struct FakeClosedTarget { + port: u16, +} + +impl FakeClosedTarget { + async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind closed target")?; + let port = listener.local_addr()?.port(); + tokio::spawn(async move { + loop { + let Ok((_stream, _)) = listener.accept().await else { + break; + }; + } + }); + Ok(Self { port }) + } +} + const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- MIIDCzCCAfOgAwIBAgIUPRJa8i280unV3/kW6TE2fSUw8PwwDQYJKoZIhvcNAQEL BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTEyNTA5NDAzMFoYDzIxMjUx From 9809185f69ec09c2df3c3726d2fc4a1cce5ae54f Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 21 Aug 2026 11:22:43 -0400 Subject: [PATCH 23/36] test(dgw): assert KDC principals and CredSSP identities Decode AS-REQ/TGS-REQ on the mock KDC (cname, realm, TERMSRV/localhost), record CredSSP Finished account names and X.224 cookies, and require /jet/KdcProxy AS-REP plus TGS-REP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + testsuite/Cargo.toml | 1 + testsuite/tests/cli/dgw/cred_injection_kdc.rs | 253 ++++++++++++++++-- 3 files changed, 230 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3f239ccc..758047ea8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7526,6 +7526,7 @@ dependencies = [ "mcp-proxy", "network-scanner", "network-scanner-proto", + "picky-asn1-der", "picky-krb", "proxy-socks", "rstest", diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 5c023c919..5c6614577 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -38,6 +38,7 @@ ironrdp-core = { version = "0.2", features = ["std"] } ironrdp-pdu = { version = "0.9", features = ["std"] } ironrdp-tokio = "0.10" kdc = "0.1" +picky-asn1-der = "0.5" picky-krb = "0.12" proxy-socks = { path = "../crates/proxy-socks" } libsql = { version = "0.9", default-features = false, features = ["core"] } diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index 364097ca0..e1c80d1be 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -4,8 +4,8 @@ //! sspi-rs) and completes CredSSP with a fake RDP acceptor. The Gateway-facing client uses //! NTLM so the test does not depend on the in-process synthetic KDC. -use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use anyhow::Context as _; @@ -16,7 +16,8 @@ use ironrdp_pdu::nego::{ }; use ironrdp_pdu::x224::X224; use ironrdp_tokio::{FramedWrite as _, TokioFramed}; -use picky_krb::messages::KdcProxyMessage; +use picky_krb::data_types::PrincipalName; +use picky_krb::messages::{AsRep, AsReq, KdcProxyMessage, KrbError, TgsRep, TgsReq}; use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio_rustls::rustls::pki_types::pem::PemObject as _; @@ -36,9 +37,17 @@ const SERVICE_HOST: &str = "localhost"; const KRBTGT_KEY: [u8; 32] = [0x11; 32]; const TERMSRV_KEY: [u8; 32] = [0x22; 32]; +#[derive(Clone, Debug, PartialEq, Eq)] +enum ObservedKdcReq { + As { cname: String, realm: String }, + Tgs { sname: Vec, realm: String }, + Other, +} + struct MockKdc { port: u16, exchanges: Arc, + requests: Arc>>, } impl MockKdc { @@ -46,7 +55,9 @@ impl MockKdc { let listener = TcpListener::bind("127.0.0.1:0").await.context("bind mock KDC")?; let port = listener.local_addr().context("mock KDC local_addr")?.port(); let exchanges = Arc::new(AtomicUsize::new(0)); + let requests = Arc::new(Mutex::new(Vec::new())); let exchanges_task = Arc::clone(&exchanges); + let requests_task = Arc::clone(&requests); let config = kdc_config(); tokio::spawn(async move { @@ -56,8 +67,9 @@ impl MockKdc { }; let config = config.clone(); let exchanges = Arc::clone(&exchanges_task); + let requests = Arc::clone(&requests_task); tokio::spawn(async move { - match serve_kdc_exchange(stream, &config).await { + match serve_kdc_exchange(stream, &config, &requests).await { Ok(()) => { exchanges.fetch_add(1, Ordering::SeqCst); } @@ -67,7 +79,11 @@ impl MockKdc { } }); - Ok(Self { port, exchanges }) + Ok(Self { + port, + exchanges, + requests, + }) } fn url(&self) -> String { @@ -77,6 +93,10 @@ impl MockKdc { fn exchanges(&self) -> usize { self.exchanges.load(Ordering::SeqCst) } + + fn requests(&self) -> Vec { + self.requests.lock().expect("kdc request mutex").clone() + } } fn kdc_config() -> kdc::config::KerberosServer { @@ -95,13 +115,19 @@ fn kdc_config() -> kdc::config::KerberosServer { } } -async fn serve_kdc_exchange(mut stream: TcpStream, config: &kdc::config::KerberosServer) -> anyhow::Result<()> { +async fn serve_kdc_exchange( + mut stream: TcpStream, + config: &kdc::config::KerberosServer, + requests: &Mutex>, +) -> anyhow::Result<()> { let mut len_buf = [0u8; 4]; stream.read_exact(&mut len_buf).await.context("read KDC length")?; let len = usize::try_from(u32::from_be_bytes(len_buf)).context("KDC length")?; let mut body = vec![0u8; len]; stream.read_exact(&mut body).await.context("read KDC body")?; + requests.lock().expect("kdc request mutex").push(observe_kdc_req(&body)); + let mut raw = Vec::with_capacity(4 + len); raw.extend_from_slice(&len_buf); raw.extend_from_slice(&body); @@ -115,6 +141,61 @@ async fn serve_kdc_exchange(mut stream: TcpStream, config: &kdc::config::Kerbero Ok(()) } +fn principal_strings(name: &PrincipalName) -> Vec { + name.name_string.0.0.iter().map(|part| part.0.to_string()).collect() +} + +fn observe_kdc_req(body: &[u8]) -> ObservedKdcReq { + if let Ok(as_req) = picky_asn1_der::from_bytes::(body) { + let req = &as_req.0.req_body.0; + let cname = req + .cname + .0 + .as_ref() + .map(|name| principal_strings(&name.0).join("/")) + .unwrap_or_default(); + return ObservedKdcReq::As { + cname, + realm: req.realm.0.to_string(), + }; + } + if let Ok(tgs_req) = picky_asn1_der::from_bytes::(body) { + let req = &tgs_req.0.req_body.0; + let sname = req + .sname + .0 + .as_ref() + .map(|name| principal_strings(&name.0)) + .unwrap_or_default(); + return ObservedKdcReq::Tgs { + sname, + realm: req.realm.0.to_string(), + }; + } + ObservedKdcReq::Other +} + +fn observe_kdc_reply(body: &[u8]) -> ObservedKdcReply { + let krb = body.get(4..).unwrap_or(body); + if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::AsRep + } else if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::TgsRep + } else if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::KrbError + } else { + ObservedKdcReply::Other + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ObservedKdcReply { + AsRep, + TgsRep, + KrbError, + Other, +} + #[derive(Clone)] enum MockRdpMode { Kerberos { kdc_url: String }, @@ -124,6 +205,8 @@ enum MockRdpMode { struct MockRdp { port: u16, credssp_ok: Arc, + finished_account: Arc>>, + cookies: Arc>>, } impl MockRdp { @@ -144,7 +227,11 @@ impl MockRdp { }; let port = listener.local_addr().context("mock RDP local_addr")?.port(); let credssp_ok = Arc::new(AtomicBool::new(false)); + let finished_account = Arc::new(Mutex::new(None)); + let cookies = Arc::new(Mutex::new(Vec::new())); let credssp_ok_task = Arc::clone(&credssp_ok); + let finished_account_task = Arc::clone(&finished_account); + let cookies_task = Arc::clone(&cookies); let acceptor = tls_acceptor()?; let public_key = server_public_key()?; @@ -156,13 +243,26 @@ impl MockRdp { let acceptor = acceptor.clone(); let public_key = public_key.clone(); let credssp_ok = Arc::clone(&credssp_ok_task); + let finished_account = Arc::clone(&finished_account_task); + let cookies = Arc::clone(&cookies_task); let mode = mode.clone(); tokio::spawn(async move { let result = match &mode { MockRdpMode::Kerberos { kdc_url } => { - accept_kerberos_rdp(stream, peer, acceptor, public_key, kdc_url).await + accept_kerberos_rdp( + stream, + peer, + acceptor, + public_key, + kdc_url, + &cookies, + &finished_account, + ) + .await + } + MockRdpMode::Ntlm => { + accept_ntlm_rdp(stream, peer, acceptor, public_key, &cookies, &finished_account).await } - MockRdpMode::Ntlm => accept_ntlm_rdp(stream, peer, acceptor, public_key).await, }; match result { Ok(()) => credssp_ok.store(true, Ordering::SeqCst), @@ -172,13 +272,26 @@ impl MockRdp { } }); - Ok(Self { port, credssp_ok }) + Ok(Self { + port, + credssp_ok, + finished_account, + cookies, + }) } fn credssp_ok(&self) -> bool { self.credssp_ok.load(Ordering::SeqCst) } + fn finished_account(&self) -> Option { + self.finished_account.lock().expect("finished account mutex").clone() + } + + fn cookies(&self) -> Vec { + self.cookies.lock().expect("cookie mutex").clone() + } + async fn wait_credssp(&self) -> anyhow::Result<()> { let deadline = Instant::now() + Duration::from_secs(30); loop { @@ -199,10 +312,13 @@ async fn accept_kerberos_rdp( acceptor: tokio_rustls::TlsAcceptor, public_key: Vec, kdc_url: &str, + cookies: &Mutex>, + finished_account: &Mutex>, ) -> anyhow::Result<()> { let mut framed = TokioFramed::new(stream); let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; - let _: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + let cr: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + record_cookie(&cr, cookies); let confirm = X224(ConnectionConfirm::Response { flags: ResponseFlags::empty(), @@ -269,7 +385,11 @@ async fn accept_kerberos_rdp( buf.advance(length); framed.write_all(&buf[..length]).await.context("write CredSSP")?; } - sspi::credssp::ServerState::Finished(_) => return Ok(()), + sspi::credssp::ServerState::Finished(identity) => { + *finished_account.lock().expect("finished account mutex") = + Some(identity.username.account_name().to_owned()); + return Ok(()); + } } } anyhow::bail!("mock RDP CredSSP exceeded 6 round trips") @@ -280,10 +400,13 @@ async fn accept_ntlm_rdp( peer: std::net::SocketAddr, acceptor: tokio_rustls::TlsAcceptor, public_key: Vec, + cookies: &Mutex>, + finished_account: &Mutex>, ) -> anyhow::Result<()> { let mut framed = TokioFramed::new(stream); let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; - let _: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + let cr: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + record_cookie(&cr, cookies); let confirm = X224(ConnectionConfirm::Response { flags: ResponseFlags::empty(), @@ -332,12 +455,22 @@ async fn accept_ntlm_rdp( buf.advance(length); framed.write_all(&buf[..length]).await.context("write CredSSP")?; } - sspi::credssp::ServerState::Finished(_) => return Ok(()), + sspi::credssp::ServerState::Finished(identity) => { + *finished_account.lock().expect("finished account mutex") = + Some(identity.username.account_name().to_owned()); + return Ok(()); + } } } anyhow::bail!("mock RDP NTLM CredSSP exceeded 6 round trips") } +fn record_cookie(cr: &X224, cookies: &Mutex>) { + if let Some(NegoRequestData::Cookie(cookie)) = &cr.0.nego_data { + cookies.lock().expect("cookie mutex").push(cookie.0.clone()); + } +} + async fn resolve_sspi_server( generator: &mut sspi::generator::Generator< '_, @@ -427,11 +560,11 @@ async fn connect_ntlm_client( } async fn complete_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { - complete_client_credssp(tls, PROXY_USER, None, false).await + complete_client_credssp(tls, PROXY_USER, None, false, None).await } async fn complete_raw_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { - complete_client_credssp(tls, PROXY_USER, None, true).await + complete_client_credssp(tls, PROXY_USER, None, true, None).await } async fn complete_client_credssp( @@ -439,6 +572,7 @@ async fn complete_client_credssp( username: &str, kdc_proxy_url: Option<&str>, raw_ntlm: bool, + proxy_replies: Option<&Mutex>>, ) -> anyhow::Result<()> { use sspi::credssp::{ClientMode, ClientState, CredSspClient, CredSspMode, TsRequest}; use sspi::ntlm::NtlmConfig; @@ -488,7 +622,7 @@ async fn complete_client_credssp( for _ in 0..8 { let client_state = { let mut generator = client.process(std::mem::take(&mut ts_request)); - resolve_sspi_client(&mut generator).await? + resolve_sspi_client(&mut generator, proxy_replies).await? }; let (outbound, finished) = match client_state { ClientState::ReplyNeeded(request) => (request, false), @@ -518,6 +652,7 @@ async fn resolve_sspi_client( sspi::Result>, sspi::Result, >, + proxy_replies: Option<&Mutex>>, ) -> anyhow::Result { let mut state = generator.start(); loop { @@ -525,7 +660,7 @@ async fn resolve_sspi_client( GeneratorState::Suspended(request) => { let reply = match request.url.scheme() { "tcp" | "udp" => send_kdc_tcp(&request).await?, - "http" | "https" => send_kdc_http(&request).await?, + "http" | "https" => send_kdc_http(&request, proxy_replies).await?, other => anyhow::bail!("unsupported KDC scheme {other}: {}", request.url), }; state = generator.resume(Ok(reply)); @@ -537,7 +672,10 @@ async fn resolve_sspi_client( } } -async fn send_kdc_http(request: &sspi::generator::NetworkRequest) -> anyhow::Result> { +async fn send_kdc_http( + request: &sspi::generator::NetworkRequest, + proxy_replies: Option<&Mutex>>, +) -> anyhow::Result> { let host = request.url.host_str().context("KDC proxy host")?; let port = request.url.port_or_known_default().unwrap_or(80); let path = if request.url.query().is_some() { @@ -586,19 +724,27 @@ async fn send_kdc_http(request: &sspi::generator::NetworkRequest) -> anyhow::Res } } - if let Some(len) = content_length { + let buf = if let Some(len) = content_length { let mut buf = vec![0u8; len]; tokio::io::AsyncReadExt::read_exact(&mut reader, &mut buf) .await .context("read KDC proxy body")?; - Ok(buf) + buf } else { let mut buf = Vec::new(); tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buf) .await .context("read KDC proxy eof body")?; - Ok(buf) + buf + }; + if let Ok(message) = KdcProxyMessage::from_raw(&buf) + && let Some(log) = proxy_replies + { + log.lock() + .expect("proxy reply mutex") + .push(observe_kdc_reply(&message.kerb_message.0.0)); } + Ok(buf) } fn kdc_inject_token(association_jti: &str) -> anyhow::Result { @@ -749,6 +895,27 @@ impl tokio_rustls::rustls::client::danger::ServerCertVerifier for NoCertificateV } } +fn assert_target_kdc_as_and_tgs(kdc: &MockKdc) -> anyhow::Result<()> { + let reqs = kdc.requests(); + anyhow::ensure!( + reqs.iter().any(|req| matches!( + req, + ObservedKdcReq::As { cname, realm } + if cname.eq_ignore_ascii_case("administrator") && realm.eq_ignore_ascii_case(REALM) + )), + "KDC must see AS-REQ cname=administrator realm={REALM}; requests={reqs:?}" + ); + anyhow::ensure!( + reqs.iter().any(|req| matches!( + req, + ObservedKdcReq::Tgs { sname, realm } + if *sname == ["TERMSRV", SERVICE_HOST] && realm.eq_ignore_ascii_case(REALM) + )), + "KDC must see TGS-REQ sname=TERMSRV/{SERVICE_HOST} realm={REALM}; requests={reqs:?}" + ); + Ok(()) +} + #[tokio::test] async fn kerberos_injection_completes_credssp_against_mock_kdc() -> anyhow::Result<()> { install_crypto_provider(); @@ -781,6 +948,18 @@ async fn kerberos_injection_completes_credssp_against_mock_kdc() -> anyhow::Resu kdc.exchanges(), gateway.logs.snapshot() ); + assert_target_kdc_as_and_tgs(&kdc)?; + anyhow::ensure!( + rdp.finished_account().as_deref() == Some("administrator"), + "RDP CredSSP Finished account must be administrator; got={:?}; cookies={:?}", + rdp.finished_account(), + rdp.cookies() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "RDP X.224 cookie must be {KERBEROS_TARGET_USER}; cookies={:?}", + rdp.cookies() + ); let _ = gateway.process.start_kill(); Ok(()) @@ -808,8 +987,9 @@ async fn kerberos_client_and_target_legs_complete_credssp() -> anyhow::Result<() .await?; let kdc_proxy = kdc_proxy_url(gateway.config.http_port(), &jti)?; + let proxy_replies = Mutex::new(Vec::new()); let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; - complete_client_credssp(tls, PROXY_KERBEROS_USER, Some(&kdc_proxy), false) + complete_client_credssp(tls, PROXY_KERBEROS_USER, Some(&kdc_proxy), false, Some(&proxy_replies)) .await .with_context(|| { format!( @@ -829,6 +1009,17 @@ async fn kerberos_client_and_target_legs_complete_credssp() -> anyhow::Result<() kdc.exchanges(), gateway.logs.snapshot() ); + assert_target_kdc_as_and_tgs(&kdc)?; + let replies = proxy_replies.lock().expect("proxy reply mutex").clone(); + anyhow::ensure!( + replies.contains(&ObservedKdcReply::AsRep) && replies.contains(&ObservedKdcReply::TgsRep), + "/jet/KdcProxy must return AS-REP and TGS-REP (PREAUTH KRB-ERROR is allowed first); replies={replies:?}" + ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some("administrator"), + "RDP CredSSP Finished account must be administrator; got={:?}", + rdp.finished_account() + ); let _ = gateway.process.start_kill(); Ok(()) @@ -859,8 +1050,9 @@ async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { let _ = complete_ntlm_credssp(tls).await; tokio::time::sleep(Duration::from_secs(2)).await; anyhow::ensure!( - !rdp.credssp_ok(), - "wrong target password must not complete Kerberos CredSSP; logs:\n{}", + !rdp.credssp_ok() && rdp.finished_account().is_none(), + "wrong target password must not complete Kerberos CredSSP; account={:?}; logs:\n{}", + rdp.finished_account(), gateway.logs.snapshot() ); let logs = gateway.logs.snapshot(); @@ -895,8 +1087,9 @@ async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { let _ = complete_ntlm_credssp(tls).await; tokio::time::sleep(Duration::from_secs(2)).await; anyhow::ensure!( - !rdp.credssp_ok(), - "unreachable KDC must not complete CredSSP; logs:\n{}", + !rdp.credssp_ok() && rdp.finished_account().is_none(), + "unreachable KDC must not complete CredSSP; account={:?}; logs:\n{}", + rdp.finished_account(), gateway.logs.snapshot() ); let logs = gateway.logs.snapshot(); @@ -958,6 +1151,16 @@ async fn ntlm_injection_completes_credssp_both_legs() -> anyhow::Result<()> { logs.contains("kerberos=false"), "expected NTLM injection; logs:\n{logs}" ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some(TARGET_USER), + "RDP NTLM CredSSP Finished account must be {TARGET_USER}; got={:?}", + rdp.finished_account() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == TARGET_USER), + "RDP X.224 cookie must be {TARGET_USER}; cookies={:?}", + rdp.cookies() + ); let _ = gateway.process.start_kill(); Ok(()) From 958d85b94e3b8ee7c6128289ea24d9e0d84d42f0 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 21 Aug 2026 11:32:56 -0400 Subject: [PATCH 24/36] test(dgw): decode cookies and KdcProxy AS-REQ principal Routing tests now decode X.224 Cookie instead of raw-byte search. Client-leg Kerberos asserts synthetic-KDC AS-REQ cname. Fail-closed Kerberos paths require inject-started then no Finished identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testsuite/tests/cli/dgw/cred_injection.rs | 103 +++++++++--------- testsuite/tests/cli/dgw/cred_injection_kdc.rs | 62 ++++++++--- 2 files changed, 101 insertions(+), 64 deletions(-) diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs index 6c5d236d4..c42d9430c 100644 --- a/testsuite/tests/cli/dgw/cred_injection.rs +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -12,6 +12,8 @@ use std::time::{Duration, Instant}; use anyhow::Context as _; use base64::Engine as _; +use ironrdp_pdu::nego::{ConnectionRequest, NegoRequestData}; +use ironrdp_pdu::x224::X224; use testsuite::cli::{dgw_tokio_cmd, wait_for_tcp_port}; use testsuite::dgw_config::{DgwConfig, DgwConfigHandle, VerbosityProfile}; use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; @@ -144,7 +146,7 @@ impl LogBuffer { struct FakeRdpTarget { port: u16, accepted: Arc, - payloads: Arc>>>, + cookies: Arc>>, } impl FakeRdpTarget { @@ -152,9 +154,9 @@ impl FakeRdpTarget { let listener = TcpListener::bind("127.0.0.1:0").await.context("bind fake RDP target")?; let port = listener.local_addr().context("fake RDP local_addr")?.port(); let accepted = Arc::new(AtomicUsize::new(0)); - let payloads = Arc::new(Mutex::new(Vec::new())); + let cookies = Arc::new(Mutex::new(Vec::new())); let accepted_task = Arc::clone(&accepted); - let payloads_task = Arc::clone(&payloads); + let cookies_task = Arc::clone(&cookies); tokio::spawn(async move { loop { @@ -162,14 +164,15 @@ impl FakeRdpTarget { break; }; accepted_task.fetch_add(1, Ordering::SeqCst); - let payloads = Arc::clone(&payloads_task); + let cookies = Arc::clone(&cookies_task); tokio::spawn(async move { let mut buf = vec![0u8; 4096]; // CredSSP cert generation can delay the rewritten X.224 CR. if let Ok(Ok(n)) = tokio::time::timeout(Duration::from_secs(30), stream.read(&mut buf)).await && n > 0 + && let Some(cookie) = decode_x224_cookie(&buf[..n]) { - payloads.lock().expect("payload mutex").push(buf[..n].to_vec()); + cookies.lock().expect("cookie mutex").push(cookie); } // Keep the accepted socket open so the proxy can finish writing the CR. tokio::time::sleep(Duration::from_secs(30)).await; @@ -180,7 +183,7 @@ impl FakeRdpTarget { Ok(Self { port, accepted, - payloads, + cookies, }) } @@ -188,18 +191,18 @@ impl FakeRdpTarget { self.accepted.load(Ordering::SeqCst) } - async fn wait_payloads(&self, count: usize) -> anyhow::Result>> { + async fn wait_cookies(&self, count: usize) -> anyhow::Result> { let deadline = Instant::now() + Duration::from_secs(30); loop { { - let payloads = self.payloads.lock().expect("payload mutex"); - if payloads.len() >= count { - return Ok(payloads.clone()); + let cookies = self.cookies.lock().expect("cookie mutex"); + if cookies.len() >= count { + return Ok(cookies.clone()); } } if Instant::now() >= deadline { anyhow::bail!( - "timed out waiting for {count} target payload(s); accepted={}", + "timed out waiting for {count} decoded X.224 cookie(s); accepted={}", self.accepted() ); } @@ -208,6 +211,14 @@ impl FakeRdpTarget { } } +fn decode_x224_cookie(payload: &[u8]) -> Option { + let cr: X224 = ironrdp_core::decode(payload).ok()?; + match cr.0.nego_data { + Some(NegoRequestData::Cookie(cookie)) => Some(cookie.0), + _ => None, + } +} + pub(crate) struct GatewayProc { pub(crate) config: DgwConfigHandle, pub(crate) process: Child, @@ -416,16 +427,6 @@ async fn connect_rdp_client(gateway_tcp: u16, association_jwt: &str) -> anyhow:: Ok(stream) } -fn cookie_line(username: &str) -> String { - format!("Cookie: mstshash={username}") -} - -fn payloads_contain(payloads: &[Vec], needle: &str) -> bool { - payloads - .iter() - .any(|payload| String::from_utf8_lossy(payload).contains(needle)) -} - #[tokio::test] async fn first_rdp_connection_injects_ntlm() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; @@ -447,14 +448,11 @@ async fn first_rdp_connection_injects_ntlm() -> anyhow::Result<()> { "injection must not fall back to ordinary forward; logs:\n{logs}" ); - let payloads = target.wait_payloads(1).await?; - assert!( - payloads_contain(&payloads, &cookie_line(TARGET_USER)), - "target should see injected cookie; payloads={payloads:?}" - ); - assert!( - !payloads_contain(&payloads, &cookie_line(CLIENT_COOKIE)), - "target must not see the client cookie; payloads={payloads:?}" + let cookies = target.wait_cookies(1).await?; + assert_eq!( + cookies, + vec![TARGET_USER], + "decoded X.224 cookie must be the injected user" ); let _ = gateway.process.start_kill(); @@ -473,7 +471,7 @@ async fn reconnect_same_jwt_still_injects() -> anyhow::Result<()> { let first = connect_rdp_client(gateway.config.tcp_port(), &token).await?; gateway.logs.wait_count(INJECT_LOG, 1).await?; - target.wait_payloads(1).await?; + target.wait_cookies(1).await?; drop(first); let _second = connect_rdp_client(gateway.config.tcp_port(), &token).await?; @@ -483,13 +481,11 @@ async fn reconnect_same_jwt_still_injects() -> anyhow::Result<()> { "reconnect must keep injecting, not ordinary-forward; logs:\n{logs}" ); - let payloads = target.wait_payloads(2).await?; - assert_eq!(payloads.len(), 2, "both connections should reach the fake RDP target"); - assert!( - payloads - .iter() - .all(|payload| String::from_utf8_lossy(payload).contains(&cookie_line(TARGET_USER))), - "both reconnects should inject the target cookie; payloads={payloads:?}" + let cookies = target.wait_cookies(2).await?; + assert_eq!( + cookies, + vec![TARGET_USER, TARGET_USER], + "both decoded X.224 cookies must be the injected user" ); let _ = gateway.process.start_kill(); @@ -541,14 +537,11 @@ async fn unprovisioned_rdp_uses_ordinary_forward() -> anyhow::Result<()> { "absent mapping should ordinary-forward; logs:\n{logs}" ); - let payloads = target.wait_payloads(1).await?; - assert!( - payloads_contain(&payloads, &cookie_line(CLIENT_COOKIE)), - "ordinary forward should keep the client cookie; payloads={payloads:?}" - ); - assert!( - !payloads_contain(&payloads, &cookie_line(TARGET_USER)), - "ordinary forward must not invent an injection cookie; payloads={payloads:?}" + let cookies = target.wait_cookies(1).await?; + assert_eq!( + cookies, + vec![CLIENT_COOKIE], + "ordinary forward must keep the decoded client cookie" ); let _ = gateway.process.start_kill(); @@ -612,6 +605,11 @@ async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Res "newer provisioning generation should replace and still inject; logs:\n{logs}" ); let logs = gateway.logs.wait_count(PUBLISHED_KDC_LOG, 2).await?; + assert_eq!( + logs.matches(PUBLISHED_KDC_LOG).count(), + 2, + "re-provision must publish exactly one extra synthetic KDC; logs:\n{logs}" + ); assert_eq!( logs.matches(REGISTERED_KDC_LOG).count(), 3, @@ -622,12 +620,15 @@ async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Res "Kerberos injection must not ordinary-forward; logs:\n{logs}" ); - let payloads = target.wait_payloads(3).await?; - assert!( - payloads - .iter() - .all(|payload| String::from_utf8_lossy(payload).contains(&cookie_line(KERBEROS_TARGET_USER))), - "each generation should inject the Kerberos target username; payloads={payloads:?}" + let cookies = target.wait_cookies(3).await?; + assert_eq!( + cookies, + vec![ + KERBEROS_TARGET_USER.to_owned(), + KERBEROS_TARGET_USER.to_owned(), + KERBEROS_TARGET_USER.to_owned() + ], + "each decoded X.224 cookie must be the Kerberos target user" ); let _ = gateway.process.start_kill(); diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index e1c80d1be..010041a6c 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -560,11 +560,11 @@ async fn connect_ntlm_client( } async fn complete_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { - complete_client_credssp(tls, PROXY_USER, None, false, None).await + complete_client_credssp(tls, PROXY_USER, None, false, None, None).await } async fn complete_raw_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { - complete_client_credssp(tls, PROXY_USER, None, true, None).await + complete_client_credssp(tls, PROXY_USER, None, true, None, None).await } async fn complete_client_credssp( @@ -573,6 +573,7 @@ async fn complete_client_credssp( kdc_proxy_url: Option<&str>, raw_ntlm: bool, proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, ) -> anyhow::Result<()> { use sspi::credssp::{ClientMode, ClientState, CredSspClient, CredSspMode, TsRequest}; use sspi::ntlm::NtlmConfig; @@ -622,7 +623,7 @@ async fn complete_client_credssp( for _ in 0..8 { let client_state = { let mut generator = client.process(std::mem::take(&mut ts_request)); - resolve_sspi_client(&mut generator, proxy_replies).await? + resolve_sspi_client(&mut generator, proxy_replies, proxy_requests).await? }; let (outbound, finished) = match client_state { ClientState::ReplyNeeded(request) => (request, false), @@ -653,6 +654,7 @@ async fn resolve_sspi_client( sspi::Result, >, proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, ) -> anyhow::Result { let mut state = generator.start(); loop { @@ -660,7 +662,7 @@ async fn resolve_sspi_client( GeneratorState::Suspended(request) => { let reply = match request.url.scheme() { "tcp" | "udp" => send_kdc_tcp(&request).await?, - "http" | "https" => send_kdc_http(&request, proxy_replies).await?, + "http" | "https" => send_kdc_http(&request, proxy_replies, proxy_requests).await?, other => anyhow::bail!("unsupported KDC scheme {other}: {}", request.url), }; state = generator.resume(Ok(reply)); @@ -675,6 +677,7 @@ async fn resolve_sspi_client( async fn send_kdc_http( request: &sspi::generator::NetworkRequest, proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, ) -> anyhow::Result> { let host = request.url.host_str().context("KDC proxy host")?; let port = request.url.port_or_known_default().unwrap_or(80); @@ -699,6 +702,12 @@ async fn send_kdc_http( .context("write KDC proxy headers")?; stream.write_all(&request.data).await.context("write KDC proxy body")?; stream.flush().await.context("flush KDC proxy")?; + if let Ok(message) = KdcProxyMessage::from_raw(&request.data) + && let Some(log) = proxy_requests + { + let kerb = message.kerb_message.0.0.get(4..).unwrap_or(&message.kerb_message.0.0); + log.lock().expect("proxy request mutex").push(observe_kdc_req(kerb)); + } let mut reader = BufReader::new(stream); let mut status_line = String::new(); @@ -988,15 +997,23 @@ async fn kerberos_client_and_target_legs_complete_credssp() -> anyhow::Result<() let kdc_proxy = kdc_proxy_url(gateway.config.http_port(), &jti)?; let proxy_replies = Mutex::new(Vec::new()); + let proxy_requests = Mutex::new(Vec::new()); let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; - complete_client_credssp(tls, PROXY_KERBEROS_USER, Some(&kdc_proxy), false, Some(&proxy_replies)) - .await - .with_context(|| { - format!( - "client-leg Kerberos CredSSP; gateway logs:\n{}", - gateway.logs.snapshot() - ) - })?; + complete_client_credssp( + tls, + PROXY_KERBEROS_USER, + Some(&kdc_proxy), + false, + Some(&proxy_replies), + Some(&proxy_requests), + ) + .await + .with_context(|| { + format!( + "client-leg Kerberos CredSSP; gateway logs:\n{}", + gateway.logs.snapshot() + ) + })?; rdp.wait_credssp().await.with_context(|| { format!( "target-leg Kerberos CredSSP; gateway logs:\n{}", @@ -1015,6 +1032,15 @@ async fn kerberos_client_and_target_legs_complete_credssp() -> anyhow::Result<() replies.contains(&ObservedKdcReply::AsRep) && replies.contains(&ObservedKdcReply::TgsRep), "/jet/KdcProxy must return AS-REP and TGS-REP (PREAUTH KRB-ERROR is allowed first); replies={replies:?}" ); + let requests = proxy_requests.lock().expect("proxy request mutex").clone(); + anyhow::ensure!( + requests.iter().any(|req| matches!( + req, + ObservedKdcReq::As { cname, realm } + if cname.eq_ignore_ascii_case("injected-proxy-user") && realm.eq_ignore_ascii_case(REALM) + )), + "synthetic KDC AS-REQ must be proxy user injected-proxy-user@{REALM}; requests={requests:?}" + ); anyhow::ensure!( rdp.finished_account().as_deref() == Some("administrator"), "RDP CredSSP Finished account must be administrator; got={:?}", @@ -1048,6 +1074,11 @@ async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; let _ = complete_ntlm_credssp(tls).await; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + anyhow::ensure!( + logs.contains("kerberos=true"), + "wrong password must still start Kerberos injection; logs:\n{logs}" + ); tokio::time::sleep(Duration::from_secs(2)).await; anyhow::ensure!( !rdp.credssp_ok() && rdp.finished_account().is_none(), @@ -1085,6 +1116,11 @@ async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; let _ = complete_ntlm_credssp(tls).await; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + anyhow::ensure!( + logs.contains("kerberos=true"), + "KDC down must still start Kerberos injection; logs:\n{logs}" + ); tokio::time::sleep(Duration::from_secs(2)).await; anyhow::ensure!( !rdp.credssp_ok() && rdp.finished_account().is_none(), @@ -1120,7 +1156,7 @@ async fn kerberos_missing_krb_kdc_fails_closed() -> anyhow::Result<()> { stream.flush().await.context("flush CR")?; let logs = gateway.logs.wait_contains(MISSING_LOG).await?; anyhow::ensure!( - !logs.contains(FORWARD_LOG), + !logs.contains(FORWARD_LOG) && !logs.contains(INJECT_LOG), "missing krb_kdc must fail closed; logs:\n{logs}" ); From 82086fdfe0adbf55ca1fecc2dd548306ccc7c8a7 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 21 Aug 2026 12:34:21 -0400 Subject: [PATCH 25/36] test(dgw): tighten hop asserts without Gateway source changes Decode X.224 cookies, KdcProxy AS/TGS principals, and attribute KDC-down TCP to Gateway via a separate refusing listener. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testsuite/tests/cli/dgw/cred_injection.rs | 11 +- testsuite/tests/cli/dgw/cred_injection_kdc.rs | 119 ++++++++++++++++-- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs index c42d9430c..83810c432 100644 --- a/testsuite/tests/cli/dgw/cred_injection.rs +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -476,6 +476,11 @@ async fn reconnect_same_jwt_still_injects() -> anyhow::Result<()> { let _second = connect_rdp_client(gateway.config.tcp_port(), &token).await?; let logs = gateway.logs.wait_count(INJECT_LOG, 2).await?; + assert_eq!( + logs.matches(INJECT_LOG).count(), + 2, + "reconnect must inject exactly twice; logs:\n{logs}" + ); assert!( !logs.contains(FORWARD_LOG), "reconnect must keep injecting, not ordinary-forward; logs:\n{logs}" @@ -514,7 +519,11 @@ async fn required_missing_fails_closed() -> anyhow::Result<()> { "expired mapping must fail closed, never silent ordinary forward; logs:\n{logs}" ); - tokio::time::sleep(Duration::from_millis(500)).await; + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + anyhow::ensure!(target.accepted() == 0, "fail-closed routing must not connect upstream"); + tokio::time::sleep(Duration::from_millis(50)).await; + } assert_eq!(target.accepted(), 0, "fail-closed routing must not connect upstream"); let _ = gateway.process.start_kill(); diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index 010041a6c..dabb4e6fc 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -198,7 +198,7 @@ enum ObservedKdcReply { #[derive(Clone)] enum MockRdpMode { - Kerberos { kdc_url: String }, + Kerberos { kdc_url: Option }, Ntlm, } @@ -211,7 +211,7 @@ struct MockRdp { impl MockRdp { async fn start_kerberos(kdc_url: String) -> anyhow::Result { - Self::start(MockRdpMode::Kerberos { kdc_url }).await + Self::start(MockRdpMode::Kerberos { kdc_url: Some(kdc_url) }).await } async fn start_ntlm() -> anyhow::Result { @@ -254,7 +254,7 @@ impl MockRdp { peer, acceptor, public_key, - kdc_url, + kdc_url.as_deref(), &cookies, &finished_account, ) @@ -311,7 +311,7 @@ async fn accept_kerberos_rdp( peer: std::net::SocketAddr, acceptor: tokio_rustls::TlsAcceptor, public_key: Vec, - kdc_url: &str, + kdc_url: Option<&str>, cookies: &Mutex>, finished_account: &Mutex>, ) -> anyhow::Result<()> { @@ -339,7 +339,10 @@ async fn accept_kerberos_rdp( }; let kerberos_config = sspi::KerberosServerConfig { kerberos_config: sspi::KerberosConfig { - kdc_url: Some(kdc_url.parse().context("parse mock KDC URL")?), + kdc_url: kdc_url + .map(|url| url.parse()) + .transpose() + .context("parse mock KDC URL")?, client_computer_name: peer.to_string(), }, server_properties: sspi::kerberos::ServerProperties::new( @@ -715,7 +718,10 @@ async fn send_kdc_http( .read_line(&mut status_line) .await .context("read KDC proxy status")?; - anyhow::ensure!(status_line.contains("200"), "KDC proxy HTTP status was {status_line:?}"); + anyhow::ensure!( + status_line.starts_with("HTTP/1.1 200") || status_line.starts_with("HTTP/1.0 200"), + "KDC proxy HTTP status was {status_line:?}" + ); let mut content_length = None; loop { @@ -1041,11 +1047,24 @@ async fn kerberos_client_and_target_legs_complete_credssp() -> anyhow::Result<() )), "synthetic KDC AS-REQ must be proxy user injected-proxy-user@{REALM}; requests={requests:?}" ); + anyhow::ensure!( + requests.iter().any(|req| matches!( + req, + ObservedKdcReq::Tgs { sname, realm } + if *sname == ["TERMSRV", SERVICE_HOST] && realm.eq_ignore_ascii_case(REALM) + )), + "synthetic KDC TGS-REQ must be TERMSRV/{SERVICE_HOST}; requests={requests:?}" + ); anyhow::ensure!( rdp.finished_account().as_deref() == Some("administrator"), "RDP CredSSP Finished account must be administrator; got={:?}", rdp.finished_account() ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "RDP X.224 cookie must be {KERBEROS_TARGET_USER}; cookies={:?}", + rdp.cookies() + ); let _ = gateway.process.start_kill(); Ok(()) @@ -1079,6 +1098,25 @@ async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { logs.contains("kerberos=true"), "wrong password must still start Kerberos injection; logs:\n{logs}" ); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if kdc.requests().iter().any(|req| { + matches!( + req, + ObservedKdcReq::As { cname, realm } + if cname.eq_ignore_ascii_case("administrator") && realm.eq_ignore_ascii_case(REALM) + ) + }) { + break; + } + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for AS-REQ as administrator; requests={:?}", + kdc.requests() + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } tokio::time::sleep(Duration::from_secs(2)).await; anyhow::ensure!( !rdp.credssp_ok() && rdp.finished_account().is_none(), @@ -1086,6 +1124,18 @@ async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { rdp.finished_account(), gateway.logs.snapshot() ); + anyhow::ensure!( + !kdc.requests() + .iter() + .any(|req| matches!(req, ObservedKdcReq::Tgs { .. })), + "wrong password must not obtain a TGS; requests={:?}", + kdc.requests() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "wrong password still rewrites the X.224 cookie; cookies={:?}", + rdp.cookies() + ); let logs = gateway.logs.snapshot(); anyhow::ensure!( !logs.contains(FORWARD_LOG), @@ -1096,10 +1146,43 @@ async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { Ok(()) } +struct RefusingKdc { + port: u16, + accepted: Arc, +} + +impl RefusingKdc { + async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind refusing KDC")?; + let port = listener.local_addr()?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_task = Arc::clone(&accepted); + tokio::spawn(async move { + loop { + let Ok((_stream, _)) = listener.accept().await else { + break; + }; + accepted_task.fetch_add(1, Ordering::SeqCst); + } + }); + Ok(Self { port, accepted }) + } + + fn url(&self) -> String { + format!("tcp://127.0.0.1:{}", self.port) + } + + fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) + } +} + #[tokio::test] async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { install_crypto_provider(); - let rdp = MockRdp::start_kerberos("tcp://127.0.0.1:1".to_owned()).await?; + let kdc = RefusingKdc::start().await?; + let rdp_kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(rdp_kdc.url()).await?; let mut gateway = GatewayProc::start(true).await?; let jti = next_id(); @@ -1110,7 +1193,7 @@ async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { &token, KERBEROS_TARGET_USER, 300, - Some("tcp://127.0.0.1:1"), + Some(&kdc.url()), ) .await?; @@ -1121,13 +1204,30 @@ async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { logs.contains("kerberos=true"), "KDC down must still start Kerberos injection; logs:\n{logs}" ); - tokio::time::sleep(Duration::from_secs(2)).await; + let deadline = Instant::now() + Duration::from_secs(10); + while kdc.accepted() == 0 { + if Instant::now() >= deadline { + anyhow::bail!("Gateway never TCP-connected the provisioned KDC"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + tokio::time::sleep(Duration::from_secs(1)).await; anyhow::ensure!( !rdp.credssp_ok() && rdp.finished_account().is_none(), "unreachable KDC must not complete CredSSP; account={:?}; logs:\n{}", rdp.finished_account(), gateway.logs.snapshot() ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "KDC down still rewrites the X.224 cookie; cookies={:?}", + rdp.cookies() + ); + anyhow::ensure!( + kdc.accepted() >= 1, + "Gateway must TCP-connect the provisioned KDC; accepted={}", + kdc.accepted() + ); let logs = gateway.logs.snapshot(); anyhow::ensure!( !logs.contains(FORWARD_LOG), @@ -1159,7 +1259,6 @@ async fn kerberos_missing_krb_kdc_fails_closed() -> anyhow::Result<()> { !logs.contains(FORWARD_LOG) && !logs.contains(INJECT_LOG), "missing krb_kdc must fail closed; logs:\n{logs}" ); - let _ = gateway.process.start_kill(); Ok(()) } From 171a18c4675ecc529794abd55400230e4619f512 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 21 Aug 2026 13:57:57 -0400 Subject: [PATCH 26/36] test(dgw): assert missing krb_kdc never dials the target Checkout-before-connect on the stack below lets the fail-closed path prove accepted==0. Issue: DGW-1900 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testsuite/tests/cli/dgw/cred_injection_kdc.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index dabb4e6fc..8242923d4 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -1259,6 +1259,12 @@ async fn kerberos_missing_krb_kdc_fails_closed() -> anyhow::Result<()> { !logs.contains(FORWARD_LOG) && !logs.contains(INJECT_LOG), "missing krb_kdc must fail closed; logs:\n{logs}" ); + tokio::time::sleep(Duration::from_millis(250)).await; + anyhow::ensure!( + rdp.accepted() == 0, + "missing krb_kdc must not dial the target; accepted={}", + rdp.accepted() + ); let _ = gateway.process.start_kill(); Ok(()) } @@ -1303,20 +1309,28 @@ async fn ntlm_injection_completes_credssp_both_legs() -> anyhow::Result<()> { struct FakeClosedTarget { port: u16, + accepted: Arc, } impl FakeClosedTarget { async fn start() -> anyhow::Result { let listener = TcpListener::bind("127.0.0.1:0").await.context("bind closed target")?; let port = listener.local_addr()?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_task = Arc::clone(&accepted); tokio::spawn(async move { loop { let Ok((_stream, _)) = listener.accept().await else { break; }; + accepted_task.fetch_add(1, Ordering::SeqCst); } }); - Ok(Self { port }) + Ok(Self { port, accepted }) + } + + fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) } } From 2847ec132983e412606334142cfa74fc66a8db83 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 21 Aug 2026 14:40:06 -0400 Subject: [PATCH 27/36] test(dgw): drive RDCleanPath injection with ironrdp-agent 0.1.0 Pin the public CLI release and complete NTLM and Kerberos target CredSSP over ws://127.0.0.1/jet/rdp. Issue: DGW-1900 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testsuite/tests/cli/dgw/cred_injection_kdc.rs | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index 8242923d4..411bbbeaa 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -3,7 +3,12 @@ //! Proves the target-leg path: Gateway fetches tickets from a TCP KDC (`kdc` crate from //! sspi-rs) and completes CredSSP with a fake RDP acceptor. The Gateway-facing client uses //! NTLM so the test does not depend on the in-process synthetic KDC. +//! +//! RDCleanPath coverage drives the public `ironrdp-agent` 0.1.0 CLI (`cargo install +//! ironrdp-agent --version 0.1.0`) over `ws://127.0.0.1/jet/rdp`. +use std::path::{Path, PathBuf}; +use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -1334,6 +1339,268 @@ impl FakeClosedTarget { } } +const IRONRDP_AGENT_VERSION: &str = "0.1.0"; +const RDCLEANPATH_INJECT_LOG: &str = "Switching to RdpProxy for credential injection (WebSocket)"; +const RDCLEANPATH_FORWARD_LOG: &str = "RDP-TLS forwarding (RDCleanPath)"; + +fn ironrdp_agent_bin() -> Option { + if let Ok(path) = std::env::var("IRONRDP_AGENT") { + return Some(PathBuf::from(path)); + } + let name = if cfg!(windows) { + "ironrdp-agent.exe" + } else { + "ironrdp-agent" + }; + if let Ok(home) = std::env::var("CARGO_HOME") { + let path = PathBuf::from(home).join("bin").join(name); + if path.is_file() { + return Some(path); + } + } + let cargo_home = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .map(|home| home.join(".cargo").join("bin").join(name)); + if let Some(path) = cargo_home + && path.is_file() + { + return Some(path); + } + if let Ok(path) = std::env::var("PATH") { + for dir in std::env::split_paths(&path) { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +fn require_ironrdp_agent() -> anyhow::Result> { + let Some(bin) = ironrdp_agent_bin() else { + eprintln!( + "skipping RDCleanPath ironrdp-agent test: cargo install ironrdp-agent --version {IRONRDP_AGENT_VERSION}" + ); + return Ok(None); + }; + let output = std::process::Command::new(&bin) + .arg("--version") + .output() + .with_context(|| format!("run {} --version", bin.display()))?; + let version = String::from_utf8_lossy(&output.stdout); + anyhow::ensure!( + version.contains(IRONRDP_AGENT_VERSION), + "expected ironrdp-agent {IRONRDP_AGENT_VERSION}, got {version:?} from {}", + bin.display() + ); + Ok(Some(bin)) +} + +fn ironrdp_agent_endpoint() -> String { + let name = format!("ironrdp-e2e-{}", next_id().replace('-', "")); + if cfg!(windows) { + format!(r"\\.\pipe\{name}") + } else { + std::env::temp_dir().join(format!("{name}.sock")).display().to_string() + } +} + +async fn start_ironrdp_daemon(bin: &Path, endpoint: &str) -> anyhow::Result { + let child = tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "daemon-start"]) + .kill_on_drop(true) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("start ironrdp-agent daemon")?; + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let status = tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "status"]) + .output() + .await + .context("ironrdp-agent status")?; + if status.status.success() { + return Ok(child); + } + if Instant::now() >= deadline { + anyhow::bail!( + "ironrdp-agent daemon not ready at {endpoint}: {}", + String::from_utf8_lossy(&status.stderr) + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn connect_ironrdp_rdcleanpath( + bin: &Path, + endpoint: &str, + server: &str, + token: &str, + http_port: u16, +) -> anyhow::Result { + let url = format!("ws://127.0.0.1:{http_port}/jet/rdp"); + tokio::process::Command::new(bin) + .args([ + "--endpoint", + endpoint, + "connect", + "--server", + server, + "--username", + PROXY_USER, + "--password", + PROXY_PASSWORD, + "--prop", + &format!("ironrdp_rdcleanpathurl:s:{url}"), + "--prop", + &format!("ironrdp_rdcleanpathtoken:s:{token}"), + ]) + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("start ironrdp-agent connect") +} + +async fn agent_query_logs(bin: &Path, endpoint: &str) -> String { + tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "query-logs"]) + .output() + .await + .ok() + .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) + .unwrap_or_default() +} + +#[tokio::test] +async fn ironrdp_agent_rdcleanpath_ntlm_injection() -> anyhow::Result<()> { + let Some(bin) = require_ironrdp_agent()? else { + return Ok(()); + }; + install_crypto_provider(); + let rdp = MockRdp::start_ntlm().await?; + let mut gateway = GatewayProc::start(false).await?; + let endpoint = ironrdp_agent_endpoint(); + let mut daemon = start_ironrdp_daemon(&bin, &endpoint).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + + let mut connect = connect_ironrdp_rdcleanpath( + &bin, + &endpoint, + &format!("{SERVICE_HOST}:{}", rdp.port), + &token, + gateway.config.http_port(), + ) + .await?; + let wait = rdp.wait_credssp().await; + let agent_logs = agent_query_logs(&bin, &endpoint).await; + wait.with_context(|| { + format!( + "RDCleanPath NTLM target CredSSP; gateway logs:\n{}\nagent logs:\n{agent_logs}", + gateway.logs.snapshot() + ) + })?; + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + logs.contains(RDCLEANPATH_INJECT_LOG), + "RDCleanPath must take the injection path; logs:\n{logs}" + ); + anyhow::ensure!( + !logs.contains(RDCLEANPATH_FORWARD_LOG), + "RDCleanPath injection must not ordinary-forward; logs:\n{logs}" + ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some(TARGET_USER), + "RDP NTLM CredSSP Finished account must be {TARGET_USER}; got={:?}", + rdp.finished_account() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == PROXY_USER), + "RDCleanPath forwards the client X.224 cookie; cookies={:?}", + rdp.cookies() + ); + + let _ = connect.start_kill(); + let _ = daemon.start_kill(); + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn ironrdp_agent_rdcleanpath_kerberos_injection() -> anyhow::Result<()> { + let Some(bin) = require_ironrdp_agent()? else { + return Ok(()); + }; + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; + let mut gateway = GatewayProc::start(true).await?; + let endpoint = ironrdp_agent_endpoint(); + let mut daemon = start_ironrdp_daemon(&bin, &endpoint).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some(&kdc.url()), + ) + .await?; + + let mut connect = connect_ironrdp_rdcleanpath( + &bin, + &endpoint, + &format!("{SERVICE_HOST}:{}", rdp.port), + &token, + gateway.config.http_port(), + ) + .await?; + let wait = rdp.wait_credssp().await; + let agent_logs = agent_query_logs(&bin, &endpoint).await; + wait.with_context(|| { + format!( + "RDCleanPath Kerberos target CredSSP; gateway logs:\n{}\nagent logs:\n{agent_logs}", + gateway.logs.snapshot() + ) + })?; + assert_target_kdc_as_and_tgs(&kdc)?; + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + logs.contains(RDCLEANPATH_INJECT_LOG), + "RDCleanPath must take the injection path; logs:\n{logs}" + ); + anyhow::ensure!( + !logs.contains(RDCLEANPATH_FORWARD_LOG), + "RDCleanPath injection must not ordinary-forward; logs:\n{logs}" + ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some("administrator"), + "RDP CredSSP Finished account must be administrator; got={:?}", + rdp.finished_account() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == PROXY_USER), + "RDCleanPath forwards the client X.224 cookie; cookies={:?}", + rdp.cookies() + ); + + let _ = connect.start_kill(); + let _ = daemon.start_kill(); + let _ = gateway.process.start_kill(); + Ok(()) +} + const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- MIIDCzCCAfOgAwIBAgIUPRJa8i280unV3/kW6TE2fSUw8PwwDQYJKoZIhvcNAQEL BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTEyNTA5NDAzMFoYDzIxMjUx From 0bf9b6d6f430d2869cfe72fdc0d09a68fce3f183 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Mon, 24 Aug 2026 11:12:55 -0400 Subject: [PATCH 28/36] test(dgw): tighten injection e2e review nits Read framed X.224 cookies, bound CredSSP handshakes, and share the localhost TLS fixture. Issue: DVLS-14697 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 - testsuite/Cargo.toml | 1 - testsuite/tests/cli/dgw/cred_injection.rs | 10 +-- testsuite/tests/cli/dgw/cred_injection_kdc.rs | 80 +++++++------------ testsuite/tests/cli/dgw/mod.rs | 1 + testsuite/tests/cli/dgw/tls_anchoring.rs | 51 +----------- testsuite/tests/cli/dgw/tls_fixtures.rs | 52 ++++++++++++ 7 files changed, 90 insertions(+), 106 deletions(-) create mode 100644 testsuite/tests/cli/dgw/tls_fixtures.rs diff --git a/Cargo.lock b/Cargo.lock index 758047ea8..000747e10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7516,7 +7516,6 @@ dependencies = [ "escargot", "expect-test", "fastrand", - "ironrdp-acceptor", "ironrdp-connector", "ironrdp-core 0.2.1", "ironrdp-pdu", diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 5c6614577..65852584c 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -32,7 +32,6 @@ tokio-tungstenite = { version = "0.29", features = ["rustls-tls-native-roots"] } [dev-dependencies] base64 = "0.23" -ironrdp-acceptor = "0.10" ironrdp-connector = "0.10" ironrdp-core = { version = "0.2", features = ["std"] } ironrdp-pdu = { version = "0.9", features = ["std"] } diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs index 83810c432..ae6250ca1 100644 --- a/testsuite/tests/cli/dgw/cred_injection.rs +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -14,6 +14,7 @@ use anyhow::Context as _; use base64::Engine as _; use ironrdp_pdu::nego::{ConnectionRequest, NegoRequestData}; use ironrdp_pdu::x224::X224; +use ironrdp_tokio::TokioFramed; use testsuite::cli::{dgw_tokio_cmd, wait_for_tcp_port}; use testsuite::dgw_config::{DgwConfig, DgwConfigHandle, VerbosityProfile}; use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; @@ -160,17 +161,16 @@ impl FakeRdpTarget { tokio::spawn(async move { loop { - let Ok((mut stream, _)) = listener.accept().await else { + let Ok((stream, _)) = listener.accept().await else { break; }; accepted_task.fetch_add(1, Ordering::SeqCst); let cookies = Arc::clone(&cookies_task); tokio::spawn(async move { - let mut buf = vec![0u8; 4096]; + let mut framed = TokioFramed::new(stream); // CredSSP cert generation can delay the rewritten X.224 CR. - if let Ok(Ok(n)) = tokio::time::timeout(Duration::from_secs(30), stream.read(&mut buf)).await - && n > 0 - && let Some(cookie) = decode_x224_cookie(&buf[..n]) + if let Ok(Ok((_, request))) = tokio::time::timeout(Duration::from_secs(30), framed.read_pdu()).await + && let Some(cookie) = decode_x224_cookie(&request) { cookies.lock().expect("cookie mutex").push(cookie); } diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index 411bbbeaa..ea79ca9a3 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -35,6 +35,7 @@ use super::cred_injection::{ PROXY_USER, TARGET_PASSWORD, TARGET_USER, encode_pcb, next_id, provision_credentials, provision_mapping, unsigned_jws, }; +use super::tls_fixtures::{CERT_PEM, KEY_PEM}; const REALM: &str = "EXAMPLE.INVALID"; // sspi-rs downgrades Negotiate to NTLM when the SPN host is an IP address. @@ -539,9 +540,23 @@ impl sspi::credssp::CredentialsProxy for IdentityProxy { } } +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20); + async fn connect_ntlm_client( gateway_tcp: u16, association_jwt: &str, +) -> anyhow::Result> { + tokio::time::timeout( + HANDSHAKE_TIMEOUT, + connect_ntlm_client_inner(gateway_tcp, association_jwt), + ) + .await + .context("timed out connecting NTLM client to Gateway")? +} + +async fn connect_ntlm_client_inner( + gateway_tcp: u16, + association_jwt: &str, ) -> anyhow::Result> { let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) .await @@ -582,6 +597,22 @@ async fn complete_client_credssp( raw_ntlm: bool, proxy_replies: Option<&Mutex>>, proxy_requests: Option<&Mutex>>, +) -> anyhow::Result<()> { + tokio::time::timeout( + HANDSHAKE_TIMEOUT, + complete_client_credssp_inner(tls, username, kdc_proxy_url, raw_ntlm, proxy_replies, proxy_requests), + ) + .await + .context("timed out completing client CredSSP")? +} + +async fn complete_client_credssp_inner( + tls: tokio_rustls::client::TlsStream, + username: &str, + kdc_proxy_url: Option<&str>, + raw_ntlm: bool, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, ) -> anyhow::Result<()> { use sspi::credssp::{ClientMode, ClientState, CredSspClient, CredSspMode, TsRequest}; use sspi::ntlm::NtlmConfig; @@ -1600,52 +1631,3 @@ async fn ironrdp_agent_rdcleanpath_kerberos_injection() -> anyhow::Result<()> { let _ = gateway.process.start_kill(); Ok(()) } - -const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- -MIIDCzCCAfOgAwIBAgIUPRJa8i280unV3/kW6TE2fSUw8PwwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTEyNTA5NDAzMFoYDzIxMjUx -MTAxMDk0MDMwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQDHpBlyRgUx/V9cQGw/eqDFc6odxB2hvnbudi67LvEj -cNIWOU79R1e/NswME4oecqT9W05n4UyxkABfm2qjODO0nDf47W0DsgbEA87qE715 -RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgWa8rhPc7E6In1uDRak0lWKQPQSBbc -34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEvXp+g9WsuMaU913ecacWDjyWDkf80 -pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsgUbK+TMWvkF9bcxAQDnJsrZRL7Jfh -ofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1jrU3cPA7dAgMBAAGjUzBRMB0GA1Ud -DgQWBBTT+m6dyc/c3mXF3JAsZr9OqUwgWTAfBgNVHSMEGDAWgBTT+m6dyc/c3mXF -3JAsZr9OqUwgWTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBB -i/yonZY3ztaeGElzD8xkI+rJ+daJ5WzdfKnzudJllg/Ht8m7wO5SdQnMt2T44gbH -05uekc1zXnXb7fJKqs3R6DacctG0nQ3acuI+IMtTaBbbAcf3PJJlo0Pap0ypVC0R -IUiUhJGFNi4cCBOvJqsly0d3T5xqOXU1Q5j3mIwRBY68+m9btwwuZWvASRADtCyZ -RpisBzS4a6jSeHXa4iG/VhskbiZkcnfHNTw7yNJJdv125y2zQkWWF9wlLbYwWr40 -x9Ba6YbssOz6epATKhvt80yclO34AzUyimssvViIUpgFEyaPhZZTw46Q/6X3ixK4 -/v4eYM0cCHN0h+rynSor ------END CERTIFICATE-----"#; - -const KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHpBlyRgUx/V9c -QGw/eqDFc6odxB2hvnbudi67LvEjcNIWOU79R1e/NswME4oecqT9W05n4UyxkABf -m2qjODO0nDf47W0DsgbEA87qE715RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgW -a8rhPc7E6In1uDRak0lWKQPQSBbc34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEv -Xp+g9WsuMaU913ecacWDjyWDkf80pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsg -UbK+TMWvkF9bcxAQDnJsrZRL7JfhofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1j -rU3cPA7dAgMBAAECggEAKh7KK5zwTaq6atlAvWfe8anEk4EkC1MG/qq6k02FHMgZ -2wx+SNu7fKFQDaA1vNTNUJLqCOq05qWOHp3IsuURq6JmAMP/Aw+Vc9el2ScPC74E -Dt09MmlZKl77H3fxPYwoFx5RHrbIuvoSH/DgHgOPU2YIbWpOyWlXyLDgmBoNkM3N -fXYLXJONpStPHeQLhh7LcHO3CZgn6kycJyByEO2NtcchS5zITiJuwL+qR5/QIlvD -Yo7jdCjelJat38MZ9dE1us8xlIjQtsYF/acZZtcpYho+7ZpDCNcb+xF8KStKei+B -MMpWISsa+Zh9g7lPYTnG/i1dSMMT100XCEw8o4rBoQKBgQDnptz8acp7DB2wJH4L -c0xuw8IlrSl3BGUEj8H+RyFlpH3+//i6/fE9MrtF8b4FSYUp5AG4NVFGcRbwJVGW -jeL13YwIKMdXjmx8fDIylCgBB1tzBS9T/0ws3HS8avxhKvjgoXIZm6D3XDcBslrH -c9/LojT8YGI1wx7jWI2qKj8yeQKBgQDcn+kQ1QjzgIz6bAVWY3t1jr5uHHyaS+5G -ihY/mx4Mn3DURgPXZHz/HrN9rZkax0zuq9wuIlqgZ2KI37iCF49M4aZxC788LyDo -Hp0Cak3wt3g0Tj6J7SJiQe8h/6VBS4R5dRD2vhEc3xPAOf7WIFdlLYBOOvE/LmOt -N6ChkfgGhQKBgQDSiDqLRPJ7BjXtIh1T9sPeXxeR+mCXBG1yydx7ZtYZdHf2S1kZ -STX4cqT1GpGiaIEX41sUuZBWPu2j76bI98bvwRxFRhp1nsFGGfHdOf1pgfBBBtNO -udXXZ7zIiUs6XD24mcIDOAgBB9QOPLR4VP1uKsuRG1/mkKD/6jlGEANDsQKBgQDC -AoEygxQnBVFz2c/rwvnLS+Zb8AMGsGTtdPrRnjeThBX1JUi1fbGJq1bN2v27Fa2q -aEjr7NvjGGcG1C1tgQhL5Fa4LEtTwmHenSUW/aJiXwR+gpvuMDC/VRnTvPp2a9En -+XEcedGUoPq+XIGjjLctyxB8Osrw83tF1JgV3MXN/QKBgQC83B54rYDd4QmVH5nL -WLw834fgr+Z1hA6UqJIaahlD/bDwzbbJEv0pHCBxe01ywQFivqWBdVbuoy9YSeLS -KKEklzh+L0SorrYoBA5F63qx0zy05bba0ASplgDUEUNZn7oIFi7x5pVsNNaNxZpR -bQGM8UrNQvWQ+tutRmp7PM6VuQ== ------END PRIVATE KEY-----"#; diff --git a/testsuite/tests/cli/dgw/mod.rs b/testsuite/tests/cli/dgw/mod.rs index acb5a50a6..aadf2cefa 100644 --- a/testsuite/tests/cli/dgw/mod.rs +++ b/testsuite/tests/cli/dgw/mod.rs @@ -5,4 +5,5 @@ mod cred_injection_kdc; mod heartbeat; mod preflight; mod tls_anchoring; +mod tls_fixtures; mod traffic_audit; diff --git a/testsuite/tests/cli/dgw/tls_anchoring.rs b/testsuite/tests/cli/dgw/tls_anchoring.rs index b45669ad2..79f355cf5 100644 --- a/testsuite/tests/cli/dgw/tls_anchoring.rs +++ b/testsuite/tests/cli/dgw/tls_anchoring.rs @@ -168,56 +168,7 @@ async fn start_dummy_tls_server() -> anyhow::Result { } mod tls { - /// Self-signed certificate for localhost (valid for 100 years). - pub(super) const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- -MIIDCzCCAfOgAwIBAgIUPRJa8i280unV3/kW6TE2fSUw8PwwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTEyNTA5NDAzMFoYDzIxMjUx -MTAxMDk0MDMwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQDHpBlyRgUx/V9cQGw/eqDFc6odxB2hvnbudi67LvEj -cNIWOU79R1e/NswME4oecqT9W05n4UyxkABfm2qjODO0nDf47W0DsgbEA87qE715 -RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgWa8rhPc7E6In1uDRak0lWKQPQSBbc -34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEvXp+g9WsuMaU913ecacWDjyWDkf80 -pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsgUbK+TMWvkF9bcxAQDnJsrZRL7Jfh -ofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1jrU3cPA7dAgMBAAGjUzBRMB0GA1Ud -DgQWBBTT+m6dyc/c3mXF3JAsZr9OqUwgWTAfBgNVHSMEGDAWgBTT+m6dyc/c3mXF -3JAsZr9OqUwgWTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBB -i/yonZY3ztaeGElzD8xkI+rJ+daJ5WzdfKnzudJllg/Ht8m7wO5SdQnMt2T44gbH -05uekc1zXnXb7fJKqs3R6DacctG0nQ3acuI+IMtTaBbbAcf3PJJlo0Pap0ypVC0R -IUiUhJGFNi4cCBOvJqsly0d3T5xqOXU1Q5j3mIwRBY68+m9btwwuZWvASRADtCyZ -RpisBzS4a6jSeHXa4iG/VhskbiZkcnfHNTw7yNJJdv125y2zQkWWF9wlLbYwWr40 -x9Ba6YbssOz6epATKhvt80yclO34AzUyimssvViIUpgFEyaPhZZTw46Q/6X3ixK4 -/v4eYM0cCHN0h+rynSor ------END CERTIFICATE-----"#; - - /// Private key for the self-signed certificate. - pub(super) const KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHpBlyRgUx/V9c -QGw/eqDFc6odxB2hvnbudi67LvEjcNIWOU79R1e/NswME4oecqT9W05n4UyxkABf -m2qjODO0nDf47W0DsgbEA87qE715RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgW -a8rhPc7E6In1uDRak0lWKQPQSBbc34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEv -Xp+g9WsuMaU913ecacWDjyWDkf80pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsg -UbK+TMWvkF9bcxAQDnJsrZRL7JfhofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1j -rU3cPA7dAgMBAAECggEAKh7KK5zwTaq6atlAvWfe8anEk4EkC1MG/qq6k02FHMgZ -2wx+SNu7fKFQDaA1vNTNUJLqCOq05qWOHp3IsuURq6JmAMP/Aw+Vc9el2ScPC74E -Dt09MmlZKl77H3fxPYwoFx5RHrbIuvoSH/DgHgOPU2YIbWpOyWlXyLDgmBoNkM3N -fXYLXJONpStPHeQLhh7LcHO3CZgn6kycJyByEO2NtcchS5zITiJuwL+qR5/QIlvD -Yo7jdCjelJat38MZ9dE1us8xlIjQtsYF/acZZtcpYho+7ZpDCNcb+xF8KStKei+B -MMpWISsa+Zh9g7lPYTnG/i1dSMMT100XCEw8o4rBoQKBgQDnptz8acp7DB2wJH4L -c0xuw8IlrSl3BGUEj8H+RyFlpH3+//i6/fE9MrtF8b4FSYUp5AG4NVFGcRbwJVGW -jeL13YwIKMdXjmx8fDIylCgBB1tzBS9T/0ws3HS8avxhKvjgoXIZm6D3XDcBslrH -c9/LojT8YGI1wx7jWI2qKj8yeQKBgQDcn+kQ1QjzgIz6bAVWY3t1jr5uHHyaS+5G -ihY/mx4Mn3DURgPXZHz/HrN9rZkax0zuq9wuIlqgZ2KI37iCF49M4aZxC788LyDo -Hp0Cak3wt3g0Tj6J7SJiQe8h/6VBS4R5dRD2vhEc3xPAOf7WIFdlLYBOOvE/LmOt -N6ChkfgGhQKBgQDSiDqLRPJ7BjXtIh1T9sPeXxeR+mCXBG1yydx7ZtYZdHf2S1kZ -STX4cqT1GpGiaIEX41sUuZBWPu2j76bI98bvwRxFRhp1nsFGGfHdOf1pgfBBBtNO -udXXZ7zIiUs6XD24mcIDOAgBB9QOPLR4VP1uKsuRG1/mkKD/6jlGEANDsQKBgQDC -AoEygxQnBVFz2c/rwvnLS+Zb8AMGsGTtdPrRnjeThBX1JUi1fbGJq1bN2v27Fa2q -aEjr7NvjGGcG1C1tgQhL5Fa4LEtTwmHenSUW/aJiXwR+gpvuMDC/VRnTvPp2a9En -+XEcedGUoPq+XIGjjLctyxB8Osrw83tF1JgV3MXN/QKBgQC83B54rYDd4QmVH5nL -WLw834fgr+Z1hA6UqJIaahlD/bDwzbbJEv0pHCBxe01ywQFivqWBdVbuoy9YSeLS -KKEklzh+L0SorrYoBA5F63qx0zy05bba0ASplgDUEUNZn7oIFi7x5pVsNNaNxZpR -bQGM8UrNQvWQ+tutRmp7PM6VuQ== ------END PRIVATE KEY-----"#; + pub(super) use super::super::tls_fixtures::{CERT_PEM, KEY_PEM}; /// SHA-256 thumbprint of the certificate. pub(super) const CERT_THUMBPRINT: &str = "bce13f257b9d856404c51b46f2420eff6d01b3a4c99fe3d0e11e4517c2291b70"; diff --git a/testsuite/tests/cli/dgw/tls_fixtures.rs b/testsuite/tests/cli/dgw/tls_fixtures.rs new file mode 100644 index 000000000..7d4d2f4b0 --- /dev/null +++ b/testsuite/tests/cli/dgw/tls_fixtures.rs @@ -0,0 +1,52 @@ +//! Shared localhost TLS material for process-level Gateway tests. + +/// Self-signed certificate for localhost (valid for 100 years). +pub(crate) const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- +MIIDCzCCAfOgAwIBAgIUPRJa8i280unV3/kW6TE2fSUw8PwwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTEyNTA5NDAzMFoYDzIxMjUx +MTAxMDk0MDMwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDHpBlyRgUx/V9cQGw/eqDFc6odxB2hvnbudi67LvEj +cNIWOU79R1e/NswME4oecqT9W05n4UyxkABfm2qjODO0nDf47W0DsgbEA87qE715 +RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgWa8rhPc7E6In1uDRak0lWKQPQSBbc +34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEvXp+g9WsuMaU913ecacWDjyWDkf80 +pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsgUbK+TMWvkF9bcxAQDnJsrZRL7Jfh +ofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1jrU3cPA7dAgMBAAGjUzBRMB0GA1Ud +DgQWBBTT+m6dyc/c3mXF3JAsZr9OqUwgWTAfBgNVHSMEGDAWgBTT+m6dyc/c3mXF +3JAsZr9OqUwgWTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBB +i/yonZY3ztaeGElzD8xkI+rJ+daJ5WzdfKnzudJllg/Ht8m7wO5SdQnMt2T44gbH +05uekc1zXnXb7fJKqs3R6DacctG0nQ3acuI+IMtTaBbbAcf3PJJlo0Pap0ypVC0R +IUiUhJGFNi4cCBOvJqsly0d3T5xqOXU1Q5j3mIwRBY68+m9btwwuZWvASRADtCyZ +RpisBzS4a6jSeHXa4iG/VhskbiZkcnfHNTw7yNJJdv125y2zQkWWF9wlLbYwWr40 +x9Ba6YbssOz6epATKhvt80yclO34AzUyimssvViIUpgFEyaPhZZTw46Q/6X3ixK4 +/v4eYM0cCHN0h+rynSor +-----END CERTIFICATE-----"#; + +/// Private key for the self-signed certificate. +pub(crate) const KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHpBlyRgUx/V9c +QGw/eqDFc6odxB2hvnbudi67LvEjcNIWOU79R1e/NswME4oecqT9W05n4UyxkABf +m2qjODO0nDf47W0DsgbEA87qE715RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgW +a8rhPc7E6In1uDRak0lWKQPQSBbc34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEv +Xp+g9WsuMaU913ecacWDjyWDkf80pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsg +UbK+TMWvkF9bcxAQDnJsrZRL7JfhofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1j +rU3cPA7dAgMBAAECggEAKh7KK5zwTaq6atlAvWfe8anEk4EkC1MG/qq6k02FHMgZ +2wx+SNu7fKFQDaA1vNTNUJLqCOq05qWOHp3IsuURq6JmAMP/Aw+Vc9el2ScPC74E +Dt09MmlZKl77H3fxPYwoFx5RHrbIuvoSH/DgHgOPU2YIbWpOyWlXyLDgmBoNkM3N +fXYLXJONpStPHeQLhh7LcHO3CZgn6kycJyByEO2NtcchS5zITiJuwL+qR5/QIlvD +Yo7jdCjelJat38MZ9dE1us8xlIjQtsYF/acZZtcpYho+7ZpDCNcb+xF8KStKei+B +MMpWISsa+Zh9g7lPYTnG/i1dSMMT100XCEw8o4rBoQKBgQDnptz8acp7DB2wJH4L +c0xuw8IlrSl3BGUEj8H+RyFlpH3+//i6/fE9MrtF8b4FSYUp5AG4NVFGcRbwJVGW +jeL13YwIKMdXjmx8fDIylCgBB1tzBS9T/0ws3HS8avxhKvjgoXIZm6D3XDcBslrH +c9/LojT8YGI1wx7jWI2qKj8yeQKBgQDcn+kQ1QjzgIz6bAVWY3t1jr5uHHyaS+5G +ihY/mx4Mn3DURgPXZHz/HrN9rZkax0zuq9wuIlqgZ2KI37iCF49M4aZxC788LyDo +Hp0Cak3wt3g0Tj6J7SJiQe8h/6VBS4R5dRD2vhEc3xPAOf7WIFdlLYBOOvE/LmOt +N6ChkfgGhQKBgQDSiDqLRPJ7BjXtIh1T9sPeXxeR+mCXBG1yydx7ZtYZdHf2S1kZ +STX4cqT1GpGiaIEX41sUuZBWPu2j76bI98bvwRxFRhp1nsFGGfHdOf1pgfBBBtNO +udXXZ7zIiUs6XD24mcIDOAgBB9QOPLR4VP1uKsuRG1/mkKD/6jlGEANDsQKBgQDC +AoEygxQnBVFz2c/rwvnLS+Zb8AMGsGTtdPrRnjeThBX1JUi1fbGJq1bN2v27Fa2q +aEjr7NvjGGcG1C1tgQhL5Fa4LEtTwmHenSUW/aJiXwR+gpvuMDC/VRnTvPp2a9En ++XEcedGUoPq+XIGjjLctyxB8Osrw83tF1JgV3MXN/QKBgQC83B54rYDd4QmVH5nL +WLw834fgr+Z1hA6UqJIaahlD/bDwzbbJEv0pHCBxe01ywQFivqWBdVbuoy9YSeLS +KKEklzh+L0SorrYoBA5F63qx0zy05bba0ASplgDUEUNZn7oIFi7x5pVsNNaNxZpR +bQGM8UrNQvWQ+tutRmp7PM6VuQ== +-----END PRIVATE KEY-----"#; From 3241eaf25c9f55f5637990e39a7be8b0d2fca380 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 25 Aug 2026 12:28:08 -0400 Subject: [PATCH 29/36] test(dgw): run injection E2E without debug flags Keep credential-injection tests focused on public behavior instead of serializing the removed Kerberos debug option. Issue: DVLS-14697 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testsuite/src/dgw_config.rs | 7 +----- testsuite/tests/cli/dgw/cred_injection.rs | 24 +++++++++---------- testsuite/tests/cli/dgw/cred_injection_kdc.rs | 16 ++++++------- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/testsuite/src/dgw_config.rs b/testsuite/src/dgw_config.rs index 885e71e48..f5fc7a441 100644 --- a/testsuite/src/dgw_config.rs +++ b/testsuite/src/dgw_config.rs @@ -45,9 +45,6 @@ pub struct DgwConfig { /// Enable unstable features. #[builder(default = false)] enable_unstable: bool, - /// Enable Kerberos credential injection (also requires `enable_unstable`). - #[builder(default = false)] - kerberos_credential_injection: bool, /// Override the recording path in the gateway config. /// /// When `None`, the gateway uses its default (`/recordings`). @@ -87,7 +84,6 @@ impl DgwConfigHandle { disable_token_validation, verbosity_profile, enable_unstable, - kerberos_credential_injection, recording_path, agent_tunnel, } = config; @@ -141,8 +137,7 @@ impl DgwConfigHandle { "VerbosityProfile": "{verbosity_profile}", "__debug__": {{ "disable_token_validation": {disable_token_validation}, - "enable_unstable": {enable_unstable}, - "kerberos_credential_injection": {kerberos_credential_injection} + "enable_unstable": {enable_unstable} }}{recording_path_json}{agent_tunnel_json} }}"# ); diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs index ae6250ca1..0e52625a9 100644 --- a/testsuite/tests/cli/dgw/cred_injection.rs +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -226,12 +226,10 @@ pub(crate) struct GatewayProc { } impl GatewayProc { - pub(crate) async fn start(kerberos: bool) -> anyhow::Result { + pub(crate) async fn start() -> anyhow::Result { let config = DgwConfig::builder() .disable_token_validation(true) .verbosity_profile(VerbosityProfile::DEBUG) - .enable_unstable(kerberos) - .kerberos_credential_injection(kerberos) .build() .init() .context("init gateway config")?; @@ -430,7 +428,7 @@ async fn connect_rdp_client(gateway_tcp: u16, association_jwt: &str) -> anyhow:: #[tokio::test] async fn first_rdp_connection_injects_ntlm() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; - let mut gateway = GatewayProc::start(false).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -462,7 +460,7 @@ async fn first_rdp_connection_injects_ntlm() -> anyhow::Result<()> { #[tokio::test] async fn reconnect_same_jwt_still_injects() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; - let mut gateway = GatewayProc::start(false).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -500,7 +498,7 @@ async fn reconnect_same_jwt_still_injects() -> anyhow::Result<()> { #[tokio::test] async fn required_missing_fails_closed() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; - let mut gateway = GatewayProc::start(false).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -533,7 +531,7 @@ async fn required_missing_fails_closed() -> anyhow::Result<()> { #[tokio::test] async fn unprovisioned_rdp_uses_ordinary_forward() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; - let mut gateway = GatewayProc::start(false).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -560,7 +558,7 @@ async fn unprovisioned_rdp_uses_ordinary_forward() -> anyhow::Result<()> { #[tokio::test] async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; - let mut gateway = GatewayProc::start(true).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -647,7 +645,7 @@ async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Res #[tokio::test] async fn domainless_target_stays_ntlm_even_with_krb_kdc() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; - let mut gateway = GatewayProc::start(true).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -673,9 +671,9 @@ async fn domainless_target_stays_ntlm_even_with_krb_kdc() -> anyhow::Result<()> } #[tokio::test] -async fn kerberos_opt_out_uses_ntlm_for_domain_user() -> anyhow::Result<()> { +async fn kerberos_injection_does_not_need_debug_flags() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; - let mut gateway = GatewayProc::start(false).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -692,8 +690,8 @@ async fn kerberos_opt_out_uses_ntlm_for_domain_user() -> anyhow::Result<()> { let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; let logs = gateway.logs.wait_contains(INJECT_LOG).await?; assert!( - logs.contains("kerberos=false"), - "Kerberos injection opt-out must NTLM even with a domain username; logs:\n{logs}" + logs.contains("kerberos=true"), + "Kerberos injection must run without debug flags; logs:\n{logs}" ); let _ = gateway.process.start_kill(); diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index ea79ca9a3..95bc21662 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -972,7 +972,7 @@ async fn kerberos_injection_completes_credssp_against_mock_kdc() -> anyhow::Resu install_crypto_provider(); let kdc = MockKdc::start().await?; let rdp = MockRdp::start_kerberos(kdc.url()).await?; - let mut gateway = GatewayProc::start(true).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -1021,7 +1021,7 @@ async fn kerberos_client_and_target_legs_complete_credssp() -> anyhow::Result<() install_crypto_provider(); let kdc = MockKdc::start().await?; let rdp = MockRdp::start_kerberos(kdc.url()).await?; - let mut gateway = GatewayProc::start(true).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -1111,7 +1111,7 @@ async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { install_crypto_provider(); let kdc = MockKdc::start().await?; let rdp = MockRdp::start_kerberos(kdc.url()).await?; - let mut gateway = GatewayProc::start(true).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -1219,7 +1219,7 @@ async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { let kdc = RefusingKdc::start().await?; let rdp_kdc = MockKdc::start().await?; let rdp = MockRdp::start_kerberos(rdp_kdc.url()).await?; - let mut gateway = GatewayProc::start(true).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -1277,7 +1277,7 @@ async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { #[tokio::test] async fn kerberos_missing_krb_kdc_fails_closed() -> anyhow::Result<()> { let rdp = FakeClosedTarget::start().await?; - let mut gateway = GatewayProc::start(true).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -1309,7 +1309,7 @@ async fn kerberos_missing_krb_kdc_fails_closed() -> anyhow::Result<()> { async fn ntlm_injection_completes_credssp_both_legs() -> anyhow::Result<()> { install_crypto_provider(); let rdp = MockRdp::start_ntlm().await?; - let mut gateway = GatewayProc::start(false).await?; + let mut gateway = GatewayProc::start().await?; let jti = next_id(); let jet_aid = next_id(); @@ -1514,7 +1514,7 @@ async fn ironrdp_agent_rdcleanpath_ntlm_injection() -> anyhow::Result<()> { }; install_crypto_provider(); let rdp = MockRdp::start_ntlm().await?; - let mut gateway = GatewayProc::start(false).await?; + let mut gateway = GatewayProc::start().await?; let endpoint = ironrdp_agent_endpoint(); let mut daemon = start_ironrdp_daemon(&bin, &endpoint).await?; @@ -1573,7 +1573,7 @@ async fn ironrdp_agent_rdcleanpath_kerberos_injection() -> anyhow::Result<()> { install_crypto_provider(); let kdc = MockKdc::start().await?; let rdp = MockRdp::start_kerberos(kdc.url()).await?; - let mut gateway = GatewayProc::start(true).await?; + let mut gateway = GatewayProc::start().await?; let endpoint = ironrdp_agent_endpoint(); let mut daemon = start_ironrdp_daemon(&bin, &endpoint).await?; From 7a6fd9a6914f3b296430daf0907473307d37d1f3 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 25 Aug 2026 18:17:56 -0400 Subject: [PATCH 30/36] test(dgw): allow forwarding after staging expiry Treat an expired staging mapping as absent because association tokens do not carry an injection requirement. Verify ordinary forwarding preserves the client X.224 cookie. Issue: DVLS-14697 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testsuite/tests/cli/dgw/cred_injection.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs index 0e52625a9..ca7b6bd1d 100644 --- a/testsuite/tests/cli/dgw/cred_injection.rs +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -496,7 +496,7 @@ async fn reconnect_same_jwt_still_injects() -> anyhow::Result<()> { } #[tokio::test] -async fn required_missing_fails_closed() -> anyhow::Result<()> { +async fn expired_staging_uses_ordinary_forward() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; let mut gateway = GatewayProc::start().await?; @@ -507,22 +507,18 @@ async fn required_missing_fails_closed() -> anyhow::Result<()> { tokio::time::sleep(Duration::from_secs(2)).await; let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; - let logs = gateway.logs.wait_contains(MISSING_LOG).await?; + let logs = gateway.logs.wait_contains(FORWARD_LOG).await?; assert!( !logs.contains(INJECT_LOG), - "expired mapping must not inject; logs:\n{logs}" - ); - assert!( - !logs.contains(FORWARD_LOG), - "expired mapping must fail closed, never silent ordinary forward; logs:\n{logs}" + "evicted staging credentials must not inject; logs:\n{logs}" ); - let deadline = Instant::now() + Duration::from_secs(2); - while Instant::now() < deadline { - anyhow::ensure!(target.accepted() == 0, "fail-closed routing must not connect upstream"); - tokio::time::sleep(Duration::from_millis(50)).await; - } - assert_eq!(target.accepted(), 0, "fail-closed routing must not connect upstream"); + let cookies = target.wait_cookies(1).await?; + assert_eq!( + cookies, + vec![CLIENT_COOKIE], + "ordinary forward must keep the decoded client cookie" + ); let _ = gateway.process.start_kill(); Ok(()) From 2bc49086c9dbd50fa86e8c93882d2bf5de1ed092 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Tue, 25 Aug 2026 20:22:05 -0400 Subject: [PATCH 31/36] test(dgw): use direct rustls paths Use the direct rustls dependency added on master so the credential-injection E2E compiles under the workspace unused-qualifications lint. Issue: DVLS-14697 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testsuite/tests/cli/dgw/cred_injection_kdc.rs | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index 95bc21662..409ee8ef1 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -23,11 +23,11 @@ use ironrdp_pdu::x224::X224; use ironrdp_tokio::{FramedWrite as _, TokioFramed}; use picky_krb::data_types::PrincipalName; use picky_krb::messages::{AsRep, AsReq, KdcProxyMessage, KrbError, TgsRep, TgsReq}; +use rustls::pki_types::pem::PemObject as _; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use rustls::{ClientConfig, ServerConfig}; use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; use tokio::net::{TcpListener, TcpStream}; -use tokio_rustls::rustls::pki_types::pem::PemObject as _; -use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; -use tokio_rustls::rustls::{ClientConfig, ServerConfig}; use x509_cert::der::Decode as _; use super::cred_injection::{ @@ -895,53 +895,53 @@ fn dangerous_tls_connector() -> tokio_rustls::TlsConnector { .dangerous() .with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) .with_no_client_auth(); - config.resumption = tokio_rustls::rustls::client::Resumption::disabled(); + config.resumption = rustls::client::Resumption::disabled(); tokio_rustls::TlsConnector::from(Arc::new(config)) } fn install_crypto_provider() { - let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default(); + let _ = rustls::crypto::ring::default_provider().install_default(); } #[derive(Debug)] struct NoCertificateVerification; -impl tokio_rustls::rustls::client::danger::ServerCertVerifier for NoCertificateVerification { +impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { fn verify_server_cert( &self, _: &CertificateDer<'_>, _: &[CertificateDer<'_>], _: &ServerName<'_>, _: &[u8], - _: tokio_rustls::rustls::pki_types::UnixTime, - ) -> Result { - Ok(tokio_rustls::rustls::client::danger::ServerCertVerified::assertion()) + _: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) } fn verify_tls12_signature( &self, _: &[u8], _: &CertificateDer<'_>, - _: &tokio_rustls::rustls::DigitallySignedStruct, - ) -> Result { - Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion()) + _: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _: &[u8], _: &CertificateDer<'_>, - _: &tokio_rustls::rustls::DigitallySignedStruct, - ) -> Result { - Ok(tokio_rustls::rustls::client::danger::HandshakeSignatureValid::assertion()) + _: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } - fn supported_verify_schemes(&self) -> Vec { + fn supported_verify_schemes(&self) -> Vec { vec![ - tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA256, - tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP256_SHA256, - tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA256, - tokio_rustls::rustls::SignatureScheme::ED25519, + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::ED25519, ] } } From f2c24bdfaab1fffb2f55e9b32cf5564de55ed59a Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Thu, 27 Aug 2026 10:55:41 -0400 Subject: [PATCH 32/36] test(dgw): move injection helpers into testsuite lib Move GatewayProc, provisioning, fake RDP target, mock KDC/RDP, CredSSP client leg, agent harness, and TLS fixtures from the test files into testsuite/src so the abstractions are self-contained, per review. Add empty INTENT.md placeholders to be filled by hand. Issue: DVLS-14697 --- testsuite/Cargo.toml | 24 +- testsuite/src/lib.rs | 2 + testsuite/src/rdp_injection/INTENT.md | 0 testsuite/src/rdp_injection/agent.rs | 146 ++ testsuite/src/rdp_injection/credssp.rs | 292 ++++ testsuite/src/rdp_injection/gateway.rs | 116 ++ testsuite/src/rdp_injection/mock_kdc.rs | 242 ++++ testsuite/src/rdp_injection/mock_rdp.rs | 336 +++++ testsuite/src/rdp_injection/mod.rs | 38 + testsuite/src/rdp_injection/preflight.rs | 141 ++ testsuite/src/rdp_injection/rdp.rs | 168 +++ testsuite/src/rdp_injection/tls.rs | 105 ++ testsuite/src/rdp_injection/tokens.rs | 73 + .../{tests/cli/dgw => src}/tls_fixtures.rs | 4 +- .../tests/cli/dgw/cred_injection.intent.md | 0 testsuite/tests/cli/dgw/cred_injection.rs | 426 +----- .../cli/dgw/cred_injection_kdc.intent.md | 0 testsuite/tests/cli/dgw/cred_injection_kdc.rs | 1169 +---------------- testsuite/tests/cli/dgw/mod.rs | 1 - testsuite/tests/cli/dgw/tls_anchoring.rs | 2 +- 20 files changed, 1702 insertions(+), 1583 deletions(-) create mode 100644 testsuite/src/rdp_injection/INTENT.md create mode 100644 testsuite/src/rdp_injection/agent.rs create mode 100644 testsuite/src/rdp_injection/credssp.rs create mode 100644 testsuite/src/rdp_injection/gateway.rs create mode 100644 testsuite/src/rdp_injection/mock_kdc.rs create mode 100644 testsuite/src/rdp_injection/mock_rdp.rs create mode 100644 testsuite/src/rdp_injection/mod.rs create mode 100644 testsuite/src/rdp_injection/preflight.rs create mode 100644 testsuite/src/rdp_injection/rdp.rs create mode 100644 testsuite/src/rdp_injection/tls.rs create mode 100644 testsuite/src/rdp_injection/tokens.rs rename testsuite/{tests/cli/dgw => src}/tls_fixtures.rs (95%) create mode 100644 testsuite/tests/cli/dgw/cred_injection.intent.md create mode 100644 testsuite/tests/cli/dgw/cred_injection_kdc.intent.md diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 1a0b7c3a3..f5272a52b 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -18,34 +18,37 @@ harness = true [dependencies] anyhow = "1.0" assert_cmd = "2.2" +base64 = "0.23" dynosaur = "0.3" escargot = "0.5" expect-test = "1.5" fastrand = "2" +ironrdp-connector = "0.10" +ironrdp-core = { version = "0.2", features = ["std"] } +ironrdp-pdu = { version = "0.9", features = ["std"] } +ironrdp-tokio = "0.10" +kdc = "0.1" +picky-asn1-der = "0.5" +picky-krb = "0.12" +rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } serde_json = "1" serde = { version = "1", features = ["derive"] } tempfile = "3" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "process"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "process", "io-util"] } +tokio-rustls = { version = "0.26", features = ["ring"] } tokio-util = "0.7" typed-builder = "0.21" tokio-tungstenite = { version = "0.29", features = ["rustls-tls-native-roots"] } +x509-cert = { version = "0.3", default-features = false, features = ["std"] } [dev-dependencies] agent-tunnel = { path = "../crates/agent-tunnel", features = ["test-utils"] } agent-tunnel-proto = { path = "../crates/agent-tunnel-proto", features = ["serde"] } -base64 = "0.23" camino = "1" devolutions-gateway-task = { path = "../crates/devolutions-gateway-task" } devolutions-gateway = { path = "../devolutions-gateway" } futures-util = "0.3" ipnetwork = "0.20" -ironrdp-connector = "0.10" -ironrdp-core = { version = "0.2", features = ["std"] } -ironrdp-pdu = { version = "0.9", features = ["std"] } -ironrdp-tokio = "0.10" -kdc = "0.1" -picky-asn1-der = "0.5" -picky-krb = "0.12" libsql = { version = "0.9", default-features = false, features = ["core"] } mcp-proxy.path = "../crates/mcp-proxy" network-scanner = { path = "../crates/network-scanner", features = ["test-utils"] } @@ -57,16 +60,13 @@ quinn = "0.11" rcgen = { version = "0.13", features = ["pem", "x509-parser"] } reqwest = { version = "0.12", default-features = false, features = ["json"] } rstest = "0.25" -rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls-pemfile = "2" rustls-pki-types = "1" serde_json = "1" sysevent.path = "../crates/sysevent" tempfile = "3" test-utils.path = "../crates/test-utils" -tokio-rustls = { version = "0.26", features = ["ring"] } uuid = { version = "1", features = ["v4"] } -x509-cert = { version = "0.3", default-features = false, features = ["std"] } [target.'cfg(unix)'.dev-dependencies] sysevent-syslog.path = "../crates/sysevent-syslog" diff --git a/testsuite/src/lib.rs b/testsuite/src/lib.rs index 56ae206ba..d5fafab8d 100644 --- a/testsuite/src/lib.rs +++ b/testsuite/src/lib.rs @@ -8,3 +8,5 @@ pub mod cli; pub mod dgw_config; pub mod mcp_client; pub mod mcp_server; +pub mod rdp_injection; +pub mod tls_fixtures; diff --git a/testsuite/src/rdp_injection/INTENT.md b/testsuite/src/rdp_injection/INTENT.md new file mode 100644 index 000000000..e69de29bb diff --git a/testsuite/src/rdp_injection/agent.rs b/testsuite/src/rdp_injection/agent.rs new file mode 100644 index 000000000..09d75d9b6 --- /dev/null +++ b/testsuite/src/rdp_injection/agent.rs @@ -0,0 +1,146 @@ +//! Drives the public `ironrdp-agent` CLI as a real RDCleanPath client. Tests skip when the +//! binary is not installed (`cargo install ironrdp-agent --version 0.1.0`). + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; + +use super::tokens::next_id; +use super::{PROXY_PASSWORD, PROXY_USER}; + +pub const IRONRDP_AGENT_VERSION: &str = "0.1.0"; + +fn ironrdp_agent_bin() -> Option { + if let Ok(path) = std::env::var("IRONRDP_AGENT") { + return Some(PathBuf::from(path)); + } + let name = if cfg!(windows) { + "ironrdp-agent.exe" + } else { + "ironrdp-agent" + }; + if let Ok(home) = std::env::var("CARGO_HOME") { + let path = PathBuf::from(home).join("bin").join(name); + if path.is_file() { + return Some(path); + } + } + let cargo_home = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .map(|home| home.join(".cargo").join("bin").join(name)); + if let Some(path) = cargo_home + && path.is_file() + { + return Some(path); + } + if let Ok(path) = std::env::var("PATH") { + for dir in std::env::split_paths(&path) { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +pub fn require_ironrdp_agent() -> anyhow::Result> { + let Some(bin) = ironrdp_agent_bin() else { + eprintln!( + "skipping RDCleanPath ironrdp-agent test: cargo install ironrdp-agent --version {IRONRDP_AGENT_VERSION}" + ); + return Ok(None); + }; + let output = std::process::Command::new(&bin) + .arg("--version") + .output() + .with_context(|| format!("run {} --version", bin.display()))?; + let version = String::from_utf8_lossy(&output.stdout); + anyhow::ensure!( + version.contains(IRONRDP_AGENT_VERSION), + "expected ironrdp-agent {IRONRDP_AGENT_VERSION}, got {version:?} from {}", + bin.display() + ); + Ok(Some(bin)) +} + +pub fn ironrdp_agent_endpoint() -> String { + let name = format!("ironrdp-e2e-{}", next_id().replace('-', "")); + if cfg!(windows) { + format!(r"\\.\pipe\{name}") + } else { + std::env::temp_dir().join(format!("{name}.sock")).display().to_string() + } +} + +pub async fn start_ironrdp_daemon(bin: &Path, endpoint: &str) -> anyhow::Result { + let child = tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "daemon-start"]) + .kill_on_drop(true) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("start ironrdp-agent daemon")?; + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let status = tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "status"]) + .output() + .await + .context("ironrdp-agent status")?; + if status.status.success() { + return Ok(child); + } + if Instant::now() >= deadline { + anyhow::bail!( + "ironrdp-agent daemon not ready at {endpoint}: {}", + String::from_utf8_lossy(&status.stderr) + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +pub async fn connect_ironrdp_rdcleanpath( + bin: &Path, + endpoint: &str, + server: &str, + token: &str, + http_port: u16, +) -> anyhow::Result { + let url = format!("ws://127.0.0.1:{http_port}/jet/rdp"); + tokio::process::Command::new(bin) + .args([ + "--endpoint", + endpoint, + "connect", + "--server", + server, + "--username", + PROXY_USER, + "--password", + PROXY_PASSWORD, + "--prop", + &format!("ironrdp_rdcleanpathurl:s:{url}"), + "--prop", + &format!("ironrdp_rdcleanpathtoken:s:{token}"), + ]) + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("start ironrdp-agent connect") +} + +pub async fn agent_query_logs(bin: &Path, endpoint: &str) -> String { + tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "query-logs"]) + .output() + .await + .ok() + .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) + .unwrap_or_default() +} diff --git a/testsuite/src/rdp_injection/credssp.rs b/testsuite/src/rdp_injection/credssp.rs new file mode 100644 index 000000000..2979f71f8 --- /dev/null +++ b/testsuite/src/rdp_injection/credssp.rs @@ -0,0 +1,292 @@ +//! Client-side CredSSP against the Gateway: X.224 negotiation, TLS, then an sspi +//! `CredSspClient` whose KDC traffic is resolved over TCP or through `/jet/KdcProxy`. + +use std::sync::Mutex; +use std::time::Duration; + +use anyhow::Context as _; +use ironrdp_connector::sspi; +use ironrdp_connector::sspi::generator::GeneratorState; +use ironrdp_pdu::nego::{ConnectionConfirm, SecurityProtocol}; +use ironrdp_pdu::x224::X224; +use ironrdp_tokio::{FramedWrite as _, TokioFramed}; +use picky_krb::messages::KdcProxyMessage; +use rustls::pki_types::ServerName; +use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader}; +use tokio::net::TcpStream; + +use super::mock_kdc::{ObservedKdcReply, ObservedKdcReq, observe_kdc_reply, observe_kdc_req, send_kdc_tcp}; +use super::rdp::{encode_hybrid_cr, encode_pcb}; +use super::tls::{dangerous_tls_connector, peer_public_key}; +use super::{PROXY_PASSWORD, PROXY_USER, SERVICE_HOST}; + +pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20); + +pub async fn connect_ntlm_client( + gateway_tcp: u16, + association_jwt: &str, +) -> anyhow::Result> { + tokio::time::timeout( + HANDSHAKE_TIMEOUT, + connect_ntlm_client_inner(gateway_tcp, association_jwt), + ) + .await + .context("timed out connecting NTLM client to Gateway")? +} + +async fn connect_ntlm_client_inner( + gateway_tcp: u16, + association_jwt: &str, +) -> anyhow::Result> { + let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) + .await + .context("connect gateway TCP")?; + stream + .write_all(&encode_pcb(association_jwt)?) + .await + .context("write PCB")?; + stream.write_all(&encode_hybrid_cr()?).await.context("write X.224 CR")?; + stream.flush().await.context("flush CR")?; + + let mut framed = TokioFramed::new(stream); + let (_, confirm) = framed.read_pdu().await.context("read X.224 CC")?; + let confirm: X224 = ironrdp_core::decode(&confirm).context("decode X.224 CC")?; + anyhow::ensure!( + matches!(confirm.0, ConnectionConfirm::Response { protocol, .. } if protocol.contains(SecurityProtocol::HYBRID)), + "gateway did not confirm CredSSP: {confirm:?}" + ); + + let tcp = framed.into_inner_no_leftover(); + let connector = dangerous_tls_connector(); + let server_name = ServerName::try_from("localhost").map_err(|error| anyhow::anyhow!("{error}"))?; + connector.connect(server_name, tcp).await.context("TLS to gateway") +} + +pub async fn complete_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { + complete_client_credssp(tls, PROXY_USER, None, false, None, None).await +} + +pub async fn complete_raw_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { + complete_client_credssp(tls, PROXY_USER, None, true, None, None).await +} + +pub async fn complete_client_credssp( + tls: tokio_rustls::client::TlsStream, + username: &str, + kdc_proxy_url: Option<&str>, + raw_ntlm: bool, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result<()> { + tokio::time::timeout( + HANDSHAKE_TIMEOUT, + complete_client_credssp_inner(tls, username, kdc_proxy_url, raw_ntlm, proxy_replies, proxy_requests), + ) + .await + .context("timed out completing client CredSSP")? +} + +async fn complete_client_credssp_inner( + tls: tokio_rustls::client::TlsStream, + username: &str, + kdc_proxy_url: Option<&str>, + raw_ntlm: bool, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result<()> { + use sspi::credssp::{ClientMode, ClientState, CredSspClient, CredSspMode, TsRequest}; + use sspi::ntlm::NtlmConfig; + + let public_key = peer_public_key(&tls)?; + let mut framed = TokioFramed::new(tls); + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(username).context("parse client username")?, + password: PROXY_PASSWORD.to_owned().into(), + }; + let client_mode = if let Some(kdc_url) = kdc_proxy_url { + ClientMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(sspi::KerberosConfig { + kdc_url: Some(kdc_url.parse().context("parse KDC proxy URL")?), + client_computer_name: "cred-injection-e2e".to_owned(), + }), + Some("kerberos,!ntlm".to_owned()), + "cred-injection-e2e".to_owned(), + )) + } else if raw_ntlm { + // Gateway NTLM injection uses ServerMode::Ntlm, which rejects SPNEGO. + ClientMode::Ntlm(NtlmConfig { + client_computer_name: Some("cred-injection-e2e".to_owned()), + }) + } else { + ClientMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(NtlmConfig { + client_computer_name: Some("cred-injection-e2e".to_owned()), + }), + Some("ntlm,!kerberos,!pku2u".to_owned()), + "cred-injection-e2e".to_owned(), + )) + }; + let mut client = CredSspClient::new( + public_key, + identity.into(), + CredSspMode::WithCredentials, + client_mode, + format!("TERMSRV/{SERVICE_HOST}"), + ) + .context("init CredSSP client")?; + + let mut ts_request = TsRequest::default(); + let mut buf = ironrdp_pdu::WriteBuf::new(); + let hint = TsRequestHint; + + for _ in 0..8 { + let client_state = { + let mut generator = client.process(std::mem::take(&mut ts_request)); + resolve_sspi_client(&mut generator, proxy_replies, proxy_requests).await? + }; + let (outbound, finished) = match client_state { + ClientState::ReplyNeeded(request) => (request, false), + ClientState::FinalMessage(request) => (request, true), + }; + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode client TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write client CredSSP")?; + if finished { + return Ok(()); + } + let pdu = framed.read_by_hint(&hint).await.context("read server CredSSP")?; + ts_request = TsRequest::from_buffer(&pdu).context("decode server TSRequest")?; + } + + anyhow::bail!("CredSSP exceeded 8 round trips") +} + +async fn resolve_sspi_client( + generator: &mut sspi::generator::Generator< + '_, + sspi::generator::NetworkRequest, + sspi::Result>, + sspi::Result, + >, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result { + let mut state = generator.start(); + loop { + match state { + GeneratorState::Suspended(request) => { + let reply = match request.url.scheme() { + "tcp" | "udp" => send_kdc_tcp(&request).await?, + "http" | "https" => send_kdc_http(&request, proxy_replies, proxy_requests).await?, + other => anyhow::bail!("unsupported KDC scheme {other}: {}", request.url), + }; + state = generator.resume(Ok(reply)); + } + GeneratorState::Completed(result) => { + break result.map_err(|error| anyhow::anyhow!("client CredSSP: {error}")); + } + } + } +} + +async fn send_kdc_http( + request: &sspi::generator::NetworkRequest, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result> { + let host = request.url.host_str().context("KDC proxy host")?; + let port = request.url.port_or_known_default().unwrap_or(80); + let path = if request.url.query().is_some() { + format!("{}?{}", request.url.path(), request.url.query().unwrap_or_default()) + } else { + request.url.path().to_owned() + }; + let mut stream = TcpStream::connect((host, port)).await.context("connect KDC proxy")?; + let header = format!( + "POST {path} HTTP/1.1\r\n\ + Host: {host}:{port}\r\n\ + Content-Type: application/octet-stream\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + request.data.len() + ); + stream + .write_all(header.as_bytes()) + .await + .context("write KDC proxy headers")?; + stream.write_all(&request.data).await.context("write KDC proxy body")?; + stream.flush().await.context("flush KDC proxy")?; + if let Ok(message) = KdcProxyMessage::from_raw(&request.data) + && let Some(log) = proxy_requests + { + let kerb = message.kerb_message.0.0.get(4..).unwrap_or(&message.kerb_message.0.0); + log.lock().expect("proxy request mutex").push(observe_kdc_req(kerb)); + } + + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + reader + .read_line(&mut status_line) + .await + .context("read KDC proxy status")?; + anyhow::ensure!( + status_line.starts_with("HTTP/1.1 200") || status_line.starts_with("HTTP/1.0 200"), + "KDC proxy HTTP status was {status_line:?}" + ); + + let mut content_length = None; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.context("read KDC proxy header")?; + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().to_owned()) + { + content_length = Some(value.parse::().context("parse KDC proxy Content-Length")?); + } + } + + let buf = if let Some(len) = content_length { + let mut buf = vec![0u8; len]; + tokio::io::AsyncReadExt::read_exact(&mut reader, &mut buf) + .await + .context("read KDC proxy body")?; + buf + } else { + let mut buf = Vec::new(); + tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buf) + .await + .context("read KDC proxy eof body")?; + buf + }; + if let Ok(message) = KdcProxyMessage::from_raw(&buf) + && let Some(log) = proxy_replies + { + log.lock() + .expect("proxy reply mutex") + .push(observe_kdc_reply(&message.kerb_message.0.0)); + } + Ok(buf) +} + +#[derive(Debug)] +pub(crate) struct TsRequestHint; + +impl ironrdp_pdu::PduHint for TsRequestHint { + fn find_size(&self, bytes: &[u8]) -> ironrdp_core::DecodeResult> { + match sspi::credssp::TsRequest::read_length(bytes) { + Ok(length) => Ok(Some((true, length))), + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(None), + Err(error) => Err(ironrdp_core::other_err!("TsRequestHint", source: error)), + } + } +} diff --git a/testsuite/src/rdp_injection/gateway.rs b/testsuite/src/rdp_injection/gateway.rs new file mode 100644 index 000000000..7633358cd --- /dev/null +++ b/testsuite/src/rdp_injection/gateway.rs @@ -0,0 +1,116 @@ +//! Runs a real Gateway child process and exposes its logs for assertions. + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use tokio::io::{AsyncBufReadExt as _, BufReader}; +use tokio::process::Child; + +use crate::cli::{dgw_tokio_cmd, wait_for_tcp_port}; +use crate::dgw_config::{DgwConfig, DgwConfigHandle, VerbosityProfile}; + +fn strip_ansi(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' && chars.peek() == Some(&'[') { + chars.next(); + for next in chars.by_ref() { + if next.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out +} + +pub struct LogBuffer(Arc>); + +impl LogBuffer { + fn new() -> Self { + Self(Arc::new(Mutex::new(String::new()))) + } + + pub fn snapshot(&self) -> String { + strip_ansi(&self.0.lock().expect("log mutex")) + } + + pub async fn wait_contains(&self, needle: &str) -> anyhow::Result { + self.wait_count(needle, 1).await + } + + pub async fn wait_count(&self, needle: &str, count: usize) -> anyhow::Result { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let snapshot = self.snapshot(); + if snapshot.matches(needle).count() >= count { + return Ok(snapshot); + } + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for {count} occurrence(s) of {needle:?}; logs:\n{snapshot}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +pub struct GatewayProc { + pub config: DgwConfigHandle, + pub process: Child, + pub logs: LogBuffer, +} + +impl GatewayProc { + pub async fn start() -> anyhow::Result { + let config = DgwConfig::builder() + .disable_token_validation(true) + .verbosity_profile(VerbosityProfile::DEBUG) + .build() + .init() + .context("init gateway config")?; + + let mut process = dgw_tokio_cmd() + .env("DGATEWAY_CONFIG_PATH", config.config_dir()) + .env("RUST_LOG", "devolutions_gateway=debug") + .env("NO_COLOR", "1") + .kill_on_drop(true) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("start Devolutions Gateway")?; + + let logs = LogBuffer::new(); + spawn_stdio_collector(process.stdout.take(), Arc::clone(&logs.0)); + spawn_stdio_collector(process.stderr.take(), Arc::clone(&logs.0)); + + wait_for_tcp_port(config.http_port()) + .await + .context("wait for gateway HTTP port")?; + + Ok(Self { config, process, logs }) + } +} + +fn spawn_stdio_collector(stream: Option, logs: Arc>) +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, +{ + let Some(stream) = stream else { + return; + }; + tokio::spawn(async move { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => logs.lock().expect("log mutex").push_str(&line), + } + } + }); +} diff --git a/testsuite/src/rdp_injection/mock_kdc.rs b/testsuite/src/rdp_injection/mock_kdc.rs new file mode 100644 index 000000000..d64384825 --- /dev/null +++ b/testsuite/src/rdp_injection/mock_kdc.rs @@ -0,0 +1,242 @@ +//! Loopback Kerberos KDCs: a real one backed by the `kdc` crate (the same crate the Gateway +//! embeds for its synthetic KDC) and a refusing one for fail-closed coverage. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use anyhow::Context as _; +use ironrdp_connector::sspi; +use picky_krb::data_types::PrincipalName; +use picky_krb::messages::{AsRep, AsReq, KdcProxyMessage, KrbError, TgsRep, TgsReq}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; + +use super::{KRBTGT_KEY, REALM, SERVICE_HOST, TARGET_PASSWORD, TERMSRV_KEY}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ObservedKdcReq { + As { cname: String, realm: String }, + Tgs { sname: Vec, realm: String }, + Other, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ObservedKdcReply { + AsRep, + TgsRep, + KrbError, + Other, +} + +pub struct MockKdc { + port: u16, + exchanges: Arc, + requests: Arc>>, +} + +impl MockKdc { + pub async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind mock KDC")?; + let port = listener.local_addr().context("mock KDC local_addr")?.port(); + let exchanges = Arc::new(AtomicUsize::new(0)); + let requests = Arc::new(Mutex::new(Vec::new())); + let exchanges_task = Arc::clone(&exchanges); + let requests_task = Arc::clone(&requests); + let config = kdc_config(); + + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let config = config.clone(); + let exchanges = Arc::clone(&exchanges_task); + let requests = Arc::clone(&requests_task); + tokio::spawn(async move { + match serve_kdc_exchange(stream, &config, &requests).await { + Ok(()) => { + exchanges.fetch_add(1, Ordering::SeqCst); + } + Err(error) => eprintln!("mock KDC exchange failed: {error:#}"), + } + }); + } + }); + + Ok(Self { + port, + exchanges, + requests, + }) + } + + pub fn url(&self) -> String { + format!("tcp://127.0.0.1:{}", self.port) + } + + pub fn exchanges(&self) -> usize { + self.exchanges.load(Ordering::SeqCst) + } + + pub fn requests(&self) -> Vec { + self.requests.lock().expect("kdc request mutex").clone() + } +} + +fn kdc_config() -> kdc::config::KerberosServer { + let username = format!("administrator@{REALM}"); + kdc::config::KerberosServer { + realm: REALM.to_owned(), + users: vec![kdc::config::DomainUser { + username, + password: TARGET_PASSWORD.to_owned(), + salt: format!("{}administrator", REALM.to_ascii_uppercase()), + }], + max_time_skew: 300, + krbtgt_key: KRBTGT_KEY.to_vec(), + ticket_decryption_key: Some(TERMSRV_KEY.to_vec()), + service_user: None, + } +} + +async fn serve_kdc_exchange( + mut stream: TcpStream, + config: &kdc::config::KerberosServer, + requests: &Mutex>, +) -> anyhow::Result<()> { + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.context("read KDC length")?; + let len = usize::try_from(u32::from_be_bytes(len_buf)).context("KDC length")?; + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.context("read KDC body")?; + + requests.lock().expect("kdc request mutex").push(observe_kdc_req(&body)); + + let mut raw = Vec::with_capacity(4 + len); + raw.extend_from_slice(&len_buf); + raw.extend_from_slice(&body); + + let request = KdcProxyMessage::from_raw_kerb_message(&raw).context("wrap KDC TCP payload")?; + let reply = kdc::handle_kdc_proxy_message(request, config, SERVICE_HOST).context("handle KDC message")?; + stream + .write_all(&reply.kerb_message.0.0) + .await + .context("write KDC reply")?; + Ok(()) +} + +fn principal_strings(name: &PrincipalName) -> Vec { + name.name_string.0.0.iter().map(|part| part.0.to_string()).collect() +} + +pub(crate) fn observe_kdc_req(body: &[u8]) -> ObservedKdcReq { + if let Ok(as_req) = picky_asn1_der::from_bytes::(body) { + let req = &as_req.0.req_body.0; + let cname = req + .cname + .0 + .as_ref() + .map(|name| principal_strings(&name.0).join("/")) + .unwrap_or_default(); + return ObservedKdcReq::As { + cname, + realm: req.realm.0.to_string(), + }; + } + if let Ok(tgs_req) = picky_asn1_der::from_bytes::(body) { + let req = &tgs_req.0.req_body.0; + let sname = req + .sname + .0 + .as_ref() + .map(|name| principal_strings(&name.0)) + .unwrap_or_default(); + return ObservedKdcReq::Tgs { + sname, + realm: req.realm.0.to_string(), + }; + } + ObservedKdcReq::Other +} + +pub(crate) fn observe_kdc_reply(body: &[u8]) -> ObservedKdcReply { + let krb = body.get(4..).unwrap_or(body); + if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::AsRep + } else if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::TgsRep + } else if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::KrbError + } else { + ObservedKdcReply::Other + } +} + +pub async fn send_kdc_tcp(request: &sspi::generator::NetworkRequest) -> anyhow::Result> { + let host = request.url.host_str().context("KDC host")?; + let port = request.url.port().unwrap_or(88); + let mut stream = TcpStream::connect((host, port)).await.context("connect mock KDC")?; + stream.write_all(&request.data).await.context("write KDC request")?; + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.context("read KDC length")?; + let len = usize::try_from(u32::from_be_bytes(len_buf)).context("KDC length")?; + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.context("read KDC body")?; + let mut reply = Vec::with_capacity(4 + len); + reply.extend_from_slice(&len_buf); + reply.extend_from_slice(&body); + Ok(reply) +} + +/// Accepts TCP and never answers, so ticket fetching hangs until the caller gives up. +pub struct RefusingKdc { + port: u16, + accepted: Arc, +} + +impl RefusingKdc { + pub async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind refusing KDC")?; + let port = listener.local_addr()?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_task = Arc::clone(&accepted); + tokio::spawn(async move { + loop { + let Ok((_stream, _)) = listener.accept().await else { + break; + }; + accepted_task.fetch_add(1, Ordering::SeqCst); + } + }); + Ok(Self { port, accepted }) + } + + pub fn url(&self) -> String { + format!("tcp://127.0.0.1:{}", self.port) + } + + pub fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) + } +} + +pub fn assert_target_kdc_as_and_tgs(kdc: &MockKdc) -> anyhow::Result<()> { + let reqs = kdc.requests(); + anyhow::ensure!( + reqs.iter().any(|req| matches!( + req, + ObservedKdcReq::As { cname, realm } + if cname.eq_ignore_ascii_case("administrator") && realm.eq_ignore_ascii_case(REALM) + )), + "KDC must see AS-REQ cname=administrator realm={REALM}; requests={reqs:?}" + ); + anyhow::ensure!( + reqs.iter().any(|req| matches!( + req, + ObservedKdcReq::Tgs { sname, realm } + if *sname == ["TERMSRV", SERVICE_HOST] && realm.eq_ignore_ascii_case(REALM) + )), + "KDC must see TGS-REQ sname=TERMSRV/{SERVICE_HOST} realm={REALM}; requests={reqs:?}" + ); + Ok(()) +} diff --git a/testsuite/src/rdp_injection/mock_rdp.rs b/testsuite/src/rdp_injection/mock_rdp.rs new file mode 100644 index 000000000..4660f6a0e --- /dev/null +++ b/testsuite/src/rdp_injection/mock_rdp.rs @@ -0,0 +1,336 @@ +//! Fake RDP server that completes CredSSP as the target: X.224 accept, TLS, then an +//! sspi `CredSspServer` in Kerberos or NTLM mode. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use ironrdp_connector::sspi; +use ironrdp_connector::sspi::generator::GeneratorState; +use ironrdp_pdu::nego::{ConnectionConfirm, ConnectionRequest, ResponseFlags, SecurityProtocol}; +use ironrdp_pdu::x224::X224; +use ironrdp_tokio::{FramedWrite as _, TokioFramed}; +use tokio::net::{TcpListener, TcpStream}; + +use super::credssp::TsRequestHint; +use super::mock_kdc::send_kdc_tcp; +use super::rdp::record_cookie; +use super::tls::{install_crypto_provider, server_public_key, tls_acceptor}; +use super::{KERBEROS_TARGET_USER, REALM, SERVICE_HOST, TARGET_PASSWORD, TARGET_USER, TERMSRV_KEY}; + +#[derive(Clone)] +enum MockRdpMode { + Kerberos { kdc_url: Option }, + Ntlm, +} + +pub struct MockRdp { + pub port: u16, + credssp_ok: Arc, + finished_account: Arc>>, + cookies: Arc>>, +} + +impl MockRdp { + pub async fn start_kerberos(kdc_url: String) -> anyhow::Result { + Self::start(MockRdpMode::Kerberos { kdc_url: Some(kdc_url) }).await + } + + pub async fn start_ntlm() -> anyhow::Result { + Self::start(MockRdpMode::Ntlm).await + } + + async fn start(mode: MockRdpMode) -> anyhow::Result { + install_crypto_provider(); + // Dual-stack so Windows `localhost` (IPv6 first) still hits the fake server. + let listener = match TcpListener::bind("[::]:0").await { + Ok(listener) => listener, + Err(_) => TcpListener::bind("127.0.0.1:0").await.context("bind mock RDP")?, + }; + let port = listener.local_addr().context("mock RDP local_addr")?.port(); + let credssp_ok = Arc::new(AtomicBool::new(false)); + let finished_account = Arc::new(Mutex::new(None)); + let cookies = Arc::new(Mutex::new(Vec::new())); + let credssp_ok_task = Arc::clone(&credssp_ok); + let finished_account_task = Arc::clone(&finished_account); + let cookies_task = Arc::clone(&cookies); + let acceptor = tls_acceptor()?; + let public_key = server_public_key()?; + + tokio::spawn(async move { + loop { + let Ok((stream, peer)) = listener.accept().await else { + break; + }; + let acceptor = acceptor.clone(); + let public_key = public_key.clone(); + let credssp_ok = Arc::clone(&credssp_ok_task); + let finished_account = Arc::clone(&finished_account_task); + let cookies = Arc::clone(&cookies_task); + let mode = mode.clone(); + tokio::spawn(async move { + let result = match &mode { + MockRdpMode::Kerberos { kdc_url } => { + accept_kerberos_rdp( + stream, + peer, + acceptor, + public_key, + kdc_url.as_deref(), + &cookies, + &finished_account, + ) + .await + } + MockRdpMode::Ntlm => { + accept_ntlm_rdp(stream, peer, acceptor, public_key, &cookies, &finished_account).await + } + }; + match result { + Ok(()) => credssp_ok.store(true, Ordering::SeqCst), + Err(error) => eprintln!("mock RDP CredSSP failed: {error:#}"), + } + }); + } + }); + + Ok(Self { + port, + credssp_ok, + finished_account, + cookies, + }) + } + + pub fn credssp_ok(&self) -> bool { + self.credssp_ok.load(Ordering::SeqCst) + } + + pub fn finished_account(&self) -> Option { + self.finished_account.lock().expect("finished account mutex").clone() + } + + pub fn cookies(&self) -> Vec { + self.cookies.lock().expect("cookie mutex").clone() + } + + pub async fn wait_credssp(&self) -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if self.credssp_ok() { + return Ok(()); + } + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for Kerberos CredSSP on mock RDP"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +async fn accept_kerberos_rdp( + stream: TcpStream, + peer: std::net::SocketAddr, + acceptor: tokio_rustls::TlsAcceptor, + public_key: Vec, + kdc_url: Option<&str>, + cookies: &Mutex>, + finished_account: &Mutex>, +) -> anyhow::Result<()> { + let mut framed = TokioFramed::new(stream); + let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; + let cr: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + record_cookie(&cr, cookies); + + let confirm = X224(ConnectionConfirm::Response { + flags: ResponseFlags::empty(), + protocol: SecurityProtocol::HYBRID, + }); + framed + .write_all(&ironrdp_core::encode_vec(&confirm).context("encode X.224 CC")?) + .await + .context("write X.224 CC")?; + + let tcp = framed.into_inner_no_leftover(); + let tls = acceptor.accept(tcp).await.context("TLS accept")?; + let mut framed = TokioFramed::new(tls); + + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(KERBEROS_TARGET_USER).context("parse target username")?, + password: TARGET_PASSWORD.to_owned().into(), + }; + let kerberos_config = sspi::KerberosServerConfig { + kerberos_config: sspi::KerberosConfig { + kdc_url: kdc_url + .map(|url| url.parse()) + .transpose() + .context("parse mock KDC URL")?, + client_computer_name: peer.to_string(), + }, + server_properties: sspi::kerberos::ServerProperties::new( + &["TERMSRV", SERVICE_HOST], + Some(sspi::CredentialsBuffers::AuthIdentity( + sspi::AuthIdentityBuffers::from_utf8(identity.username.account_name(), REALM, TARGET_PASSWORD), + )), + Duration::from_secs(300), + Some(sspi::Secret::new(TERMSRV_KEY.to_vec())), + ) + .context("Kerberos server properties")?, + }; + + let mut server = sspi::credssp::CredSspServer::new( + public_key, + IdentityProxy(identity), + sspi::credssp::ServerMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(kerberos_config), + Some("kerberos,!ntlm".to_owned()), + peer.to_string(), + )), + ) + .context("init Kerberos-only CredSSP server")?; + + let hint = TsRequestHint; + let mut buf = ironrdp_pdu::WriteBuf::new(); + for _ in 0..6 { + let pdu = framed.read_by_hint(&hint).await.context("read CredSSP TSRequest")?; + let ts_request = sspi::credssp::TsRequest::from_buffer(&pdu).context("decode CredSSP")?; + let result = { + let mut generator = server.process(ts_request); + resolve_sspi_server(&mut generator) + .await + .map_err(|error| anyhow::anyhow!("mock RDP CredSSP: {error:?}"))? + }; + match result { + sspi::credssp::ServerState::ReplyNeeded(outbound) => { + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode server TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write CredSSP")?; + } + sspi::credssp::ServerState::Finished(identity) => { + *finished_account.lock().expect("finished account mutex") = + Some(identity.username.account_name().to_owned()); + return Ok(()); + } + } + } + anyhow::bail!("mock RDP CredSSP exceeded 6 round trips") +} + +async fn accept_ntlm_rdp( + stream: TcpStream, + peer: std::net::SocketAddr, + acceptor: tokio_rustls::TlsAcceptor, + public_key: Vec, + cookies: &Mutex>, + finished_account: &Mutex>, +) -> anyhow::Result<()> { + let mut framed = TokioFramed::new(stream); + let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; + let cr: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + record_cookie(&cr, cookies); + + let confirm = X224(ConnectionConfirm::Response { + flags: ResponseFlags::empty(), + protocol: SecurityProtocol::HYBRID, + }); + framed + .write_all(&ironrdp_core::encode_vec(&confirm).context("encode X.224 CC")?) + .await + .context("write X.224 CC")?; + + let tcp = framed.into_inner_no_leftover(); + let tls = acceptor.accept(tcp).await.context("TLS accept")?; + let mut framed = TokioFramed::new(tls); + + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(TARGET_USER).context("parse NTLM target username")?, + password: TARGET_PASSWORD.to_owned().into(), + }; + let mut server = sspi::credssp::CredSspServer::new( + public_key, + IdentityProxy(identity), + sspi::credssp::ServerMode::Ntlm(sspi::ntlm::NtlmConfig { + client_computer_name: Some(peer.to_string()), + }), + ) + .context("init NTLM CredSSP server")?; + + let hint = TsRequestHint; + let mut buf = ironrdp_pdu::WriteBuf::new(); + for _ in 0..6 { + let pdu = framed.read_by_hint(&hint).await.context("read CredSSP TSRequest")?; + let ts_request = sspi::credssp::TsRequest::from_buffer(&pdu).context("decode CredSSP")?; + let result = { + let mut generator = server.process(ts_request); + resolve_sspi_server(&mut generator) + .await + .map_err(|error| anyhow::anyhow!("mock RDP NTLM CredSSP: {error:?}"))? + }; + match result { + sspi::credssp::ServerState::ReplyNeeded(outbound) => { + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode server TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write CredSSP")?; + } + sspi::credssp::ServerState::Finished(identity) => { + *finished_account.lock().expect("finished account mutex") = + Some(identity.username.account_name().to_owned()); + return Ok(()); + } + } + } + anyhow::bail!("mock RDP NTLM CredSSP exceeded 6 round trips") +} + +async fn resolve_sspi_server( + generator: &mut sspi::generator::Generator< + '_, + sspi::generator::NetworkRequest, + sspi::Result>, + Result, + >, +) -> Result { + let mut state = generator.start(); + loop { + match state { + GeneratorState::Suspended(request) => { + let reply = send_kdc_tcp(&request) + .await + .map_err(|error| sspi::credssp::ServerError { + ts_request: None, + error: sspi::Error::new(sspi::ErrorKind::NoAuthenticatingAuthority, error), + })?; + state = generator.resume(Ok(reply)); + } + GeneratorState::Completed(result) => break result, + } + } +} + +struct IdentityProxy(sspi::AuthIdentity); + +impl sspi::credssp::CredentialsProxy for IdentityProxy { + type AuthenticationData = sspi::AuthIdentity; + + fn auth_data_by_user(&mut self, username: &sspi::Username) -> std::io::Result { + if username.account_name() != self.0.username.account_name() { + return Err(std::io::Error::other("invalid username")); + } + let mut data = self.0.clone(); + data.username = username.clone(); + Ok(data) + } + + fn auth_data(&mut self) -> Result, std::io::Error> { + Ok(vec![self.0.clone()]) + } +} diff --git a/testsuite/src/rdp_injection/mod.rs b/testsuite/src/rdp_injection/mod.rs new file mode 100644 index 000000000..4f65dabbe --- /dev/null +++ b/testsuite/src/rdp_injection/mod.rs @@ -0,0 +1,38 @@ +//! Process-level helpers for RDP credential-injection tests. +//! +//! A real Gateway runs as a child process; loopback peers stand in for the destination RDP +//! server and the Kerberos KDC. Tests observe injection from Gateway logs, from the rewritten +//! mstshash cookie arriving at the fake target, and from the Kerberos exchanges recorded by +//! the mock KDC. + +pub mod agent; +pub mod credssp; +pub mod gateway; +pub mod mock_kdc; +pub mod mock_rdp; +pub mod preflight; +pub mod rdp; +pub mod tls; +pub mod tokens; + +pub const CLIENT_COOKIE: &str = "client-cookie-user"; +pub const TARGET_USER: &str = "injected-target-user"; +pub const PROXY_USER: &str = "injected-proxy-user"; +pub const PROXY_PASSWORD: &str = "proxy-secret"; +pub const TARGET_PASSWORD: &str = "target-secret"; +pub const KERBEROS_TARGET_USER: &str = "administrator@example.invalid"; +pub const PROXY_KERBEROS_USER: &str = "injected-proxy-user@example.invalid"; + +pub const REALM: &str = "EXAMPLE.INVALID"; +// sspi-rs downgrades Negotiate to NTLM when the SPN host is an IP address. +pub const SERVICE_HOST: &str = "localhost"; +pub const KRBTGT_KEY: [u8; 32] = [0x11; 32]; +pub const TERMSRV_KEY: [u8; 32] = [0x22; 32]; + +pub const INJECT_LOG: &str = "RDP-TLS forwarding with credential injection"; +pub const FORWARD_LOG: &str = "Upstream forwarding"; +pub const MISSING_LOG: &str = "missing or expired; re-provision to retry"; +pub const PUBLISHED_KDC_LOG: &str = "Published synthetic KDC"; +pub const REGISTERED_KDC_LOG: &str = "Registered synthetic KDC for credential-injection session"; +pub const RDCLEANPATH_INJECT_LOG: &str = "Switching to RdpProxy for credential injection (WebSocket)"; +pub const RDCLEANPATH_FORWARD_LOG: &str = "RDP-TLS forwarding (RDCleanPath)"; diff --git a/testsuite/src/rdp_injection/preflight.rs b/testsuite/src/rdp_injection/preflight.rs new file mode 100644 index 000000000..e6f7cdbfc --- /dev/null +++ b/testsuite/src/rdp_injection/preflight.rs @@ -0,0 +1,141 @@ +//! Provisions credentials over `/jet/preflight` the way DVLS does. + +use anyhow::Context as _; +use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; +use tokio::net::TcpStream; + +use super::tokens::{next_id, preflight_scope_token}; +use super::{PROXY_PASSWORD, PROXY_USER, TARGET_PASSWORD}; + +pub async fn post_preflight(http_port: u16, operations: serde_json::Value) -> anyhow::Result { + let bearer = preflight_scope_token()?; + let body = serde_json::to_string(&operations).context("serialize preflight body")?; + let request = format!( + "POST /jet/preflight HTTP/1.1\r\n\ + Host: 127.0.0.1:{http_port}\r\n\ + Content-Type: application/json\r\n\ + Authorization: Bearer {bearer}\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len() + ); + + let mut stream = TcpStream::connect(("127.0.0.1", http_port)) + .await + .context("connect to gateway HTTP")?; + stream.write_all(request.as_bytes()).await.context("write preflight")?; + stream.flush().await.context("flush preflight")?; + + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + reader + .read_line(&mut status_line) + .await + .context("read preflight status")?; + anyhow::ensure!(status_line.contains("200"), "preflight HTTP status was {status_line:?}"); + + let mut content_length = None; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.context("read preflight header")?; + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().to_owned()) + { + content_length = Some(value.parse::().context("parse Content-Length")?); + } + } + + let response_body = if let Some(len) = content_length { + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf).await.context("read preflight body")?; + String::from_utf8(buf).context("preflight body utf-8")? + } else { + let mut buf = String::new(); + reader + .read_to_string(&mut buf) + .await + .context("read preflight eof body")?; + buf + }; + + let json: serde_json::Value = + serde_json::from_str(&response_body).with_context(|| format!("parse preflight JSON: {response_body}"))?; + let outputs = json.as_array().context("preflight response is not an array")?; + // Re-provisioning the same JTI emits an info alert, then still acks. + let mut acked = 0usize; + for output in outputs { + match output["kind"].as_str() { + Some("ack") => acked += 1, + Some("alert") if output["alert_status"] == "info" => {} + _ => anyhow::bail!("preflight operation was not ack: {output}"), + } + } + anyhow::ensure!(acked > 0, "preflight returned no ack: {json}"); + Ok(json) +} + +pub async fn provision_credentials( + http_port: u16, + token: &str, + target_username: &str, + time_to_live: u32, + krb_kdc: Option<&str>, +) -> anyhow::Result<()> { + provision_mapping( + http_port, + token, + PROXY_USER, + target_username, + TARGET_PASSWORD, + time_to_live, + krb_kdc, + ) + .await +} + +pub async fn provision_mapping( + http_port: u16, + token: &str, + proxy_username: &str, + target_username: &str, + target_password: &str, + time_to_live: u32, + krb_kdc: Option<&str>, +) -> anyhow::Result<()> { + let mut operations = vec![serde_json::json!({ + "id": next_id(), + "kind": "provision-credentials", + "token": token, + "proxy_credential": { + "kind": "username-password", + "username": proxy_username, + "password": PROXY_PASSWORD + }, + "target_credential": { + "kind": "username-password", + "username": target_username, + "password": target_password + }, + "time_to_live": time_to_live + })]; + + if let Some(krb_kdc) = krb_kdc { + operations.push(serde_json::json!({ + "id": next_id(), + "kind": "provision-connection-options", + "token": token, + "connection_options": { "krb_kdc": krb_kdc }, + "time_to_live": time_to_live + })); + } + + post_preflight(http_port, serde_json::Value::Array(operations)).await?; + Ok(()) +} diff --git a/testsuite/src/rdp_injection/rdp.rs b/testsuite/src/rdp_injection/rdp.rs new file mode 100644 index 000000000..b21f84ee5 --- /dev/null +++ b/testsuite/src/rdp_injection/rdp.rs @@ -0,0 +1,168 @@ +//! Minimal RDP protocol pieces: the client's opening bytes and loopback targets. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use ironrdp_pdu::nego::{ConnectionRequest, NegoRequestData, RequestFlags, SecurityProtocol}; +use ironrdp_pdu::x224::X224; +use ironrdp_tokio::TokioFramed; +use tokio::io::AsyncWriteExt as _; +use tokio::net::{TcpListener, TcpStream}; + +use super::CLIENT_COOKIE; + +pub fn encode_pcb(token: &str) -> anyhow::Result> { + let pcb = ironrdp_pdu::pcb::PreconnectionBlob { + version: ironrdp_pdu::pcb::PcbVersion::V2, + id: 0, + v2_payload: Some(token.to_owned()), + }; + ironrdp_core::encode_vec(&pcb).context("encode preconnection blob") +} + +pub fn encode_connection_request(cookie: &str) -> anyhow::Result> { + let pdu = X224(ConnectionRequest { + nego_data: Some(NegoRequestData::cookie(cookie.to_owned())), + flags: RequestFlags::empty(), + protocol: SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX | SecurityProtocol::SSL, + }); + ironrdp_core::encode_vec(&pdu).context("encode X.224 connection request") +} + +pub fn encode_hybrid_cr() -> anyhow::Result> { + let pdu = X224(ConnectionRequest { + nego_data: Some(NegoRequestData::cookie(CLIENT_COOKIE.to_owned())), + flags: RequestFlags::empty(), + protocol: SecurityProtocol::HYBRID | SecurityProtocol::SSL, + }); + ironrdp_core::encode_vec(&pdu).context("encode hybrid CR") +} + +pub fn decode_x224_cookie(payload: &[u8]) -> Option { + let cr: X224 = ironrdp_core::decode(payload).ok()?; + match cr.0.nego_data { + Some(NegoRequestData::Cookie(cookie)) => Some(cookie.0), + _ => None, + } +} + +pub(crate) fn record_cookie(cr: &X224, cookies: &Mutex>) { + if let Some(NegoRequestData::Cookie(cookie)) = &cr.0.nego_data { + cookies.lock().expect("cookie mutex").push(cookie.0.clone()); + } +} + +pub async fn connect_rdp_client(gateway_tcp: u16, association_jwt: &str) -> anyhow::Result { + let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) + .await + .context("connect to gateway TCP")?; + stream + .write_all(&encode_pcb(association_jwt)?) + .await + .context("write preconnection blob")?; + stream + .write_all(&encode_connection_request(CLIENT_COOKIE)?) + .await + .context("write connection request")?; + stream.flush().await.context("flush RDP client")?; + Ok(stream) +} + +/// Stands in for the destination RDP server and records the X.224 Connection Request cookie +/// the proxy forwards. +pub struct FakeRdpTarget { + pub port: u16, + accepted: Arc, + cookies: Arc>>, +} + +impl FakeRdpTarget { + pub async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind fake RDP target")?; + let port = listener.local_addr().context("fake RDP local_addr")?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let cookies = Arc::new(Mutex::new(Vec::new())); + let accepted_task = Arc::clone(&accepted); + let cookies_task = Arc::clone(&cookies); + + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + accepted_task.fetch_add(1, Ordering::SeqCst); + let cookies = Arc::clone(&cookies_task); + tokio::spawn(async move { + let mut framed = TokioFramed::new(stream); + // CredSSP cert generation can delay the rewritten X.224 CR. + if let Ok(Ok((_, request))) = tokio::time::timeout(Duration::from_secs(30), framed.read_pdu()).await + && let Some(cookie) = decode_x224_cookie(&request) + { + cookies.lock().expect("cookie mutex").push(cookie); + } + // Keep the accepted socket open so the proxy can finish writing the CR. + tokio::time::sleep(Duration::from_secs(30)).await; + }); + } + }); + + Ok(Self { + port, + accepted, + cookies, + }) + } + + pub fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) + } + + pub async fn wait_cookies(&self, count: usize) -> anyhow::Result> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + { + let cookies = self.cookies.lock().expect("cookie mutex"); + if cookies.len() >= count { + return Ok(cookies.clone()); + } + } + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for {count} decoded X.224 cookie(s); accepted={}", + self.accepted() + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +/// Accepts connections and never speaks; proves the proxy did (or did not) dial the target. +pub struct FakeClosedTarget { + pub port: u16, + accepted: Arc, +} + +impl FakeClosedTarget { + pub async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind closed target")?; + let port = listener.local_addr()?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_task = Arc::clone(&accepted); + tokio::spawn(async move { + loop { + let Ok((_stream, _)) = listener.accept().await else { + break; + }; + accepted_task.fetch_add(1, Ordering::SeqCst); + } + }); + Ok(Self { port, accepted }) + } + + pub fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) + } +} diff --git a/testsuite/src/rdp_injection/tls.rs b/testsuite/src/rdp_injection/tls.rs new file mode 100644 index 000000000..07afe4f3c --- /dev/null +++ b/testsuite/src/rdp_injection/tls.rs @@ -0,0 +1,105 @@ +//! TLS plumbing for the fake RDP server and the test client. + +use std::sync::Arc; + +use anyhow::Context as _; +use rustls::pki_types::pem::PemObject as _; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use rustls::{ClientConfig, ServerConfig}; +use tokio::net::TcpStream; +use x509_cert::der::Decode as _; + +use crate::tls_fixtures::{CERT_PEM, KEY_PEM}; + +pub fn install_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +pub fn tls_acceptor() -> anyhow::Result { + let cert = CertificateDer::from_pem_slice(CERT_PEM.as_bytes()).context("parse cert PEM")?; + let key = PrivateKeyDer::from_pem_slice(KEY_PEM.as_bytes()).context("parse key PEM")?; + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert], key) + .context("TLS server config")?; + Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config))) +} + +pub fn dangerous_tls_connector() -> tokio_rustls::TlsConnector { + let mut config = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) + .with_no_client_auth(); + config.resumption = rustls::client::Resumption::disabled(); + tokio_rustls::TlsConnector::from(Arc::new(config)) +} + +pub fn peer_public_key(tls: &tokio_rustls::client::TlsStream) -> anyhow::Result> { + let cert = tls + .get_ref() + .1 + .peer_certificates() + .and_then(|certs| certs.first()) + .context("gateway TLS certificate missing")?; + extract_public_key(cert) +} + +pub fn server_public_key() -> anyhow::Result> { + let cert = CertificateDer::from_pem_slice(CERT_PEM.as_bytes()).context("parse mock RDP cert")?; + extract_public_key(&cert) +} + +fn extract_public_key(cert: &CertificateDer<'_>) -> anyhow::Result> { + let cert = x509_cert::Certificate::from_der(cert.as_ref()).context("parse X509")?; + let public_key = cert + .tbs_certificate() + .subject_public_key_info() + .subject_public_key + .as_bytes() + .context("unaligned subject public key")? + .to_owned(); + Ok(public_key) +} + +#[derive(Debug)] +struct NoCertificateVerification; + +impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _: &CertificateDer<'_>, + _: &[CertificateDer<'_>], + _: &ServerName<'_>, + _: &[u8], + _: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::ED25519, + ] + } +} diff --git a/testsuite/src/rdp_injection/tokens.rs b/testsuite/src/rdp_injection/tokens.rs new file mode 100644 index 000000000..a82dda637 --- /dev/null +++ b/testsuite/src/rdp_injection/tokens.rs @@ -0,0 +1,73 @@ +//! Unsigned JWTs for suites running with `disable_token_validation`. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::Context as _; +use base64::Engine as _; + +use super::SERVICE_HOST; + +pub fn next_id() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(1); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("00000000-0000-4000-a000-{n:012x}") +} + +pub fn unsigned_jws(header: serde_json::Value, payload: serde_json::Value) -> anyhow::Result { + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode(serde_json::to_vec(&header).context("serialize JWT header")?); + let payload = engine.encode(serde_json::to_vec(&payload).context("serialize JWT payload")?); + Ok(format!("{header}.{payload}.ZHVtbXlfc2lnbmF0dXJl")) +} + +pub fn preflight_scope_token() -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"SCOPE"}), + serde_json::json!({ + "scope": "gateway.preflight", + "exp": 9_999_999_999i64, + "jti": next_id(), + }), + ) +} + +pub fn association_token(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { + association_claims(jti, jet_aid, format!("127.0.0.1:{dest_port}"), jet_reuse) +} + +pub fn association_token_for_host(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { + association_claims(jti, jet_aid, format!("{SERVICE_HOST}:{dest_port}"), jet_reuse) +} + +fn association_claims(jti: &str, jet_aid: &str, dst_hst: String, jet_reuse: u32) -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"ASSOCIATION"}), + serde_json::json!({ + "dst_hst": dst_hst, + "exp": 9_999_999_999i64, + "jet_aid": jet_aid, + "jet_ap": "rdp", + "jet_cm": "fwd", + "jet_rec": "none", + "jet_reuse": jet_reuse, + "jti": jti, + "nbf": 0, + }), + ) +} + +pub fn kdc_inject_token(association_jti: &str) -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"KDC"}), + serde_json::json!({ + "exp": 9_999_999_999i64, + "jet_cred_id": association_jti, + "jti": next_id(), + }), + ) +} + +pub fn kdc_proxy_url(http_port: u16, association_jti: &str) -> anyhow::Result { + let token = kdc_inject_token(association_jti)?; + Ok(format!("http://127.0.0.1:{http_port}/jet/KdcProxy/{token}")) +} diff --git a/testsuite/tests/cli/dgw/tls_fixtures.rs b/testsuite/src/tls_fixtures.rs similarity index 95% rename from testsuite/tests/cli/dgw/tls_fixtures.rs rename to testsuite/src/tls_fixtures.rs index 7d4d2f4b0..1bfa7bdeb 100644 --- a/testsuite/tests/cli/dgw/tls_fixtures.rs +++ b/testsuite/src/tls_fixtures.rs @@ -1,7 +1,7 @@ //! Shared localhost TLS material for process-level Gateway tests. /// Self-signed certificate for localhost (valid for 100 years). -pub(crate) const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- +pub const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- MIIDCzCCAfOgAwIBAgIUPRJa8i280unV3/kW6TE2fSUw8PwwDQYJKoZIhvcNAQEL BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTEyNTA5NDAzMFoYDzIxMjUx MTAxMDk0MDMwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB @@ -22,7 +22,7 @@ x9Ba6YbssOz6epATKhvt80yclO34AzUyimssvViIUpgFEyaPhZZTw46Q/6X3ixK4 -----END CERTIFICATE-----"#; /// Private key for the self-signed certificate. -pub(crate) const KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- +pub const KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHpBlyRgUx/V9c QGw/eqDFc6odxB2hvnbudi67LvEjcNIWOU79R1e/NswME4oecqT9W05n4UyxkABf m2qjODO0nDf47W0DsgbEA87qE715RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgW diff --git a/testsuite/tests/cli/dgw/cred_injection.intent.md b/testsuite/tests/cli/dgw/cred_injection.intent.md new file mode 100644 index 000000000..e69de29bb diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs index ca7b6bd1d..ef12c96f0 100644 --- a/testsuite/tests/cli/dgw/cred_injection.rs +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -6,424 +6,14 @@ //! Injection is observed from Gateway logs and from the rewritten mstshash cookie. CredSSP is not //! completed: the contract under test is checkout, reconnect, and fail-closed routing. -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use anyhow::Context as _; -use base64::Engine as _; -use ironrdp_pdu::nego::{ConnectionRequest, NegoRequestData}; -use ironrdp_pdu::x224::X224; -use ironrdp_tokio::TokioFramed; -use testsuite::cli::{dgw_tokio_cmd, wait_for_tcp_port}; -use testsuite::dgw_config::{DgwConfig, DgwConfigHandle, VerbosityProfile}; -use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; -use tokio::net::{TcpListener, TcpStream}; -use tokio::process::Child; - -pub(crate) const CLIENT_COOKIE: &str = "client-cookie-user"; -pub(crate) const TARGET_USER: &str = "injected-target-user"; -pub(crate) const PROXY_USER: &str = "injected-proxy-user"; -pub(crate) const PROXY_PASSWORD: &str = "proxy-secret"; -pub(crate) const TARGET_PASSWORD: &str = "target-secret"; -pub(crate) const KERBEROS_TARGET_USER: &str = "administrator@example.invalid"; -pub(crate) const INJECT_LOG: &str = "RDP-TLS forwarding with credential injection"; -pub(crate) const FORWARD_LOG: &str = "Upstream forwarding"; -pub(crate) const MISSING_LOG: &str = "missing or expired; re-provision to retry"; -pub(crate) const PROXY_KERBEROS_USER: &str = "injected-proxy-user@example.invalid"; -const PUBLISHED_KDC_LOG: &str = "Published synthetic KDC"; -const REGISTERED_KDC_LOG: &str = "Registered synthetic KDC for credential-injection session"; - -pub(crate) fn next_id() -> String { - static COUNTER: AtomicU64 = AtomicU64::new(1); - let n = COUNTER.fetch_add(1, Ordering::Relaxed); - format!("00000000-0000-4000-a000-{n:012x}") -} - -pub(crate) fn unsigned_jws(header: serde_json::Value, payload: serde_json::Value) -> anyhow::Result { - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header = engine.encode(serde_json::to_vec(&header).context("serialize JWT header")?); - let payload = engine.encode(serde_json::to_vec(&payload).context("serialize JWT payload")?); - Ok(format!("{header}.{payload}.ZHVtbXlfc2lnbmF0dXJl")) -} - -fn preflight_scope_token() -> anyhow::Result { - unsigned_jws( - serde_json::json!({"alg":"RS256","typ":"JWT","cty":"SCOPE"}), - serde_json::json!({ - "scope": "gateway.preflight", - "exp": 9_999_999_999i64, - "jti": next_id(), - }), - ) -} - -pub(crate) fn association_token(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { - unsigned_jws( - serde_json::json!({"alg":"RS256","typ":"JWT","cty":"ASSOCIATION"}), - serde_json::json!({ - "dst_hst": format!("127.0.0.1:{dest_port}"), - "exp": 9_999_999_999i64, - "jet_aid": jet_aid, - "jet_ap": "rdp", - "jet_cm": "fwd", - "jet_rec": "none", - "jet_reuse": jet_reuse, - "jti": jti, - "nbf": 0, - }), - ) -} - -pub(crate) fn encode_pcb(token: &str) -> anyhow::Result> { - let pcb = ironrdp_pdu::pcb::PreconnectionBlob { - version: ironrdp_pdu::pcb::PcbVersion::V2, - id: 0, - v2_payload: Some(token.to_owned()), - }; - ironrdp_core::encode_vec(&pcb).context("encode preconnection blob") -} - -fn encode_connection_request(cookie: &str) -> anyhow::Result> { - use ironrdp_pdu::nego::{ConnectionRequest, NegoRequestData, RequestFlags, SecurityProtocol}; - use ironrdp_pdu::x224::X224; - - let pdu = X224(ConnectionRequest { - nego_data: Some(NegoRequestData::cookie(cookie.to_owned())), - flags: RequestFlags::empty(), - protocol: SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX | SecurityProtocol::SSL, - }); - ironrdp_core::encode_vec(&pdu).context("encode X.224 connection request") -} - -fn strip_ansi(input: &str) -> String { - let mut out = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - while let Some(c) = chars.next() { - if c == '\u{1b}' && chars.peek() == Some(&'[') { - chars.next(); - for next in chars.by_ref() { - if next.is_ascii_alphabetic() { - break; - } - } - } else { - out.push(c); - } - } - out -} - -pub(crate) struct LogBuffer(Arc>); - -impl LogBuffer { - fn new() -> Self { - Self(Arc::new(Mutex::new(String::new()))) - } - - pub(crate) fn snapshot(&self) -> String { - strip_ansi(&self.0.lock().expect("log mutex")) - } - - pub(crate) async fn wait_contains(&self, needle: &str) -> anyhow::Result { - self.wait_count(needle, 1).await - } - - pub(crate) async fn wait_count(&self, needle: &str, count: usize) -> anyhow::Result { - let deadline = Instant::now() + Duration::from_secs(15); - loop { - let snapshot = self.snapshot(); - if snapshot.matches(needle).count() >= count { - return Ok(snapshot); - } - if Instant::now() >= deadline { - anyhow::bail!("timed out waiting for {count} occurrence(s) of {needle:?}; logs:\n{snapshot}"); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - } -} - -struct FakeRdpTarget { - port: u16, - accepted: Arc, - cookies: Arc>>, -} - -impl FakeRdpTarget { - async fn start() -> anyhow::Result { - let listener = TcpListener::bind("127.0.0.1:0").await.context("bind fake RDP target")?; - let port = listener.local_addr().context("fake RDP local_addr")?.port(); - let accepted = Arc::new(AtomicUsize::new(0)); - let cookies = Arc::new(Mutex::new(Vec::new())); - let accepted_task = Arc::clone(&accepted); - let cookies_task = Arc::clone(&cookies); - - tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - break; - }; - accepted_task.fetch_add(1, Ordering::SeqCst); - let cookies = Arc::clone(&cookies_task); - tokio::spawn(async move { - let mut framed = TokioFramed::new(stream); - // CredSSP cert generation can delay the rewritten X.224 CR. - if let Ok(Ok((_, request))) = tokio::time::timeout(Duration::from_secs(30), framed.read_pdu()).await - && let Some(cookie) = decode_x224_cookie(&request) - { - cookies.lock().expect("cookie mutex").push(cookie); - } - // Keep the accepted socket open so the proxy can finish writing the CR. - tokio::time::sleep(Duration::from_secs(30)).await; - }); - } - }); - - Ok(Self { - port, - accepted, - cookies, - }) - } - - fn accepted(&self) -> usize { - self.accepted.load(Ordering::SeqCst) - } - - async fn wait_cookies(&self, count: usize) -> anyhow::Result> { - let deadline = Instant::now() + Duration::from_secs(30); - loop { - { - let cookies = self.cookies.lock().expect("cookie mutex"); - if cookies.len() >= count { - return Ok(cookies.clone()); - } - } - if Instant::now() >= deadline { - anyhow::bail!( - "timed out waiting for {count} decoded X.224 cookie(s); accepted={}", - self.accepted() - ); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - } -} - -fn decode_x224_cookie(payload: &[u8]) -> Option { - let cr: X224 = ironrdp_core::decode(payload).ok()?; - match cr.0.nego_data { - Some(NegoRequestData::Cookie(cookie)) => Some(cookie.0), - _ => None, - } -} - -pub(crate) struct GatewayProc { - pub(crate) config: DgwConfigHandle, - pub(crate) process: Child, - pub(crate) logs: LogBuffer, -} - -impl GatewayProc { - pub(crate) async fn start() -> anyhow::Result { - let config = DgwConfig::builder() - .disable_token_validation(true) - .verbosity_profile(VerbosityProfile::DEBUG) - .build() - .init() - .context("init gateway config")?; - - let mut process = dgw_tokio_cmd() - .env("DGATEWAY_CONFIG_PATH", config.config_dir()) - .env("RUST_LOG", "devolutions_gateway=debug") - .env("NO_COLOR", "1") - .kill_on_drop(true) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .context("start Devolutions Gateway")?; - - let logs = LogBuffer::new(); - spawn_stdio_collector(process.stdout.take(), Arc::clone(&logs.0)); - spawn_stdio_collector(process.stderr.take(), Arc::clone(&logs.0)); - - wait_for_tcp_port(config.http_port()) - .await - .context("wait for gateway HTTP port")?; - - Ok(Self { config, process, logs }) - } -} - -fn spawn_stdio_collector(stream: Option, logs: Arc>) -where - R: tokio::io::AsyncRead + Unpin + Send + 'static, -{ - let Some(stream) = stream else { - return; - }; - tokio::spawn(async move { - let mut reader = BufReader::new(stream); - let mut line = String::new(); - loop { - line.clear(); - match reader.read_line(&mut line).await { - Ok(0) | Err(_) => break, - Ok(_) => logs.lock().expect("log mutex").push_str(&line), - } - } - }); -} - -async fn post_preflight(http_port: u16, operations: serde_json::Value) -> anyhow::Result { - let bearer = preflight_scope_token()?; - let body = serde_json::to_string(&operations).context("serialize preflight body")?; - let request = format!( - "POST /jet/preflight HTTP/1.1\r\n\ - Host: 127.0.0.1:{http_port}\r\n\ - Content-Type: application/json\r\n\ - Authorization: Bearer {bearer}\r\n\ - Content-Length: {}\r\n\ - Connection: close\r\n\ - \r\n\ - {body}", - body.len() - ); - - let mut stream = TcpStream::connect(("127.0.0.1", http_port)) - .await - .context("connect to gateway HTTP")?; - stream.write_all(request.as_bytes()).await.context("write preflight")?; - stream.flush().await.context("flush preflight")?; - - let mut reader = BufReader::new(stream); - let mut status_line = String::new(); - reader - .read_line(&mut status_line) - .await - .context("read preflight status")?; - anyhow::ensure!(status_line.contains("200"), "preflight HTTP status was {status_line:?}"); - - let mut content_length = None; - loop { - let mut line = String::new(); - reader.read_line(&mut line).await.context("read preflight header")?; - if line == "\r\n" || line.is_empty() { - break; - } - if let Some(value) = line - .split_once(':') - .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) - .map(|(_, value)| value.trim().to_owned()) - { - content_length = Some(value.parse::().context("parse Content-Length")?); - } - } - - let response_body = if let Some(len) = content_length { - let mut buf = vec![0u8; len]; - reader.read_exact(&mut buf).await.context("read preflight body")?; - String::from_utf8(buf).context("preflight body utf-8")? - } else { - let mut buf = String::new(); - reader - .read_to_string(&mut buf) - .await - .context("read preflight eof body")?; - buf - }; - - let json: serde_json::Value = - serde_json::from_str(&response_body).with_context(|| format!("parse preflight JSON: {response_body}"))?; - let outputs = json.as_array().context("preflight response is not an array")?; - // Re-provisioning the same JTI emits an info alert, then still acks. - let mut acked = 0usize; - for output in outputs { - match output["kind"].as_str() { - Some("ack") => acked += 1, - Some("alert") if output["alert_status"] == "info" => {} - _ => anyhow::bail!("preflight operation was not ack: {output}"), - } - } - anyhow::ensure!(acked > 0, "preflight returned no ack: {json}"); - Ok(json) -} - -pub(crate) async fn provision_credentials( - http_port: u16, - token: &str, - target_username: &str, - time_to_live: u32, - krb_kdc: Option<&str>, -) -> anyhow::Result<()> { - provision_mapping( - http_port, - token, - PROXY_USER, - target_username, - TARGET_PASSWORD, - time_to_live, - krb_kdc, - ) - .await -} - -pub(crate) async fn provision_mapping( - http_port: u16, - token: &str, - proxy_username: &str, - target_username: &str, - target_password: &str, - time_to_live: u32, - krb_kdc: Option<&str>, -) -> anyhow::Result<()> { - let mut operations = vec![serde_json::json!({ - "id": next_id(), - "kind": "provision-credentials", - "token": token, - "proxy_credential": { - "kind": "username-password", - "username": proxy_username, - "password": PROXY_PASSWORD - }, - "target_credential": { - "kind": "username-password", - "username": target_username, - "password": target_password - }, - "time_to_live": time_to_live - })]; - - if let Some(krb_kdc) = krb_kdc { - operations.push(serde_json::json!({ - "id": next_id(), - "kind": "provision-connection-options", - "token": token, - "connection_options": { "krb_kdc": krb_kdc }, - "time_to_live": time_to_live - })); - } - - post_preflight(http_port, serde_json::Value::Array(operations)).await?; - Ok(()) -} - -async fn connect_rdp_client(gateway_tcp: u16, association_jwt: &str) -> anyhow::Result { - let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) - .await - .context("connect to gateway TCP")?; - stream - .write_all(&encode_pcb(association_jwt)?) - .await - .context("write preconnection blob")?; - stream - .write_all(&encode_connection_request(CLIENT_COOKIE)?) - .await - .context("write connection request")?; - stream.flush().await.context("flush RDP client")?; - Ok(stream) -} +use testsuite::rdp_injection::gateway::GatewayProc; +use testsuite::rdp_injection::preflight::provision_credentials; +use testsuite::rdp_injection::rdp::{FakeRdpTarget, connect_rdp_client}; +use testsuite::rdp_injection::tokens::{association_token, next_id}; +use testsuite::rdp_injection::{ + CLIENT_COOKIE, FORWARD_LOG, INJECT_LOG, KERBEROS_TARGET_USER, PUBLISHED_KDC_LOG, REGISTERED_KDC_LOG, TARGET_USER, +}; +use tokio::time::Duration; #[tokio::test] async fn first_rdp_connection_injects_ntlm() -> anyhow::Result<()> { diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.intent.md b/testsuite/tests/cli/dgw/cred_injection_kdc.intent.md new file mode 100644 index 000000000..e69de29bb diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index 409ee8ef1..1b3c4ec87 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -7,965 +7,31 @@ //! RDCleanPath coverage drives the public `ironrdp-agent` 0.1.0 CLI (`cargo install //! ironrdp-agent --version 0.1.0`) over `ws://127.0.0.1/jet/rdp`. -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::Mutex; use std::time::{Duration, Instant}; use anyhow::Context as _; -use ironrdp_connector::sspi; -use ironrdp_connector::sspi::generator::GeneratorState; -use ironrdp_pdu::nego::{ - ConnectionConfirm, ConnectionRequest, NegoRequestData, RequestFlags, ResponseFlags, SecurityProtocol, +use testsuite::rdp_injection::agent::{ + agent_query_logs, connect_ironrdp_rdcleanpath, ironrdp_agent_endpoint, require_ironrdp_agent, start_ironrdp_daemon, }; -use ironrdp_pdu::x224::X224; -use ironrdp_tokio::{FramedWrite as _, TokioFramed}; -use picky_krb::data_types::PrincipalName; -use picky_krb::messages::{AsRep, AsReq, KdcProxyMessage, KrbError, TgsRep, TgsReq}; -use rustls::pki_types::pem::PemObject as _; -use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; -use rustls::{ClientConfig, ServerConfig}; -use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; -use tokio::net::{TcpListener, TcpStream}; -use x509_cert::der::Decode as _; - -use super::cred_injection::{ - FORWARD_LOG, GatewayProc, INJECT_LOG, KERBEROS_TARGET_USER, MISSING_LOG, PROXY_KERBEROS_USER, PROXY_PASSWORD, - PROXY_USER, TARGET_PASSWORD, TARGET_USER, encode_pcb, next_id, provision_credentials, provision_mapping, - unsigned_jws, +use testsuite::rdp_injection::credssp::{ + complete_client_credssp, complete_ntlm_credssp, complete_raw_ntlm_credssp, connect_ntlm_client, }; -use super::tls_fixtures::{CERT_PEM, KEY_PEM}; - -const REALM: &str = "EXAMPLE.INVALID"; -// sspi-rs downgrades Negotiate to NTLM when the SPN host is an IP address. -const SERVICE_HOST: &str = "localhost"; -const KRBTGT_KEY: [u8; 32] = [0x11; 32]; -const TERMSRV_KEY: [u8; 32] = [0x22; 32]; - -#[derive(Clone, Debug, PartialEq, Eq)] -enum ObservedKdcReq { - As { cname: String, realm: String }, - Tgs { sname: Vec, realm: String }, - Other, -} - -struct MockKdc { - port: u16, - exchanges: Arc, - requests: Arc>>, -} - -impl MockKdc { - async fn start() -> anyhow::Result { - let listener = TcpListener::bind("127.0.0.1:0").await.context("bind mock KDC")?; - let port = listener.local_addr().context("mock KDC local_addr")?.port(); - let exchanges = Arc::new(AtomicUsize::new(0)); - let requests = Arc::new(Mutex::new(Vec::new())); - let exchanges_task = Arc::clone(&exchanges); - let requests_task = Arc::clone(&requests); - let config = kdc_config(); - - tokio::spawn(async move { - loop { - let Ok((stream, _)) = listener.accept().await else { - break; - }; - let config = config.clone(); - let exchanges = Arc::clone(&exchanges_task); - let requests = Arc::clone(&requests_task); - tokio::spawn(async move { - match serve_kdc_exchange(stream, &config, &requests).await { - Ok(()) => { - exchanges.fetch_add(1, Ordering::SeqCst); - } - Err(error) => eprintln!("mock KDC exchange failed: {error:#}"), - } - }); - } - }); - - Ok(Self { - port, - exchanges, - requests, - }) - } - - fn url(&self) -> String { - format!("tcp://127.0.0.1:{}", self.port) - } - - fn exchanges(&self) -> usize { - self.exchanges.load(Ordering::SeqCst) - } - - fn requests(&self) -> Vec { - self.requests.lock().expect("kdc request mutex").clone() - } -} - -fn kdc_config() -> kdc::config::KerberosServer { - let username = format!("administrator@{REALM}"); - kdc::config::KerberosServer { - realm: REALM.to_owned(), - users: vec![kdc::config::DomainUser { - username, - password: TARGET_PASSWORD.to_owned(), - salt: format!("{}administrator", REALM.to_ascii_uppercase()), - }], - max_time_skew: 300, - krbtgt_key: KRBTGT_KEY.to_vec(), - ticket_decryption_key: Some(TERMSRV_KEY.to_vec()), - service_user: None, - } -} - -async fn serve_kdc_exchange( - mut stream: TcpStream, - config: &kdc::config::KerberosServer, - requests: &Mutex>, -) -> anyhow::Result<()> { - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf).await.context("read KDC length")?; - let len = usize::try_from(u32::from_be_bytes(len_buf)).context("KDC length")?; - let mut body = vec![0u8; len]; - stream.read_exact(&mut body).await.context("read KDC body")?; - - requests.lock().expect("kdc request mutex").push(observe_kdc_req(&body)); - - let mut raw = Vec::with_capacity(4 + len); - raw.extend_from_slice(&len_buf); - raw.extend_from_slice(&body); - - let request = KdcProxyMessage::from_raw_kerb_message(&raw).context("wrap KDC TCP payload")?; - let reply = kdc::handle_kdc_proxy_message(request, config, SERVICE_HOST).context("handle KDC message")?; - stream - .write_all(&reply.kerb_message.0.0) - .await - .context("write KDC reply")?; - Ok(()) -} - -fn principal_strings(name: &PrincipalName) -> Vec { - name.name_string.0.0.iter().map(|part| part.0.to_string()).collect() -} - -fn observe_kdc_req(body: &[u8]) -> ObservedKdcReq { - if let Ok(as_req) = picky_asn1_der::from_bytes::(body) { - let req = &as_req.0.req_body.0; - let cname = req - .cname - .0 - .as_ref() - .map(|name| principal_strings(&name.0).join("/")) - .unwrap_or_default(); - return ObservedKdcReq::As { - cname, - realm: req.realm.0.to_string(), - }; - } - if let Ok(tgs_req) = picky_asn1_der::from_bytes::(body) { - let req = &tgs_req.0.req_body.0; - let sname = req - .sname - .0 - .as_ref() - .map(|name| principal_strings(&name.0)) - .unwrap_or_default(); - return ObservedKdcReq::Tgs { - sname, - realm: req.realm.0.to_string(), - }; - } - ObservedKdcReq::Other -} - -fn observe_kdc_reply(body: &[u8]) -> ObservedKdcReply { - let krb = body.get(4..).unwrap_or(body); - if picky_asn1_der::from_bytes::(krb).is_ok() { - ObservedKdcReply::AsRep - } else if picky_asn1_der::from_bytes::(krb).is_ok() { - ObservedKdcReply::TgsRep - } else if picky_asn1_der::from_bytes::(krb).is_ok() { - ObservedKdcReply::KrbError - } else { - ObservedKdcReply::Other - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -enum ObservedKdcReply { - AsRep, - TgsRep, - KrbError, - Other, -} - -#[derive(Clone)] -enum MockRdpMode { - Kerberos { kdc_url: Option }, - Ntlm, -} - -struct MockRdp { - port: u16, - credssp_ok: Arc, - finished_account: Arc>>, - cookies: Arc>>, -} - -impl MockRdp { - async fn start_kerberos(kdc_url: String) -> anyhow::Result { - Self::start(MockRdpMode::Kerberos { kdc_url: Some(kdc_url) }).await - } - - async fn start_ntlm() -> anyhow::Result { - Self::start(MockRdpMode::Ntlm).await - } - - async fn start(mode: MockRdpMode) -> anyhow::Result { - install_crypto_provider(); - // Dual-stack so Windows `localhost` (IPv6 first) still hits the fake server. - let listener = match TcpListener::bind("[::]:0").await { - Ok(listener) => listener, - Err(_) => TcpListener::bind("127.0.0.1:0").await.context("bind mock RDP")?, - }; - let port = listener.local_addr().context("mock RDP local_addr")?.port(); - let credssp_ok = Arc::new(AtomicBool::new(false)); - let finished_account = Arc::new(Mutex::new(None)); - let cookies = Arc::new(Mutex::new(Vec::new())); - let credssp_ok_task = Arc::clone(&credssp_ok); - let finished_account_task = Arc::clone(&finished_account); - let cookies_task = Arc::clone(&cookies); - let acceptor = tls_acceptor()?; - let public_key = server_public_key()?; - - tokio::spawn(async move { - loop { - let Ok((stream, peer)) = listener.accept().await else { - break; - }; - let acceptor = acceptor.clone(); - let public_key = public_key.clone(); - let credssp_ok = Arc::clone(&credssp_ok_task); - let finished_account = Arc::clone(&finished_account_task); - let cookies = Arc::clone(&cookies_task); - let mode = mode.clone(); - tokio::spawn(async move { - let result = match &mode { - MockRdpMode::Kerberos { kdc_url } => { - accept_kerberos_rdp( - stream, - peer, - acceptor, - public_key, - kdc_url.as_deref(), - &cookies, - &finished_account, - ) - .await - } - MockRdpMode::Ntlm => { - accept_ntlm_rdp(stream, peer, acceptor, public_key, &cookies, &finished_account).await - } - }; - match result { - Ok(()) => credssp_ok.store(true, Ordering::SeqCst), - Err(error) => eprintln!("mock RDP CredSSP failed: {error:#}"), - } - }); - } - }); - - Ok(Self { - port, - credssp_ok, - finished_account, - cookies, - }) - } - - fn credssp_ok(&self) -> bool { - self.credssp_ok.load(Ordering::SeqCst) - } - - fn finished_account(&self) -> Option { - self.finished_account.lock().expect("finished account mutex").clone() - } - - fn cookies(&self) -> Vec { - self.cookies.lock().expect("cookie mutex").clone() - } - - async fn wait_credssp(&self) -> anyhow::Result<()> { - let deadline = Instant::now() + Duration::from_secs(30); - loop { - if self.credssp_ok() { - return Ok(()); - } - if Instant::now() >= deadline { - anyhow::bail!("timed out waiting for Kerberos CredSSP on mock RDP"); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - } -} - -async fn accept_kerberos_rdp( - stream: TcpStream, - peer: std::net::SocketAddr, - acceptor: tokio_rustls::TlsAcceptor, - public_key: Vec, - kdc_url: Option<&str>, - cookies: &Mutex>, - finished_account: &Mutex>, -) -> anyhow::Result<()> { - let mut framed = TokioFramed::new(stream); - let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; - let cr: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; - record_cookie(&cr, cookies); - - let confirm = X224(ConnectionConfirm::Response { - flags: ResponseFlags::empty(), - protocol: SecurityProtocol::HYBRID, - }); - framed - .write_all(&ironrdp_core::encode_vec(&confirm).context("encode X.224 CC")?) - .await - .context("write X.224 CC")?; - - let tcp = framed.into_inner_no_leftover(); - let tls = acceptor.accept(tcp).await.context("TLS accept")?; - let mut framed = TokioFramed::new(tls); - - let identity = sspi::AuthIdentity { - username: sspi::Username::parse(KERBEROS_TARGET_USER).context("parse target username")?, - password: TARGET_PASSWORD.to_owned().into(), - }; - let kerberos_config = sspi::KerberosServerConfig { - kerberos_config: sspi::KerberosConfig { - kdc_url: kdc_url - .map(|url| url.parse()) - .transpose() - .context("parse mock KDC URL")?, - client_computer_name: peer.to_string(), - }, - server_properties: sspi::kerberos::ServerProperties::new( - &["TERMSRV", SERVICE_HOST], - Some(sspi::CredentialsBuffers::AuthIdentity( - sspi::AuthIdentityBuffers::from_utf8(identity.username.account_name(), REALM, TARGET_PASSWORD), - )), - Duration::from_secs(300), - Some(sspi::Secret::new(TERMSRV_KEY.to_vec())), - ) - .context("Kerberos server properties")?, - }; - - let mut server = sspi::credssp::CredSspServer::new( - public_key, - IdentityProxy(identity), - sspi::credssp::ServerMode::Negotiate(sspi::NegotiateConfig::new( - Box::new(kerberos_config), - Some("kerberos,!ntlm".to_owned()), - peer.to_string(), - )), - ) - .context("init Kerberos-only CredSSP server")?; - - let hint = TsRequestHint; - let mut buf = ironrdp_pdu::WriteBuf::new(); - for _ in 0..6 { - let pdu = framed.read_by_hint(&hint).await.context("read CredSSP TSRequest")?; - let ts_request = sspi::credssp::TsRequest::from_buffer(&pdu).context("decode CredSSP")?; - let result = { - let mut generator = server.process(ts_request); - resolve_sspi_server(&mut generator) - .await - .map_err(|error| anyhow::anyhow!("mock RDP CredSSP: {error:?}"))? - }; - match result { - sspi::credssp::ServerState::ReplyNeeded(outbound) => { - buf.clear(); - let length = usize::from(outbound.buffer_len()); - outbound - .encode_ts_request(buf.unfilled_to(length)) - .context("encode server TSRequest")?; - buf.advance(length); - framed.write_all(&buf[..length]).await.context("write CredSSP")?; - } - sspi::credssp::ServerState::Finished(identity) => { - *finished_account.lock().expect("finished account mutex") = - Some(identity.username.account_name().to_owned()); - return Ok(()); - } - } - } - anyhow::bail!("mock RDP CredSSP exceeded 6 round trips") -} - -async fn accept_ntlm_rdp( - stream: TcpStream, - peer: std::net::SocketAddr, - acceptor: tokio_rustls::TlsAcceptor, - public_key: Vec, - cookies: &Mutex>, - finished_account: &Mutex>, -) -> anyhow::Result<()> { - let mut framed = TokioFramed::new(stream); - let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; - let cr: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; - record_cookie(&cr, cookies); - - let confirm = X224(ConnectionConfirm::Response { - flags: ResponseFlags::empty(), - protocol: SecurityProtocol::HYBRID, - }); - framed - .write_all(&ironrdp_core::encode_vec(&confirm).context("encode X.224 CC")?) - .await - .context("write X.224 CC")?; - - let tcp = framed.into_inner_no_leftover(); - let tls = acceptor.accept(tcp).await.context("TLS accept")?; - let mut framed = TokioFramed::new(tls); - - let identity = sspi::AuthIdentity { - username: sspi::Username::parse(TARGET_USER).context("parse NTLM target username")?, - password: TARGET_PASSWORD.to_owned().into(), - }; - let mut server = sspi::credssp::CredSspServer::new( - public_key, - IdentityProxy(identity), - sspi::credssp::ServerMode::Ntlm(sspi::ntlm::NtlmConfig { - client_computer_name: Some(peer.to_string()), - }), - ) - .context("init NTLM CredSSP server")?; - - let hint = TsRequestHint; - let mut buf = ironrdp_pdu::WriteBuf::new(); - for _ in 0..6 { - let pdu = framed.read_by_hint(&hint).await.context("read CredSSP TSRequest")?; - let ts_request = sspi::credssp::TsRequest::from_buffer(&pdu).context("decode CredSSP")?; - let result = { - let mut generator = server.process(ts_request); - resolve_sspi_server(&mut generator) - .await - .map_err(|error| anyhow::anyhow!("mock RDP NTLM CredSSP: {error:?}"))? - }; - match result { - sspi::credssp::ServerState::ReplyNeeded(outbound) => { - buf.clear(); - let length = usize::from(outbound.buffer_len()); - outbound - .encode_ts_request(buf.unfilled_to(length)) - .context("encode server TSRequest")?; - buf.advance(length); - framed.write_all(&buf[..length]).await.context("write CredSSP")?; - } - sspi::credssp::ServerState::Finished(identity) => { - *finished_account.lock().expect("finished account mutex") = - Some(identity.username.account_name().to_owned()); - return Ok(()); - } - } - } - anyhow::bail!("mock RDP NTLM CredSSP exceeded 6 round trips") -} - -fn record_cookie(cr: &X224, cookies: &Mutex>) { - if let Some(NegoRequestData::Cookie(cookie)) = &cr.0.nego_data { - cookies.lock().expect("cookie mutex").push(cookie.0.clone()); - } -} - -async fn resolve_sspi_server( - generator: &mut sspi::generator::Generator< - '_, - sspi::generator::NetworkRequest, - sspi::Result>, - Result, - >, -) -> Result { - let mut state = generator.start(); - loop { - match state { - GeneratorState::Suspended(request) => { - let reply = send_kdc_tcp(&request) - .await - .map_err(|error| sspi::credssp::ServerError { - ts_request: None, - error: sspi::Error::new(sspi::ErrorKind::NoAuthenticatingAuthority, error), - })?; - state = generator.resume(Ok(reply)); - } - GeneratorState::Completed(result) => break result, - } - } -} - -async fn send_kdc_tcp(request: &sspi::generator::NetworkRequest) -> anyhow::Result> { - let host = request.url.host_str().context("KDC host")?; - let port = request.url.port().unwrap_or(88); - let mut stream = TcpStream::connect((host, port)).await.context("connect mock KDC")?; - stream.write_all(&request.data).await.context("write KDC request")?; - let mut len_buf = [0u8; 4]; - stream.read_exact(&mut len_buf).await.context("read KDC length")?; - let len = usize::try_from(u32::from_be_bytes(len_buf)).context("KDC length")?; - let mut body = vec![0u8; len]; - stream.read_exact(&mut body).await.context("read KDC body")?; - let mut reply = Vec::with_capacity(4 + len); - reply.extend_from_slice(&len_buf); - reply.extend_from_slice(&body); - Ok(reply) -} - -struct IdentityProxy(sspi::AuthIdentity); - -impl sspi::credssp::CredentialsProxy for IdentityProxy { - type AuthenticationData = sspi::AuthIdentity; - - fn auth_data_by_user(&mut self, username: &sspi::Username) -> std::io::Result { - if username.account_name() != self.0.username.account_name() { - return Err(std::io::Error::other("invalid username")); - } - let mut data = self.0.clone(); - data.username = username.clone(); - Ok(data) - } - - fn auth_data(&mut self) -> Result, std::io::Error> { - Ok(vec![self.0.clone()]) - } -} - -const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20); - -async fn connect_ntlm_client( - gateway_tcp: u16, - association_jwt: &str, -) -> anyhow::Result> { - tokio::time::timeout( - HANDSHAKE_TIMEOUT, - connect_ntlm_client_inner(gateway_tcp, association_jwt), - ) - .await - .context("timed out connecting NTLM client to Gateway")? -} - -async fn connect_ntlm_client_inner( - gateway_tcp: u16, - association_jwt: &str, -) -> anyhow::Result> { - let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) - .await - .context("connect gateway TCP")?; - stream - .write_all(&encode_pcb(association_jwt)?) - .await - .context("write PCB")?; - stream.write_all(&encode_hybrid_cr()?).await.context("write X.224 CR")?; - stream.flush().await.context("flush CR")?; - - let mut framed = TokioFramed::new(stream); - let (_, confirm) = framed.read_pdu().await.context("read X.224 CC")?; - let confirm: X224 = ironrdp_core::decode(&confirm).context("decode X.224 CC")?; - anyhow::ensure!( - matches!(confirm.0, ConnectionConfirm::Response { protocol, .. } if protocol.contains(SecurityProtocol::HYBRID)), - "gateway did not confirm CredSSP: {confirm:?}" - ); - - let tcp = framed.into_inner_no_leftover(); - let connector = dangerous_tls_connector(); - let server_name = ServerName::try_from("localhost").map_err(|error| anyhow::anyhow!("{error}"))?; - connector.connect(server_name, tcp).await.context("TLS to gateway") -} - -async fn complete_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { - complete_client_credssp(tls, PROXY_USER, None, false, None, None).await -} - -async fn complete_raw_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { - complete_client_credssp(tls, PROXY_USER, None, true, None, None).await -} - -async fn complete_client_credssp( - tls: tokio_rustls::client::TlsStream, - username: &str, - kdc_proxy_url: Option<&str>, - raw_ntlm: bool, - proxy_replies: Option<&Mutex>>, - proxy_requests: Option<&Mutex>>, -) -> anyhow::Result<()> { - tokio::time::timeout( - HANDSHAKE_TIMEOUT, - complete_client_credssp_inner(tls, username, kdc_proxy_url, raw_ntlm, proxy_replies, proxy_requests), - ) - .await - .context("timed out completing client CredSSP")? -} - -async fn complete_client_credssp_inner( - tls: tokio_rustls::client::TlsStream, - username: &str, - kdc_proxy_url: Option<&str>, - raw_ntlm: bool, - proxy_replies: Option<&Mutex>>, - proxy_requests: Option<&Mutex>>, -) -> anyhow::Result<()> { - use sspi::credssp::{ClientMode, ClientState, CredSspClient, CredSspMode, TsRequest}; - use sspi::ntlm::NtlmConfig; - - let public_key = peer_public_key(&tls)?; - let mut framed = TokioFramed::new(tls); - let identity = sspi::AuthIdentity { - username: sspi::Username::parse(username).context("parse client username")?, - password: PROXY_PASSWORD.to_owned().into(), - }; - let client_mode = if let Some(kdc_url) = kdc_proxy_url { - ClientMode::Negotiate(sspi::NegotiateConfig::new( - Box::new(sspi::KerberosConfig { - kdc_url: Some(kdc_url.parse().context("parse KDC proxy URL")?), - client_computer_name: "cred-injection-e2e".to_owned(), - }), - Some("kerberos,!ntlm".to_owned()), - "cred-injection-e2e".to_owned(), - )) - } else if raw_ntlm { - // Gateway NTLM injection uses ServerMode::Ntlm, which rejects SPNEGO. - ClientMode::Ntlm(NtlmConfig { - client_computer_name: Some("cred-injection-e2e".to_owned()), - }) - } else { - ClientMode::Negotiate(sspi::NegotiateConfig::new( - Box::new(NtlmConfig { - client_computer_name: Some("cred-injection-e2e".to_owned()), - }), - Some("ntlm,!kerberos,!pku2u".to_owned()), - "cred-injection-e2e".to_owned(), - )) - }; - let mut client = CredSspClient::new( - public_key, - identity.into(), - CredSspMode::WithCredentials, - client_mode, - format!("TERMSRV/{SERVICE_HOST}"), - ) - .context("init CredSSP client")?; - - let mut ts_request = TsRequest::default(); - let mut buf = ironrdp_pdu::WriteBuf::new(); - let hint = TsRequestHint; - - for _ in 0..8 { - let client_state = { - let mut generator = client.process(std::mem::take(&mut ts_request)); - resolve_sspi_client(&mut generator, proxy_replies, proxy_requests).await? - }; - let (outbound, finished) = match client_state { - ClientState::ReplyNeeded(request) => (request, false), - ClientState::FinalMessage(request) => (request, true), - }; - buf.clear(); - let length = usize::from(outbound.buffer_len()); - outbound - .encode_ts_request(buf.unfilled_to(length)) - .context("encode client TSRequest")?; - buf.advance(length); - framed.write_all(&buf[..length]).await.context("write client CredSSP")?; - if finished { - return Ok(()); - } - let pdu = framed.read_by_hint(&hint).await.context("read server CredSSP")?; - ts_request = TsRequest::from_buffer(&pdu).context("decode server TSRequest")?; - } - - anyhow::bail!("CredSSP exceeded 8 round trips") -} - -async fn resolve_sspi_client( - generator: &mut sspi::generator::Generator< - '_, - sspi::generator::NetworkRequest, - sspi::Result>, - sspi::Result, - >, - proxy_replies: Option<&Mutex>>, - proxy_requests: Option<&Mutex>>, -) -> anyhow::Result { - let mut state = generator.start(); - loop { - match state { - GeneratorState::Suspended(request) => { - let reply = match request.url.scheme() { - "tcp" | "udp" => send_kdc_tcp(&request).await?, - "http" | "https" => send_kdc_http(&request, proxy_replies, proxy_requests).await?, - other => anyhow::bail!("unsupported KDC scheme {other}: {}", request.url), - }; - state = generator.resume(Ok(reply)); - } - GeneratorState::Completed(result) => { - break result.map_err(|error| anyhow::anyhow!("client CredSSP: {error}")); - } - } - } -} - -async fn send_kdc_http( - request: &sspi::generator::NetworkRequest, - proxy_replies: Option<&Mutex>>, - proxy_requests: Option<&Mutex>>, -) -> anyhow::Result> { - let host = request.url.host_str().context("KDC proxy host")?; - let port = request.url.port_or_known_default().unwrap_or(80); - let path = if request.url.query().is_some() { - format!("{}?{}", request.url.path(), request.url.query().unwrap_or_default()) - } else { - request.url.path().to_owned() - }; - let mut stream = TcpStream::connect((host, port)).await.context("connect KDC proxy")?; - let header = format!( - "POST {path} HTTP/1.1\r\n\ - Host: {host}:{port}\r\n\ - Content-Type: application/octet-stream\r\n\ - Content-Length: {}\r\n\ - Connection: close\r\n\ - \r\n", - request.data.len() - ); - stream - .write_all(header.as_bytes()) - .await - .context("write KDC proxy headers")?; - stream.write_all(&request.data).await.context("write KDC proxy body")?; - stream.flush().await.context("flush KDC proxy")?; - if let Ok(message) = KdcProxyMessage::from_raw(&request.data) - && let Some(log) = proxy_requests - { - let kerb = message.kerb_message.0.0.get(4..).unwrap_or(&message.kerb_message.0.0); - log.lock().expect("proxy request mutex").push(observe_kdc_req(kerb)); - } - - let mut reader = BufReader::new(stream); - let mut status_line = String::new(); - reader - .read_line(&mut status_line) - .await - .context("read KDC proxy status")?; - anyhow::ensure!( - status_line.starts_with("HTTP/1.1 200") || status_line.starts_with("HTTP/1.0 200"), - "KDC proxy HTTP status was {status_line:?}" - ); - - let mut content_length = None; - loop { - let mut line = String::new(); - reader.read_line(&mut line).await.context("read KDC proxy header")?; - if line == "\r\n" || line.is_empty() { - break; - } - if let Some(value) = line - .split_once(':') - .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) - .map(|(_, value)| value.trim().to_owned()) - { - content_length = Some(value.parse::().context("parse KDC proxy Content-Length")?); - } - } - - let buf = if let Some(len) = content_length { - let mut buf = vec![0u8; len]; - tokio::io::AsyncReadExt::read_exact(&mut reader, &mut buf) - .await - .context("read KDC proxy body")?; - buf - } else { - let mut buf = Vec::new(); - tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buf) - .await - .context("read KDC proxy eof body")?; - buf - }; - if let Ok(message) = KdcProxyMessage::from_raw(&buf) - && let Some(log) = proxy_replies - { - log.lock() - .expect("proxy reply mutex") - .push(observe_kdc_reply(&message.kerb_message.0.0)); - } - Ok(buf) -} - -fn kdc_inject_token(association_jti: &str) -> anyhow::Result { - unsigned_jws( - serde_json::json!({"alg":"RS256","typ":"JWT","cty":"KDC"}), - serde_json::json!({ - "exp": 9_999_999_999i64, - "jet_cred_id": association_jti, - "jti": next_id(), - }), - ) -} - -fn kdc_proxy_url(http_port: u16, association_jti: &str) -> anyhow::Result { - let token = kdc_inject_token(association_jti)?; - Ok(format!("http://127.0.0.1:{http_port}/jet/KdcProxy/{token}")) -} - -#[derive(Debug)] -struct TsRequestHint; - -impl ironrdp_pdu::PduHint for TsRequestHint { - fn find_size(&self, bytes: &[u8]) -> ironrdp_core::DecodeResult> { - match sspi::credssp::TsRequest::read_length(bytes) { - Ok(length) => Ok(Some((true, length))), - Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(None), - Err(error) => Err(ironrdp_core::other_err!("TsRequestHint", source: error)), - } - } -} - -fn association_token_for_host(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { - unsigned_jws( - serde_json::json!({"alg":"RS256","typ":"JWT","cty":"ASSOCIATION"}), - serde_json::json!({ - "dst_hst": format!("{SERVICE_HOST}:{dest_port}"), - "exp": 9_999_999_999i64, - "jet_aid": jet_aid, - "jet_ap": "rdp", - "jet_cm": "fwd", - "jet_rec": "none", - "jet_reuse": jet_reuse, - "jti": jti, - "nbf": 0, - }), - ) -} - -fn encode_hybrid_cr() -> anyhow::Result> { - let pdu = X224(ConnectionRequest { - nego_data: Some(NegoRequestData::cookie(super::cred_injection::CLIENT_COOKIE.to_owned())), - flags: RequestFlags::empty(), - protocol: SecurityProtocol::HYBRID | SecurityProtocol::SSL, - }); - ironrdp_core::encode_vec(&pdu).context("encode hybrid CR") -} - -fn peer_public_key(tls: &tokio_rustls::client::TlsStream) -> anyhow::Result> { - let cert = tls - .get_ref() - .1 - .peer_certificates() - .and_then(|certs| certs.first()) - .context("gateway TLS certificate missing")?; - extract_public_key(cert) -} - -fn server_public_key() -> anyhow::Result> { - let cert = CertificateDer::from_pem_slice(CERT_PEM.as_bytes()).context("parse mock RDP cert")?; - extract_public_key(&cert) -} - -fn extract_public_key(cert: &CertificateDer<'_>) -> anyhow::Result> { - let cert = x509_cert::Certificate::from_der(cert.as_ref()).context("parse X509")?; - let public_key = cert - .tbs_certificate() - .subject_public_key_info() - .subject_public_key - .as_bytes() - .context("unaligned subject public key")? - .to_owned(); - Ok(public_key) -} - -fn tls_acceptor() -> anyhow::Result { - let cert = CertificateDer::from_pem_slice(CERT_PEM.as_bytes()).context("parse cert PEM")?; - let key = PrivateKeyDer::from_pem_slice(KEY_PEM.as_bytes()).context("parse key PEM")?; - let config = ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(vec![cert], key) - .context("TLS server config")?; - Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config))) -} - -fn dangerous_tls_connector() -> tokio_rustls::TlsConnector { - let mut config = ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) - .with_no_client_auth(); - config.resumption = rustls::client::Resumption::disabled(); - tokio_rustls::TlsConnector::from(Arc::new(config)) -} - -fn install_crypto_provider() { - let _ = rustls::crypto::ring::default_provider().install_default(); -} - -#[derive(Debug)] -struct NoCertificateVerification; - -impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { - fn verify_server_cert( - &self, - _: &CertificateDer<'_>, - _: &[CertificateDer<'_>], - _: &ServerName<'_>, - _: &[u8], - _: rustls::pki_types::UnixTime, - ) -> Result { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _: &[u8], - _: &CertificateDer<'_>, - _: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _: &[u8], - _: &CertificateDer<'_>, - _: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - vec![ - rustls::SignatureScheme::RSA_PKCS1_SHA256, - rustls::SignatureScheme::ECDSA_NISTP256_SHA256, - rustls::SignatureScheme::RSA_PSS_SHA256, - rustls::SignatureScheme::ED25519, - ] - } -} - -fn assert_target_kdc_as_and_tgs(kdc: &MockKdc) -> anyhow::Result<()> { - let reqs = kdc.requests(); - anyhow::ensure!( - reqs.iter().any(|req| matches!( - req, - ObservedKdcReq::As { cname, realm } - if cname.eq_ignore_ascii_case("administrator") && realm.eq_ignore_ascii_case(REALM) - )), - "KDC must see AS-REQ cname=administrator realm={REALM}; requests={reqs:?}" - ); - anyhow::ensure!( - reqs.iter().any(|req| matches!( - req, - ObservedKdcReq::Tgs { sname, realm } - if *sname == ["TERMSRV", SERVICE_HOST] && realm.eq_ignore_ascii_case(REALM) - )), - "KDC must see TGS-REQ sname=TERMSRV/{SERVICE_HOST} realm={REALM}; requests={reqs:?}" - ); - Ok(()) -} +use testsuite::rdp_injection::gateway::GatewayProc; +use testsuite::rdp_injection::mock_kdc::{ + MockKdc, ObservedKdcReply, ObservedKdcReq, RefusingKdc, assert_target_kdc_as_and_tgs, +}; +use testsuite::rdp_injection::mock_rdp::MockRdp; +use testsuite::rdp_injection::preflight::{provision_credentials, provision_mapping}; +use testsuite::rdp_injection::rdp::{FakeClosedTarget, encode_hybrid_cr, encode_pcb}; +use testsuite::rdp_injection::tls::install_crypto_provider; +use testsuite::rdp_injection::tokens::{association_token_for_host, kdc_proxy_url, next_id}; +use testsuite::rdp_injection::{ + FORWARD_LOG, INJECT_LOG, KERBEROS_TARGET_USER, MISSING_LOG, PROXY_KERBEROS_USER, PROXY_USER, + RDCLEANPATH_FORWARD_LOG, RDCLEANPATH_INJECT_LOG, REALM, SERVICE_HOST, TARGET_PASSWORD, TARGET_USER, +}; +use tokio::io::AsyncWriteExt as _; +use tokio::net::TcpStream; #[tokio::test] async fn kerberos_injection_completes_credssp_against_mock_kdc() -> anyhow::Result<()> { @@ -1182,37 +248,6 @@ async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { Ok(()) } -struct RefusingKdc { - port: u16, - accepted: Arc, -} - -impl RefusingKdc { - async fn start() -> anyhow::Result { - let listener = TcpListener::bind("127.0.0.1:0").await.context("bind refusing KDC")?; - let port = listener.local_addr()?.port(); - let accepted = Arc::new(AtomicUsize::new(0)); - let accepted_task = Arc::clone(&accepted); - tokio::spawn(async move { - loop { - let Ok((_stream, _)) = listener.accept().await else { - break; - }; - accepted_task.fetch_add(1, Ordering::SeqCst); - } - }); - Ok(Self { port, accepted }) - } - - fn url(&self) -> String { - format!("tcp://127.0.0.1:{}", self.port) - } - - fn accepted(&self) -> usize { - self.accepted.load(Ordering::SeqCst) - } -} - #[tokio::test] async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { install_crypto_provider(); @@ -1343,170 +378,6 @@ async fn ntlm_injection_completes_credssp_both_legs() -> anyhow::Result<()> { Ok(()) } -struct FakeClosedTarget { - port: u16, - accepted: Arc, -} - -impl FakeClosedTarget { - async fn start() -> anyhow::Result { - let listener = TcpListener::bind("127.0.0.1:0").await.context("bind closed target")?; - let port = listener.local_addr()?.port(); - let accepted = Arc::new(AtomicUsize::new(0)); - let accepted_task = Arc::clone(&accepted); - tokio::spawn(async move { - loop { - let Ok((_stream, _)) = listener.accept().await else { - break; - }; - accepted_task.fetch_add(1, Ordering::SeqCst); - } - }); - Ok(Self { port, accepted }) - } - - fn accepted(&self) -> usize { - self.accepted.load(Ordering::SeqCst) - } -} - -const IRONRDP_AGENT_VERSION: &str = "0.1.0"; -const RDCLEANPATH_INJECT_LOG: &str = "Switching to RdpProxy for credential injection (WebSocket)"; -const RDCLEANPATH_FORWARD_LOG: &str = "RDP-TLS forwarding (RDCleanPath)"; - -fn ironrdp_agent_bin() -> Option { - if let Ok(path) = std::env::var("IRONRDP_AGENT") { - return Some(PathBuf::from(path)); - } - let name = if cfg!(windows) { - "ironrdp-agent.exe" - } else { - "ironrdp-agent" - }; - if let Ok(home) = std::env::var("CARGO_HOME") { - let path = PathBuf::from(home).join("bin").join(name); - if path.is_file() { - return Some(path); - } - } - let cargo_home = std::env::var_os("USERPROFILE") - .or_else(|| std::env::var_os("HOME")) - .map(PathBuf::from) - .map(|home| home.join(".cargo").join("bin").join(name)); - if let Some(path) = cargo_home - && path.is_file() - { - return Some(path); - } - if let Ok(path) = std::env::var("PATH") { - for dir in std::env::split_paths(&path) { - let candidate = dir.join(name); - if candidate.is_file() { - return Some(candidate); - } - } - } - None -} - -fn require_ironrdp_agent() -> anyhow::Result> { - let Some(bin) = ironrdp_agent_bin() else { - eprintln!( - "skipping RDCleanPath ironrdp-agent test: cargo install ironrdp-agent --version {IRONRDP_AGENT_VERSION}" - ); - return Ok(None); - }; - let output = std::process::Command::new(&bin) - .arg("--version") - .output() - .with_context(|| format!("run {} --version", bin.display()))?; - let version = String::from_utf8_lossy(&output.stdout); - anyhow::ensure!( - version.contains(IRONRDP_AGENT_VERSION), - "expected ironrdp-agent {IRONRDP_AGENT_VERSION}, got {version:?} from {}", - bin.display() - ); - Ok(Some(bin)) -} - -fn ironrdp_agent_endpoint() -> String { - let name = format!("ironrdp-e2e-{}", next_id().replace('-', "")); - if cfg!(windows) { - format!(r"\\.\pipe\{name}") - } else { - std::env::temp_dir().join(format!("{name}.sock")).display().to_string() - } -} - -async fn start_ironrdp_daemon(bin: &Path, endpoint: &str) -> anyhow::Result { - let child = tokio::process::Command::new(bin) - .args(["--endpoint", endpoint, "daemon-start"]) - .kill_on_drop(true) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .context("start ironrdp-agent daemon")?; - let deadline = Instant::now() + Duration::from_secs(10); - loop { - let status = tokio::process::Command::new(bin) - .args(["--endpoint", endpoint, "status"]) - .output() - .await - .context("ironrdp-agent status")?; - if status.status.success() { - return Ok(child); - } - if Instant::now() >= deadline { - anyhow::bail!( - "ironrdp-agent daemon not ready at {endpoint}: {}", - String::from_utf8_lossy(&status.stderr) - ); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - -async fn connect_ironrdp_rdcleanpath( - bin: &Path, - endpoint: &str, - server: &str, - token: &str, - http_port: u16, -) -> anyhow::Result { - let url = format!("ws://127.0.0.1:{http_port}/jet/rdp"); - tokio::process::Command::new(bin) - .args([ - "--endpoint", - endpoint, - "connect", - "--server", - server, - "--username", - PROXY_USER, - "--password", - PROXY_PASSWORD, - "--prop", - &format!("ironrdp_rdcleanpathurl:s:{url}"), - "--prop", - &format!("ironrdp_rdcleanpathtoken:s:{token}"), - ]) - .kill_on_drop(true) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("start ironrdp-agent connect") -} - -async fn agent_query_logs(bin: &Path, endpoint: &str) -> String { - tokio::process::Command::new(bin) - .args(["--endpoint", endpoint, "query-logs"]) - .output() - .await - .ok() - .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) - .unwrap_or_default() -} - #[tokio::test] async fn ironrdp_agent_rdcleanpath_ntlm_injection() -> anyhow::Result<()> { let Some(bin) = require_ironrdp_agent()? else { diff --git a/testsuite/tests/cli/dgw/mod.rs b/testsuite/tests/cli/dgw/mod.rs index aadf2cefa..acb5a50a6 100644 --- a/testsuite/tests/cli/dgw/mod.rs +++ b/testsuite/tests/cli/dgw/mod.rs @@ -5,5 +5,4 @@ mod cred_injection_kdc; mod heartbeat; mod preflight; mod tls_anchoring; -mod tls_fixtures; mod traffic_audit; diff --git a/testsuite/tests/cli/dgw/tls_anchoring.rs b/testsuite/tests/cli/dgw/tls_anchoring.rs index 79f355cf5..36fbafac5 100644 --- a/testsuite/tests/cli/dgw/tls_anchoring.rs +++ b/testsuite/tests/cli/dgw/tls_anchoring.rs @@ -168,7 +168,7 @@ async fn start_dummy_tls_server() -> anyhow::Result { } mod tls { - pub(super) use super::super::tls_fixtures::{CERT_PEM, KEY_PEM}; + pub(super) use testsuite::tls_fixtures::{CERT_PEM, KEY_PEM}; /// SHA-256 thumbprint of the certificate. pub(super) const CERT_THUMBPRINT: &str = "bce13f257b9d856404c51b46f2420eff6d01b3a4c99fe3d0e11e4517c2291b70"; From 4659ab11499bfab1f837710e375418740d86b8fa Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 28 Aug 2026 14:04:52 -0400 Subject: [PATCH 33/36] test(dgw): clarify credential injection tests Record the intended NTLM, Kerberos, reconnect, and failure scenarios. Name test tokens and identifiers by their protocol roles. --- testsuite/src/rdp_injection/INTENT.md | 28 +++++ testsuite/src/rdp_injection/agent.rs | 4 +- testsuite/src/rdp_injection/credssp.rs | 8 +- testsuite/src/rdp_injection/preflight.rs | 27 +++-- testsuite/src/rdp_injection/rdp.rs | 8 +- testsuite/src/rdp_injection/tokens.rs | 43 +++++-- testsuite/tests/cli/dgw/cred_injection.rs | 99 +++++++++------- testsuite/tests/cli/dgw/cred_injection_kdc.rs | 106 +++++++++++------- 8 files changed, 212 insertions(+), 111 deletions(-) diff --git a/testsuite/src/rdp_injection/INTENT.md b/testsuite/src/rdp_injection/INTENT.md index e69de29bb..71b92a38a 100644 --- a/testsuite/src/rdp_injection/INTENT.md +++ b/testsuite/src/rdp_injection/INTENT.md @@ -0,0 +1,28 @@ +# Intent + +## Senarios +1. NTLM + a. Preflight send a NTLM credential injection request to the Gateway + b. RDP client will send association token with matching JTI in the credential injection request to Gateway. + c. Gateway will correctly recognize this connetion is intended for credential injection + d. RDP client will proceed with TLS + CredSSP + e. Gateway will intercept the CredSSP and also create CredSSP request as client to RDP server. + f. We assert on that the RDP server receives the target credentials while the RDP client only sends the proxy credentials + + +2. Kerberos + a. Preflight send a Kerberos credential injection request to the Gateway including the KDC information. + b. RDP client will send association token with matching JTI in the credential injection request to Gateway. + c. Gateway will correctly recognize this connetion is intended for credential injection + d. RDP client will proceed with TLS + CredSSP, here it will also call KDC Proxy (which is also Gateway) + e. Gateway will intercept the CredSSP and also create CredSSP request as client to RDP server. + f. Gateway will forward the Kerberos request to the KDC with state driven by the target credentials, and we will assert on that the KDC receives the correct request. + g. We assert on that the RDP server receives the target credentials while the RDP client only sends the proxy credentials + +3. Reconnect stability, make sure we could reconnect with the same association token. + +4. Failure behavior + a. Missing or expired provisioning fails at 1.c or 2.c and uses ordinary forwarding. + b. An incorrect target password or unavailable KDC fails at 2.e and must not fall back to ordinary forwarding. + c. Missing KDC information fails before 2.e and must not connect to the RDP server. + diff --git a/testsuite/src/rdp_injection/agent.rs b/testsuite/src/rdp_injection/agent.rs index 09d75d9b6..76facac43 100644 --- a/testsuite/src/rdp_injection/agent.rs +++ b/testsuite/src/rdp_injection/agent.rs @@ -108,7 +108,7 @@ pub async fn connect_ironrdp_rdcleanpath( bin: &Path, endpoint: &str, server: &str, - token: &str, + association_token: &str, http_port: u16, ) -> anyhow::Result { let url = format!("ws://127.0.0.1:{http_port}/jet/rdp"); @@ -126,7 +126,7 @@ pub async fn connect_ironrdp_rdcleanpath( "--prop", &format!("ironrdp_rdcleanpathurl:s:{url}"), "--prop", - &format!("ironrdp_rdcleanpathtoken:s:{token}"), + &format!("ironrdp_rdcleanpathtoken:s:{association_token}"), ]) .kill_on_drop(true) .stdout(Stdio::piped()) diff --git a/testsuite/src/rdp_injection/credssp.rs b/testsuite/src/rdp_injection/credssp.rs index 2979f71f8..e124d41b7 100644 --- a/testsuite/src/rdp_injection/credssp.rs +++ b/testsuite/src/rdp_injection/credssp.rs @@ -24,11 +24,11 @@ pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20); pub async fn connect_ntlm_client( gateway_tcp: u16, - association_jwt: &str, + association_token: &str, ) -> anyhow::Result> { tokio::time::timeout( HANDSHAKE_TIMEOUT, - connect_ntlm_client_inner(gateway_tcp, association_jwt), + connect_ntlm_client_inner(gateway_tcp, association_token), ) .await .context("timed out connecting NTLM client to Gateway")? @@ -36,13 +36,13 @@ pub async fn connect_ntlm_client( async fn connect_ntlm_client_inner( gateway_tcp: u16, - association_jwt: &str, + association_token: &str, ) -> anyhow::Result> { let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) .await .context("connect gateway TCP")?; stream - .write_all(&encode_pcb(association_jwt)?) + .write_all(&encode_pcb(association_token)?) .await .context("write PCB")?; stream.write_all(&encode_hybrid_cr()?).await.context("write X.224 CR")?; diff --git a/testsuite/src/rdp_injection/preflight.rs b/testsuite/src/rdp_injection/preflight.rs index e6f7cdbfc..ffb9a715c 100644 --- a/testsuite/src/rdp_injection/preflight.rs +++ b/testsuite/src/rdp_injection/preflight.rs @@ -7,14 +7,17 @@ use tokio::net::TcpStream; use super::tokens::{next_id, preflight_scope_token}; use super::{PROXY_PASSWORD, PROXY_USER, TARGET_PASSWORD}; -pub async fn post_preflight(http_port: u16, operations: serde_json::Value) -> anyhow::Result { - let bearer = preflight_scope_token()?; - let body = serde_json::to_string(&operations).context("serialize preflight body")?; +pub async fn post_preflight( + http_port: u16, + provisioning_operations: serde_json::Value, +) -> anyhow::Result { + let preflight_token = preflight_scope_token()?; + let body = serde_json::to_string(&provisioning_operations).context("serialize preflight body")?; let request = format!( "POST /jet/preflight HTTP/1.1\r\n\ Host: 127.0.0.1:{http_port}\r\n\ Content-Type: application/json\r\n\ - Authorization: Bearer {bearer}\r\n\ + Authorization: Bearer {preflight_token}\r\n\ Content-Length: {}\r\n\ Connection: close\r\n\ \r\n\ @@ -83,14 +86,14 @@ pub async fn post_preflight(http_port: u16, operations: serde_json::Value) -> an pub async fn provision_credentials( http_port: u16, - token: &str, + association_token: &str, target_username: &str, time_to_live: u32, krb_kdc: Option<&str>, ) -> anyhow::Result<()> { provision_mapping( http_port, - token, + association_token, PROXY_USER, target_username, TARGET_PASSWORD, @@ -102,17 +105,17 @@ pub async fn provision_credentials( pub async fn provision_mapping( http_port: u16, - token: &str, + association_token: &str, proxy_username: &str, target_username: &str, target_password: &str, time_to_live: u32, krb_kdc: Option<&str>, ) -> anyhow::Result<()> { - let mut operations = vec![serde_json::json!({ + let mut provisioning_operations = vec![serde_json::json!({ "id": next_id(), "kind": "provision-credentials", - "token": token, + "token": association_token, "proxy_credential": { "kind": "username-password", "username": proxy_username, @@ -127,15 +130,15 @@ pub async fn provision_mapping( })]; if let Some(krb_kdc) = krb_kdc { - operations.push(serde_json::json!({ + provisioning_operations.push(serde_json::json!({ "id": next_id(), "kind": "provision-connection-options", - "token": token, + "token": association_token, "connection_options": { "krb_kdc": krb_kdc }, "time_to_live": time_to_live })); } - post_preflight(http_port, serde_json::Value::Array(operations)).await?; + post_preflight(http_port, serde_json::Value::Array(provisioning_operations)).await?; Ok(()) } diff --git a/testsuite/src/rdp_injection/rdp.rs b/testsuite/src/rdp_injection/rdp.rs index b21f84ee5..4ce5655bf 100644 --- a/testsuite/src/rdp_injection/rdp.rs +++ b/testsuite/src/rdp_injection/rdp.rs @@ -13,11 +13,11 @@ use tokio::net::{TcpListener, TcpStream}; use super::CLIENT_COOKIE; -pub fn encode_pcb(token: &str) -> anyhow::Result> { +pub fn encode_pcb(association_token: &str) -> anyhow::Result> { let pcb = ironrdp_pdu::pcb::PreconnectionBlob { version: ironrdp_pdu::pcb::PcbVersion::V2, id: 0, - v2_payload: Some(token.to_owned()), + v2_payload: Some(association_token.to_owned()), }; ironrdp_core::encode_vec(&pcb).context("encode preconnection blob") } @@ -54,12 +54,12 @@ pub(crate) fn record_cookie(cr: &X224, cookies: &Mutex anyhow::Result { +pub async fn connect_rdp_client(gateway_tcp: u16, association_token: &str) -> anyhow::Result { let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) .await .context("connect to gateway TCP")?; stream - .write_all(&encode_pcb(association_jwt)?) + .write_all(&encode_pcb(association_token)?) .await .context("write preconnection blob")?; stream diff --git a/testsuite/src/rdp_injection/tokens.rs b/testsuite/src/rdp_injection/tokens.rs index a82dda637..3d52ca400 100644 --- a/testsuite/src/rdp_injection/tokens.rs +++ b/testsuite/src/rdp_injection/tokens.rs @@ -31,26 +31,51 @@ pub fn preflight_scope_token() -> anyhow::Result { ) } -pub fn association_token(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { - association_claims(jti, jet_aid, format!("127.0.0.1:{dest_port}"), jet_reuse) +pub fn association_token( + association_jti: &str, + association_id: &str, + dest_port: u16, + jet_reuse: u32, +) -> anyhow::Result { + association_claims( + association_jti, + association_id, + format!("127.0.0.1:{dest_port}"), + jet_reuse, + ) } -pub fn association_token_for_host(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { - association_claims(jti, jet_aid, format!("{SERVICE_HOST}:{dest_port}"), jet_reuse) +pub fn association_token_for_host( + association_jti: &str, + association_id: &str, + dest_port: u16, + jet_reuse: u32, +) -> anyhow::Result { + association_claims( + association_jti, + association_id, + format!("{SERVICE_HOST}:{dest_port}"), + jet_reuse, + ) } -fn association_claims(jti: &str, jet_aid: &str, dst_hst: String, jet_reuse: u32) -> anyhow::Result { +fn association_claims( + association_jti: &str, + association_id: &str, + dst_hst: String, + jet_reuse: u32, +) -> anyhow::Result { unsigned_jws( serde_json::json!({"alg":"RS256","typ":"JWT","cty":"ASSOCIATION"}), serde_json::json!({ "dst_hst": dst_hst, "exp": 9_999_999_999i64, - "jet_aid": jet_aid, + "jet_aid": association_id, "jet_ap": "rdp", "jet_cm": "fwd", "jet_rec": "none", "jet_reuse": jet_reuse, - "jti": jti, + "jti": association_jti, "nbf": 0, }), ) @@ -68,6 +93,6 @@ pub fn kdc_inject_token(association_jti: &str) -> anyhow::Result { } pub fn kdc_proxy_url(http_port: u16, association_jti: &str) -> anyhow::Result { - let token = kdc_inject_token(association_jti)?; - Ok(format!("http://127.0.0.1:{http_port}/jet/KdcProxy/{token}")) + let kdc_token = kdc_inject_token(association_jti)?; + Ok(format!("http://127.0.0.1:{http_port}/jet/KdcProxy/{kdc_token}")) } diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs index ef12c96f0..bdd145d91 100644 --- a/testsuite/tests/cli/dgw/cred_injection.rs +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -20,12 +20,19 @@ async fn first_rdp_connection_injects_ntlm() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token(&jti, &jet_aid, target.port, 60)?; - provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + TARGET_USER, + 300, + None, + ) + .await?; - let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; let logs = gateway.logs.wait_contains(INJECT_LOG).await?; assert!( logs.contains("kerberos=false"), @@ -48,21 +55,28 @@ async fn first_rdp_connection_injects_ntlm() -> anyhow::Result<()> { } #[tokio::test] -async fn reconnect_same_jwt_still_injects() -> anyhow::Result<()> { +async fn reconnect_same_association_token_still_injects() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token(&jti, &jet_aid, target.port, 60)?; - provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + TARGET_USER, + 300, + None, + ) + .await?; - let first = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let first = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; gateway.logs.wait_count(INJECT_LOG, 1).await?; target.wait_cookies(1).await?; drop(first); - let _second = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let _second = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; let logs = gateway.logs.wait_count(INJECT_LOG, 2).await?; assert_eq!( logs.matches(INJECT_LOG).count(), @@ -90,13 +104,20 @@ async fn expired_staging_uses_ordinary_forward() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token(&jti, &jet_aid, target.port, 60)?; - provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 1, None).await?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + TARGET_USER, + 1, + None, + ) + .await?; tokio::time::sleep(Duration::from_secs(2)).await; - let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; let logs = gateway.logs.wait_contains(FORWARD_LOG).await?; assert!( !logs.contains(INJECT_LOG), @@ -119,11 +140,11 @@ async fn unprovisioned_rdp_uses_ordinary_forward() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token(&jti, &jet_aid, target.port, 60)?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; - let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; let logs = gateway.logs.wait_contains(FORWARD_LOG).await?; assert!( !logs.contains(INJECT_LOG), @@ -146,12 +167,12 @@ async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Res let target = FakeRdpTarget::start().await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token(&jti, &jet_aid, target.port, 60)?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; provision_credentials( gateway.config.http_port(), - &token, + &association_token, KERBEROS_TARGET_USER, 300, Some("tcp://127.0.0.1:88"), @@ -159,7 +180,7 @@ async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Res .await?; // Keep overlapping reconnect sockets so the same-generation KDC lease stays live. - let _first = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let _first = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; let logs = gateway.logs.wait_count(INJECT_LOG, 1).await?; assert!( logs.contains("kerberos=true"), @@ -167,7 +188,7 @@ async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Res ); gateway.logs.wait_count(PUBLISHED_KDC_LOG, 1).await?; - let _second = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let _second = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; let logs = gateway.logs.wait_count(INJECT_LOG, 2).await?; assert_eq!( logs.matches("kerberos=true").count(), @@ -183,14 +204,14 @@ async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Res provision_credentials( gateway.config.http_port(), - &token, + &association_token, KERBEROS_TARGET_USER, 300, Some("tcp://127.0.0.1:88"), ) .await?; - let _third = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let _third = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; let logs = gateway.logs.wait_count(INJECT_LOG, 3).await?; assert_eq!( logs.matches("kerberos=true").count(), @@ -233,19 +254,19 @@ async fn domainless_target_stays_ntlm_even_with_krb_kdc() -> anyhow::Result<()> let target = FakeRdpTarget::start().await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token(&jti, &jet_aid, target.port, 60)?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; provision_credentials( gateway.config.http_port(), - &token, + &association_token, TARGET_USER, 300, Some("tcp://127.0.0.1:88"), ) .await?; - let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; let logs = gateway.logs.wait_contains(INJECT_LOG).await?; assert!( logs.contains("kerberos=false"), @@ -261,19 +282,19 @@ async fn kerberos_injection_does_not_need_debug_flags() -> anyhow::Result<()> { let target = FakeRdpTarget::start().await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token(&jti, &jet_aid, target.port, 60)?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token(&association_jti, &association_id, target.port, 60)?; provision_credentials( gateway.config.http_port(), - &token, + &association_token, KERBEROS_TARGET_USER, 300, Some("tcp://127.0.0.1:88"), ) .await?; - let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; let logs = gateway.logs.wait_contains(INJECT_LOG).await?; assert!( logs.contains("kerberos=true"), diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index 1b3c4ec87..43d1e3811 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -40,19 +40,19 @@ async fn kerberos_injection_completes_credssp_against_mock_kdc() -> anyhow::Resu let rdp = MockRdp::start_kerberos(kdc.url()).await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; provision_credentials( gateway.config.http_port(), - &token, + &association_token, KERBEROS_TARGET_USER, 300, Some(&kdc.url()), ) .await?; - let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + let tls = connect_ntlm_client(gateway.config.tcp_port(), &association_token).await?; complete_ntlm_credssp(tls) .await .context("Gateway-facing NTLM CredSSP")?; @@ -89,12 +89,12 @@ async fn kerberos_client_and_target_legs_complete_credssp() -> anyhow::Result<() let rdp = MockRdp::start_kerberos(kdc.url()).await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; provision_mapping( gateway.config.http_port(), - &token, + &association_token, PROXY_KERBEROS_USER, KERBEROS_TARGET_USER, TARGET_PASSWORD, @@ -103,10 +103,10 @@ async fn kerberos_client_and_target_legs_complete_credssp() -> anyhow::Result<() ) .await?; - let kdc_proxy = kdc_proxy_url(gateway.config.http_port(), &jti)?; + let kdc_proxy = kdc_proxy_url(gateway.config.http_port(), &association_jti)?; let proxy_replies = Mutex::new(Vec::new()); let proxy_requests = Mutex::new(Vec::new()); - let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + let tls = connect_ntlm_client(gateway.config.tcp_port(), &association_token).await?; complete_client_credssp( tls, PROXY_KERBEROS_USER, @@ -179,12 +179,12 @@ async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { let rdp = MockRdp::start_kerberos(kdc.url()).await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; provision_mapping( gateway.config.http_port(), - &token, + &association_token, PROXY_USER, KERBEROS_TARGET_USER, "wrong-target-password", @@ -193,7 +193,7 @@ async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { ) .await?; - let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + let tls = connect_ntlm_client(gateway.config.tcp_port(), &association_token).await?; let _ = complete_ntlm_credssp(tls).await; let logs = gateway.logs.wait_contains(INJECT_LOG).await?; anyhow::ensure!( @@ -256,19 +256,19 @@ async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { let rdp = MockRdp::start_kerberos(rdp_kdc.url()).await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; provision_credentials( gateway.config.http_port(), - &token, + &association_token, KERBEROS_TARGET_USER, 300, Some(&kdc.url()), ) .await?; - let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + let tls = connect_ntlm_client(gateway.config.tcp_port(), &association_token).await?; let _ = complete_ntlm_credssp(tls).await; let logs = gateway.logs.wait_contains(INJECT_LOG).await?; anyhow::ensure!( @@ -314,15 +314,25 @@ async fn kerberos_missing_krb_kdc_fails_closed() -> anyhow::Result<()> { let rdp = FakeClosedTarget::start().await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; - provision_credentials(gateway.config.http_port(), &token, KERBEROS_TARGET_USER, 300, None).await?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + KERBEROS_TARGET_USER, + 300, + None, + ) + .await?; let mut stream = TcpStream::connect(("127.0.0.1", gateway.config.tcp_port())) .await .context("connect gateway TCP")?; - stream.write_all(&encode_pcb(&token)?).await.context("write PCB")?; + stream + .write_all(&encode_pcb(&association_token)?) + .await + .context("write PCB")?; stream.write_all(&encode_hybrid_cr()?).await.context("write CR")?; stream.flush().await.context("flush CR")?; let logs = gateway.logs.wait_contains(MISSING_LOG).await?; @@ -346,12 +356,19 @@ async fn ntlm_injection_completes_credssp_both_legs() -> anyhow::Result<()> { let rdp = MockRdp::start_ntlm().await?; let mut gateway = GatewayProc::start().await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; - provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + TARGET_USER, + 300, + None, + ) + .await?; - let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + let tls = connect_ntlm_client(gateway.config.tcp_port(), &association_token).await?; complete_raw_ntlm_credssp(tls) .await .with_context(|| format!("client-leg NTLM CredSSP; logs:\n{}", gateway.logs.snapshot()))?; @@ -389,16 +406,23 @@ async fn ironrdp_agent_rdcleanpath_ntlm_injection() -> anyhow::Result<()> { let endpoint = ironrdp_agent_endpoint(); let mut daemon = start_ironrdp_daemon(&bin, &endpoint).await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; - provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &association_token, + TARGET_USER, + 300, + None, + ) + .await?; let mut connect = connect_ironrdp_rdcleanpath( &bin, &endpoint, &format!("{SERVICE_HOST}:{}", rdp.port), - &token, + &association_token, gateway.config.http_port(), ) .await?; @@ -448,12 +472,12 @@ async fn ironrdp_agent_rdcleanpath_kerberos_injection() -> anyhow::Result<()> { let endpoint = ironrdp_agent_endpoint(); let mut daemon = start_ironrdp_daemon(&bin, &endpoint).await?; - let jti = next_id(); - let jet_aid = next_id(); - let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + let association_jti = next_id(); + let association_id = next_id(); + let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; provision_credentials( gateway.config.http_port(), - &token, + &association_token, KERBEROS_TARGET_USER, 300, Some(&kdc.url()), @@ -464,7 +488,7 @@ async fn ironrdp_agent_rdcleanpath_kerberos_injection() -> anyhow::Result<()> { &bin, &endpoint, &format!("{SERVICE_HOST}:{}", rdp.port), - &token, + &association_token, gateway.config.http_port(), ) .await?; From 22981678a8c654f60d3cabb6d47816fcdb127fb1 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 28 Aug 2026 14:19:51 -0400 Subject: [PATCH 34/36] test(dgw): keep one intent file for the injection suite The behavioral contract already lives in devolutions-gateway/src/credential/INTENT.md; the suite-level INTENT.md covers the test scenarios, so the per-file placeholders are unnecessary. Issue: DVLS-14697 --- .../tests/cli/dgw/cred_injection.intent.md | 0 testsuite/tests/cli/dgw/cred_injection.rs | 27 +++---------------- .../cli/dgw/cred_injection_kdc.intent.md | 0 testsuite/tests/cli/dgw/cred_injection_kdc.rs | 18 ++----------- 4 files changed, 5 insertions(+), 40 deletions(-) delete mode 100644 testsuite/tests/cli/dgw/cred_injection.intent.md delete mode 100644 testsuite/tests/cli/dgw/cred_injection_kdc.intent.md diff --git a/testsuite/tests/cli/dgw/cred_injection.intent.md b/testsuite/tests/cli/dgw/cred_injection.intent.md deleted file mode 100644 index e69de29bb..000000000 diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs index bdd145d91..90c62187f 100644 --- a/testsuite/tests/cli/dgw/cred_injection.rs +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -23,14 +23,7 @@ async fn first_rdp_connection_injects_ntlm() -> anyhow::Result<()> { let association_jti = next_id(); let association_id = next_id(); let association_token = association_token(&association_jti, &association_id, target.port, 60)?; - provision_credentials( - gateway.config.http_port(), - &association_token, - TARGET_USER, - 300, - None, - ) - .await?; + provision_credentials(gateway.config.http_port(), &association_token, TARGET_USER, 300, None).await?; let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; let logs = gateway.logs.wait_contains(INJECT_LOG).await?; @@ -62,14 +55,7 @@ async fn reconnect_same_association_token_still_injects() -> anyhow::Result<()> let association_jti = next_id(); let association_id = next_id(); let association_token = association_token(&association_jti, &association_id, target.port, 60)?; - provision_credentials( - gateway.config.http_port(), - &association_token, - TARGET_USER, - 300, - None, - ) - .await?; + provision_credentials(gateway.config.http_port(), &association_token, TARGET_USER, 300, None).await?; let first = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; gateway.logs.wait_count(INJECT_LOG, 1).await?; @@ -107,14 +93,7 @@ async fn expired_staging_uses_ordinary_forward() -> anyhow::Result<()> { let association_jti = next_id(); let association_id = next_id(); let association_token = association_token(&association_jti, &association_id, target.port, 60)?; - provision_credentials( - gateway.config.http_port(), - &association_token, - TARGET_USER, - 1, - None, - ) - .await?; + provision_credentials(gateway.config.http_port(), &association_token, TARGET_USER, 1, None).await?; tokio::time::sleep(Duration::from_secs(2)).await; let _client = connect_rdp_client(gateway.config.tcp_port(), &association_token).await?; diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.intent.md b/testsuite/tests/cli/dgw/cred_injection_kdc.intent.md deleted file mode 100644 index e69de29bb..000000000 diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index 43d1e3811..a72a5fea8 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -359,14 +359,7 @@ async fn ntlm_injection_completes_credssp_both_legs() -> anyhow::Result<()> { let association_jti = next_id(); let association_id = next_id(); let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; - provision_credentials( - gateway.config.http_port(), - &association_token, - TARGET_USER, - 300, - None, - ) - .await?; + provision_credentials(gateway.config.http_port(), &association_token, TARGET_USER, 300, None).await?; let tls = connect_ntlm_client(gateway.config.tcp_port(), &association_token).await?; complete_raw_ntlm_credssp(tls) @@ -409,14 +402,7 @@ async fn ironrdp_agent_rdcleanpath_ntlm_injection() -> anyhow::Result<()> { let association_jti = next_id(); let association_id = next_id(); let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; - provision_credentials( - gateway.config.http_port(), - &association_token, - TARGET_USER, - 300, - None, - ) - .await?; + provision_credentials(gateway.config.http_port(), &association_token, TARGET_USER, 300, None).await?; let mut connect = connect_ironrdp_rdcleanpath( &bin, From 0eeea97bb564febb6f5bf2fa3aa50779ac10eada Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 28 Aug 2026 14:33:10 -0400 Subject: [PATCH 35/36] test(dgw): require ironrdp-agent for RDCleanPath coverage The RDCleanPath injection tests are the only coverage of that path; skipping them when the binary is missing silently drops it. Missing binary is now a failure, with IRONRDP_AGENT_SKIP=1 as explicit opt-out. CI installs a pinned ironrdp-agent (cached per version). Issue: DVLS-14697 --- .github/workflows/ci.yml | 16 ++++++++++++++++ testsuite/src/rdp_injection/agent.rs | 15 ++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 928eb54be..8f2fd09e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,6 +228,22 @@ jobs: sudo apt-get update sudo apt-get -o Acquire::Retries=3 install libsystemd-dev + ## Required by the RDCleanPath credential-injection tests in the testsuite. + ## Keep the version in sync with IRONRDP_AGENT_VERSION in testsuite/src/rdp_injection/agent.rs. + - name: Cache ironrdp-agent + id: cache-ironrdp-agent + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ironrdp-agent + ~/.cargo/bin/ironrdp-agent.exe + key: ironrdp-agent-0.1.0-${{ runner.os }} + + - name: Install ironrdp-agent + if: ${{ steps.cache-ironrdp-agent.outputs.cache-hit != 'true' }} + shell: pwsh + run: cargo install ironrdp-agent --version 0.1.0 --locked + - name: Tests run: ./ci/tlk.ps1 test -Platform ${{ matrix.os }} -Architecture ${{ matrix.arch }} -CargoProfile 'dev' shell: pwsh diff --git a/testsuite/src/rdp_injection/agent.rs b/testsuite/src/rdp_injection/agent.rs index 76facac43..6ea832d55 100644 --- a/testsuite/src/rdp_injection/agent.rs +++ b/testsuite/src/rdp_injection/agent.rs @@ -1,5 +1,5 @@ -//! Drives the public `ironrdp-agent` CLI as a real RDCleanPath client. Tests skip when the -//! binary is not installed (`cargo install ironrdp-agent --version 0.1.0`). +//! Drives the public `ironrdp-agent` CLI as a real RDCleanPath client. The binary is required +//! (`cargo install ironrdp-agent --version 0.1.0`); set `IRONRDP_AGENT_SKIP=1` to skip instead. use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -48,11 +48,16 @@ fn ironrdp_agent_bin() -> Option { } pub fn require_ironrdp_agent() -> anyhow::Result> { + if std::env::var_os("IRONRDP_AGENT_SKIP").is_some_and(|value| !value.is_empty()) { + eprintln!("skipping RDCleanPath ironrdp-agent test: IRONRDP_AGENT_SKIP is set"); + return Ok(None); + } let Some(bin) = ironrdp_agent_bin() else { - eprintln!( - "skipping RDCleanPath ironrdp-agent test: cargo install ironrdp-agent --version {IRONRDP_AGENT_VERSION}" + anyhow::bail!( + "ironrdp-agent {IRONRDP_AGENT_VERSION} is required for RDCleanPath coverage: \ + `cargo install ironrdp-agent --version {IRONRDP_AGENT_VERSION}`, \ + or set IRONRDP_AGENT_SKIP=1 to skip" ); - return Ok(None); }; let output = std::process::Command::new(&bin) .arg("--version") From 8d600d80dd78414c7b52f21a6efc480ec9685917 Mon Sep 17 00:00:00 2001 From: Junyi Ou Date: Fri, 28 Aug 2026 14:59:34 -0400 Subject: [PATCH 36/36] test(dgw): lift ironrdp-agent harness to a top-level helper Any Gateway test can drive a real RDP client, not just credential injection, so the harness lives at testsuite::ironrdp_agent and connect_rdcleanpath takes the credentials as parameters. Issue: DVLS-14697 --- .github/workflows/ci.yml | 2 +- .../agent.rs => ironrdp_agent.rs} | 28 +++++++++++-------- testsuite/src/lib.rs | 1 + testsuite/src/rdp_injection/mod.rs | 1 - testsuite/tests/cli/dgw/cred_injection_kdc.rs | 14 ++++++---- 5 files changed, 28 insertions(+), 18 deletions(-) rename testsuite/src/{rdp_injection/agent.rs => ironrdp_agent.rs} (85%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f2fd09e6..d0e6318f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -229,7 +229,7 @@ jobs: sudo apt-get -o Acquire::Retries=3 install libsystemd-dev ## Required by the RDCleanPath credential-injection tests in the testsuite. - ## Keep the version in sync with IRONRDP_AGENT_VERSION in testsuite/src/rdp_injection/agent.rs. + ## Keep the version in sync with IRONRDP_AGENT_VERSION in testsuite/src/ironrdp_agent.rs. - name: Cache ironrdp-agent id: cache-ironrdp-agent uses: actions/cache@v4 diff --git a/testsuite/src/rdp_injection/agent.rs b/testsuite/src/ironrdp_agent.rs similarity index 85% rename from testsuite/src/rdp_injection/agent.rs rename to testsuite/src/ironrdp_agent.rs index 6ea832d55..c155ecae4 100644 --- a/testsuite/src/rdp_injection/agent.rs +++ b/testsuite/src/ironrdp_agent.rs @@ -1,15 +1,14 @@ -//! Drives the public `ironrdp-agent` CLI as a real RDCleanPath client. The binary is required -//! (`cargo install ironrdp-agent --version 0.1.0`); set `IRONRDP_AGENT_SKIP=1` to skip instead. +//! Drives the public `ironrdp-agent` CLI as a real RDP client for process-level Gateway tests. +//! The binary is required (`cargo install ironrdp-agent --version 0.1.0`); set +//! `IRONRDP_AGENT_SKIP=1` to skip agent-based tests instead. use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use anyhow::Context as _; -use super::tokens::next_id; -use super::{PROXY_PASSWORD, PROXY_USER}; - pub const IRONRDP_AGENT_VERSION: &str = "0.1.0"; fn ironrdp_agent_bin() -> Option { @@ -49,12 +48,12 @@ fn ironrdp_agent_bin() -> Option { pub fn require_ironrdp_agent() -> anyhow::Result> { if std::env::var_os("IRONRDP_AGENT_SKIP").is_some_and(|value| !value.is_empty()) { - eprintln!("skipping RDCleanPath ironrdp-agent test: IRONRDP_AGENT_SKIP is set"); + eprintln!("skipping ironrdp-agent test: IRONRDP_AGENT_SKIP is set"); return Ok(None); } let Some(bin) = ironrdp_agent_bin() else { anyhow::bail!( - "ironrdp-agent {IRONRDP_AGENT_VERSION} is required for RDCleanPath coverage: \ + "ironrdp-agent {IRONRDP_AGENT_VERSION} is required: \ `cargo install ironrdp-agent --version {IRONRDP_AGENT_VERSION}`, \ or set IRONRDP_AGENT_SKIP=1 to skip" ); @@ -73,7 +72,12 @@ pub fn require_ironrdp_agent() -> anyhow::Result> { } pub fn ironrdp_agent_endpoint() -> String { - let name = format!("ironrdp-e2e-{}", next_id().replace('-', "")); + static COUNTER: AtomicU64 = AtomicU64::new(1); + let name = format!( + "ironrdp-e2e-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + ); if cfg!(windows) { format!(r"\\.\pipe\{name}") } else { @@ -109,10 +113,12 @@ pub async fn start_ironrdp_daemon(bin: &Path, endpoint: &str) -> anyhow::Result< } } -pub async fn connect_ironrdp_rdcleanpath( +pub async fn connect_rdcleanpath( bin: &Path, endpoint: &str, server: &str, + username: &str, + password: &str, association_token: &str, http_port: u16, ) -> anyhow::Result { @@ -125,9 +131,9 @@ pub async fn connect_ironrdp_rdcleanpath( "--server", server, "--username", - PROXY_USER, + username, "--password", - PROXY_PASSWORD, + password, "--prop", &format!("ironrdp_rdcleanpathurl:s:{url}"), "--prop", diff --git a/testsuite/src/lib.rs b/testsuite/src/lib.rs index d5fafab8d..605181db1 100644 --- a/testsuite/src/lib.rs +++ b/testsuite/src/lib.rs @@ -6,6 +6,7 @@ pub mod cli; pub mod dgw_config; +pub mod ironrdp_agent; pub mod mcp_client; pub mod mcp_server; pub mod rdp_injection; diff --git a/testsuite/src/rdp_injection/mod.rs b/testsuite/src/rdp_injection/mod.rs index 4f65dabbe..0e2f884b6 100644 --- a/testsuite/src/rdp_injection/mod.rs +++ b/testsuite/src/rdp_injection/mod.rs @@ -5,7 +5,6 @@ //! mstshash cookie arriving at the fake target, and from the Kerberos exchanges recorded by //! the mock KDC. -pub mod agent; pub mod credssp; pub mod gateway; pub mod mock_kdc; diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs index a72a5fea8..624554b1a 100644 --- a/testsuite/tests/cli/dgw/cred_injection_kdc.rs +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -11,8 +11,8 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; use anyhow::Context as _; -use testsuite::rdp_injection::agent::{ - agent_query_logs, connect_ironrdp_rdcleanpath, ironrdp_agent_endpoint, require_ironrdp_agent, start_ironrdp_daemon, +use testsuite::ironrdp_agent::{ + agent_query_logs, connect_rdcleanpath, ironrdp_agent_endpoint, require_ironrdp_agent, start_ironrdp_daemon, }; use testsuite::rdp_injection::credssp::{ complete_client_credssp, complete_ntlm_credssp, complete_raw_ntlm_credssp, connect_ntlm_client, @@ -27,7 +27,7 @@ use testsuite::rdp_injection::rdp::{FakeClosedTarget, encode_hybrid_cr, encode_p use testsuite::rdp_injection::tls::install_crypto_provider; use testsuite::rdp_injection::tokens::{association_token_for_host, kdc_proxy_url, next_id}; use testsuite::rdp_injection::{ - FORWARD_LOG, INJECT_LOG, KERBEROS_TARGET_USER, MISSING_LOG, PROXY_KERBEROS_USER, PROXY_USER, + FORWARD_LOG, INJECT_LOG, KERBEROS_TARGET_USER, MISSING_LOG, PROXY_KERBEROS_USER, PROXY_PASSWORD, PROXY_USER, RDCLEANPATH_FORWARD_LOG, RDCLEANPATH_INJECT_LOG, REALM, SERVICE_HOST, TARGET_PASSWORD, TARGET_USER, }; use tokio::io::AsyncWriteExt as _; @@ -404,10 +404,12 @@ async fn ironrdp_agent_rdcleanpath_ntlm_injection() -> anyhow::Result<()> { let association_token = association_token_for_host(&association_jti, &association_id, rdp.port, 60)?; provision_credentials(gateway.config.http_port(), &association_token, TARGET_USER, 300, None).await?; - let mut connect = connect_ironrdp_rdcleanpath( + let mut connect = connect_rdcleanpath( &bin, &endpoint, &format!("{SERVICE_HOST}:{}", rdp.port), + PROXY_USER, + PROXY_PASSWORD, &association_token, gateway.config.http_port(), ) @@ -470,10 +472,12 @@ async fn ironrdp_agent_rdcleanpath_kerberos_injection() -> anyhow::Result<()> { ) .await?; - let mut connect = connect_ironrdp_rdcleanpath( + let mut connect = connect_rdcleanpath( &bin, &endpoint, &format!("{SERVICE_HOST}:{}", rdp.port), + PROXY_USER, + PROXY_PASSWORD, &association_token, gateway.config.http_port(), )