diff --git a/.gitignore b/.gitignore index 809ffc7a2..094292691 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ /output/ /config/ *.DotSettings +*.csproj.lscache # Downloaded build dependencies tun2socks.exe 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 664ebc7d4..df5a09c5c 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::{ + 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, KdcDestination}; +use crate::token::{KdcDestination, KdcTokenClaims}; pub fn make_router(state: DgwState) -> Router { Router::new().route("/{token}", post(kdc_proxy)).with_state(state) @@ -22,106 +25,144 @@ pub fn make_router(state: DgwState) -> Router { async fn kdc_proxy( State(DgwState { conf_handle, - token_cache, - jrl, - recordings, + credentials, .. }): State, - extract::Path(token): extract::Path, - ConnectInfo(source_addr): ConnectInfo, + KdcToken(KdcTokenClaims { destination }): 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}"); + match destination { + KdcDestination::Inject { jti } => { + enforce_credential_injection_enabled(jti, conf.debug.enable_unstable)?; - let (claims_realm, claims_kdc) = match &claims.destination { - KdcDestination::Real { krb_realm, krb_kdc } => (krb_realm, krb_kdc), - KdcDestination::Inject { .. } => { - // TODO(DGW-378): dispatch credential-injection KDC requests to the in-process - // sspi-rs server backed by the credentials provisioned at session establishment. - return Err(HttpError::internal().msg("credential injection KDC dispatch is not implemented yet")); - } - }; + let kdc = credentials.kdc_for(jti).map_err(credential_injection_resolve_error)?; - if !claims_realm.eq_ignore_ascii_case(&realm) { - if conf.debug.disable_token_validation { - warn!( - token_realm = %claims_realm, - request_realm = %realm, - "**DEBUG OPTION** Allowed a KDC request towards a KDC whose Kerberos realm differs from what's inside the KDC token" + debug!( + jti = %kdc.jti(), + "Proxy-based credential injection with Kerberos. Processing KdcProxy message internally" ); - } else { - let error_message = format!("expected: {}, got: {}", claims_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)) + .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")) + } + } + } + KdcDestination::Real { krb_realm, krb_kdc } => { + 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..."); - - 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())?; - - return kdc_reply_message.to_vec().map_err(HttpError::internal().err()); +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), } +} - 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_kdc +// 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_message = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?; + let kdc_reply_bytes = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?; - let kdc_reply_message = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_message) + let reply = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_bytes) .map_err(HttpError::internal().with_msg("couldn't create KDC proxy reply").err())?; - trace!(?kdc_reply_message, "Sending back KDC reply"); + trace!(?reply, "Sending back KDC reply"); + + reply.to_vec().map_err(HttpError::internal().err()) +} + +fn enforce_credential_injection_enabled(jet_cred_id: uuid::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 +/// 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(()); + } + + 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> { @@ -221,3 +262,37 @@ pub async fn send_krb_message(kdc_addr: &TargetAddr, message: &[u8]) -> Result, _scope: PreflightScope, @@ -222,14 +223,13 @@ pub(super) async fn post_preflight( let outputs = outputs.clone(); let conf = conf_handle.get_conf(); let sessions = sessions.clone(); - let credential_store = credential_store.clone(); + let credentials = credentials.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, &credentials).await { outputs.push(PreflightOutput { operation_id, kind: PreflightOutputKind::Alert { @@ -256,7 +256,7 @@ async fn handle_operation( outputs: &Outputs, conf: &Conf, sessions: &SessionMessageSender, - credential_store: &CredentialStoreHandle, + credentials: &CredentialService, ) -> Result<(), PreflightError> { match operation.kind.as_str() { OP_GET_VERSION => outputs.push(PreflightOutput { @@ -310,6 +310,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 +338,32 @@ async fn handle_operation( }); } - let previous_entry = credential_store + // Provision-credentials tokens must be valid association tokens with the credential + // injection shape (JTI + dst_hst + no dst_alt). Fail-fast at preflight so the request + // never reaches the credential store with malformed input. + if is_provision_credentials { + 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:#}"), + ) + })?; + } + + let previous_entry = credentials .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 +371,8 @@ async fn handle_operation( ), })?; + // `CredentialService::insert` already drops the cached Kerberos session for a + // replaced entry, so no explicit invalidation is needed here. 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..b3d45dbcb 100644 --- a/devolutions-gateway/src/api/rdp.rs +++ b/devolutions-gateway/src/api/rdp.rs @@ -25,7 +25,7 @@ pub async fn handler( subscriber_tx, recordings, shutdown_signal, - credential_store, + credentials, agent_tunnel_handle, .. }): State, @@ -46,7 +46,7 @@ pub async fn handler( subscriber_tx, recordings.active_recordings, source_addr, - credential_store, + credentials, agent_tunnel_handle, ) .instrument(span) @@ -66,7 +66,7 @@ async fn handle_socket( subscriber_tx: SubscriberSender, active_recordings: Arc, source_addr: SocketAddr, - credential_store: crate::credential::CredentialStoreHandle, + credentials: crate::credential_injection_kdc::CredentialService, agent_tunnel_handle: Option>, ) { let (stream, close_handle) = crate::ws::handle( @@ -84,7 +84,7 @@ async fn handle_socket( sessions, subscriber_tx, &active_recordings, - &credential_store, + &credentials, agent_tunnel_handle, ) .await; diff --git a/devolutions-gateway/src/credential_injection_kdc.rs b/devolutions-gateway/src/credential_injection_kdc.rs new file mode 100644 index 000000000..b42a83529 --- /dev/null +++ b/devolutions-gateway/src/credential_injection_kdc.rs @@ -0,0 +1,1025 @@ +//! 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}; + +// 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, + 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 for {jti} has expired")] + ExpiredCredential { 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: 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 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" + ); + + 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 [`CredentialStoreHandle`] 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(Debug, Clone)] +pub struct CredentialService { + credentials: CredentialStoreHandle, + sessions: Arc>>>, +} + +impl Default for CredentialService { + fn default() -> Self { + Self::new() + } +} + +impl CredentialService { + pub fn new() -> Self { + Self { + credentials: CredentialStoreHandle::new(), + sessions: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Insert (or replace) a credential entry 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 + /// `CredentialStoreHandle::insert` reports no replacement, because the prior entry may have + /// already been evicted by `credential::CleanupTask` while its session cache entry was still + /// awaiting the next `sweep_orphans` tick — without an unconditional drop here, a fresh + /// provisioning under the same JTI would reuse stale key material. + pub fn insert( + &self, + token: String, + mapping: Option, + time_to_live: time::Duration, + ) -> Result, crate::credential::InsertError> { + // Snapshot the JTI from the new token so we can invalidate the matching session entry + // regardless of whether the credential store reports a replacement. `CredentialStore::insert` + // 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::credential::InsertError::InvalidToken)?; + let previous = self.credentials.insert(token, mapping, time_to_live)?; + self.sessions.lock().remove(&jti); + Ok(previous) + } + + /// Look up a credential entry by its association-token JTI. + pub fn get(&self, jti: Uuid) -> Option { + self.credentials.get(jti) + } + + /// Borrow the inner [`CredentialStoreHandle`] for plumbing that genuinely needs the + /// protocol-neutral primitive (e.g. wiring the background expiry task). + pub fn credential_store(&self) -> &CredentialStoreHandle { + &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 } + })?; + + // `CredentialStoreHandle::get` does not enforce expiry — entries are evicted asynchronously + // by the credential cleanup task. Treat a stale entry as already gone so we never build a + // KDC against expired credentials. + if time::OffsetDateTime::now_utc() >= credential_entry.expires_at { + warn!(%jti, "KDC token references expired credential-injection state"); + self.sessions.lock().remove(&jti); + return Err(CredentialInjectionKdcResolveError::ExpiredCredential { jti }); + } + + let mapping = credential_entry.mapping.as_ref().ok_or_else(|| { + warn!(%jti, "KDC token references non-injection credential state"); + CredentialInjectionKdcResolveError::NonInjectionCredential { 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 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) + }; + + CredentialInjectionKdc::from_parts(jti, credential_entry, target_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::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 service_kdc_for_rejects_expired_credential_entry() { + let service = CredentialService::new(); + let jti = Uuid::new_v4(); + + // Negative TTL: entry is born already expired. `CredentialStoreHandle::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( + 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::ExpiredCredential { .. }) + ), + "expired credentials must not yield a KDC" + ); + } + + #[test] + fn service_kdc_for_returns_same_session_under_concurrent_calls() { + let service = CredentialService::new(); + let jti = Uuid::new_v4(); + + service + .insert( + 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(); + 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 + // `credential::cleanup_task`) and `sweep_orphans` has not run yet. A fresh provisioning + // under the same JTI must drop the stale session regardless of whether + // `CredentialStoreHandle::insert` 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 previous = service + .insert( + association_token(jti), + Some(cleartext_mapping_with_target_username("target")), + time::Duration::minutes(5), + ) + .expect("credential entry inserts"); + assert!(previous.is_none(), "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(); + let jti = Uuid::new_v4(); + + service + .insert( + 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( + 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(); + let jti = Uuid::new_v4(); + + service + .insert( + 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 `credential::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 { + credentials: CredentialStoreHandle::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(); + + 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(); + let jti = Uuid::new_v4(); + + service + .insert(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_lazily_extracts_target_hostname_from_entry_token() { + let service = CredentialService::new(); + let jti = Uuid::new_v4(); + + service + .insert( + 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, "target.example"); + } + + #[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..d44595864 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -8,7 +8,7 @@ use tracing::field; use typed_builder::TypedBuilder; use crate::config::Conf; -use crate::credential::CredentialStoreHandle; +use crate::credential_injection_kdc::CredentialService; use crate::proxy::Proxy; use crate::rdp_pcb::{extract_association_claims, read_pcb}; use crate::recording::ActiveRecordings; @@ -27,7 +27,7 @@ pub struct GenericClient { sessions: SessionMessageSender, subscriber_tx: SubscriberSender, active_recordings: Arc, - credential_store: CredentialStoreHandle, + credentials: CredentialService, #[builder(default)] agent_tunnel_handle: Option>, } @@ -51,7 +51,7 @@ where sessions, subscriber_tx, active_recordings, - credential_store, + credentials, agent_tunnel_handle, } = self; @@ -147,35 +147,40 @@ 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) = credentials.get(claims.jti) + && entry.mapping.is_some() + { + anyhow::ensure!(token == entry.token, "token mismatch"); + let credential_injection_kdc = credentials.kdc_for(claims.jti)?; + + 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..5c1777e6e 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; @@ -59,7 +60,7 @@ pub struct DgwState { pub shutdown_signal: devolutions_gateway_task::ShutdownSignal, pub recordings: recording::RecordingMessageSender, pub job_queue_handle: job_queue::JobQueueHandle, - pub credential_store: credential::CredentialStoreHandle, + pub credentials: credential_injection_kdc::CredentialService, pub monitoring_state: Arc, pub traffic_audit_handle: traffic_audit::TrafficAuditHandle, pub agent_tunnel_handle: Option>, @@ -87,7 +88,7 @@ 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 credential_store = credential::CredentialStoreHandle::new(); + let credentials = credential_injection_kdc::CredentialService::new(); let monitoring_state = Arc::new(network_monitor::State::new(Arc::new(MockMonitorsCache))?); let state = Self { @@ -100,7 +101,7 @@ impl DgwState { recordings: recording_manager_handle, job_queue_handle, traffic_audit_handle, - credential_store, + credentials, monitoring_state, agent_tunnel_handle: None, }; diff --git a/devolutions-gateway/src/listener.rs b/devolutions-gateway/src/listener.rs index 0b7ce2740..5e23f5f8a 100644 --- a/devolutions-gateway/src/listener.rs +++ b/devolutions-gateway/src/listener.rs @@ -158,7 +158,7 @@ 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) - .credential_store(state.credential_store) + .credentials(state.credentials) .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..9e2e846bd 100644 --- a/devolutions-gateway/src/ngrok.rs +++ b/devolutions-gateway/src/ngrok.rs @@ -237,7 +237,7 @@ 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) - .credential_store(state.credential_store) + .credentials(state.credentials) .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..bbe69290c 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -3,17 +3,15 @@ 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_injection_kdc::{CredentialInjectionKdc, CredentialService}; use crate::proxy::Proxy; use crate::recording::ActiveRecordings; use crate::session::{ConnectionModeDetails, DisconnectInterest, DisconnectedInfo, SessionInfo, SessionMessageSender}; @@ -316,15 +314,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 +414,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 +441,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, ); @@ -564,7 +520,7 @@ pub async fn handle( sessions: SessionMessageSender, subscriber_tx: SubscriberSender, active_recordings: &ActiveRecordings, - credential_store: &CredentialStoreHandle, + credentials: &CredentialService, agent_tunnel_handle: Option>, ) -> anyhow::Result<()> { // Special handshake of our RDP extension @@ -583,14 +539,17 @@ 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) = credentials.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 = credentials.kdc_for(jti)?; + 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 +561,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..e847e1d94 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -3,7 +3,6 @@ use std::time::Duration; use anyhow::Context as _; use devolutions_gateway::config::{Conf, ConfHandle}; -use devolutions_gateway::credential::CredentialStoreHandle; use devolutions_gateway::listener::{GatewayListener, ListenerUrls}; use devolutions_gateway::log::GatewayLog; use devolutions_gateway::recording::recording_message_channel; @@ -268,7 +267,7 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { .await .context("failed to initialize traffic audit manager")?; - let credential_store = CredentialStoreHandle::new(); + let credentials = devolutions_gateway::credential_injection_kdc::CredentialService::new(); let filesystem_monitor_config_cache = devolutions_gateway::api::monitoring::FilesystemConfigCache::new( config::get_data_dir().join("monitors_cache.json"), @@ -316,7 +315,7 @@ 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(), - credential_store: credential_store.clone(), + credentials: credentials.clone(), monitoring_state, traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, @@ -352,9 +351,11 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { tasks.register(devolutions_gateway::token::CleanupTask { token_cache }); tasks.register(devolutions_gateway::credential::CleanupTask { - handle: credential_store, + handle: credentials.credential_store().clone(), }); + tasks.register(devolutions_gateway::credential_injection_kdc::CleanupTask { service: credentials }); + 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 aa8cf7371..72884fcda 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 @@ -1222,11 +1223,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")?; @@ -1242,6 +1247,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 @@ -1724,3 +1791,32 @@ mod serde_impl { } } } + +#[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 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/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 c08f43f4f..668123fa1 100644 --- a/utils/dotnet/Devolutions.Gateway.Utils/src/KdcClaims.cs +++ b/utils/dotnet/Devolutions.Gateway.Utils/src/KdcClaims.cs @@ -47,4 +47,4 @@ public string GetContentType() { return "KDC"; } -} \ No newline at end of file +}