diff --git a/Cargo.lock b/Cargo.lock index d8a02aa66..7efc390e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1618,6 +1618,7 @@ dependencies = [ "axum 0.8.9", "axum-extra", "backoff", + "base64 0.22.1", "bitflags 2.11.1", "bytes 1.11.1", "cadeau", diff --git a/devolutions-gateway/Cargo.toml b/devolutions-gateway/Cargo.toml index a4465f93d..0149d19a6 100644 --- a/devolutions-gateway/Cargo.toml +++ b/devolutions-gateway/Cargo.toml @@ -140,6 +140,7 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32 embed-resource = "3.0" [dev-dependencies] +base64 = "0.22" tokio-test = "0.4" proptest = "1.7" tempfile = "3" diff --git a/devolutions-gateway/src/api/kdc_proxy.rs b/devolutions-gateway/src/api/kdc_proxy.rs index 8847009d0..637ccaaa3 100644 --- a/devolutions-gateway/src/api/kdc_proxy.rs +++ b/devolutions-gateway/src/api/kdc_proxy.rs @@ -1,19 +1,22 @@ use std::io; -use std::net::SocketAddr; use axum::Router; -use axum::extract::{self, ConnectInfo, State}; +use axum::extract::State; use axum::http::StatusCode; use axum::routing::post; -use kdc::handle_kdc_proxy_message; use picky_krb::messages::KdcProxyMessage; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpStream, UdpSocket}; use crate::DgwState; +use crate::credential_injection_kdc::{ + CredentialInjectionKdc, CredentialInjectionKdcInterception, CredentialInjectionKdcRequest, + CredentialInjectionKdcResolveError, kdc_proxy_message_realm, +}; +use crate::extract::KdcToken; use crate::http::{HttpError, HttpErrorBuilder}; use crate::target_addr::TargetAddr; -use crate::token::AccessTokenClaims; +use crate::token::KdcTokenClaims; pub fn make_router(state: DgwState) -> Router { Router::new().route("/{token}", post(kdc_proxy)).with_state(state) @@ -22,97 +25,160 @@ pub fn make_router(state: DgwState) -> Router { async fn kdc_proxy( State(DgwState { conf_handle, - token_cache, - jrl, - recordings, + credential_store, + credential_injection_context_store, .. }): State, - extract::Path(token): extract::Path, - ConnectInfo(source_addr): ConnectInfo, + KdcToken(KdcTokenClaims { + krb_realm, + krb_kdc, + jet_cred_id, + }): KdcToken, body: axum::body::Bytes, ) -> Result, HttpError> { let conf = conf_handle.get_conf(); - let claims = crate::middleware::auth::authenticate( - source_addr, - &token, - &conf, - &token_cache, - &jrl, - &recordings.active_recordings, - None, - ) - .map_err(HttpError::unauthorized().err())?; - - let AccessTokenClaims::Kdc(claims) = claims else { - return Err(HttpError::forbidden().msg("token not allowed (expected KDC token)")); - }; - let kdc_proxy_message = KdcProxyMessage::from_raw(&body).map_err(HttpError::bad_request().err())?; trace!(?kdc_proxy_message, "Received KDC message"); - debug!( ?kdc_proxy_message.target_domain, ?kdc_proxy_message.dclocator_hint, "KDC message", ); - let realm = if let Some(realm) = &kdc_proxy_message.target_domain.0 { - realm.0.to_string() - } else { - return Err(HttpError::bad_request().msg("realm is missing from KDC request")); - }; - - debug!("Request is for realm (target_domain): {realm}"); + enforce_credential_injection_enabled(jet_cred_id, conf.debug.enable_unstable)?; - if !claims.krb_realm.eq_ignore_ascii_case(&realm) { - if conf.debug.disable_token_validation { - warn!( - token_realm = %claims.krb_realm, - request_realm = %realm, - "**DEBUG OPTION** Allowed a KDC request towards a KDC whose Kerberos realm differs from what's inside the KDC token" + match CredentialInjectionKdc::resolve(jet_cred_id, &credential_store, &credential_injection_context_store) + .map_err(credential_injection_resolve_error)? + { + Some(kdc) => { + debug!( + jti = %kdc.jti(), + "Proxy-based credential injection with Kerberos. Processing KdcProxy message internally" ); - } else { - let error_message = format!("expected: {}, got: {}", claims.krb_realm, realm); - return Err(HttpError::bad_request() - .with_msg("requested domain is not allowed") - .err()(error_message)); + match kdc + .handle_kdc_proxy_request(CredentialInjectionKdcRequest::from_token( + kdc_proxy_message, + &krb_realm, + conf.debug.disable_token_validation, + )) + .map_err(HttpError::internal().err())? + { + CredentialInjectionKdcInterception::Intercepted(reply) => Ok(reply), + CredentialInjectionKdcInterception::NotInjectionRealm(mismatch) => { + Err(HttpError::bad_request() + .with_msg("requested domain is not allowed") + .err()(mismatch)) + } + CredentialInjectionKdcInterception::NotInjectionRequest => { + Err(HttpError::internal().msg("credential-injection KDC did not handle the KDC proxy request")) + } + } + } + None => { + let envelope_realm = kdc_proxy_message_realm(&kdc_proxy_message); + forward_to_real_kdc( + kdc_proxy_message, + envelope_realm, + &krb_realm, + &krb_kdc, + conf.debug.override_kdc.as_ref(), + conf.debug.disable_token_validation, + ) + .await } } +} - let gateway_id = conf - .id - .ok_or_else(|| HttpError::internal().build("Gateway ID is missing"))?; - if let Some(krb_config) = &conf.debug.kerberos - && realm.eq_ignore_ascii_case(&krb_config.kerberos_server.realm(gateway_id)) - && conf.debug.enable_unstable - { - debug!("Proxy-based credential injection with Kerberos. Processing KdcProxy message internally..."); +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`. +// +// The forward path requires the envelope realm to be set: there is no fallback since this is +// not a credential-injection session. After resolving, validates the realm against the +// token's `krb_realm` claim before forwarding anything. +async fn forward_to_real_kdc( + kdc_proxy_message: KdcProxyMessage, + envelope_realm: Option, + token_realm: &str, + token_kdc_addr: &TargetAddr, + override_kdc: Option<&TargetAddr>, + bypass_realm_check: bool, +) -> Result, HttpError> { + let realm = envelope_realm.ok_or_else(|| HttpError::bad_request().msg("realm is missing from KDC request"))?; + debug!(resolved_realm = %realm, "Forward-to-real-KDC realm resolved"); + enforce_realm_token_match(token_realm, &realm, bypass_realm_check)?; + + let kdc_addr = match override_kdc { + Some(override_addr) => { + warn!(%override_addr, "**DEBUG OPTION** KDC address has been overridden"); + override_addr + } + None => token_kdc_addr, + }; + + let kdc_reply_bytes = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?; + + let reply = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_bytes) + .map_err(HttpError::internal().with_msg("couldn't create KDC proxy reply").err())?; + + trace!(?reply, "Sending back KDC reply"); - let config = krb_config.kerberos_server.clone().into_kdc_kerberos_config(gateway_id); - let kdc_reply_message = handle_kdc_proxy_message(kdc_proxy_message, &config, &conf.hostname) - .map_err(HttpError::internal().err())?; + reply.to_vec().map_err(HttpError::internal().err()) +} - return kdc_reply_message.to_vec().map_err(HttpError::internal().err()); +fn enforce_credential_injection_enabled( + jet_cred_id: Option, + enable_unstable: bool, +) -> Result<(), HttpError> { + if enable_unstable { + return Ok(()); } - let kdc_addr = if let Some(kdc_addr) = &conf.debug.override_kdc { - warn!("**DEBUG OPTION** KDC address has been overridden with {kdc_addr}"); - kdc_addr - } else { - &claims.krb_kdc + let Some(jet_cred_id) = jet_cred_id else { + return Ok(()); }; - let kdc_reply_message = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?; + 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")) +} - let kdc_reply_message = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_message) - .map_err(HttpError::internal().with_msg("couldn't create KDC proxy reply").err())?; +/// 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 +/// to a warning. Production never opts into this. +fn enforce_realm_token_match(token_realm: &str, request_realm: &str, bypass: bool) -> Result<(), HttpError> { + if token_realm.eq_ignore_ascii_case(request_realm) { + return Ok(()); + } - trace!(?kdc_reply_message, "Sending back KDC reply"); + if bypass { + warn!( + %token_realm, + %request_realm, + "**DEBUG OPTION** Allowed a KDC request towards a KDC whose Kerberos realm differs from what's inside the KDC token" + ); + return Ok(()); + } - kdc_reply_message.to_vec().map_err(HttpError::internal().err()) + Err(HttpError::bad_request() + .with_msg("requested domain is not allowed") + .err()(format!("expected: {token_realm}, got: {request_realm}"))) } async fn read_kdc_reply_message(connection: &mut TcpStream) -> io::Result> { @@ -212,3 +278,42 @@ pub async fn send_krb_message(kdc_addr: &TargetAddr, message: &[u8]) -> Result, _scope: PreflightScope, @@ -223,12 +225,21 @@ pub(super) async fn post_preflight( let conf = conf_handle.get_conf(); let sessions = sessions.clone(); let credential_store = credential_store.clone(); + let credential_injection_context_store = credential_injection_context_store.clone(); async move { let operation_id = operation.id; trace!(%operation.id, "Process preflight operation"); - if let Err(error) = handle_operation(operation, &outputs, &conf, &sessions, &credential_store).await + if let Err(error) = handle_operation( + operation, + &outputs, + &conf, + &sessions, + &credential_store, + &credential_injection_context_store, + ) + .await { outputs.push(PreflightOutput { operation_id, @@ -257,6 +268,7 @@ async fn handle_operation( conf: &Conf, sessions: &SessionMessageSender, credential_store: &CredentialStoreHandle, + credential_injection_context_store: &CredentialInjectionKdcContextStoreHandle, ) -> Result<(), PreflightError> { match operation.kind.as_str() { OP_GET_VERSION => outputs.push(PreflightOutput { @@ -310,6 +322,7 @@ async fn handle_operation( }); } OP_PROVISION_TOKEN | OP_PROVISION_CREDENTIALS => { + let is_provision_credentials = operation.kind.as_str() == OP_PROVISION_CREDENTIALS; let (token, time_to_live, mapping) = if operation.kind.as_str() == OP_PROVISION_TOKEN { let ProvisionTokenParams { token, time_to_live } = from_params(operation.params).map_err(PreflightError::invalid_params)?; @@ -337,12 +350,33 @@ async fn handle_operation( }); } + let credential_injection_jti = if is_provision_credentials { + Some( + crate::token::validate_credential_injection_association_token(&token) + .inspect_err(|error| { + warn!( + %operation.id, + error = format!("{error:#}"), + "Credential-injection token is not valid" + ) + }) + .map_err(|error| { + PreflightError::new( + PreflightAlertStatus::InvalidParams, + format!("invalid credential-injection token: {error:#}"), + ) + })?, + ) + } else { + None + }; + let previous_entry = credential_store .insert(token, mapping, time_to_live) .inspect_err(|error| warn!(%operation.id, error = format!("{error:#}"), "Failed to insert credentials")) - .map_err(|e| match e { - InsertError::InvalidToken(_) => { - PreflightError::new(PreflightAlertStatus::InvalidParams, format!("{e:#}")) + .map_err(|error| match error { + InsertError::InvalidToken(error) => { + PreflightError::new(PreflightAlertStatus::InvalidParams, format!("invalid token: {error:#}")) } InsertError::Internal(_) => PreflightError::new( PreflightAlertStatus::InternalServerError, @@ -350,6 +384,10 @@ async fn handle_operation( ), })?; + if let Some(jti) = credential_injection_jti { + credential_injection_context_store.register_provisioned_credentials(jti, time_to_live); + } + if previous_entry.is_some() { outputs.push(PreflightOutput { operation_id: operation.id, diff --git a/devolutions-gateway/src/api/rdp.rs b/devolutions-gateway/src/api/rdp.rs index 6129a776c..468d762ea 100644 --- a/devolutions-gateway/src/api/rdp.rs +++ b/devolutions-gateway/src/api/rdp.rs @@ -26,6 +26,7 @@ pub async fn handler( recordings, shutdown_signal, credential_store, + credential_injection_context_store, agent_tunnel_handle, .. }): State, @@ -47,6 +48,7 @@ pub async fn handler( recordings.active_recordings, source_addr, credential_store, + credential_injection_context_store, agent_tunnel_handle, ) .instrument(span) @@ -67,6 +69,7 @@ async fn handle_socket( active_recordings: Arc, source_addr: SocketAddr, credential_store: crate::credential::CredentialStoreHandle, + credential_injection_context_store: crate::credential_injection_kdc::CredentialInjectionKdcContextStoreHandle, agent_tunnel_handle: Option>, ) { let (stream, close_handle) = crate::ws::handle( @@ -85,6 +88,7 @@ async fn handle_socket( subscriber_tx, &active_recordings, &credential_store, + &credential_injection_context_store, agent_tunnel_handle, ) .await; diff --git a/devolutions-gateway/src/api/webapp.rs b/devolutions-gateway/src/api/webapp.rs index f266f4207..0eacadd39 100644 --- a/devolutions-gateway/src/api/webapp.rs +++ b/devolutions-gateway/src/api/webapp.rs @@ -388,6 +388,7 @@ pub(crate) async fn sign_session_token( KdcTokenClaims { krb_realm: krb_realm.into(), krb_kdc: krb_kdc.clone(), + jet_cred_id: None, } .pipe(serde_json::to_value) .map(|mut claims| { diff --git a/devolutions-gateway/src/credential_injection_kdc.rs b/devolutions-gateway/src/credential_injection_kdc.rs new file mode 100644 index 000000000..9dfbf0634 --- /dev/null +++ b/devolutions-gateway/src/credential_injection_kdc.rs @@ -0,0 +1,963 @@ +//! 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::credential::{AppCredential, AppCredentialMapping, ArcCredentialEntry, CredentialStoreHandle}; + +const IN_PROCESS_KDC_HOST: &str = "cred.invalid"; + +pub(crate) struct CredentialInjectionKdc { + jti: Uuid, + raw_token: String, + credential_mapping: AppCredentialMapping, + 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, +} + +pub(crate) type CredentialInjectionKdcResolution = Option>; + +#[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("credential-injection context is not available for {jti}")] + MissingContext { 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<'a> { + message: KdcProxyMessage, + realm_policy: CredentialInjectionKdcRealmPolicy<'a>, +} + +enum CredentialInjectionKdcRealmPolicy<'a> { + InProcess, + Token { realm: &'a str, bypass_for_debug: bool }, +} + +impl<'a> CredentialInjectionKdcRequest<'a> { + pub(crate) fn from_token(message: KdcProxyMessage, realm: &'a str, bypass_for_debug: bool) -> Self { + Self { + message, + realm_policy: CredentialInjectionKdcRealmPolicy::Token { + realm, + bypass_for_debug, + }, + } + } + + fn in_process(message: KdcProxyMessage) -> Self { + Self { + message, + realm_policy: CredentialInjectionKdcRealmPolicy::InProcess, + } + } +} + +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 { + pub(crate) fn from_entry( + jti: Uuid, + credential_entry: ArcCredentialEntry, + context_store: &CredentialInjectionKdcContextStoreHandle, + ) -> anyhow::Result { + let mapping = credential_entry + .mapping + .as_ref() + .context("credential entry has no credential-injection mapping")?; + let proxy_username = app_credential_username(&mapping.proxy).to_owned(); + let session = context_store + .get_or_insert_session(jti, || derive_credential_injection_kdc_session(&proxy_username, jti)) + .context("credential-injection context is not available")?; + let target_hostname = crate::token::extract_credential_injection_target_hostname(&credential_entry.token) + .context("extract credential-injection target hostname from association token")?; + + Self::from_parts(jti, credential_entry, target_hostname, session) + } + + fn from_parts( + jti: Uuid, + credential_entry: ArcCredentialEntry, + 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(), + target_hostname, + session, + kdc_config, + }) + } + + pub(crate) fn resolve( + jet_cred_id: Option, + credential_store: &CredentialStoreHandle, + context_store: &CredentialInjectionKdcContextStoreHandle, + ) -> Result { + let Some(jti) = jet_cred_id else { + return Ok(None); + }; + + let credential_entry = credential_store.get(jti).ok_or_else(|| { + warn!(%jti, "KDC token references missing credential-injection state"); + CredentialInjectionKdcResolveError::MissingCredential { jti } + })?; + + let Some(mapping) = credential_entry.mapping.as_ref() else { + warn!(%jti, "KDC token references non-injection credential state"); + return Err(CredentialInjectionKdcResolveError::NonInjectionCredential { jti }); + }; + + let proxy_username = app_credential_username(&mapping.proxy).to_owned(); + let session = context_store + .get_or_insert_session(jti, || derive_credential_injection_kdc_session(&proxy_username, jti)) + .ok_or_else(|| { + warn!(%jti, "KDC token references missing credential-injection context"); + CredentialInjectionKdcResolveError::MissingContext { jti } + })?; + let target_hostname = 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 kdc = Self::from_parts(jti, credential_entry, target_hostname, session) + .map_err(|source| CredentialInjectionKdcResolveError::BuildKdcConfig { jti, source })?; + + Ok(Some(Box::new(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 or sspi-rs rejects the ticket. + Ok(sspi::KerberosServerConfig { + kerberos_config: sspi::KerberosConfig { + kdc_url: Some(kdc_url), + client_computer_name: Some(client_addr.to_string()), + }, + server_properties: sspi::kerberos::ServerProperties::new( + &["TERMSRV", &self.target_hostname], + Some(user), + Duration::from_secs(300), + Some(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" + ); + + match request.realm_policy { + CredentialInjectionKdcRealmPolicy::InProcess => {} + CredentialInjectionKdcRealmPolicy::Token { + realm, + bypass_for_debug, + } => { + if let Some(mismatch) = token_realm_mismatch(realm, &request_realm, bypass_for_debug) { + return Ok(CredentialInjectionKdcInterception::NotInjectionRealm(mismatch)); + } + } + } + + 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 token_realm_mismatch(token_realm: &str, request_realm: &str, bypass: bool) -> Option { + if token_realm.eq_ignore_ascii_case(request_realm) { + return None; + } + + if bypass { + warn!( + %token_realm, + %request_realm, + "**DEBUG OPTION** Allowed a KDC request towards a KDC whose Kerberos realm differs from what's inside the KDC token" + ); + return None; + } + + Some(RealmMismatch { + expected: token_realm.to_owned(), + actual: request_realm.to_owned(), + }) +} + +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 +} + +/// Store of credential-injection contexts keyed by association-token JTI. +/// +/// Association tokens are validated at `provision-credentials` time, but target hostnames are +/// extracted lazily from the original token on first CredSSP/KDC use. +/// Kerberos sessions are also created lazily from the credential entry on first CredSSP/KDC use. +/// The store is separate from the credential store so that the credential store stays Kerberos-unaware. +#[derive(Debug, Clone, Default)] +pub struct CredentialInjectionKdcContextStoreHandle(Arc>>); + +#[derive(Debug)] +struct Entry { + session: Option>, + expires_at: time::OffsetDateTime, +} + +impl CredentialInjectionKdcContextStoreHandle { + pub fn new() -> Self { + Self(Arc::new(Mutex::new(HashMap::new()))) + } + + pub(crate) fn register_provisioned_credentials(&self, jti: Uuid, time_to_live: time::Duration) { + let entry = Entry { + session: None, + expires_at: time::OffsetDateTime::now_utc() + time_to_live, + }; + self.0.lock().insert(jti, entry); + } + + fn get_or_insert_session( + &self, + jti: Uuid, + make_session: impl FnOnce() -> CredentialInjectionKdcSession, + ) -> Option> { + let now = time::OffsetDateTime::now_utc(); + let mut entries = self.0.lock(); + + let entry = entries.get_mut(&jti).filter(|entry| now < entry.expires_at)?; + + let session = match &entry.session { + Some(session) => Arc::clone(session), + None => { + let session = Arc::new(make_session()); + entry.session = Some(Arc::clone(&session)); + session + } + }; + + Some(session) + } +} + +pub struct CleanupTask { + pub handle: CredentialInjectionKdcContextStoreHandle, +} + +#[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.handle, shutdown_signal).await; + Ok(()) + } +} + +#[instrument(skip_all)] +async fn cleanup_task(handle: CredentialInjectionKdcContextStoreHandle, 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; + } + } + + let now = time::OffsetDateTime::now_utc(); + handle.0.lock().retain(|_, entry| now < entry.expires_at); + } + + 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::credential::{CleartextAppCredential, CleartextAppCredentialMapping}; + + 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) -> ArcCredentialEntry { + let store = CredentialStoreHandle::new(); + store + .insert( + 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) -> ArcCredentialEntry { + 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 store_lookup_filters_expired_entries() { + let store = CredentialInjectionKdcContextStoreHandle::new(); + let jti = Uuid::new_v4(); + + // Negative TTL: entry is born already expired. + store.register_provisioned_credentials(jti, time::Duration::seconds(-1)); + + assert!( + store + .get_or_insert_session(jti, || derive_credential_injection_kdc_session( + "proxy@example.invalid", + jti + )) + .is_none(), + "expired entry must not be returned" + ); + } + + #[test] + fn store_returns_fresh_entry() { + let store = CredentialInjectionKdcContextStoreHandle::new(); + let jti = Uuid::new_v4(); + + store.register_provisioned_credentials(jti, time::Duration::minutes(5)); + + let session = store + .get_or_insert_session(jti, || { + derive_credential_injection_kdc_session("proxy@example.invalid", jti) + }) + .expect("fresh entry returned"); + + assert_eq!(session.realm, "example.invalid"); + } + + #[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 resolve_with_no_jet_cred_id_forwards_to_real_kdc() { + let credential_store = CredentialStoreHandle::new(); + let context_store = CredentialInjectionKdcContextStoreHandle::new(); + + let dispatch = CredentialInjectionKdc::resolve(None, &credential_store, &context_store) + .expect("plain KDC token should dispatch"); + + assert!(dispatch.is_none()); + } + + #[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 resolve_with_missing_jet_cred_id_fails_closed() { + let credential_store = CredentialStoreHandle::new(); + let context_store = CredentialInjectionKdcContextStoreHandle::new(); + + assert!( + CredentialInjectionKdc::resolve(Some(Uuid::new_v4()), &credential_store, &context_store).is_err(), + "KDC tokens with jet_cred_id must not fall back to real-KDC forwarding" + ); + } + + #[test] + fn resolve_with_non_injection_entry_fails_closed() { + let credential_store = CredentialStoreHandle::new(); + let context_store = CredentialInjectionKdcContextStoreHandle::new(); + let jti = Uuid::new_v4(); + + credential_store + .insert(association_token(jti), None, time::Duration::minutes(5)) + .expect("provision-token entry inserts"); + + assert!( + CredentialInjectionKdc::resolve(Some(jti), &credential_store, &context_store).is_err(), + "KDC tokens with jet_cred_id must require provision-credentials state" + ); + } + + #[test] + fn resolve_lazily_extracts_target_hostname_from_entry_token() { + let credential_store = CredentialStoreHandle::new(); + let context_store = CredentialInjectionKdcContextStoreHandle::new(); + let jti = Uuid::new_v4(); + + credential_store + .insert( + association_token(jti), + Some(cleartext_mapping_with_target_username("target")), + time::Duration::minutes(5), + ) + .expect("credential entry inserts"); + context_store.register_provisioned_credentials(jti, time::Duration::minutes(5)); + + let kdc = CredentialInjectionKdc::resolve(Some(jti), &credential_store, &context_store) + .expect("credential-injection KDC resolves") + .expect("credential-injection KDC is selected"); + + assert_eq!(kdc.target_hostname, "target.example"); + } + + #[test] + fn resolve_with_missing_context_fails_closed() { + let credential_store = CredentialStoreHandle::new(); + let context_store = CredentialInjectionKdcContextStoreHandle::new(); + let jti = Uuid::new_v4(); + + credential_store + .insert( + association_token(jti), + Some(cleartext_mapping_with_target_username("target")), + time::Duration::minutes(5), + ) + .expect("credential entry inserts"); + + assert!( + matches!( + CredentialInjectionKdc::resolve(Some(jti), &credential_store, &context_store), + Err(CredentialInjectionKdcResolveError::MissingContext { .. }) + ), + "KDC tokens with jet_cred_id must require provision-credentials context" + ); + } + + #[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/extract.rs b/devolutions-gateway/src/extract.rs index bbf3be4e4..227029aba 100644 --- a/devolutions-gateway/src/extract.rs +++ b/devolutions-gateway/src/extract.rs @@ -1,11 +1,14 @@ +use std::net::SocketAddr; + use axum::Extension; -use axum::extract::{FromRequest, FromRequestParts, RawQuery, Request}; +use axum::extract::{ConnectInfo, FromRequest, FromRequestParts, Path, RawQuery, Request}; use axum::http::request::Parts; +use crate::DgwState; use crate::http::HttpError; use crate::token::{ AccessScope, AccessTokenClaims, AssociationTokenClaims, BridgeTokenClaims, JmuxTokenClaims, JrecTokenClaims, - JrlTokenClaims, ScopeTokenClaims, WebAppTokenClaims, + JrlTokenClaims, KdcTokenClaims, ScopeTokenClaims, WebAppTokenClaims, }; #[derive(Clone)] @@ -98,6 +101,46 @@ where } } +/// Extractor for the KDC proxy route's path-bound token. +/// +/// `/jet/KdcProxy/{token}` carries the token in the URL path rather than the standard +/// `Authorization: Bearer` header or `?token=` query parameter, so the global auth middleware +/// (`middleware/auth.rs`) skips it (see `AUTH_EXCEPTIONS`). This extractor reads the token from +/// the path, runs it through the same `authenticate()` routine the middleware would, and +/// unwraps the `Kdc` variant so handlers receive `KdcTokenClaims` directly. +#[derive(Clone)] +pub struct KdcToken(pub KdcTokenClaims); + +impl FromRequestParts for KdcToken { + type Rejection = HttpError; + + async fn from_request_parts(parts: &mut Parts, state: &DgwState) -> Result { + let Path(token) = Path::::from_request_parts(parts, state) + .await + .map_err(HttpError::bad_request().with_msg("KDC token missing from path").err())?; + let ConnectInfo(source_addr) = ConnectInfo::::from_request_parts(parts, state) + .await + .map_err(HttpError::internal().with_msg("source address unavailable").err())?; + + let conf = state.conf_handle.get_conf(); + let claims = crate::middleware::auth::authenticate( + source_addr, + &token, + &conf, + &state.token_cache, + &state.jrl, + &state.recordings.active_recordings, + None, + ) + .map_err(HttpError::unauthorized().err())?; + + match claims { + AccessTokenClaims::Kdc(claims) => Ok(Self(claims)), + _ => Err(HttpError::forbidden().msg("token not allowed (expected KDC token)")), + } + } +} + #[derive(Clone)] pub struct ScopeToken(pub ScopeTokenClaims); diff --git a/devolutions-gateway/src/generic_client.rs b/devolutions-gateway/src/generic_client.rs index 7b5e6c47b..5ea8ee41d 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -9,6 +9,7 @@ use typed_builder::TypedBuilder; use crate::config::Conf; use crate::credential::CredentialStoreHandle; +use crate::credential_injection_kdc::{CredentialInjectionKdc, CredentialInjectionKdcContextStoreHandle}; use crate::proxy::Proxy; use crate::rdp_pcb::{extract_association_claims, read_pcb}; use crate::recording::ActiveRecordings; @@ -28,6 +29,7 @@ pub struct GenericClient { subscriber_tx: SubscriberSender, active_recordings: Arc, credential_store: CredentialStoreHandle, + credential_injection_context_store: CredentialInjectionKdcContextStoreHandle, #[builder(default)] agent_tunnel_handle: Option>, } @@ -52,6 +54,7 @@ where subscriber_tx, active_recordings, credential_store, + credential_injection_context_store, agent_tunnel_handle, } = self; @@ -147,35 +150,41 @@ where // 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 now works + // RdpProxy is generic over the server stream, so credential injection works // regardless of whether the upstream is direct TCP or tunnelled via an agent. - if is_rdp { - let token_id = token::extract_jti(token).context("failed to extract jti claim from token")?; - - if let Some(entry) = credential_store.get(token_id) { - anyhow::ensure!(token == entry.token, "token mismatch"); - - // NOTE: In the future, we could imagine performing proxy-based recording as well using RdpProxy. - if entry.mapping.is_some() { - 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_entry(entry) - .client_stream_leftover_bytes(leftover_bytes) - .server_dns_name(selected_target.host().to_owned()) - .disconnect_interest(disconnect_interest) - .build() - .run() - .await - .context("encountered a failure during RDP proxying (credential injection)"); - } - } + // 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) = credential_store.get(claims.jti) + && entry.mapping.is_some() + { + anyhow::ensure!(token == entry.token, "token mismatch"); + let credential_injection_kdc = + CredentialInjectionKdc::from_entry(claims.jti, entry, &credential_injection_context_store)?; + + info!( + jti = %credential_injection_kdc.jti(), + "RDP-TLS forwarding with credential injection" + ); + + // 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_kdc(credential_injection_kdc) + .client_stream_leftover_bytes(leftover_bytes) + .server_dns_name(selected_target.host().to_owned()) + .disconnect_interest(disconnect_interest) + .build() + .run() + .await + .context("encountered a failure during RDP proxying (credential injection)"); } info!("Upstream forwarding"); diff --git a/devolutions-gateway/src/lib.rs b/devolutions-gateway/src/lib.rs index 3af9f7999..bebcd53a8 100644 --- a/devolutions-gateway/src/lib.rs +++ b/devolutions-gateway/src/lib.rs @@ -17,6 +17,7 @@ pub mod api; pub mod cli; pub mod config; pub mod credential; +pub mod credential_injection_kdc; pub mod extract; pub mod generic_client; pub mod http; @@ -60,6 +61,7 @@ pub struct DgwState { pub recordings: recording::RecordingMessageSender, pub job_queue_handle: job_queue::JobQueueHandle, pub credential_store: credential::CredentialStoreHandle, + pub credential_injection_context_store: credential_injection_kdc::CredentialInjectionKdcContextStoreHandle, pub monitoring_state: Arc, pub traffic_audit_handle: traffic_audit::TrafficAuditHandle, pub agent_tunnel_handle: Option>, @@ -88,6 +90,8 @@ impl DgwState { let (job_queue_handle, job_queue_rx) = job_queue::JobQueueHandle::new(); let (traffic_audit_handle, traffic_audit_rx) = traffic_audit::TrafficAuditHandle::new(); let credential_store = credential::CredentialStoreHandle::new(); + let credential_injection_context_store = + credential_injection_kdc::CredentialInjectionKdcContextStoreHandle::new(); let monitoring_state = Arc::new(network_monitor::State::new(Arc::new(MockMonitorsCache))?); let state = Self { @@ -101,6 +105,7 @@ impl DgwState { job_queue_handle, traffic_audit_handle, credential_store, + credential_injection_context_store, monitoring_state, agent_tunnel_handle: None, }; diff --git a/devolutions-gateway/src/listener.rs b/devolutions-gateway/src/listener.rs index 0b7ce2740..288884ab4 100644 --- a/devolutions-gateway/src/listener.rs +++ b/devolutions-gateway/src/listener.rs @@ -159,6 +159,7 @@ async fn handle_tcp_peer(stream: TcpStream, state: DgwState, peer_addr: SocketAd .subscriber_tx(state.subscriber_tx) .active_recordings(state.recordings.active_recordings) .credential_store(state.credential_store) + .credential_injection_context_store(state.credential_injection_context_store) .agent_tunnel_handle(state.agent_tunnel_handle) .build() .serve() diff --git a/devolutions-gateway/src/ngrok.rs b/devolutions-gateway/src/ngrok.rs index 71c0c005f..2a1fd3167 100644 --- a/devolutions-gateway/src/ngrok.rs +++ b/devolutions-gateway/src/ngrok.rs @@ -238,6 +238,7 @@ async fn run_tcp_tunnel(mut tunnel: ngrok::tunnel::TcpTunnel, state: DgwState) { .subscriber_tx(state.subscriber_tx) .active_recordings(state.recordings.active_recordings) .credential_store(state.credential_store) + .credential_injection_context_store(state.credential_injection_context_store) .agent_tunnel_handle(state.agent_tunnel_handle) .build() .serve() diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index 41a118a02..0ca951242 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -3,17 +3,16 @@ use std::net::SocketAddr; use std::sync::Arc; use anyhow::Context as _; -use ironrdp_connector::sspi; use ironrdp_pdu::nego; use ironrdp_rdcleanpath::RDCleanPathPdu; -use secrecy::ExposeSecret as _; use tap::prelude::*; use thiserror::Error; use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; use tracing::field; use crate::config::Conf; -use crate::credential::{CredentialEntry, CredentialStoreHandle}; +use crate::credential::CredentialStoreHandle; +use crate::credential_injection_kdc::{CredentialInjectionKdc, CredentialInjectionKdcContextStoreHandle}; use crate::proxy::Proxy; use crate::recording::ActiveRecordings; use crate::session::{ConnectionModeDetails, DisconnectInterest, DisconnectedInfo, SessionInfo, SessionMessageSender}; @@ -316,15 +315,13 @@ async fn handle_with_credential_injection( subscriber_tx: SubscriberSender, active_recordings: &ActiveRecordings, cleanpath_pdu: RDCleanPathPdu, - credential_entry: Arc, + credential_injection_kdc: CredentialInjectionKdc, agent_tunnel_handle: Option>, ) -> anyhow::Result<()> { let tls_conf = conf.credssp_tls.get().context("CredSSP TLS configuration")?; let gateway_hostname = conf.hostname.clone(); - let credential_mapping = credential_entry.mapping.as_ref().context("no credential mapping")?; - let x224_req = cleanpath_pdu .x224_connection_pdu .as_ref() @@ -418,57 +415,17 @@ async fn handle_with_credential_injection( let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream); let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); - let krb_server_config = if conf.debug.enable_unstable - && let Some(crate::config::dto::KerberosConfig { - kerberos_server: - crate::config::dto::KerberosServer { - max_time_skew, - ticket_decryption_key, - service_user, - .. - }, - kdc_url: _, - }) = conf.debug.kerberos.as_ref() - { - let user = service_user.as_ref().map(|user| { - let crate::config::dto::DomainUser { - fqdn, - password, - salt: _, - } = user; - - // The username is in the FQDN format. Thus, the domain field can be empty. - sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8( - fqdn, - "", - password.expose_secret(), - )) - }); - - Some(sspi::KerberosServerConfig { - kerberos_config: sspi::KerberosConfig { - // The sspi-rs can automatically resolve the KDC host via DNS and/or env variable. - kdc_url: None, - client_computer_name: Some(client_addr.to_string()), - }, - server_properties: sspi::kerberos::ServerProperties::new( - &["TERMSRV", &gateway_hostname], - user, - std::time::Duration::from_secs(*max_time_skew), - ticket_decryption_key.clone(), - )?, - }) - } else { - None - }; + let krb_server_config = + crate::rdp_proxy::credential_injection_kerberos_server_config(&conf, client_addr, &credential_injection_kdc)?; - let client_credssp_fut = crate::rdp_proxy::perform_credssp_with_client( + let client_credssp_fut = crate::rdp_proxy::perform_credssp_as_server( &mut client_framed, client_addr.ip(), gateway_public_key, client_security_protocol, - &credential_mapping.proxy, + credential_injection_kdc.proxy_credential(), krb_server_config, + &credential_injection_kdc, ); let krb_client_config = if conf.debug.enable_unstable @@ -485,12 +442,12 @@ async fn handle_with_credential_injection( None }; - let server_credssp_fut = crate::rdp_proxy::perform_credssp_with_server( + 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_mapping.target, + credential_injection_kdc.target_credential(), krb_client_config, ); @@ -565,6 +522,7 @@ pub async fn handle( subscriber_tx: SubscriberSender, active_recordings: &ActiveRecordings, credential_store: &CredentialStoreHandle, + credential_injection_context_store: &CredentialInjectionKdcContextStoreHandle, agent_tunnel_handle: Option>, ) -> anyhow::Result<()> { // Special handshake of our RDP extension @@ -583,14 +541,18 @@ 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. - if let Some(entry) = crate::token::extract_jti(token) - .ok() - .and_then(|token_id| credential_store.get(token_id)) - .filter(|entry| entry.mapping.is_some()) + // 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) = credential_store.get(jti) + && entry.mapping.is_some() { - anyhow::ensure!(token == entry.token, "token mismatch"); - debug!("Switching to RdpProxy for credential injection (WebSocket)"); + let credential_injection_kdc = + CredentialInjectionKdc::from_entry(jti, entry, credential_injection_context_store)?; + anyhow::ensure!(token == credential_injection_kdc.raw_token(), "token mismatch"); + debug!( + jti = %credential_injection_kdc.jti(), + "Switching to RdpProxy for credential injection (WebSocket)" + ); return handle_with_credential_injection( client_stream, @@ -602,7 +564,7 @@ pub async fn handle( subscriber_tx, active_recordings, cleanpath_pdu, - entry, + credential_injection_kdc, agent_tunnel_handle.clone(), ) .await; diff --git a/devolutions-gateway/src/rdp_proxy.rs b/devolutions-gateway/src/rdp_proxy.rs index af7d5f090..17a37e757 100644 --- a/devolutions-gateway/src/rdp_proxy.rs +++ b/devolutions-gateway/src/rdp_proxy.rs @@ -13,7 +13,10 @@ use typed_builder::TypedBuilder; use crate::api::kdc_proxy::send_krb_message; use crate::config::Conf; -use crate::credential::{AppCredentialMapping, ArcCredentialEntry}; +use crate::credential::AppCredential; +use crate::credential_injection_kdc::{ + CredentialInjectionClientAcceptorProtocol, CredentialInjectionKdc, CredentialInjectionKdcInterception, +}; use crate::proxy::Proxy; use crate::session::{DisconnectInterest, SessionInfo, SessionMessageSender}; use crate::subscriber::SubscriberSender; @@ -27,7 +30,7 @@ pub struct RdpProxy { client_addr: SocketAddr, server_stream: S, server_addr: SocketAddr, - credential_entry: ArcCredentialEntry, + credential_injection_kdc: CredentialInjectionKdc, client_stream_leftover_bytes: bytes::BytesMut, sessions: SessionMessageSender, subscriber_tx: SubscriberSender, @@ -58,7 +61,7 @@ where client_addr, server_stream, server_addr, - credential_entry, + credential_injection_kdc, client_stream_leftover_bytes, sessions, subscriber_tx, @@ -69,8 +72,6 @@ where let tls_conf = conf.credssp_tls.get().context("CredSSP TLS configuration")?; let gateway_hostname = conf.hostname.clone(); - let credential_mapping = credential_entry.mapping.as_ref().context("no credential mapping")?; - // -- 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( @@ -84,8 +85,12 @@ where 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_mapping).await?; + 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(); @@ -112,57 +117,16 @@ where let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream); let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); - let krb_server_config = if conf.debug.enable_unstable - && let Some(crate::config::dto::KerberosConfig { - kerberos_server: - crate::config::dto::KerberosServer { - max_time_skew, - ticket_decryption_key, - service_user, - .. - }, - kdc_url: _, - }) = conf.debug.kerberos.as_ref() - { - let user = service_user.as_ref().map(|user| { - let crate::config::dto::DomainUser { - fqdn, - password, - salt: _, - } = user; - - // The username is in the FQDN format. Thus, the domain field can be empty. - sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8( - fqdn, - "", - password.expose_secret(), - )) - }); - - Some(sspi::KerberosServerConfig { - kerberos_config: sspi::KerberosConfig { - // The sspi-rs can automatically resolve the KDC host via DNS and/or env variable. - kdc_url: None, - client_computer_name: Some(client_addr.to_string()), - }, - server_properties: sspi::kerberos::ServerProperties::new( - &["TERMSRV", &gateway_hostname], - user, - std::time::Duration::from_secs(*max_time_skew), - ticket_decryption_key.clone(), - )?, - }) - } else { - None - }; + let krb_server_config = credential_injection_kerberos_server_config(&conf, client_addr, &credential_injection_kdc)?; - let client_credssp_fut = perform_credssp_with_client( + let client_credssp_fut = perform_credssp_as_server( &mut client_framed, client_addr.ip(), gateway_public_key, handshake_result.client_security_protocol, - &credential_mapping.proxy, + credential_injection_kdc.proxy_credential(), krb_server_config, + &credential_injection_kdc, ); let krb_client_config = if conf.debug.enable_unstable @@ -179,12 +143,12 @@ where None }; - let server_credssp_fut = perform_credssp_with_server( + let server_credssp_fut = perform_credssp_as_client( &mut server_framed, server_dns_name, server_public_key, handshake_result.server_security_protocol, - &credential_mapping.target, + credential_injection_kdc.target_credential(), krb_client_config, ); @@ -282,7 +246,7 @@ where async fn dual_handshake_until_tls_upgrade( client_framed: &mut ironrdp_tokio::MovableTokioFramed, server_framed: &mut ironrdp_tokio::MovableTokioFramed, - mapping: &AppCredentialMapping, + target_credential: &AppCredential, ) -> anyhow::Result where C: AsyncWrite + AsyncRead + Unpin + Send, @@ -311,8 +275,8 @@ where }; let connection_request_to_send = nego::ConnectionRequest { - nego_data: match &mapping.target { - crate::credential::AppCredential::UsernamePassword { username, .. } => { + nego_data: match target_credential { + AppCredential::UsernamePassword { username, .. } => { Some(nego::NegoRequestData::cookie(username.to_owned())) } }, @@ -393,13 +357,36 @@ where handshake_result } +pub(crate) fn credential_injection_kerberos_server_config( + conf: &Conf, + client_addr: SocketAddr, + credential_injection_kdc: &CredentialInjectionKdc, +) -> anyhow::Result> { + if !conf.debug.enable_unstable || conf.debug.kerberos.is_none() { + return Ok(None); + } + + match credential_injection_kdc.client_acceptor_protocol()? { + CredentialInjectionClientAcceptorProtocol::Kerberos => { + credential_injection_kdc.server_kerberos_config(client_addr).map(Some) + } + CredentialInjectionClientAcceptorProtocol::Ntlm => { + debug!( + jti = %credential_injection_kdc.jti(), + "Credential-injection Kerberos acceptor disabled for NTLM target credential" + ); + Ok(None) + } + } +} + #[instrument(name = "server_credssp", level = "debug", ret, skip_all)] -pub(crate) async fn perform_credssp_with_server( +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: &crate::credential::AppCredential, + credentials: &AppCredential, kerberos_config: Option, ) -> anyhow::Result<()> where @@ -423,7 +410,7 @@ where credentials, None, security_protocol, - ironrdp_connector::ServerName::new(server_name), + ironrdp_connector::ServerName::new(server_name.clone()), server_public_key, kerberos_config, )?; @@ -465,18 +452,25 @@ where async fn resolve_server_generator( generator: &mut CredsspServerProcessGenerator<'_>, + credential_injection_kdc: &CredentialInjectionKdc, ) -> Result { let mut state = generator.start(); loop { match state { GeneratorState::Suspended(request) => { - let response = send_network_request(&request) - .await - .map_err(|err| sspi::credssp::ServerError { - ts_request: None, - error: sspi::Error::new(sspi::ErrorKind::InternalError, err), - })?; + let response = match credential_injection_kdc.intercept_network_request(&request) { + Ok(CredentialInjectionKdcInterception::Intercepted(response)) => Ok(response), + Ok(CredentialInjectionKdcInterception::NotInjectionRequest) => 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)); } @@ -508,13 +502,14 @@ async fn resolve_client_generator( } #[instrument(name = "client_credssp", level = "debug", ret, skip_all)] -pub(crate) async fn perform_credssp_with_client( +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: &crate::credential::AppCredential, + credentials: &AppCredential, kerberos_server_config: Option, + credential_injection_kdc: &CredentialInjectionKdc, ) -> anyhow::Result<()> where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, @@ -535,6 +530,7 @@ where gateway_public_key, credentials, kerberos_server_config, + credential_injection_kdc, ) .await; @@ -560,8 +556,9 @@ where buf: &mut ironrdp_pdu::WriteBuf, client_computer_name: ironrdp_connector::ServerName, public_key: Vec, - credentials: &crate::credential::AppCredential, + credentials: &AppCredential, kerberos_server_config: Option, + credential_injection_kdc: &CredentialInjectionKdc, ) -> anyhow::Result<()> where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, @@ -603,7 +600,7 @@ where let result = { let mut generator = sequence.process_ts_request(ts_request); - resolve_server_generator(&mut generator).await + resolve_server_generator(&mut generator, credential_injection_kdc).await }; // drop generator buf.clear(); @@ -634,14 +631,27 @@ where Ok(()) } +/// Generic Kerberos network-request dispatcher. +/// +/// Only handles real-network schemes (`tcp` / `udp`); credential-injection loopback requests +/// are intercepted by [`CredentialInjectionKdc`] before reaching this function. +/// +/// TODO(sspi-rs#664): when sspi-rs ships a pluggable KDC dispatcher API, the URL trick for +/// credential injection goes away entirely and this helper can be inlined back into the +/// CredSSP loops. async fn send_network_request(request: &NetworkRequest) -> anyhow::Result> { - let target_addr = TargetAddr::parse(request.url.as_str(), Some(88))?; - - // TODO(DGW-384): plumb `agent_tunnel_handle` through `RdpProxy` so - // CredSSP-originated Kerberos requests can traverse the agent tunnel. - // Currently these go direct from the gateway host, bypassing the - // routing pipeline used by every other proxy path. - send_krb_message(&target_addr, &request.data) - .await - .map_err(|err| anyhow::Error::msg("failed to send KDC message").context(err)) + match request.url.scheme() { + "tcp" | "udp" => { + let target_addr = TargetAddr::parse(request.url.as_str(), Some(88))?; + + // TODO(DGW-384): plumb `agent_tunnel_handle` through `RdpProxy` so + // CredSSP-originated Kerberos requests can traverse the agent tunnel. + // Currently these go direct from the gateway host, bypassing the + // routing pipeline used by every other proxy path. + send_krb_message(&target_addr, &request.data) + .await + .map_err(|err| anyhow::Error::msg("failed to send KDC message").context(err)) + } + unsupported => anyhow::bail!("unsupported KDC request scheme: {unsupported}"), + } } diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index f3de73ccf..3cb1ee5c1 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -269,6 +269,8 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { .context("failed to initialize traffic audit manager")?; let credential_store = CredentialStoreHandle::new(); + let credential_injection_context_store = + devolutions_gateway::credential_injection_kdc::CredentialInjectionKdcContextStoreHandle::new(); let filesystem_monitor_config_cache = devolutions_gateway::api::monitoring::FilesystemConfigCache::new( config::get_data_dir().join("monitors_cache.json"), @@ -317,6 +319,7 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { recordings: recording_manager_handle.clone(), job_queue_handle: job_queue_ctx.job_queue_handle.clone(), credential_store: credential_store.clone(), + credential_injection_context_store: credential_injection_context_store.clone(), monitoring_state, traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, @@ -355,6 +358,10 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { handle: credential_store, }); + tasks.register(devolutions_gateway::credential_injection_kdc::CleanupTask { + handle: credential_injection_context_store, + }); + 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 e188da969..7d57cf0ff 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -26,6 +26,7 @@ pub const MAX_SUBKEY_TOKEN_VALIDITY_DURATION_SECS: i64 = 60 * 60 * 2; // 2 hours const LEEWAY_SECS: u16 = 60 * 5; // 5 minutes const RDP_MAX_REUSE_INTERVAL_SECS: i64 = 10; // 10 seconds const BRIDGE_TOKEN_MAX_TOKEN_VALIDITY_DURATION_SECS: i64 = 60 * 60 * 12; // 12 hours +const CREDENTIAL_INJECTION_DEFAULT_DST_PORT: u16 = 3389; /// This is the maximum number of reconnections allowed during the reconnection window. If the /// reconnection window (e.g.: 30 seconds) is over while the connection is still alive, the counter @@ -616,6 +617,11 @@ pub struct KdcTokenClaims { /// Default scheme is `tcp`. /// Default port is `88`. pub krb_kdc: TargetAddr, + + /// JTI of the association token whose credential-injection state this KDC token should + /// dispatch through. Present only on KDC tokens minted alongside an injection session; + /// plain Kerberos proxying leaves this `None` and forwards to the real KDC. + pub jet_cred_id: Option, } // ----- jrl claims ----- // @@ -1204,11 +1210,15 @@ fn validate_token_impl( Ok(claims) } -fn extract_uuid(token: &str, field: &str) -> anyhow::Result { +fn extract_payload(token: &str) -> anyhow::Result { let jws = RawJws::decode(token) .context("failed to parse the provided JWS")? .discard_signature(); - let payload = serde_json::from_slice::(&jws.payload).context("parse JWS payload")?; + serde_json::from_slice::(&jws.payload).context("parse JWS payload") +} + +fn extract_uuid(token: &str, field: &str) -> anyhow::Result { + let payload = extract_payload(token)?; let uuid = payload.get(field).context("claim is missing from the token")?; let uuid = uuid.as_str().context("value is malformed")?; let uuid = Uuid::from_str(uuid).context("value is not a valid UUID string")?; @@ -1224,6 +1234,68 @@ pub fn extract_session_id(token: &str) -> anyhow::Result { extract_uuid(token, "jet_aid").context("extract jet_aid") } +/// Extract the destination host claim (`dst_hst`) from an association token without verifying its +/// signature. Returns `None` if the claim is missing. +/// +/// Used by the credential-injection KDC to validate the client's TGS-REQ sname against the target +/// server hostname (the SPN the client actually requested is `TERMSRV/`, not Gateway's own +/// hostname). +pub fn extract_dst_hst(token: &str) -> anyhow::Result> { + let payload = extract_payload(token)?; + let Some(value) = payload.get("dst_hst") else { + return Ok(None); + }; + let dst_hst = value.as_str().context("dst_hst is malformed")?; + Ok(Some(dst_hst.to_owned())) +} + +/// Extract alternate destination hosts (`dst_alt`) from an association token without verifying its +/// signature. +pub fn extract_dst_alt(token: &str) -> anyhow::Result> { + let payload = extract_payload(token)?; + let Some(value) = payload.get("dst_alt") else { + return Ok(Vec::new()); + }; + + let dst_alt = value.as_array().context("dst_alt is malformed")?; + dst_alt + .iter() + .map(|value| value.as_str().context("dst_alt entry is malformed").map(str::to_owned)) + .collect() +} + +/// Validate the association-token claims required by credential injection. +/// +/// This is intentionally a token-layer shape check only. +/// The credential-injection KDC still lazily extracts the target hostname from the original token +/// when it builds its per-session state. +pub fn validate_credential_injection_association_token(token: &str) -> anyhow::Result { + let jti = extract_jti(token).context("read jti from association token")?; + extract_credential_injection_target_hostname(token)?; + Ok(jti) +} + +/// Extract the target hostname used by credential injection from an association token. +/// +/// `dst_alt` is rejected for now because the Kerberos fake-KDC can validate only one target SPN for +/// the current credential-injection session. +pub fn extract_credential_injection_target_hostname(token: &str) -> anyhow::Result { + let dst_alt = extract_dst_alt(token).context("read dst_alt from association token")?; + anyhow::ensure!( + dst_alt.is_empty(), + "association token dst_alt is not supported for credential injection", + ); + + let raw_dst_hst = extract_dst_hst(token) + .context("read dst_hst from association token")? + .context("association token has no dst_hst, required for credential injection")?; + + Ok(TargetAddr::parse(&raw_dst_hst, CREDENTIAL_INJECTION_DEFAULT_DST_PORT) + .context("parse dst_hst as target address")? + .host() + .to_owned()) +} + #[deprecated = "make sure this is never used without a deliberate action"] pub mod unsafe_debug { // Any function in this module should only be used at development stage when deliberately @@ -1391,6 +1463,8 @@ mod serde_impl { struct KdcClaimsHelper { krb_realm: SmolStr, krb_kdc: SmolStr, + #[serde(default, skip_serializing_if = "Option::is_none")] + jet_cred_id: Option, } impl ser::Serialize for SessionTtl { @@ -1640,6 +1714,7 @@ mod serde_impl { KdcClaimsHelper { krb_realm: self.krb_realm.clone(), krb_kdc: SmolStr::new(self.krb_kdc.as_str()), + jet_cred_id: self.jet_cred_id, } .serialize(serializer) } @@ -1654,11 +1729,9 @@ mod serde_impl { let claims = KdcClaimsHelper::deserialize(deserializer)?; - // Validate krb_realm value - - if claims.krb_realm.chars().any(char::is_uppercase) { - return Err(de::Error::custom("krb_realm field contains uppercases")); - } + // Normalize `krb_realm` for token producers that follow the Kerberos uppercase realm + // convention. Realm authorization remains case-insensitive at use sites. + let krb_realm = SmolStr::new(claims.krb_realm.to_ascii_lowercase()); // Validate krb_kdc field @@ -1673,9 +1746,53 @@ mod serde_impl { } Ok(Self { - krb_realm: claims.krb_realm, + krb_realm, krb_kdc, + jet_cred_id: claims.jet_cred_id, }) } } } + +#[cfg(test)] +mod tests { + use base64::Engine as _; + + use super::*; + + 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}") + } + + #[test] + fn kdc_claims_normalizes_uppercase_realm() { + let claims: KdcTokenClaims = serde_json::from_value(serde_json::json!({ + "krb_realm": "CRED-D0089FC1456D4A7E84AEEB6D7C6C59B7.INVALID", + "krb_kdc": "tcp://dc.example:88" + })) + .expect("uppercase realm should deserialize"); + + assert_eq!( + claims.krb_realm.as_str(), + "cred-d0089fc1456d4a7e84aeeb6d7c6c59b7.invalid" + ); + } + + #[test] + fn extract_dst_alt_returns_alternate_targets() { + let token = unsigned_jws(serde_json::json!({ + "jti": "5e3e833f-84c7-4541-b676-acc3299e39b8", + "dst_hst": "primary.example:3389", + "dst_alt": ["secondary.example:3389"] + })); + + assert_eq!( + extract_dst_alt(&token).expect("dst_alt parses"), + vec!["secondary.example:3389".to_owned()] + ); + } +} diff --git a/devolutions-gateway/tests/preflight.rs b/devolutions-gateway/tests/preflight.rs index 0f3f175ae..8246696ac 100644 --- a/devolutions-gateway/tests/preflight.rs +++ b/devolutions-gateway/tests/preflight.rs @@ -2,13 +2,12 @@ #![allow(clippy::unwrap_used)] use std::net::SocketAddr; -use std::str::FromStr as _; use axum::Router; use axum::body::Body; use axum::extract::connect_info::MockConnectInfo; use axum::http::{self, Request, StatusCode}; -use devolutions_gateway::credential::AppCredential; +use base64::Engine as _; use devolutions_gateway::{DgwState, MockHandles}; use http_body_util::BodyExt as _; use serde_json::json; @@ -31,7 +30,8 @@ const CONFIG: &str = r#"{ } ], "__debug__": { - "disable_token_validation": true + "disable_token_validation": true, + "enable_unstable": true } }"#; @@ -46,6 +46,14 @@ fn preflight_request(operations: serde_json::Value) -> anyhow::Result anyhow::Result { + 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)?); + let signature = engine.encode(b"signature"); + Ok(format!("{header}.{payload}.{signature}")) +} + fn make_router() -> anyhow::Result<(Router, DgwState, MockHandles)> { let (state, handles) = DgwState::mock(CONFIG)?; let app = devolutions_gateway::make_http_service(state.clone()) @@ -53,6 +61,13 @@ fn make_router() -> anyhow::Result<(Router, DgwState, MockHandles)> { Ok((app, state, handles)) } +fn make_router_with_config(config: &str) -> anyhow::Result<(Router, DgwState, MockHandles)> { + let (state, handles) = DgwState::mock(config)?; + let app = devolutions_gateway::make_http_service(state.clone()) + .layer(MockConnectInfo(SocketAddr::from(([0, 0, 0, 0], 3000)))); + Ok((app, state, handles)) +} + fn init_logger() -> tracing::subscriber::DefaultGuard { tracing_subscriber::fmt() .with_test_writer() @@ -64,10 +79,11 @@ fn init_logger() -> tracing::subscriber::DefaultGuard { async fn test_provision_credentials_success() -> anyhow::Result<()> { let _guard = init_logger(); - let (app, state, _handles) = make_router()?; + let (app, _state, _handles) = make_router()?; - let token_id = Uuid::from_str("5e3e833f-84c7-4541-b676-acc3299e39b8").unwrap(); - let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI1ZTNlODMzZi04NGM3LTQ1NDEtYjY3Ni1hY2MzMjk5ZTM5YjgifQ.1qECGlrW7y9HWFArc6GPHLGTOY7PhAvzKJ5XMRBg4k4"; + // JWT payload includes `dst_hst` because credential injection requires a target hostname + // (fake-KDC validates TGS-REQ sname against `TERMSRV/`); preflight rejects tokens without it. + let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI1ZTNlODMzZi04NGM3LTQ1NDEtYjY3Ni1hY2MzMjk5ZTM5YjgiLCJkc3RfaHN0IjoidGFyZ2V0LmV4YW1wbGU6MzM4OSJ9.1qECGlrW7y9HWFArc6GPHLGTOY7PhAvzKJ5XMRBg4k4"; let op_id = Uuid::new_v4(); @@ -89,18 +105,127 @@ async fn test_provision_credentials_success() -> anyhow::Result<()> { let body: serde_json::Value = serde_json::from_slice(&body)?; assert_eq!(body.as_array().expect("an array").len(), 1); assert_eq!(body[0]["operation_id"], op_id.to_string()); - assert_eq!(body[0]["kind"], "ack", "{:?}", body[1]); + assert_eq!(body[0]["kind"], "ack", "{:?}", body[0]); + + Ok(()) +} + +#[tokio::test] +async fn test_provision_credentials_success_when_unstable_disabled() -> anyhow::Result<()> { + let _guard = init_logger(); + + let config = CONFIG.replace("\"enable_unstable\": true", "\"enable_unstable\": false"); + let (app, _state, _handles) = make_router_with_config(&config)?; + + // `provision-credentials` is protocol-neutral: NTLM credential injection relies on this + // preflight state even when the Kerberos injection path is disabled. + let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI1ZTNlODMzZi04NGM3LTQ1NDEtYjY3Ni1hY2MzMjk5ZTM5YjgiLCJkc3RfaHN0IjoidGFyZ2V0LmV4YW1wbGU6MzM4OSJ9.1qECGlrW7y9HWFArc6GPHLGTOY7PhAvzKJ5XMRBg4k4"; + + let op_id = Uuid::new_v4(); + + let op = json!([{ + "id": op_id, + "kind": "provision-credentials", + "token": token, + "proxy_credential": { "kind": "username-password", "username": "proxy_user", "password": "secret1" }, + "target_credential": { "kind": "username-password", "username": "target_user", "password": "secret2" }, + "time_to_live": 15 + }]); + + let response = app.oneshot(preflight_request(op)?).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await?.to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&body)?; + assert_eq!(body.as_array().expect("an array").len(), 1); + assert_eq!(body[0]["operation_id"], op_id.to_string()); + assert_eq!(body[0]["kind"], "ack", "{:?}", body[0]); + + Ok(()) +} + +#[tokio::test] +async fn test_provision_credentials_rejects_alternate_targets() -> anyhow::Result<()> { + let _guard = init_logger(); + + let (app, _state, _handles) = make_router()?; + + let token = unsigned_jws(json!({ + "jti": "5e3e833f-84c7-4541-b676-acc3299e39b8", + "dst_hst": "target-primary.example:3389", + "dst_alt": ["target-secondary.example:3389"] + }))?; + + let op_id = Uuid::new_v4(); + + let op = json!([{ + "id": op_id, + "kind": "provision-credentials", + "token": token, + "proxy_credential": { "kind": "username-password", "username": "proxy_user", "password": "secret1" }, + "target_credential": { "kind": "username-password", "username": "target_user", "password": "secret2" }, + "time_to_live": 15 + }]); - let entry = state.credential_store.get(token_id).expect("the provisioned entry"); - assert_eq!(entry.token, token); + let response = app.oneshot(preflight_request(op)?).await?; + assert_eq!(response.status(), StatusCode::OK); - let now = time::OffsetDateTime::now_utc(); - assert!(now + time::Duration::seconds(10) < entry.expires_at); - assert!(entry.expires_at < now + time::Duration::seconds(20)); + let body = response.into_body().collect().await?.to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&body)?; + + assert_eq!(body.as_array().expect("an array").len(), 1); + assert_eq!(body[0]["kind"], "alert"); + assert_eq!(body[0]["alert_status"], "invalid-parameters"); + assert!( + body[0]["alert_message"] + .as_str() + .expect("alert message") + .contains("dst_alt"), + "{:?}", + body[0] + ); + + Ok(()) +} + +#[tokio::test] +async fn test_provision_credentials_rejects_missing_target_hostname() -> anyhow::Result<()> { + let _guard = init_logger(); - let mapping = entry.mapping.as_ref().expect("the provisioned mapping"); - assert!(matches!(mapping.proxy, AppCredential::UsernamePassword { .. })); - assert!(matches!(mapping.target, AppCredential::UsernamePassword { .. })); + let (app, _state, _handles) = make_router()?; + + let token = unsigned_jws(json!({ + "jti": "5e3e833f-84c7-4541-b676-acc3299e39b8" + }))?; + + let op_id = Uuid::new_v4(); + + let op = json!([{ + "id": op_id, + "kind": "provision-credentials", + "token": token, + "proxy_credential": { "kind": "username-password", "username": "proxy_user", "password": "secret1" }, + "target_credential": { "kind": "username-password", "username": "target_user", "password": "secret2" }, + "time_to_live": 15 + }]); + + let response = app.oneshot(preflight_request(op)?).await?; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await?.to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&body)?; + + assert_eq!(body.as_array().expect("an array").len(), 1); + assert_eq!(body[0]["kind"], "alert"); + assert_eq!(body[0]["alert_status"], "invalid-parameters"); + assert!( + body[0]["alert_message"] + .as_str() + .expect("alert message") + .contains("dst_hst"), + "{:?}", + body[0] + ); Ok(()) } diff --git a/tools/tokengen/src/lib.rs b/tools/tokengen/src/lib.rs index 3fdb532ee..1532d6581 100644 --- a/tools/tokengen/src/lib.rs +++ b/tools/tokengen/src/lib.rs @@ -103,6 +103,8 @@ pub struct KdcClaims<'a> { pub krb_kdc: &'a str, #[serde(skip_serializing_if = "Option::is_none")] pub jet_gw_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jet_cred_id: Option, pub exp: i64, pub nbf: i64, pub jti: Uuid, @@ -259,6 +261,7 @@ pub enum SubCommandArgs { Kdc { krb_realm: String, krb_kdc: String, + jet_cred_id: Option, }, Jrl { revoked_jti_list: Vec, @@ -452,13 +455,18 @@ pub fn generate_token( }; ("JREC", serde_json::to_value(claims)?) } - SubCommandArgs::Kdc { krb_realm, krb_kdc } => { + SubCommandArgs::Kdc { + krb_realm, + krb_kdc, + jet_cred_id, + } => { let claims = KdcClaims { exp, nbf, krb_realm: &krb_realm, krb_kdc: &krb_kdc, jet_gw_id, + jet_cred_id, jti, }; ("KDC", serde_json::to_value(claims)?) diff --git a/tools/tokengen/src/main.rs b/tools/tokengen/src/main.rs index c080b8ba1..2a3fbdaca 100644 --- a/tools/tokengen/src/main.rs +++ b/tools/tokengen/src/main.rs @@ -122,7 +122,15 @@ fn sign( jet_aid, jet_reuse, }, - SignSubCommand::Kdc { krb_realm, krb_kdc } => SubCommandArgs::Kdc { krb_realm, krb_kdc }, + SignSubCommand::Kdc { + krb_realm, + krb_kdc, + jet_cred_id, + } => SubCommandArgs::Kdc { + krb_realm, + krb_kdc, + jet_cred_id, + }, SignSubCommand::Jrl { jti } => SubCommandArgs::Jrl { revoked_jti_list: jti }, SignSubCommand::NetScan {} => SubCommandArgs::NetScan {}, }; @@ -258,6 +266,8 @@ enum SignSubCommand { krb_realm: String, #[clap(long)] krb_kdc: String, + #[clap(long)] + jet_cred_id: Option, }, Jrl { #[clap(long)] diff --git a/tools/tokengen/src/server/server_impl.rs b/tools/tokengen/src/server/server_impl.rs index 400ba8bf9..7913d0f5b 100644 --- a/tools/tokengen/src/server/server_impl.rs +++ b/tools/tokengen/src/server/server_impl.rs @@ -212,6 +212,7 @@ pub(crate) async fn kdc_handler( SubCommandArgs::Kdc { krb_realm: request.krb_realm, krb_kdc: request.krb_kdc, + jet_cred_id: request.jet_cred_id, }, ) .await @@ -341,6 +342,7 @@ pub(crate) struct KdcRequest { common: CommonRequest, krb_realm: String, krb_kdc: String, + jet_cred_id: Option, } #[derive(Deserialize)] diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/AssociationClaims.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/AssociationClaims.cs index eca9a1f29..f07c6a4ae 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils/src/AssociationClaims.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/AssociationClaims.cs @@ -50,4 +50,4 @@ public string GetContentType() { return "ASSOCIATION"; } -} \ No newline at end of file +} diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/KdcClaims.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/KdcClaims.cs index d975d58a5..bb43f2ec9 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils/src/KdcClaims.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/KdcClaims.cs @@ -10,6 +10,9 @@ public class KdcClaims : IGatewayClaims public string KrbRealm { get; set; } [JsonPropertyName("jet_gw_id")] public Guid ScopeGatewayId { get; set; } + [JsonPropertyName("jet_cred_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? JetCredId { get; set; } public KdcClaims(Guid scopeGatewayId, TargetAddr krbKdc, string krbRealm) { @@ -22,4 +25,4 @@ public string GetContentType() { return "KDC"; } -} \ No newline at end of file +} diff --git a/utils/dotnet/Devolutions.Gateway.Utils/src/ProvisionCredentialsRequest.cs b/utils/dotnet/Devolutions.Gateway.Utils/src/ProvisionCredentialsRequest.cs new file mode 100644 index 000000000..ccef5a0dd --- /dev/null +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/ProvisionCredentialsRequest.cs @@ -0,0 +1,57 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.Gateway.Utils; + +public class ProvisionCredentialsRequest +{ + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("kind")] + public string Kind => "provision-credentials"; + + [JsonPropertyName("token")] + public string Token { get; set; } + + [JsonPropertyName("proxy_credential")] + public CleartextCredential ProxyCredential { get; set; } + + [JsonPropertyName("target_credential")] + public CleartextCredential TargetCredential { get; set; } + + [JsonPropertyName("time_to_live")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public uint? TimeToLive { get; set; } + + public ProvisionCredentialsRequest( + Guid id, + string token, + CleartextCredential proxyCredential, + CleartextCredential targetCredential, + uint? timeToLive = null) + { + this.Id = id; + this.Token = token; + this.ProxyCredential = proxyCredential; + this.TargetCredential = targetCredential; + this.TimeToLive = timeToLive; + } +} + +public class CleartextCredential +{ + [JsonPropertyName("kind")] + public string Kind => "username-password"; + + [JsonPropertyName("username")] + public string Username { get; set; } + + [JsonPropertyName("password")] + public string Password { get; set; } + + public CleartextCredential(string username, string password) + { + this.Username = username; + this.Password = password; + } +}