diff --git a/Cargo.lock b/Cargo.lock index a6df6c2b6..765e867bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7524,12 +7524,19 @@ dependencies = [ "fastrand", "futures-util", "ipnetwork", + "ironrdp-connector", + "ironrdp-core 0.2.1", + "ironrdp-pdu", + "ironrdp-tokio", + "kdc", "libsql", "mcp-proxy", "network-scanner", "network-scanner-proto", "nonempty", "picky", + "picky-asn1-der", + "picky-krb", "proxy-socks", "quinn", "rcgen", @@ -7551,6 +7558,7 @@ dependencies = [ "tokio-util", "typed-builder", "uuid", + "x509-cert 0.3.0", ] [[package]] diff --git a/config_schema.json b/config_schema.json index 0ffad23f4..14f98cad8 100644 --- a/config_schema.json +++ b/config_schema.json @@ -540,7 +540,7 @@ "kerberos_credential_injection": { "type": "boolean", "default": false, - "description": "Whether to enable proxy-based RDP credential injection against Kerberos-enforced targets." + "description": "Ignored. Kerberos credential injection is always available when provisioned." }, "enable_unstable": { "type": "boolean", diff --git a/devolutions-gateway/openapi/doc/index.adoc b/devolutions-gateway/openapi/doc/index.adoc index 6910ffa17..b40865063 100644 --- a/devolutions-gateway/openapi/doc/index.adoc +++ b/devolutions-gateway/openapi/doc/index.adoc @@ -4445,7 +4445,7 @@ Current auto-update schedule for Devolutions Agent. | | X | Integer -| Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. +| Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | int32 | token diff --git a/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md b/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md index 4c16436b1..ba5977cc9 100644 --- a/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md +++ b/devolutions-gateway/openapi/dotnet-client/docs/PreflightOperation.md @@ -10,8 +10,7 @@ Name | Type | Description | Notes **Kind** | **PreflightOperationKind** | | **ProxyCredential** | [**AppCredential**](AppCredential.md) | | [optional] **TargetCredential** | [**AppCredential**](AppCredential.md) | | [optional] -**TimeToLive** | **int?** | Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | [optional] +**TimeToLive** | **int?** | Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | [optional] **Token** | **string** | The token to be stored on the proxy-side. Required for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - diff --git a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs index 59212a095..e0fbcd573 100644 --- a/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs +++ b/devolutions-gateway/openapi/dotnet-client/src/Devolutions.Gateway.Client/Model/PreflightOperation.cs @@ -53,7 +53,7 @@ protected PreflightOperation() { } /// kind (required). /// proxyCredential. /// targetCredential. - /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds.. + /// Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds.. /// The token to be stored on the proxy-side. Required for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds.. public PreflightOperation(TargetConnectionOptions connectionOptions = default(TargetConnectionOptions), string hostToResolve = default(string), Guid id = default(Guid), PreflightOperationKind kind = default(PreflightOperationKind), AppCredential proxyCredential = default(AppCredential), AppCredential targetCredential = default(AppCredential), int? timeToLive = default(int?), string token = default(string)) { @@ -100,9 +100,9 @@ protected PreflightOperation() { } public AppCredential TargetCredential { get; set; } /// - /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. + /// Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. /// - /// Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. + /// Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. [DataMember(Name = "time_to_live", EmitDefaultValue = true)] public int? TimeToLive { get; set; } diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index 851fdd6af..008e0d910 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -1837,7 +1837,11 @@ components: type: integer format: int32 description: |- - Minimum persistence duration in seconds for the data provisioned via this operation. + Retention duration in seconds for data provisioned by this operation. + + For "provision-credentials", this is the maximum staging time before the first credential + checkout. After checkout, Gateway retains the credentials for later connections authorized + for the same association. Optional parameter for "provision-token", "provision-credentials", and "provision-connection-options" kinds. diff --git a/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts b/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts index 76d51046a..b06048ed3 100644 --- a/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts +++ b/devolutions-gateway/openapi/ts-angular-client/model/preflightOperation.ts @@ -26,7 +26,7 @@ export interface PreflightOperation { proxy_credential?: AppCredential | null; target_credential?: AppCredential | null; /** - * Minimum persistence duration in seconds for the data provisioned via this operation. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. + * Retention duration in seconds for data provisioned by this operation. For \"provision-credentials\", this is the maximum staging time before the first credential checkout. After checkout, Gateway retains the credentials for later connections authorized for the same association. Optional parameter for \"provision-token\", \"provision-credentials\", and \"provision-connection-options\" kinds. */ time_to_live?: number | null; /** diff --git a/devolutions-gateway/src/api/kdc_proxy.rs b/devolutions-gateway/src/api/kdc_proxy.rs index 865fc369f..5b3c07df0 100644 --- a/devolutions-gateway/src/api/kdc_proxy.rs +++ b/devolutions-gateway/src/api/kdc_proxy.rs @@ -2,12 +2,10 @@ use axum::Router; use axum::extract::State; use axum::routing::post; use picky_krb::messages::KdcProxyMessage; -use uuid::Uuid; use crate::DgwState; -use crate::credential_injection_kdc::{ - CredentialInjectionKdcInterception, CredentialInjectionKdcRequest, CredentialInjectionKdcResolveError, - kdc_proxy_message_realm, +use crate::credential_injection::{ + CredentialInjectionKdcInterception, CredentialInjectionKdcRequest, kdc_proxy_message_realm, }; use crate::extract::KdcToken; use crate::http::HttpError; @@ -22,7 +20,7 @@ pub fn make_router(state: DgwState) -> Router { async fn kdc_proxy( State(DgwState { conf_handle, - credentials, + synthetic_kdc_registry, agent_tunnel_handle, .. }): State, @@ -45,9 +43,9 @@ async fn kdc_proxy( match destination { KdcDestination::Inject { jti } => { - enforce_credential_injection_enabled(jti, conf.debug.enable_unstable)?; - - let kdc = credentials.kdc_for(jti).map_err(credential_injection_resolve_error)?; + let kdc = synthetic_kdc_registry + .get(jti) + .ok_or_else(|| HttpError::bad_request().msg("no live synthetic KDC published for this session"))?; debug!( jti = %kdc.jti(), @@ -92,17 +90,6 @@ async fn kdc_proxy( } } -fn credential_injection_resolve_error(error: CredentialInjectionKdcResolveError) -> HttpError { - match error { - CredentialInjectionKdcResolveError::BuildKdcConfig { .. } => HttpError::internal() - .with_msg("credential-injection KDC could not be initialized") - .build(error), - _ => HttpError::bad_request() - .with_msg("credential-injection state is not available") - .build(error), - } -} - // Forwards the request to the real KDC indicated by the token (or by the debug override) and // returns the response wrapped as a `KdcProxyMessage`. // @@ -142,18 +129,6 @@ async fn forward_to_real_kdc( reply.to_vec().map_err(HttpError::internal().err()) } -fn enforce_credential_injection_enabled(jet_cred_id: Uuid, enable_unstable: bool) -> Result<(), HttpError> { - if enable_unstable { - return Ok(()); - } - - warn!( - %jet_cred_id, - "Credential-injection KDC token rejected because unstable Kerberos injection is disabled" - ); - Err(HttpError::bad_request().msg("credential-injection KDC proxy is not enabled")) -} - /// Refuses to forward a KDC request whose realm disagrees with the realm the token was issued for. /// /// `bypass=true` (only when `__debug__.disable_token_validation` is on) downgrades the mismatch @@ -193,20 +168,9 @@ mod tests { #[test] fn enforce_realm_mismatch_passes_under_bypass() { - // `bypass=true` is the `__debug__.disable_token_validation` downgrade. CBenoit asked - // for explicit coverage of this branch because it is the only place the realm - // authorization is intentionally weakened, and slipping the gate (e.g. by inverting the - // condition) would only surface in production. + // `bypass=true` is the `__debug__.disable_token_validation` downgrade. + // This is the only branch where realm authorization is intentionally weakened, so pin it + // explicitly to catch an inverted gate. assert!(enforce_realm_token_match("ad.example", "evil.example", true).is_ok()); } - - #[test] - fn credential_injection_gate_allows_jet_cred_id_when_enabled() { - assert!(enforce_credential_injection_enabled(Uuid::new_v4(), true).is_ok()); - } - - #[test] - fn credential_injection_gate_rejects_jet_cred_id_when_disabled() { - assert!(enforce_credential_injection_enabled(Uuid::new_v4(), false).is_err()); - } } diff --git a/devolutions-gateway/src/api/preflight.rs b/devolutions-gateway/src/api/preflight.rs index 5598d44ec..1477f737d 100644 --- a/devolutions-gateway/src/api/preflight.rs +++ b/devolutions-gateway/src/api/preflight.rs @@ -11,10 +11,9 @@ use uuid::Uuid; use crate::DgwState; use crate::config::Conf; -use crate::credential_injection_kdc::CredentialService; use crate::extract::PreflightScope; use crate::http::HttpError; -use crate::provisioning::InsertError; +use crate::provisioning::{InsertError, ProvisioningStore}; use crate::session::SessionMessageSender; const OP_GET_VERSION: &str = "get-version"; @@ -204,7 +203,7 @@ pub(super) async fn post_preflight( State(DgwState { conf_handle, sessions, - credentials, + provisioning, .. }): State, _scope: PreflightScope, @@ -231,13 +230,13 @@ pub(super) async fn post_preflight( let outputs = outputs.clone(); let conf = conf_handle.get_conf(); let sessions = sessions.clone(); - let credentials = credentials.clone(); + let provisioning = provisioning.clone(); async move { let operation_id = operation.id; trace!(%operation.id, "Process preflight operation"); - if let Err(error) = handle_operation(operation, &outputs, &conf, &sessions, &credentials).await { + if let Err(error) = handle_operation(operation, &outputs, &conf, &sessions, &provisioning).await { outputs.push(PreflightOutput { operation_id, kind: PreflightOutputKind::Alert { @@ -264,7 +263,7 @@ async fn handle_operation( outputs: &Outputs, conf: &Conf, sessions: &SessionMessageSender, - credentials: &CredentialService, + provisioning: &ProvisioningStore, ) -> Result<(), PreflightError> { match operation.kind.as_str() { OP_GET_VERSION => outputs.push(PreflightOutput { @@ -355,7 +354,7 @@ async fn handle_operation( })?; } - let replaced = credentials + let replaced = provisioning .insert_credentials(token, mapping, time_to_live) .inspect_err(|error| warn!(%operation.id, error = format!("{error:#}"), "Failed to insert credentials")) .map_err(|error| match error { @@ -397,7 +396,7 @@ async fn handle_operation( PreflightError::new(PreflightAlertStatus::InvalidParams, format!("invalid token: {error:#}")) })?; - let replaced = credentials.insert_connection_options(jti, connection_options, time_to_live); + let replaced = provisioning.insert_connection_options(jti, connection_options, time_to_live); if replaced { outputs.push(PreflightOutput { diff --git a/devolutions-gateway/src/api/rdp.rs b/devolutions-gateway/src/api/rdp.rs index b3d45dbcb..29e5d161e 100644 --- a/devolutions-gateway/src/api/rdp.rs +++ b/devolutions-gateway/src/api/rdp.rs @@ -25,7 +25,8 @@ pub async fn handler( subscriber_tx, recordings, shutdown_signal, - credentials, + provisioning, + synthetic_kdc_registry, agent_tunnel_handle, .. }): State, @@ -46,7 +47,8 @@ pub async fn handler( subscriber_tx, recordings.active_recordings, source_addr, - credentials, + provisioning, + synthetic_kdc_registry, agent_tunnel_handle, ) .instrument(span) @@ -66,7 +68,8 @@ async fn handle_socket( subscriber_tx: SubscriberSender, active_recordings: Arc, source_addr: SocketAddr, - credentials: crate::credential_injection_kdc::CredentialService, + provisioning: crate::provisioning::ProvisioningStore, + synthetic_kdc_registry: crate::credential_injection::SyntheticKdcRegistry, agent_tunnel_handle: Option>, ) { let (stream, close_handle) = crate::ws::handle( @@ -84,7 +87,8 @@ async fn handle_socket( sessions, subscriber_tx, &active_recordings, - &credentials, + &provisioning, + &synthetic_kdc_registry, agent_tunnel_handle, ) .await; diff --git a/devolutions-gateway/src/config.rs b/devolutions-gateway/src/config.rs index f5d0de0a1..7faae0212 100644 --- a/devolutions-gateway/src/config.rs +++ b/devolutions-gateway/src/config.rs @@ -1418,12 +1418,7 @@ pub mod dto { #[serde(default = "ws_keep_alive_interval_default_value")] pub ws_keep_alive_interval: u64, - /// Enable proxy-based RDP credential injection against Kerberos-enforced targets - /// - /// Turns on the in-process KDC acceptor the Gateway presents to the client when injecting - /// credentials for accounts that can't fall back to NTLM (e.g. AD Protected Users). - /// Target-side KDC routing is not configured here. Off by default; still requires - /// `enable_unstable`. + /// Ignored. Kerberos credential injection is stable and no longer gated here. #[serde(default)] pub kerberos_credential_injection: bool, diff --git a/devolutions-gateway/src/credential/INTENT.md b/devolutions-gateway/src/credential/INTENT.md new file mode 100644 index 000000000..7d09e5904 --- /dev/null +++ b/devolutions-gateway/src/credential/INTENT.md @@ -0,0 +1,56 @@ +# Intention: + + +## Context and terminology + +Logical Session: a logical session is defined when a connection reaches Gateway and is authenticated with the association token. + +```rust +pub struct AssociationTokenClaims { + pub jet_aid: Uuid, + + .. + pub jet_ttl: SessionTtl, + + pub jet_reuse: ReconnectionPolicy, + pub exp: i64, + .. + pub jti: Uuid, +} +``` + +Injected credentials: injected credentials are the actual credentials sent by the provisioner (DVLS) to Gateway, which are used later by a logical session to serve the purpose of granting access to a client without exposing the actual credentials. + +Checkout: When an injected credential has arrived and is sitting in memory, and the association token arrives at Gateway and the lookup of the injected credential is successful, we consider the injected credential checked out by the logical session. + +Staging/Stage: when an injected credential arrives at Gateway but checkout has not happened yet, we consider the injected credential to be in staging. + +Remove/Eject: remove and eject here specifically mean actively removing the injected credentials/materials from memory and making sure they will not be accessible on a best-effort basis. + +## Decisions +1. Credential-injection support must follow the lifecycle of its logical session. + +A logical session is established when Gateway accepts its association token for the initial connection. + +As long as Gateway would authorize an initial connection or reconnect for that logical session, the same connection must remain possible with credential injection. + +When Gateway can no longer authorize any connection or reconnect for that logical session, it must immediately remove all credential-injection material owned by the session. + +This DOES NOT mean that the injected credentials should live as long as the logical session continues. +A session's lifetime is defined by `jet_ttl`, but whether it can establish a connection or reconnection is defined by `jet_reuse` and `exp`. +The injected credentials should be removed when the session can no longer establish a connection or reconnection. + +2. Provisioning for the same JTI should be permitted, but the policy for different kinds of provisioning should be defined on a per-kind basis. +For credential injection, the policy is that the old injected credentials should be removed when new injected credentials are provisioned for the same JTI. + +3. If a connection requires credential injection but its required credentials are not available, the connection should fail immediately. +The connection should not continue without the required injection support. +An association token does not identify whether credential injection is required, so this rule only applies while Gateway still has credential-injection state for the JTI. + +4. The injected credentials naturally arrive earlier than the connection that uses them. +The second half (checked out) of the lifetime of the injected credentials is defined in 1); we define the staging lifetime of the injected credentials here: + +The amount of time that the injected credentials can stay in staging is defined by the provisioning TTL, which is supplied by the provisioner through the preflight provisioning operation. +When the provisioning TTL expires, Gateway must actively remove the staged material from memory. + +5. A synthetic KDC should have only one instance per JTI at all times. diff --git a/devolutions-gateway/src/credential_injection.rs b/devolutions-gateway/src/credential_injection.rs new file mode 100644 index 000000000..1b3f8deb2 --- /dev/null +++ b/devolutions-gateway/src/credential_injection.rs @@ -0,0 +1,1287 @@ +//! Credential-injection runtime for RDP. +//! +//! - Provisioned mappings live in [`crate::provisioning::ProvisioningStore`]. +//! - [`CredentialInjection::from_provisioned`] builds a session-scoped injection plan. +//! - Kerberos sessions reuse one synthetic KDC per provisioning generation, then publish a +//! [`CredentialInjectionKdc`] into [`SyntheticKdcRegistry`] for the connection. +//! - `/jet/KdcProxy` resolves only that registry (not the provisioning store). + +use std::collections::HashMap; +use std::fmt; +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 tokio::sync::Notify; +use url::Url; +use uuid::Uuid; +use zeroize::Zeroize as _; + +use crate::credential::{AppCredential, AppCredentialMapping}; +use crate::provisioning::{ProvisioningEntry, ProvisioningStore}; + +// The reserved `.invalid` TLD (RFC 6761) lets sspi-rs CredSSP server emit "KDC requests" that +// never leave the process: `intercept_network_request` recognises this hostname and dispatches +// the message into the in-process `kdc` server below. +// +// TODO(sspi-rs#664): replace this URL-trampoline with a pluggable KDC dispatcher trait once +// sspi-rs ships the API — see https://github.com/Devolutions/sspi-rs/issues/664. +const IN_PROCESS_KDC_HOST: &str = "cred.invalid"; + +/// In-process synthetic KDC for one Kerberos credential-injection session. +/// +/// Published to [`SyntheticKdcRegistry`] for `/jet/KdcProxy`. Holds only what the fake KDC and +/// CredSSP server-leg intercept need — not proxy/target passwords or routing bags. +pub(crate) struct CredentialInjectionKdc { + jti: Uuid, + target_hostname: String, + realm: String, + acceptor_principal_name: String, + acceptor_password: SecretString, + acceptor_long_term_key: SecretBox>, + // Built once from acceptor + proxy material; kdc crate API takes this by ref on each message. + kdc_config: kdc::config::KerberosServer, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("expected: {expected}, got: {actual}")] +pub(crate) struct RealmMismatch { + pub(crate) expected: String, + pub(crate) actual: String, +} + +#[derive(Debug)] +pub(crate) enum CredentialInjectionKdcInterception { + Intercepted(Vec), + NotInjectionRequest, + NotInjectionRealm(RealmMismatch), +} + +/// Session-scoped credential injection. Holding [`CredentialInjection::Kerberos`] proves the +/// synthetic KDC is registered in [`SyntheticKdcRegistry`] for this connection. +/// +/// Build path: [`CredentialInjection::from_provisioned`] → [`PreparedCredentialInjection`] → +/// [`PreparedCredentialInjection::register_if_kerberos`]. +pub(crate) enum CredentialInjection { + Kerberos( + KerberosCredentialInjection, + #[expect(dead_code, reason = "RAII lease: Drop unpublishes the synthetic KDC")] SyntheticKdcRegistration, + ), + Ntlm(NtlmCredentialInjection), +} + +/// Kerberos injection: credentials, target KDC URL, and the session synthetic KDC. +pub(crate) struct KerberosCredentialInjection { + credential_mapping: AppCredentialMapping, + session: KerberosSessionMaterial, +} + +#[derive(Debug, Clone)] +struct KerberosSessionMaterial { + target_kdc: Url, + synthetic: Arc, +} + +/// Protocol chosen; synthetic KDC built when Kerberos, not yet published to the registry. +#[derive(Debug)] +pub(crate) enum PreparedCredentialInjection { + Kerberos(KerberosCredentialInjection), + Ntlm(NtlmCredentialInjection), +} + +impl PreparedCredentialInjection { + /// Publish the synthetic KDC when this is Kerberos; NTLM is a no-op pass-through. + pub(crate) fn register_if_kerberos( + self, + registry: &SyntheticKdcRegistry, + provision_generation: u64, + ) -> CredentialInjection { + match self { + Self::Kerberos(injection) => { + let registration = registry.register(Arc::clone(&injection.session.synthetic), provision_generation); + debug!( + jti = %injection.session.synthetic.jti(), + "Registered synthetic KDC for credential-injection session" + ); + CredentialInjection::Kerberos(injection, registration) + } + Self::Ntlm(injection) => CredentialInjection::Ntlm(injection), + } + } +} + +impl KerberosCredentialInjection { + pub(crate) fn synthetic_kdc(&self) -> &CredentialInjectionKdc { + &self.session.synthetic + } + + pub(crate) fn target_kdc(&self) -> &Url { + &self.session.target_kdc + } +} + +impl fmt::Debug for KerberosCredentialInjection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("KerberosCredentialInjection") + .field("target_kdc", &self.session.target_kdc) + .field("synthetic", &self.session.synthetic) + .finish_non_exhaustive() + } +} + +/// NTLM injection carries credentials only — no synthetic KDC is published. +#[derive(Debug)] +pub(crate) struct NtlmCredentialInjection { + jti: Uuid, + credential_mapping: AppCredentialMapping, +} + +impl NtlmCredentialInjection { + pub(crate) fn jti(&self) -> Uuid { + self.jti + } + + pub(crate) fn proxy_credential(&self) -> &AppCredential { + &self.credential_mapping.proxy + } + + pub(crate) fn target_credential(&self) -> &AppCredential { + &self.credential_mapping.target + } +} + +impl CredentialInjection { + pub(crate) fn checkout( + provisioning: &ProvisioningStore, + registry: &SyntheticKdcRegistry, + jti: Uuid, + token: &str, + kerberos_enabled: bool, + ) -> anyhow::Result { + let entry = provisioning + .get_mapping(jti, token) + .with_context(|| format!("checkout credential-injection material for {jti}"))?; + let generation = entry.generation; + let kdc_expires_at = entry.kdc_expires_at; + registry.discard_stale_session_kdc(jti, generation); + let session = registry.session_kerberos_material(jti, generation); + let prepared = Self::from_provisioned_with_session(jti, entry, kerberos_enabled, session)?; + let prepared = match prepared { + PreparedCredentialInjection::Kerberos(mut injection) => { + let expires_at = kdc_expires_at.context("mapped Kerberos row has no token deadline")?; + injection.session = registry.intern_session_kerberos(jti, generation, expires_at, injection.session); + PreparedCredentialInjection::Kerberos(injection) + } + ntlm @ PreparedCredentialInjection::Ntlm(_) => ntlm, + }; + Ok(prepared.register_if_kerberos(registry, generation)) + } + + pub(crate) fn jti(&self) -> Uuid { + match self { + Self::Kerberos(k, _) => k.session.synthetic.jti(), + Self::Ntlm(ntlm) => ntlm.jti(), + } + } + + pub(crate) fn proxy_credential(&self) -> &AppCredential { + match self { + Self::Kerberos(k, _) => &k.credential_mapping.proxy, + Self::Ntlm(ntlm) => ntlm.proxy_credential(), + } + } + + pub(crate) fn target_credential(&self) -> &AppCredential { + match self { + Self::Kerberos(k, _) => &k.credential_mapping.target, + Self::Ntlm(ntlm) => ntlm.target_credential(), + } + } + + pub(crate) fn as_kerberos(&self) -> Option<&KerberosCredentialInjection> { + match self { + Self::Kerberos(k, _) => Some(k), + Self::Ntlm(_) => None, + } + } + + pub(crate) fn uses_kerberos(&self) -> bool { + matches!(self, Self::Kerberos(_, _)) + } + + /// Build a session injection plan from a checked-out provisioning entry. + /// + /// Does not publish to [`SyntheticKdcRegistry`]; call + /// [`PreparedCredentialInjection::register_if_kerberos`] next. + #[cfg(test)] + pub(crate) fn from_provisioned( + jti: Uuid, + credential_entry: ProvisioningEntry, + kerberos_enabled: bool, + ) -> anyhow::Result { + Self::from_provisioned_with_session(jti, credential_entry, kerberos_enabled, None) + } + + fn from_provisioned_with_session( + jti: Uuid, + credential_entry: ProvisioningEntry, + kerberos_enabled: bool, + session: Option, + ) -> anyhow::Result { + let ProvisioningEntry { + token, + mapping, + connection_options, + generation: _, + kdc_expires_at: _, + } = credential_entry; + + let mapping = mapping.context("credential-injection state has no mapping")?; + + let target_hostname = crate::token::extract_credential_injection_target_hostname(&token) + .with_context(|| format!("association token for {jti} is not valid for credential injection"))?; + + let target_username = app_credential_username(&mapping.target); + if !select_kerberos_for_target(kerberos_enabled, target_username) { + return Ok(PreparedCredentialInjection::Ntlm(NtlmCredentialInjection { + jti, + credential_mapping: mapping, + })); + } + + if let Some(session) = session { + return Ok(PreparedCredentialInjection::Kerberos(KerberosCredentialInjection { + credential_mapping: mapping, + session, + })); + } + + // Kerberos path: username must parse (select_kerberos_for_target already required a domain). + sspi::Username::parse(target_username) + .with_context(|| format!("invalid target credential username for credential-injection session {jti}"))?; + + let target_kdc = connection_options + .as_ref() + .and_then(|o| o.krb_kdc()) + .cloned() + .with_context(|| { + format!("Kerberos credential injection requires target connection option krb_kdc for {jti}") + })?; + + let proxy_username = app_credential_username(&mapping.proxy).to_owned(); + let synthetic = CredentialInjectionKdc::new(jti, target_hostname, &proxy_username, &mapping.proxy) + .with_context(|| format!("credential-injection KDC config could not be initialized for {jti}"))?; + + Ok(PreparedCredentialInjection::Kerberos(KerberosCredentialInjection { + credential_mapping: mapping, + session: KerberosSessionMaterial { + target_kdc, + synthetic: Arc::new(synthetic), + }, + })) + } +} + +/// Unstable debug opt-in for Kerberos credential injection (both legs). +/// Whether the target username should use Kerberos injection (otherwise NTLM). +pub(crate) fn select_kerberos_for_target(kerberos_enabled: bool, target_username: &str) -> bool { + if !kerberos_enabled { + return false; + } + sspi::Username::parse(target_username) + .ok() + .is_some_and(|username| username.domain_name().is_some()) +} + +pub(crate) struct CredentialInjectionKdcRequest { + message: KdcProxyMessage, +} + +impl CredentialInjectionKdcRequest { + pub(crate) fn from_token(message: KdcProxyMessage) -> Self { + Self { message } + } + + fn in_process(message: KdcProxyMessage) -> Self { + Self { message } + } +} + +impl fmt::Debug for CredentialInjectionKdc { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CredentialInjectionKdc") + .field("jti", &self.jti) + .field("target_hostname", &self.target_hostname) + .field("realm", &self.realm) + .field("kdc_config", &"") + .finish() + } +} + +impl Drop for CredentialInjectionKdc { + fn drop(&mut self) { + zeroize_kdc_config(&mut self.kdc_config); + } +} + +impl CredentialInjectionKdc { + fn new( + jti: Uuid, + target_hostname: String, + proxy_username: &str, + proxy_credential: &AppCredential, + ) -> anyhow::Result { + let realm = realm_from_proxy_username(proxy_username, jti); + let acceptor_principal_name = "jet".to_owned(); + let acceptor_password = SecretString::from(hex::encode(random_32_bytes())); + let acceptor_long_term_key = SecretBox::new(Box::new(random_32_bytes())); + let krbtgt_key = SecretBox::new(Box::new(random_32_bytes())); + + let kdc_config = build_kdc_config( + &realm, + proxy_credential, + &acceptor_principal_name, + acceptor_password.expose_secret(), + krbtgt_key.expose_secret(), + acceptor_long_term_key.expose_secret(), + )?; + + Ok(Self { + jti, + target_hostname, + realm, + acceptor_principal_name, + acceptor_password, + acceptor_long_term_key, + kdc_config, + }) + } + + pub(crate) fn jti(&self) -> Uuid { + self.jti + } + + /// Session destination host from association `dst_hst` (not Gateway `conf.hostname`). + /// + /// Supported clients retain this logical destination when forming their `TERMSRV` SPN, even + /// when the transport endpoint is a Gateway listener. + /// Clients that derive the SPN from the Gateway transport hostname are not supported by the + /// unstable Kerberos credential-injection path. + pub(crate) fn target_hostname(&self) -> &str { + &self.target_hostname + } + + pub(crate) fn server_kerberos_config(&self, client_addr: SocketAddr) -> anyhow::Result { + let user = sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8( + &self.acceptor_principal_name, + &self.realm, + self.acceptor_password.expose_secret(), + )); + + let kdc_url = self.in_process_kdc_url()?; + + // Client AP-REQ SPN is TERMSRV/. Gateway-as-CredSSP-server impersonates that + // session destination, so ServerProperties must claim the same SPN. + Ok(sspi::KerberosServerConfig { + kerberos_config: sspi::KerberosConfig { + kdc_url: Some(kdc_url), + client_computer_name: client_addr.to_string(), + }, + server_properties: sspi::kerberos::ServerProperties::new( + &["TERMSRV", &self.target_hostname], + Some(user), + Duration::from_secs(300), + Some(sspi::Secret::new(self.acceptor_long_term_key.expose_secret().clone())), + )?, + }) + } + + pub(crate) fn intercept_network_request( + &self, + request: &NetworkRequest, + ) -> anyhow::Result { + if request.url.host_str() != Some(IN_PROCESS_KDC_HOST) { + return Ok(CredentialInjectionKdcInterception::NotInjectionRequest); + } + + let url_jti = request + .url + .path() + .trim_start_matches('/') + .parse::() + .context("malformed in-process KDC URL")?; + anyhow::ensure!( + url_jti == self.jti, + "in-process KDC URL JTI does not match current CredSSP session", + ); + + debug!( + jti = %self.jti, + scheme = %request.url.scheme(), + "Credential-injection KDC intercepted in-process request" + ); + + let kdc_message = KdcProxyMessage::from_raw(&request.data).context("malformed in-process KDC proxy payload")?; + self.handle_kdc_proxy_request(CredentialInjectionKdcRequest::in_process(kdc_message)) + } + + pub(crate) fn handle_kdc_proxy_request( + &self, + request: CredentialInjectionKdcRequest, + ) -> anyhow::Result { + let request_realm = self.resolve_message_realm(&request.message); + debug!( + jti = %self.jti, + resolved_realm = %request_realm, + "Credential-injection KDC realm resolved" + ); + + if let Some(mismatch) = realm_mismatch(&self.realm, &request_realm) { + return Ok(CredentialInjectionKdcInterception::NotInjectionRealm(mismatch)); + } + + let reply = self.handle_message(request.message)?; + Ok(CredentialInjectionKdcInterception::Intercepted(reply)) + } + + fn in_process_kdc_url(&self) -> anyhow::Result { + Url::parse(&format!("http://{}/{}", IN_PROCESS_KDC_HOST, self.jti)).context("build in-process KDC URL") + } + + fn resolve_message_realm(&self, kdc_proxy_message: &KdcProxyMessage) -> String { + kdc_proxy_message_realm(kdc_proxy_message).unwrap_or_else(|| self.realm.clone()) + } + + fn handle_message(&self, kdc_proxy_message: KdcProxyMessage) -> anyhow::Result> { + let reply = kdc::handle_kdc_proxy_message(kdc_proxy_message, &self.kdc_config, &self.target_hostname) + .context("handle credential-injection KDC message")?; + + reply.to_vec().context("encode credential-injection KDC reply") + } +} + +fn app_credential_username(credential: &AppCredential) -> &str { + match credential { + AppCredential::UsernamePassword { username, password: _ } => username, + } +} + +pub(crate) fn kdc_proxy_message_realm(kdc_proxy_message: &KdcProxyMessage) -> Option { + kdc_proxy_message + .target_domain + .0 + .as_ref() + .map(|realm| realm.0.to_string()) + .filter(|realm| !realm.is_empty()) +} + +fn realm_mismatch(expected: &str, actual: &str) -> Option { + if expected.eq_ignore_ascii_case(actual) { + None + } else { + Some(RealmMismatch { + expected: expected.to_owned(), + actual: actual.to_owned(), + }) + } +} + +fn realm_from_proxy_username(proxy_username: &str, jti: Uuid) -> String { + proxy_username + .split_once('@') + .map(|(_, realm)| realm) + .filter(|realm| !realm.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| synthetic_realm(jti)) +} + +fn build_kdc_config( + realm: &str, + proxy_credential: &AppCredential, + acceptor_principal_name: &str, + acceptor_password: &str, + krbtgt_key: &[u8], + acceptor_long_term_key: &[u8], +) -> anyhow::Result { + let (proxy_user_name, proxy_password) = proxy_credential.decrypt_password()?; + let proxy_user_name = principal_for_realm(&proxy_user_name, realm); + let acceptor_principal_name = principal_for_realm(acceptor_principal_name, realm); + + Ok(kdc::config::KerberosServer { + realm: realm.to_owned(), + users: vec![ + kdc::config::DomainUser { + username: proxy_user_name.clone(), + password: proxy_password.expose_secret().to_owned(), + salt: kerberos_salt(realm, &proxy_user_name), + }, + kdc::config::DomainUser { + username: acceptor_principal_name.clone(), + password: acceptor_password.to_owned(), + salt: kerberos_salt(realm, &acceptor_principal_name), + }, + ], + max_time_skew: 300, + krbtgt_key: krbtgt_key.to_vec(), + ticket_decryption_key: Some(acceptor_long_term_key.to_vec()), + service_user: Some(kdc::config::DomainUser { + username: acceptor_principal_name.clone(), + password: acceptor_password.to_owned(), + salt: kerberos_salt(realm, &acceptor_principal_name), + }), + }) +} + +fn zeroize_kdc_config(config: &mut kdc::config::KerberosServer) { + for user in &mut config.users { + user.password.zeroize(); + } + config.krbtgt_key.zeroize(); + if let Some(key) = &mut config.ticket_decryption_key { + key.zeroize(); + } + if let Some(user) = &mut config.service_user { + user.password.zeroize(); + } +} + +fn principal_for_realm(user_name: &str, realm: &str) -> String { + if user_name.contains('@') { + user_name.to_owned() + } else { + format!("{user_name}@{realm}") + } +} + +fn kerberos_salt(realm: &str, principal: &str) -> String { + let local_name = principal.split('@').next().unwrap_or(principal); + format!("{}{local_name}", realm.to_ascii_uppercase()) +} + +fn synthetic_realm(jti: Uuid) -> String { + format!("CRED-{}.INVALID", jti.simple()).to_ascii_uppercase() +} + +fn random_32_bytes() -> Vec { + let mut bytes = vec![0u8; 32]; + OsRng.fill_bytes(&mut bytes); + bytes +} + +/// Live synthetic KDCs published by active RDP credential-injection sessions. +/// +/// - The RDP path registers when a Kerberos injection session starts. +/// - `/jet/KdcProxy` only looks up published entries; it never builds a KDC from +/// [`crate::provisioning::ProvisioningStore`]. +/// +/// Connection leases publish to `/jet/KdcProxy`. The same provisioning generation is +/// reference-counted; a newer generation replaces an older one. An older lease cannot unpublish +/// or overwrite a newer generation. +#[derive(Debug, Clone)] +pub struct SyntheticKdcRegistry { + inner: Arc>, + cleanup_notify: Arc, +} + +#[derive(Debug, Default)] +struct RegistryInner { + live: HashMap, + session: HashMap, +} + +#[derive(Debug, Clone)] +struct PublishedSyntheticKdc { + provision_generation: u64, + leases: u32, + kdc: Arc, +} + +#[derive(Debug, Clone)] +struct SessionKerberosEntry { + provision_generation: u64, + expires_at: time::OffsetDateTime, + material: KerberosSessionMaterial, +} + +fn generation_is_newer(candidate: u64, than: u64) -> bool { + candidate != than && candidate.wrapping_sub(than) < than.wrapping_sub(candidate) +} + +/// RAII lease for a published synthetic KDC. +pub(crate) struct SyntheticKdcRegistration { + registry: SyntheticKdcRegistry, + jti: Uuid, + provision_generation: u64, +} + +impl Drop for SyntheticKdcRegistration { + fn drop(&mut self) { + let mut inner = self.registry.inner.lock(); + let Some(current) = inner.live.get_mut(&self.jti) else { + return; + }; + if current.provision_generation != self.provision_generation { + return; + } + current.leases = current.leases.saturating_sub(1); + if current.leases == 0 { + inner.live.remove(&self.jti); + debug!( + jti = %self.jti, + provision_generation = self.provision_generation, + "Unpublished synthetic KDC" + ); + } + } +} + +impl Default for SyntheticKdcRegistry { + fn default() -> Self { + Self::new() + } +} + +impl SyntheticKdcRegistry { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(RegistryInner::default())), + cleanup_notify: Arc::new(Notify::new()), + } + } + + pub(crate) fn register( + &self, + kdc: Arc, + provision_generation: u64, + ) -> SyntheticKdcRegistration { + let jti = kdc.jti(); + let mut inner = self.inner.lock(); + match inner.live.get_mut(&jti) { + Some(current) if current.provision_generation == provision_generation => { + current.leases = current.leases.saturating_add(1); + } + Some(current) if generation_is_newer(current.provision_generation, provision_generation) => {} + _ => { + inner.live.insert( + jti, + PublishedSyntheticKdc { + provision_generation, + leases: 1, + kdc, + }, + ); + debug!(%jti, provision_generation, "Published synthetic KDC"); + } + } + SyntheticKdcRegistration { + registry: self.clone(), + jti, + provision_generation, + } + } + + pub(crate) fn get(&self, jti: Uuid) -> Option> { + self.inner.lock().live.get(&jti).map(|entry| Arc::clone(&entry.kdc)) + } + + /// Drop an interned KDC older than this provisioning generation. + pub(crate) fn discard_stale_session_kdc(&self, jti: Uuid, provision_generation: u64) { + let mut inner = self.inner.lock(); + if inner + .session + .get(&jti) + .is_some_and(|entry| generation_is_newer(provision_generation, entry.provision_generation)) + { + inner.session.remove(&jti); + self.cleanup_notify.notify_one(); + } + } + + fn session_kerberos_material(&self, jti: Uuid, provision_generation: u64) -> Option { + let now = time::OffsetDateTime::now_utc(); + let mut inner = self.inner.lock(); + let entry = inner.session.get(&jti)?; + if now >= entry.expires_at { + inner.session.remove(&jti); + self.cleanup_notify.notify_one(); + return None; + } + (entry.provision_generation == provision_generation).then(|| entry.material.clone()) + } + + /// Reuse the Kerberos session material for this provisioning generation until `expires_at`. + /// + /// A later `provision-credentials` bumps the generation and replaces the cached KDC. + /// An older generation never overwrites a newer interned KDC. + fn intern_session_kerberos( + &self, + jti: Uuid, + provision_generation: u64, + expires_at: time::OffsetDateTime, + material: KerberosSessionMaterial, + ) -> KerberosSessionMaterial { + let now = time::OffsetDateTime::now_utc(); + let mut inner = self.inner.lock(); + if inner.session.get(&jti).is_some_and(|entry| now >= entry.expires_at) { + inner.session.remove(&jti); + } + if now >= expires_at { + if inner + .session + .get(&jti) + .is_some_and(|entry| entry.provision_generation == provision_generation) + { + inner.session.remove(&jti); + self.cleanup_notify.notify_one(); + } + return material; + } + if let Some(existing) = inner.session.get(&jti) { + if existing.provision_generation == provision_generation { + return existing.material.clone(); + } + if generation_is_newer(existing.provision_generation, provision_generation) { + return material; + } + } + inner.session.insert( + jti, + SessionKerberosEntry { + provision_generation, + expires_at, + material: material.clone(), + }, + ); + self.cleanup_notify.notify_one(); + material + } + + fn remove_expired_session_kdcs(&self, now: time::OffsetDateTime) { + self.inner.lock().session.retain(|_, entry| now < entry.expires_at); + } + + fn next_session_expiry(&self) -> Option { + self.inner.lock().session.values().map(|entry| entry.expires_at).min() + } + + #[cfg(test)] + pub(crate) fn session_kdc_live(&self, jti: Uuid) -> bool { + self.interned_kdc(jti).is_some() + } + + #[cfg(test)] + fn interned_kdc(&self, jti: Uuid) -> Option> { + let now = time::OffsetDateTime::now_utc(); + self.inner + .lock() + .session + .get(&jti) + .and_then(|entry| (now < entry.expires_at).then(|| Arc::clone(&entry.material.synthetic))) + } +} + +pub struct CleanupTask { + pub handle: SyntheticKdcRegistry, +} + +#[async_trait] +impl Task for CleanupTask { + type Output = anyhow::Result<()>; + + const NAME: &'static str = "synthetic KDC cleanup"; + + async fn run(self, shutdown_signal: ShutdownSignal) -> Self::Output { + cleanup_task(self.handle, shutdown_signal).await; + Ok(()) + } +} + +#[tracing::instrument(skip_all)] +async fn cleanup_task(handle: SyntheticKdcRegistry, mut shutdown_signal: ShutdownSignal) { + tracing::debug!("Task started"); + + loop { + let now = time::OffsetDateTime::now_utc(); + handle.remove_expired_session_kdcs(now); + + match handle.next_session_expiry() { + Some(deadline) => { + let delay = (deadline - now).try_into().unwrap_or_default(); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = handle.cleanup_notify.notified() => {} + _ = shutdown_signal.wait() => break, + } + } + None => { + tokio::select! { + _ = handle.cleanup_notify.notified() => {} + _ = shutdown_signal.wait() => break, + } + } + } + } + + tracing::debug!("Task terminated"); +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use ironrdp_connector::sspi::network_client::NetworkProtocol; + use secrecy::SecretString; + + use super::*; + use crate::credential::{CleartextAppCredential, CleartextAppCredentialMapping}; + use crate::target_connection_options::TargetConnectionOptions; + + fn cleartext_mapping_with_target_username(target_username: &str) -> CleartextAppCredentialMapping { + CleartextAppCredentialMapping { + proxy: CleartextAppCredential::UsernamePassword { + username: "proxy@example.invalid".to_owned(), + password: SecretString::from("pwd"), + }, + target: CleartextAppCredential::UsernamePassword { + username: target_username.to_owned(), + password: SecretString::from("pwd"), + }, + } + } + + fn unsigned_jws(payload: serde_json::Value) -> String { + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode(r#"{"alg":"RS256"}"#); + let payload = engine.encode(serde_json::to_vec(&payload).expect("payload serializes")); + let signature = engine.encode(b"signature"); + format!("{header}.{payload}.{signature}") + } + + fn association_token(jti: Uuid) -> String { + unsigned_jws(serde_json::json!({ + "jti": jti, + "dst_hst": "target.example:3389", + "exp": time::OffsetDateTime::now_utc().unix_timestamp() + 3600 + })) + } + + fn kdc_options() -> TargetConnectionOptions { + serde_json::from_value(serde_json::json!({ "krb_kdc": "tcp://dc.example:88" })).expect("options") + } + + fn stock_with_mapping(jti: Uuid, target_username: &str) -> ProvisioningStore { + let store = ProvisioningStore::new(); + store + .insert_credentials( + association_token(jti), + Some(cleartext_mapping_with_target_username(target_username)), + time::Duration::minutes(5), + ) + .expect("insert"); + store + } + + fn dummy_entry(jti: Uuid, target_username: &str) -> ProvisioningEntry { + stock_with_mapping(jti, target_username).take(jti).expect("entry") + } + + fn dummy_kdc(jti: Uuid) -> CredentialInjectionKdc { + let entry = dummy_entry(jti, "target"); + let mapping = entry.mapping.expect("mapping"); + CredentialInjectionKdc::new( + jti, + "target.example".to_owned(), + app_credential_username(&mapping.proxy), + &mapping.proxy, + ) + .expect("valid KDC") + } + + fn kerberos_material(kdc: Arc) -> KerberosSessionMaterial { + KerberosSessionMaterial { + target_kdc: Url::parse("tcp://dc.example:88").expect("url"), + synthetic: kdc, + } + } + + fn network_request(url: &str) -> NetworkRequest { + NetworkRequest { + protocol: NetworkProtocol::Http, + url: Url::parse(url).expect("url"), + data: Vec::new(), + } + } + + #[test] + fn select_kerberos_for_target_matrix() { + assert!(!select_kerberos_for_target(false, "user@CORP.EXAMPLE")); + assert!(!select_kerberos_for_target(true, "Administrator")); + assert!(!select_kerberos_for_target(true, "")); + assert!(select_kerberos_for_target(true, "user@CORP.EXAMPLE")); + assert!(select_kerberos_for_target(true, r"CORP\user")); + } + + #[test] + fn proxy_user_at_realm_is_used_as_realm() { + assert_eq!( + realm_from_proxy_username("proxy@example.invalid", Uuid::new_v4()), + "example.invalid" + ); + } + + #[test] + fn bare_proxy_username_yields_synthetic_realm() { + let jti = Uuid::new_v4(); + assert_eq!(realm_from_proxy_username("just-a-uuid", jti), synthetic_realm(jti)); + } + + #[test] + fn from_provisioned_selects_ntlm_when_kerberos_disabled() { + let jti = Uuid::new_v4(); + let entry = dummy_entry(jti, "administrator@example.invalid"); + let registry = SyntheticKdcRegistry::new(); + let injection = CredentialInjection::from_provisioned(jti, entry, false) + .expect("prepared") + .register_if_kerberos(®istry, 1); + assert!(!injection.uses_kerberos()); + assert!(registry.get(jti).is_none()); + } + + #[test] + fn from_provisioned_selects_ntlm_for_domainless_target() { + let jti = Uuid::new_v4(); + let entry = dummy_entry(jti, "Administrator"); + let registry = SyntheticKdcRegistry::new(); + let injection = CredentialInjection::from_provisioned(jti, entry, true) + .expect("prepared") + .register_if_kerberos(®istry, 1); + assert!(!injection.uses_kerberos()); + } + + #[test] + fn from_provisioned_requires_krb_kdc_for_kerberos() { + let jti = Uuid::new_v4(); + let entry = dummy_entry(jti, "administrator@example.invalid"); + let err = CredentialInjection::from_provisioned(jti, entry, true).expect_err("kdc"); + assert!(format!("{err:#}").contains("requires target connection option krb_kdc")); + } + + #[test] + fn from_provisioned_publishes_synthetic_kdc_for_kerberos() { + let jti = Uuid::new_v4(); + let store = stock_with_mapping(jti, "administrator@example.invalid"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let entry = store.take(jti).expect("entry"); + assert!(store.take(jti).is_none(), "test helper take removes the row"); + let registry = SyntheticKdcRegistry::new(); + let prepared = CredentialInjection::from_provisioned(jti, entry, true).expect("prepared"); + assert!(registry.get(jti).is_none(), "not published until register_if_kerberos"); + let injection = prepared.register_if_kerberos(®istry, 1); + assert!(injection.uses_kerberos()); + assert!(registry.get(jti).is_some()); + assert_eq!( + registry.get(jti).expect("live kdc").jti(), + injection.as_kerberos().expect("kerberos").synthetic_kdc().jti() + ); + } + + #[test] + fn checkout_reuses_synthetic_kdc_for_the_same_generation() { + let jti = Uuid::new_v4(); + let token = association_token(jti); + let store = ProvisioningStore::new(); + store + .insert_credentials( + token.clone(), + Some(cleartext_mapping_with_target_username("administrator@example.invalid")), + time::Duration::minutes(5), + ) + .expect("insert"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let registry = SyntheticKdcRegistry::new(); + + let first = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("first"); + let first_ptr = std::ptr::from_ref(first.as_kerberos().expect("kerberos").synthetic_kdc()); + drop(first); + + let second = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("second"); + let second_ptr = std::ptr::from_ref(second.as_kerberos().expect("kerberos").synthetic_kdc()); + assert_eq!(first_ptr, second_ptr); + + store + .insert_credentials( + token.clone(), + Some(cleartext_mapping_with_target_username("administrator@example.invalid")), + time::Duration::minutes(5), + ) + .expect("re-provision"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let third = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("third"); + let third_ptr = std::ptr::from_ref(third.as_kerberos().expect("kerberos").synthetic_kdc()); + assert_ne!(first_ptr, third_ptr); + } + + #[test] + fn checkout_reuses_kerberos_session_after_connection_options_expire() { + let jti = Uuid::new_v4(); + let token = association_token(jti); + let store = ProvisioningStore::new(); + store + .insert_credentials( + token.clone(), + Some(cleartext_mapping_with_target_username("administrator@example.invalid")), + time::Duration::minutes(5), + ) + .expect("insert"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let registry = SyntheticKdcRegistry::new(); + + let first_injection = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("first"); + let first = first_injection.as_kerberos().expect("kerberos"); + let first_kdc = std::ptr::from_ref(first.synthetic_kdc()); + let first_target_kdc = first.target_kdc().clone(); + drop(first_injection); + + store.insert_connection_options( + jti, + TargetConnectionOptions::new(Some("tcp://replacement.example:88")).expect("options"), + time::Duration::seconds(-1), + ); + + let second = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("reconnect"); + let second = second.as_kerberos().expect("kerberos"); + assert_eq!(std::ptr::from_ref(second.synthetic_kdc()), first_kdc); + assert_eq!(second.target_kdc(), &first_target_kdc); + } + + #[test] + fn interned_kdc_is_not_kept_past_deadline() { + let jti = Uuid::new_v4(); + let registry = SyntheticKdcRegistry::new(); + let kdc = Arc::new(dummy_kdc(jti)); + let deadline = time::OffsetDateTime::now_utc() + time::Duration::minutes(5); + registry.intern_session_kerberos(jti, 1, deadline, kerberos_material(kdc)); + registry.remove_expired_session_kdcs(deadline + time::Duration::seconds(1)); + assert!(!registry.session_kdc_live(jti)); + } + + #[test] + fn session_cache_expiry_keeps_active_registration() { + let jti = Uuid::new_v4(); + let registry = SyntheticKdcRegistry::new(); + let kdc = Arc::new(dummy_kdc(jti)); + let deadline = time::OffsetDateTime::now_utc() + time::Duration::minutes(5); + registry.intern_session_kerberos(jti, 1, deadline, kerberos_material(Arc::clone(&kdc))); + let registration = registry.register(Arc::clone(&kdc), 1); + + registry.remove_expired_session_kdcs(deadline + time::Duration::seconds(1)); + + assert!(!registry.session_kdc_live(jti)); + assert!(Arc::ptr_eq(®istry.get(jti).expect("active KDC"), &kdc)); + drop(registration); + assert!(registry.get(jti).is_none()); + } + + #[test] + fn zeroize_kdc_config_clears_secret_copies() { + let mut kdc = dummy_kdc(Uuid::new_v4()); + assert!(kdc.kdc_config.users.iter().any(|user| !user.password.is_empty())); + assert!(!kdc.kdc_config.krbtgt_key.is_empty()); + assert!( + kdc.kdc_config + .ticket_decryption_key + .as_ref() + .is_some_and(|key| !key.is_empty()) + ); + assert!( + kdc.kdc_config + .service_user + .as_ref() + .is_some_and(|user| !user.password.is_empty()) + ); + + zeroize_kdc_config(&mut kdc.kdc_config); + + assert!(kdc.kdc_config.users.iter().all(|user| user.password.is_empty())); + assert!(kdc.kdc_config.krbtgt_key.is_empty()); + assert!(kdc.kdc_config.ticket_decryption_key.as_ref().is_some_and(Vec::is_empty)); + assert!( + kdc.kdc_config + .service_user + .as_ref() + .is_some_and(|user| user.password.is_empty()) + ); + } + + #[test] + fn ntlm_checkout_discards_previous_generation_kdc() { + let jti = Uuid::new_v4(); + let token = association_token(jti); + let store = ProvisioningStore::new(); + store + .insert_credentials( + token.clone(), + Some(cleartext_mapping_with_target_username("administrator@example.invalid")), + time::Duration::minutes(5), + ) + .expect("insert"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let registry = SyntheticKdcRegistry::new(); + let _kerberos = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("kerberos"); + assert!(registry.session_kdc_live(jti)); + + store + .insert_credentials( + token.clone(), + Some(cleartext_mapping_with_target_username("Administrator")), + time::Duration::minutes(5), + ) + .expect("ntlm re-provision"); + let _ntlm = CredentialInjection::checkout(&store, ®istry, jti, &token, true).expect("ntlm"); + assert!(!registry.session_kdc_live(jti)); + } + + #[test] + fn provisioned_krb_kdc_is_carried_on_kerberos_injection() { + // Pins provision → from_provisioned → target_kdc for the CredSSP client leg. + let jti = Uuid::new_v4(); + let store = stock_with_mapping(jti, "administrator@example.invalid"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let entry = store.take(jti).expect("entry"); + let injection = CredentialInjection::from_provisioned(jti, entry, true) + .expect("prepared") + .register_if_kerberos(&SyntheticKdcRegistry::new(), 1); + + assert_eq!( + injection.as_kerberos().expect("kerberos").target_kdc().as_str(), + "tcp://dc.example:88", + "provisioned krb_kdc must be the URL CredSSP will use as kdc_proxy_url", + ); + } + + #[test] + fn from_provisioned_uses_association_dst_hst_for_synthetic_kdc() { + // Destination is dynamic per token. conf.hostname is Gateway identity only and must not + // drive synthetic KDC SPN / service host (deliberate correction of #1856). + let jti = Uuid::new_v4(); + let store = ProvisioningStore::new(); + store + .insert_credentials( + unsigned_jws(serde_json::json!({ + "jti": jti, + "dst_hst": "it-help-dc.corp.example:3389", + "exp": time::OffsetDateTime::now_utc().unix_timestamp() + 3600 + })), + Some(cleartext_mapping_with_target_username("administrator@example.invalid")), + time::Duration::minutes(5), + ) + .expect("insert"); + store.insert_connection_options(jti, kdc_options(), time::Duration::minutes(5)); + let entry = store.take(jti).expect("entry"); + let injection = CredentialInjection::from_provisioned(jti, entry, true) + .expect("prepared") + .register_if_kerberos(&SyntheticKdcRegistry::new(), 1); + + assert_eq!( + injection + .as_kerberos() + .expect("kerberos") + .synthetic_kdc() + .target_hostname(), + "it-help-dc.corp.example", + ); + } + + #[test] + fn older_registration_drop_keeps_reprovisioned_successor() { + let registry = SyntheticKdcRegistry::new(); + let jti = Uuid::new_v4(); + let first = Arc::new(dummy_kdc(jti)); + let first_registration = registry.register(Arc::clone(&first), 1); + assert!(Arc::ptr_eq(®istry.get(jti).expect("first"), &first)); + + let second = Arc::new(dummy_kdc(jti)); + let second_registration = registry.register(Arc::clone(&second), 2); + drop(first_registration); + assert!(Arc::ptr_eq(®istry.get(jti).expect("successor"), &second)); + + drop(second_registration); + assert!(registry.get(jti).is_none()); + } + + #[test] + fn same_generation_leases_unpublish_on_last_drop() { + let registry = SyntheticKdcRegistry::new(); + let jti = Uuid::new_v4(); + let kdc = Arc::new(dummy_kdc(jti)); + let first = registry.register(Arc::clone(&kdc), 1); + let second = registry.register(Arc::clone(&kdc), 1); + drop(first); + assert!(registry.get(jti).is_some()); + drop(second); + assert!(registry.get(jti).is_none()); + } + + #[test] + fn stale_generation_does_not_replace_interned_kdc() { + let jti = Uuid::new_v4(); + let registry = SyntheticKdcRegistry::new(); + let newer = Arc::new(dummy_kdc(jti)); + let older = Arc::new(dummy_kdc(jti)); + let deadline = time::OffsetDateTime::now_utc() + time::Duration::minutes(5); + let interned = registry.intern_session_kerberos(jti, 2, deadline, kerberos_material(Arc::clone(&newer))); + assert!(Arc::ptr_eq(&interned.synthetic, &newer)); + let rejected = registry.intern_session_kerberos(jti, 1, deadline, kerberos_material(Arc::clone(&older))); + assert!(Arc::ptr_eq(&rejected.synthetic, &older)); + assert!(Arc::ptr_eq(®istry.interned_kdc(jti).expect("kept"), &newer)); + registry.discard_stale_session_kdc(jti, 1); + assert!(Arc::ptr_eq(®istry.interned_kdc(jti).expect("still kept"), &newer)); + } + + #[test] + fn kdc_proxy_cannot_invent_from_provisioning_store() { + let jti = Uuid::new_v4(); + let _store = stock_with_mapping(jti, "administrator@example.invalid"); + let registry = SyntheticKdcRegistry::new(); + assert!(registry.get(jti).is_none()); + } + + #[test] + fn new_kdc_uses_jti_in_in_process_url() { + let jti = Uuid::new_v4(); + let kdc = dummy_kdc(jti); + let url = kdc.in_process_kdc_url().expect("url"); + assert!(url.path().contains(&jti.to_string())); + } + + #[test] + fn intercept_ignores_non_injection_host() { + let kdc = dummy_kdc(Uuid::new_v4()); + let result = kdc + .intercept_network_request(&network_request("http://kdc.real.example/path")) + .expect("intercept"); + assert!(matches!( + result, + CredentialInjectionKdcInterception::NotInjectionRequest + )); + } + + #[test] + fn intercept_rejects_malformed_url_path() { + let kdc = dummy_kdc(Uuid::new_v4()); + let err = kdc + .intercept_network_request(&network_request("http://cred.invalid/not-a-uuid")) + .expect_err("malformed path"); + assert!(format!("{err:#}").contains("malformed in-process KDC URL")); + } +} diff --git a/devolutions-gateway/src/credential_injection_kdc.rs b/devolutions-gateway/src/credential_injection_kdc.rs deleted file mode 100644 index 38031781c..000000000 --- a/devolutions-gateway/src/credential_injection_kdc.rs +++ /dev/null @@ -1,1073 +0,0 @@ -//! In-memory Kerberos KDC used by proxy-based credential injection. -//! -//! This module owns the Kerberos side of credential injection end-to-end: -//! per-session fake-KDC material, the session store, KDC proxy handling, and the -//! in-process KDC requests emitted by the server-side CredSSP acceptor. -//! Callers should only decide whether credential injection applies; once it does, this -//! component owns the Kerberos-specific behavior. - -use std::collections::HashMap; -use std::fmt; -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Context as _; -use async_trait::async_trait; -use chacha20poly1305::aead::OsRng; -use chacha20poly1305::aead::rand_core::RngCore as _; -use devolutions_gateway_task::{ShutdownSignal, Task}; -use ironrdp_connector::sspi; -use ironrdp_connector::sspi::generator::NetworkRequest; -use parking_lot::Mutex; -use picky_krb::messages::KdcProxyMessage; -use secrecy::{ExposeSecret as _, SecretBox, SecretString}; -use thiserror::Error; -use url::Url; -use uuid::Uuid; - -use crate::config::ConfHandle; -use crate::credential::{AppCredential, AppCredentialMapping}; -use crate::provisioning::{ArcProvisioningEntry, ProvisioningStore}; -use crate::target_connection_options::TargetConnectionOptions; - -// The reserved `.invalid` TLD (RFC 6761) lets sspi-rs CredSSP server emit "KDC requests" that -// never leave the process: `intercept_network_request` recognises this hostname and dispatches -// the message into the in-process `kdc` server below. -// -// TODO(sspi-rs#664): replace this URL-trampoline with a pluggable KDC dispatcher trait once -// sspi-rs ships the API — see https://github.com/Devolutions/sspi-rs/issues/664. -const IN_PROCESS_KDC_HOST: &str = "cred.invalid"; - -pub(crate) struct CredentialInjectionKdc { - jti: Uuid, - raw_token: String, - credential_mapping: AppCredentialMapping, - connection_options: Option, - // Client target hostname. It is not a hostname of the end machine, but a DGW hostname the client - // uses when connecting. - target_hostname: String, - session: Arc, - // The KDC crate models users with plaintext passwords, so this object owns those secrets - // for the lifetime of the credential-injection KDC. Keep Debug redacted. - kdc_config: kdc::config::KerberosServer, -} - -#[derive(Debug, Error)] -pub(crate) enum CredentialInjectionKdcResolveError { - #[error("credential-injection state is not available for {jti}")] - MissingCredential { jti: Uuid }, - #[error("credential-injection state is not available for {jti}")] - NonInjectionCredential { jti: Uuid }, - #[error("association token for {jti} is not valid for credential injection")] - InvalidAssociationToken { - jti: Uuid, - #[source] - source: anyhow::Error, - }, - #[error("credential-injection KDC config could not be initialized for {jti}")] - BuildKdcConfig { - jti: Uuid, - #[source] - source: anyhow::Error, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RealmMismatch { - pub(crate) expected: String, - pub(crate) actual: String, -} - -impl fmt::Display for RealmMismatch { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "expected: {}, got: {}", self.expected, self.actual) - } -} - -impl std::error::Error for RealmMismatch {} - -#[derive(Debug)] -pub(crate) enum CredentialInjectionKdcInterception { - Intercepted(Vec), - NotInjectionRequest, - NotInjectionRealm(RealmMismatch), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CredentialInjectionClientAcceptorProtocol { - Kerberos, - Ntlm, -} - -pub(crate) struct CredentialInjectionKdcRequest { - message: KdcProxyMessage, -} - -impl CredentialInjectionKdcRequest { - pub(crate) fn from_token(message: KdcProxyMessage) -> Self { - Self { message } - } - - fn in_process(message: KdcProxyMessage) -> Self { - Self { message } - } -} - -impl fmt::Debug for CredentialInjectionKdc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialInjectionKdc") - .field("jti", &self.jti) - .field("target_hostname", &self.target_hostname) - .field("realm", &self.session.realm) - .field("kdc_config", &"") - .finish() - } -} - -impl CredentialInjectionKdc { - fn from_parts( - jti: Uuid, - credential_entry: ArcProvisioningEntry, - target_hostname: String, - session: Arc, - ) -> anyhow::Result { - let mapping = credential_entry - .mapping - .as_ref() - .context("credential entry has no credential-injection mapping")?; - anyhow::ensure!( - jti == session.jti, - "credential entry JTI does not match credential-injection KDC session JTI", - ); - - let kdc_config = build_kdc_config(&session, &mapping.proxy)?; - - Ok(Self { - jti, - raw_token: credential_entry.token.clone(), - credential_mapping: mapping.clone(), - connection_options: credential_entry.connection_options.clone(), - target_hostname, - session, - kdc_config, - }) - } - - pub(crate) fn krb_kdc(&self) -> Option<&Url> { - self.connection_options.as_ref()?.krb_kdc() - } - - pub(crate) fn jti(&self) -> Uuid { - self.jti - } - - pub(crate) fn raw_token(&self) -> &str { - &self.raw_token - } - - pub(crate) fn proxy_credential(&self) -> &AppCredential { - &self.credential_mapping.proxy - } - - pub(crate) fn target_credential(&self) -> &AppCredential { - &self.credential_mapping.target - } - - /// Selects the CredSSP acceptor backend Gateway should present to the RDP client. - /// - /// The acceptor side must mirror the target-side auth package. - /// Domainless target credentials cannot acquire Kerberos tickets. - /// Enabling the Kerberos acceptor for those sessions would make incoming NTLMSSP tokens fail in Kerberos parsing. - pub(crate) fn client_acceptor_protocol(&self) -> anyhow::Result { - let target_username = sspi::Username::parse(app_credential_username(self.target_credential())) - .context("invalid target credential username")?; - - if target_username.domain_name().is_some() { - Ok(CredentialInjectionClientAcceptorProtocol::Kerberos) - } else { - Ok(CredentialInjectionClientAcceptorProtocol::Ntlm) - } - } - - pub(crate) fn server_kerberos_config(&self, client_addr: SocketAddr) -> anyhow::Result { - let user = sspi::CredentialsBuffers::AuthIdentity(sspi::AuthIdentityBuffers::from_utf8( - &self.session.acceptor.principal_name, - &self.session.realm, - self.session.acceptor.password.expose_secret(), - )); - - let kdc_url = self.in_process_kdc_url()?; - - // The SPN that the client puts on its AP-REQ ticket is the one for the target RDP - // server (`TERMSRV/`). Gateway-as-CredSSP-server is impersonating that target, - // so `ServerProperties` must claim the same SPN as the gateway listener or sspi-rs - // rejects the ticket. - Ok(sspi::KerberosServerConfig { - kerberos_config: sspi::KerberosConfig { - kdc_url: Some(kdc_url), - client_computer_name: client_addr.to_string(), - }, - server_properties: sspi::kerberos::ServerProperties::new( - &["TERMSRV", &self.target_hostname], - Some(user), - Duration::from_secs(300), - Some(sspi::Secret::new( - self.session.acceptor.long_term_key.expose_secret().clone(), - )), - )?, - }) - } - - pub(crate) fn intercept_network_request( - &self, - request: &NetworkRequest, - ) -> anyhow::Result { - if request.url.host_str() != Some(IN_PROCESS_KDC_HOST) { - return Ok(CredentialInjectionKdcInterception::NotInjectionRequest); - } - - let url_jti = request - .url - .path() - .trim_start_matches('/') - .parse::() - .context("malformed in-process KDC URL")?; - anyhow::ensure!( - url_jti == self.jti, - "in-process KDC URL JTI does not match current CredSSP session", - ); - - debug!( - jti = %self.jti, - scheme = %request.url.scheme(), - "Credential-injection KDC intercepted in-process request" - ); - - let kdc_message = KdcProxyMessage::from_raw(&request.data).context("malformed in-process KDC proxy payload")?; - self.handle_kdc_proxy_request(CredentialInjectionKdcRequest::in_process(kdc_message)) - } - - pub(crate) fn handle_kdc_proxy_request( - &self, - request: CredentialInjectionKdcRequest, - ) -> anyhow::Result { - let request_realm = self.resolve_message_realm(&request.message); - debug!( - jti = %self.jti, - resolved_realm = %request_realm, - "Credential-injection KDC realm resolved" - ); - - if let Some(mismatch) = realm_mismatch(&self.session.realm, &request_realm) { - return Ok(CredentialInjectionKdcInterception::NotInjectionRealm(mismatch)); - } - - let reply = self.handle_message(request.message)?; - Ok(CredentialInjectionKdcInterception::Intercepted(reply)) - } - - fn in_process_kdc_url(&self) -> anyhow::Result { - Url::parse(&format!("http://{}/{}", IN_PROCESS_KDC_HOST, self.jti)).context("build in-process KDC URL") - } - - fn resolve_message_realm(&self, kdc_proxy_message: &KdcProxyMessage) -> String { - kdc_proxy_message_realm(kdc_proxy_message).unwrap_or_else(|| self.session.realm.clone()) - } - - fn handle_message(&self, kdc_proxy_message: KdcProxyMessage) -> anyhow::Result> { - let reply = kdc::handle_kdc_proxy_message(kdc_proxy_message, &self.kdc_config, &self.target_hostname) - .context("handle credential-injection KDC message")?; - - reply.to_vec().context("encode credential-injection KDC reply") - } -} - -fn app_credential_username(credential: &AppCredential) -> &str { - match credential { - AppCredential::UsernamePassword { username, password: _ } => username, - } -} - -pub(crate) fn kdc_proxy_message_realm(kdc_proxy_message: &KdcProxyMessage) -> Option { - kdc_proxy_message - .target_domain - .0 - .as_ref() - .map(|realm| realm.0.to_string()) - .filter(|realm| !realm.is_empty()) -} - -fn realm_mismatch(expected: &str, actual: &str) -> Option { - if expected.eq_ignore_ascii_case(actual) { - return None; - } - - Some(RealmMismatch { - expected: expected.to_owned(), - actual: actual.to_owned(), - }) -} - -/// Per-session Kerberos material for proxy-based credential injection. -/// -/// The key material and the acceptor PA-ENC-TIMESTAMP password are wrapped in [`SecretBox`] / -/// [`SecretString`] so they cannot be accidentally written to logs through structured tracing. -/// Access requires an explicit `expose_secret()` call, which is greppable and reviewable. -struct CredentialInjectionKdcSession { - jti: Uuid, - realm: String, - kdc: CredentialInjectionKdcState, - acceptor: CredentialInjectionAcceptorState, -} - -struct CredentialInjectionKdcState { - krbtgt_key: SecretBox>, -} - -struct CredentialInjectionAcceptorState { - principal_name: String, - password: SecretString, - long_term_key: SecretBox>, -} - -impl fmt::Debug for CredentialInjectionKdcSession { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialInjectionKdcSession") - .field("jti", &self.jti) - .field("realm", &self.realm) - .field("kdc", &self.kdc) - .field("acceptor", &self.acceptor) - .finish() - } -} - -impl fmt::Debug for CredentialInjectionKdcState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialInjectionKdcState") - .field("krbtgt_key", &"<32 bytes redacted>") - .finish() - } -} - -impl fmt::Debug for CredentialInjectionAcceptorState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialInjectionAcceptorState") - .field("principal_name", &self.principal_name) - .field("password", &"") - .field("long_term_key", &"<32 bytes redacted>") - .finish() - } -} - -/// Derive per-session Kerberos material from the proxy username and the association token's JTI. -/// -/// The proxy username's optional `@realm` suffix selects the realm DVLS supplied; otherwise -/// fall back to a per-session synthetic realm derived from the JTI. The two sides agree -/// because DVLS derives the synthetic value the same way. -fn derive_credential_injection_kdc_session(proxy_username: &str, jti: Uuid) -> CredentialInjectionKdcSession { - let realm = proxy_username - .split_once('@') - .map(|(_, realm)| realm) - .filter(|realm| !realm.is_empty()) - .map(str::to_owned) - .unwrap_or_else(|| synthetic_realm(jti)); - - CredentialInjectionKdcSession { - jti, - realm, - kdc: CredentialInjectionKdcState { - krbtgt_key: SecretBox::new(Box::new(random_32_bytes())), - }, - acceptor: CredentialInjectionAcceptorState { - principal_name: "jet".to_owned(), - password: SecretString::from(hex::encode(random_32_bytes())), - long_term_key: SecretBox::new(Box::new(random_32_bytes())), - }, - } -} - -fn build_kdc_config( - session: &CredentialInjectionKdcSession, - proxy_credential: &AppCredential, -) -> anyhow::Result { - let realm = &session.realm; - let (proxy_user_name, proxy_password) = proxy_credential.decrypt_password()?; - let proxy_user_name = principal_for_realm(&proxy_user_name, realm); - let acceptor_principal_name = principal_for_realm(&session.acceptor.principal_name, realm); - - let acceptor_password = session.acceptor.password.expose_secret().to_owned(); - Ok(kdc::config::KerberosServer { - realm: realm.to_owned(), - users: vec![ - kdc::config::DomainUser { - username: proxy_user_name.clone(), - password: proxy_password.expose_secret().to_owned(), - salt: kerberos_salt(realm, &proxy_user_name), - }, - kdc::config::DomainUser { - username: acceptor_principal_name.clone(), - password: acceptor_password.clone(), - salt: kerberos_salt(realm, &acceptor_principal_name), - }, - ], - max_time_skew: 300, - krbtgt_key: session.kdc.krbtgt_key.expose_secret().clone(), - ticket_decryption_key: Some(session.acceptor.long_term_key.expose_secret().clone()), - service_user: Some(kdc::config::DomainUser { - username: acceptor_principal_name.clone(), - password: acceptor_password, - salt: kerberos_salt(realm, &acceptor_principal_name), - }), - }) -} - -fn principal_for_realm(user_name: &str, realm: &str) -> String { - if user_name.contains('@') { - user_name.to_owned() - } else { - format!("{user_name}@{realm}") - } -} - -fn kerberos_salt(realm: &str, principal: &str) -> String { - let local_name = principal.split('@').next().unwrap_or(principal); - format!("{}{local_name}", realm.to_ascii_uppercase()) -} - -fn synthetic_realm(jti: Uuid) -> String { - format!("CRED-{}.INVALID", jti.simple()).to_ascii_uppercase() -} - -fn random_32_bytes() -> Vec { - let mut bytes = vec![0u8; 32]; - OsRng.fill_bytes(&mut bytes); - bytes -} - -/// One-stop service for credential storage and credential-injection KDC state. -/// -/// Wraps the protocol-neutral [`ProvisioningStore`] and adds a Kerberos session cache keyed by -/// association-token JTI. The credential store remains the single source of truth for entry -/// lifetime; the session cache piggybacks on it (Arc-cloned credentials at lookup time, with stale -/// sessions evicted on insert-replacement and by a periodic sweep). -/// -/// All credential reads/writes — provision-credentials, RDP mode detection, KDC dispatch — go -/// through this service, so callers see one handle instead of coordinating a store and a registry. -#[derive(Clone)] -pub struct CredentialService { - // The `ConfHandle` is needed to resolve the hostname for the KDC config, which is used to - // build the SPN for the CredSSP acceptor. The hostname cannot not be a plain `String`, because - // the config can be reloaded at runtime. - conf_handle: ConfHandle, - credentials: ProvisioningStore, - sessions: Arc>>>, -} - -impl fmt::Debug for CredentialService { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialService") - .field("conf_handle", &"") - .field("credentials", &self.credentials) - .field("sessions", &self.sessions) - .finish() - } -} - -impl CredentialService { - pub fn new(conf_handle: ConfHandle) -> Self { - Self { - conf_handle, - credentials: ProvisioningStore::new(), - sessions: Arc::new(Mutex::new(HashMap::new())), - } - } - - /// Insert (or replace) the credentials half keyed by the token's JTI. - /// - /// Any previously-cached Kerberos session for the same JTI is dropped: it was derived from - /// the prior provisioning and is no longer valid for the new entry. We invalidate even when - /// the store reports no replacement, because the prior entry may have already been evicted by - /// `provisioning::CleanupTask` while its session cache entry was still awaiting the next - /// `sweep_orphans` tick. - pub(crate) fn insert_credentials( - &self, - token: String, - mapping: Option, - time_to_live: time::Duration, - ) -> Result { - // Snapshot the JTI from the new token so we can invalidate the matching session entry - // regardless of whether the credential store reports a replacement. `ProvisioningStore::insert_credentials` - // re-extracts internally; both calls go through the same code path, so an invalid token - // here will surface as the same `InvalidToken` error downstream. - let jti = crate::token::extract_jti(&token) - .context("failed to extract token ID") - .map_err(crate::provisioning::InsertError::InvalidToken)?; - let replaced = self.credentials.insert_credentials(token, mapping, time_to_live)?; - self.sessions.lock().remove(&jti); - Ok(replaced) - } - - /// Insert (or replace) the connection-options half. Drops any cached Kerberos session for the - /// JTI because `krb_kdc` is part of the session's routing inputs. - pub(crate) fn insert_connection_options( - &self, - jti: Uuid, - connection_options: TargetConnectionOptions, - time_to_live: time::Duration, - ) -> bool { - let replaced = self - .credentials - .insert_connection_options(jti, connection_options, time_to_live); - self.sessions.lock().remove(&jti); - replaced - } - - /// Look up a credential entry by its association-token JTI. - pub(crate) fn get(&self, jti: Uuid) -> Option { - self.credentials.get(jti) - } - - /// Borrow the inner [`ProvisioningStore`] for plumbing that genuinely needs the - /// protocol-neutral primitive (e.g. wiring the background expiry task). - pub fn credential_store(&self) -> &ProvisioningStore { - &self.credentials - } - - /// Resolve the credential-injection KDC bound to the given association-token JTI. - /// - /// Returns the per-call KDC view; the underlying Kerberos session (krbtgt key, acceptor - /// long-term key, acceptor password) is cached so the in-process KDC and the CredSSP acceptor - /// see identical key material for the lifetime of the provisioned credentials. - pub(crate) fn kdc_for(&self, jti: Uuid) -> Result { - let credential_entry = self.credentials.get(jti).ok_or_else(|| { - warn!(%jti, "KDC token references missing credential-injection state"); - CredentialInjectionKdcResolveError::MissingCredential { jti } - })?; - - let mapping = credential_entry.mapping.as_ref().ok_or_else(|| { - warn!(%jti, "KDC token references non-injection credential state"); - CredentialInjectionKdcResolveError::NonInjectionCredential { jti } - })?; - - // Validate association-token shape for credential injection (dst_hst present, etc.). - // SPN / acceptor hostname comes from gateway config below (#1856), not dst_hst. - crate::token::extract_credential_injection_target_hostname(&credential_entry.token).map_err(|source| { - warn!( - %jti, - error = format!("{source:#}"), - "KDC token references invalid credential-injection association token" - ); - CredentialInjectionKdcResolveError::InvalidAssociationToken { jti, source } - })?; - - let proxy_username = app_credential_username(&mapping.proxy).to_owned(); - // Atomic get-or-insert: holds the lock long enough to guarantee a single Arc - // wins for this JTI even under concurrent `kdc_for` calls. The derivation is fast (a few - // hundred bytes of OsRng) so doing it under the lock is acceptable. - let session = { - let mut sessions = self.sessions.lock(); - let session = sessions - .entry(jti) - .or_insert_with(|| Arc::new(derive_credential_injection_kdc_session(&proxy_username, jti))); - Arc::clone(session) - }; - - let hostname = self.conf_handle.get_conf().hostname.clone(); - - CredentialInjectionKdc::from_parts(jti, credential_entry, hostname, session) - .map_err(|source| CredentialInjectionKdcResolveError::BuildKdcConfig { jti, source }) - } - - fn sweep_orphans(&self) { - let stale_jtis: Vec = { - let sessions = self.sessions.lock(); - sessions - .keys() - .copied() - .filter(|jti| self.credentials.get(*jti).is_none()) - .collect() - }; - - if stale_jtis.is_empty() { - return; - } - - let mut sessions = self.sessions.lock(); - for jti in stale_jtis { - sessions.remove(&jti); - } - } -} - -pub struct CleanupTask { - pub service: CredentialService, -} - -#[async_trait] -impl Task for CleanupTask { - type Output = anyhow::Result<()>; - - const NAME: &'static str = "credential injection kdc cleanup"; - - async fn run(self, shutdown_signal: ShutdownSignal) -> Self::Output { - cleanup_task(self.service, shutdown_signal).await; - Ok(()) - } -} - -#[instrument(skip_all)] -async fn cleanup_task(service: CredentialService, mut shutdown_signal: ShutdownSignal) { - use tokio::time::{Duration, sleep}; - - const TASK_INTERVAL: Duration = Duration::from_secs(60 * 15); // 15 minutes - - debug!("Task started"); - - loop { - tokio::select! { - _ = sleep(TASK_INTERVAL) => {} - _ = shutdown_signal.wait() => { - break; - } - } - - service.sweep_orphans(); - } - - debug!("Task terminated"); -} - -#[cfg(test)] -mod tests { - use base64::Engine as _; - use ironrdp_connector::sspi::network_client::NetworkProtocol; - use secrecy::SecretString; - - use super::*; - use crate::config::ConfHandle; - use crate::credential::{CleartextAppCredential, CleartextAppCredentialMapping}; - - const TEST_CONFIG: &str = r#"{ - "Hostname": "dgateway.localhost.com", - "ProvisionerPublicKeyData": { - "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" - }, - "Listeners": [ - { "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" } - ], - "__debug__": { "disable_token_validation": true } - }"#; - - fn mock_conf_handle() -> ConfHandle { - ConfHandle::mock(TEST_CONFIG).expect("test config is valid") - } - - fn cleartext_mapping_with_target_username(target_username: &str) -> CleartextAppCredentialMapping { - CleartextAppCredentialMapping { - proxy: CleartextAppCredential::UsernamePassword { - username: "proxy@example.invalid".to_owned(), - password: SecretString::from("pwd"), - }, - target: CleartextAppCredential::UsernamePassword { - username: target_username.to_owned(), - password: SecretString::from("pwd"), - }, - } - } - - fn unsigned_jws(payload: serde_json::Value) -> String { - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header = engine.encode(r#"{"alg":"RS256"}"#); - let payload = engine.encode(serde_json::to_vec(&payload).expect("payload serializes")); - let signature = engine.encode(b"signature"); - format!("{header}.{payload}.{signature}") - } - - fn association_token(jti: Uuid) -> String { - unsigned_jws(serde_json::json!({ - "jti": jti, - "dst_hst": "target.example:3389" - })) - } - - fn dummy_entry_with_target_username(jti: Uuid, target_username: &str) -> ArcProvisioningEntry { - let store = ProvisioningStore::new(); - store - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username(target_username)), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - - store.get(jti).expect("credential entry is indexed by JTI") - } - - fn dummy_entry(jti: Uuid) -> ArcProvisioningEntry { - dummy_entry_with_target_username(jti, "target") - } - - fn dummy_kdc(jti: Uuid) -> CredentialInjectionKdc { - let entry = dummy_entry(jti); - let session = Arc::new(derive_credential_injection_kdc_session("proxy@example.invalid", jti)); - CredentialInjectionKdc::from_parts(jti, entry, "target.example".to_owned(), session) - .expect("valid credential-injection KDC") - } - - fn dummy_kdc_with_target_username(jti: Uuid, target_username: &str) -> CredentialInjectionKdc { - let entry = dummy_entry_with_target_username(jti, target_username); - let session = Arc::new(derive_credential_injection_kdc_session("proxy@example.invalid", jti)); - CredentialInjectionKdc::from_parts(jti, entry, "target.example".to_owned(), session) - .expect("valid credential-injection KDC") - } - - fn network_request(url: &str) -> NetworkRequest { - NetworkRequest { - protocol: NetworkProtocol::Http, - url: Url::parse(url).expect("test URL parses"), - data: Vec::new(), - } - } - - #[test] - fn proxy_user_at_realm_is_used_as_realm() { - let session = derive_credential_injection_kdc_session("proxy@example.invalid", Uuid::new_v4()); - assert_eq!(session.realm, "example.invalid"); - } - - #[test] - fn bare_proxy_username_yields_synthetic_realm() { - let jti = Uuid::new_v4(); - let session = derive_credential_injection_kdc_session("just-a-uuid", jti); - assert_eq!(session.realm, synthetic_realm(jti)); - assert!(!session.realm.is_empty()); - } - - #[test] - fn service_kdc_for_rejects_expired_credential_entry() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - // Negative TTL: entry is born already expired. `ProvisioningStore::get` does not - // filter on expiry, so the service's own check is what guarantees we never build a KDC - // over stale credentials. - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::seconds(-1), - ) - .expect("credential entry inserts"); - - assert!( - matches!( - service.kdc_for(jti), - Err(CredentialInjectionKdcResolveError::MissingCredential { .. }) - ), - "expired credentials must not yield a KDC" - ); - } - - #[test] - fn service_kdc_for_returns_same_session_under_concurrent_calls() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - - let first = service.kdc_for(jti).expect("first call resolves"); - let second = service.kdc_for(jti).expect("second call resolves"); - - // The Kerberos session is the piece that must be stable across calls; the per-call KDC - // view rebuilds the rest. Compare via the long-term acceptor key as a session-identity - // probe. - let first_key = first.session.acceptor.long_term_key.expose_secret().clone(); - let second_key = second.session.acceptor.long_term_key.expose_secret().clone(); - assert_eq!( - first_key, second_key, - "concurrent kdc_for must share one cached session per JTI" - ); - } - - #[test] - fn service_insert_drops_stale_session_even_without_credential_replacement() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - // Simulate the race called out by Codex: a previous provisioning's session is still - // cached, but the credential entry has already been evicted (e.g. by - // `provisioning::cleanup_task`) and `sweep_orphans` has not run yet. A fresh provisioning - // under the same JTI must drop the stale session regardless of whether - // `ProvisioningStore::insert_credentials` reports a replacement, otherwise the next `kdc_for` - // would reuse the old key material. - let stale_session = Arc::new(derive_credential_injection_kdc_session("proxy@example.invalid", jti)); - service.sessions.lock().insert(jti, Arc::clone(&stale_session)); - - let replaced = service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - assert!(!replaced, "test precondition: no credential replacement"); - - assert!( - !service.sessions.lock().contains_key(&jti), - "insert must drop stale session even when no credential replacement occurred" - ); - } - - #[test] - fn service_insert_replacement_drops_cached_kerberos_material() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - - let first = service.kdc_for(jti).expect("first call resolves"); - let first_key = first.session.acceptor.long_term_key.expose_secret().clone(); - - // Re-insert under the same JTI: the cached session for the previous entry must be evicted - // automatically, otherwise the new KDC would carry stale key material that the freshly - // provisioned credentials no longer match. - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry re-inserts"); - - let second = service.kdc_for(jti).expect("second call resolves with fresh session"); - let second_key = second.session.acceptor.long_term_key.expose_secret().clone(); - - assert_ne!( - first_key, second_key, - "insert-replacement must force a fresh session derivation" - ); - } - - #[test] - fn service_sweep_orphans_drops_sessions_with_no_credential_entry() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - - service.kdc_for(jti).expect("kdc_for populates session cache"); - assert!(service.sessions.lock().contains_key(&jti), "session cached"); - - // Simulate credential store eviction: build a parallel service whose credential store is - // empty but whose session cache is shared with the original. A more faithful test would - // drive `provisioning::cleanup_task` to expire the entry, but it sleeps for 15 minutes - // between ticks. Swapping the inner store is the deterministic equivalent. - let orphaned_service = CredentialService { - conf_handle: mock_conf_handle(), - credentials: ProvisioningStore::new(), - sessions: Arc::clone(&service.sessions), - }; - - orphaned_service.sweep_orphans(); - assert!( - !orphaned_service.sessions.lock().contains_key(&jti), - "sweep must drop sessions whose JTI is no longer in credential_store" - ); - } - - #[test] - fn client_acceptor_protocol_is_ntlm_for_domainless_target_credential() { - let kdc = dummy_kdc_with_target_username(Uuid::new_v4(), "Administrator"); - - assert_eq!( - kdc.client_acceptor_protocol().expect("protocol selected"), - CredentialInjectionClientAcceptorProtocol::Ntlm - ); - } - - #[test] - fn client_acceptor_protocol_is_kerberos_for_upn_target_credential() { - let kdc = dummy_kdc_with_target_username(Uuid::new_v4(), "administrator@example.invalid"); - - assert_eq!( - kdc.client_acceptor_protocol().expect("protocol selected"), - CredentialInjectionClientAcceptorProtocol::Kerberos - ); - } - - #[test] - fn client_acceptor_protocol_is_kerberos_for_downlevel_target_credential() { - let kdc = dummy_kdc_with_target_username(Uuid::new_v4(), "EXAMPLE\\Administrator"); - - assert_eq!( - kdc.client_acceptor_protocol().expect("protocol selected"), - CredentialInjectionClientAcceptorProtocol::Kerberos - ); - } - - #[test] - fn from_parts_rejects_mismatched_entry_and_session_jti() { - let entry_jti = Uuid::new_v4(); - let session_jti = Uuid::new_v4(); - assert_ne!(entry_jti, session_jti); - - let entry = dummy_entry(entry_jti); - let session = Arc::new(derive_credential_injection_kdc_session( - "proxy@example.invalid", - session_jti, - )); - - let err = CredentialInjectionKdc::from_parts(entry_jti, entry, "target.example".to_owned(), session) - .expect_err("mismatched entry/session JTI must fail closed"); - let msg = format!("{err:#}"); - assert!( - msg.contains("credential entry JTI does not match credential-injection KDC session JTI"), - "actual: {msg}" - ); - } - - #[test] - fn service_kdc_for_rejects_unknown_jti() { - let service = CredentialService::new(mock_conf_handle()); - - assert!( - matches!( - service.kdc_for(Uuid::new_v4()), - Err(CredentialInjectionKdcResolveError::MissingCredential { .. }) - ), - "KDC tokens with jet_cred_id must not fall back to real-KDC forwarding" - ); - } - - #[test] - fn service_kdc_for_rejects_non_injection_entry() { - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - service - .insert_credentials(association_token(jti), None, time::Duration::minutes(5)) - .expect("provision-token entry inserts"); - - assert!( - matches!( - service.kdc_for(jti), - Err(CredentialInjectionKdcResolveError::NonInjectionCredential { .. }) - ), - "KDC tokens with jet_cred_id must require provision-credentials state" - ); - } - - #[test] - fn service_kdc_for_uses_gateway_hostname_for_spn() { - // #1856: SPN / acceptor hostname is the Gateway hostname from config, not dst_hst. - // Token dst_hst is still validated (missing/invalid shape fails kdc_for). - let service = CredentialService::new(mock_conf_handle()); - let jti = Uuid::new_v4(); - - service - .insert_credentials( - association_token(jti), - Some(cleartext_mapping_with_target_username("target")), - time::Duration::minutes(5), - ) - .expect("credential entry inserts"); - - let kdc = service.kdc_for(jti).expect("credential-injection KDC resolves"); - - assert_eq!(kdc.target_hostname, "dgateway.localhost.com"); - } - - #[test] - fn intercept_ignores_non_loopback_host() { - let jti = Uuid::new_v4(); - let kdc = dummy_kdc(jti); - - let request = network_request("http://kdc.real.example/path"); - let result = kdc - .intercept_network_request(&request) - .expect("non-loopback request dispatches"); - - assert!(matches!( - result, - CredentialInjectionKdcInterception::NotInjectionRequest - )); - } - - #[test] - fn intercept_rejects_malformed_url_path() { - let jti = Uuid::new_v4(); - let kdc = dummy_kdc(jti); - - let request = network_request("http://cred.invalid/not-a-uuid"); - let err = kdc - .intercept_network_request(&request) - .expect_err("non-UUID path must fail"); - let msg = format!("{err:#}"); - assert!(msg.contains("malformed in-process KDC URL"), "actual: {msg}"); - } - - #[test] - fn intercept_rejects_mismatched_jti() { - let entry_jti = Uuid::new_v4(); - let other_jti = Uuid::new_v4(); - assert_ne!(entry_jti, other_jti); - - let kdc = dummy_kdc(entry_jti); - - let request = network_request(&format!("http://cred.invalid/{}", other_jti)); - let err = kdc - .intercept_network_request(&request) - .expect_err("JTI mismatch must fail"); - let msg = format!("{err:#}"); - assert!(msg.contains("does not match current CredSSP session"), "actual: {msg}"); - } - - #[test] - fn intercept_accepts_matching_url_path_before_payload_decode() { - let jti = Uuid::new_v4(); - let kdc = dummy_kdc(jti); - - let request = network_request(&format!("http://cred.invalid/{jti}")); - let err = kdc - .intercept_network_request(&request) - .expect_err("empty KDC payload must fail after URL/JTI validation"); - let msg = format!("{err:#}"); - assert!(msg.contains("malformed in-process KDC proxy payload"), "actual: {msg}"); - } - - #[test] - fn realm_mismatch_is_reported_as_not_injection_realm() { - let mismatch = - realm_mismatch("cred-session.invalid", "evil.example").expect("different realms produce a mismatch"); - assert_eq!(mismatch.expected, "cred-session.invalid"); - assert_eq!(mismatch.actual, "evil.example"); - } - - #[test] - fn missing_kdc_proxy_envelope_realm_falls_back_to_session_realm() { - let jti = Uuid::new_v4(); - let kdc = dummy_kdc(jti); - let message = KdcProxyMessage::from_raw_kerb_message(&[]).expect("KDC proxy wrapper builds"); - - assert_eq!(kdc.resolve_message_realm(&message), "example.invalid"); - } -} diff --git a/devolutions-gateway/src/generic_client.rs b/devolutions-gateway/src/generic_client.rs index dfc31df7f..752897114 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -8,7 +8,8 @@ use tracing::field; use typed_builder::TypedBuilder; use crate::config::Conf; -use crate::credential_injection_kdc::CredentialService; +use crate::credential_injection::{CredentialInjection, SyntheticKdcRegistry}; +use crate::provisioning::{MappingStatus, ProvisioningStore}; use crate::proxy::Proxy; use crate::rdp_pcb::{extract_association_claims, read_pcb}; use crate::recording::ActiveRecordings; @@ -27,7 +28,8 @@ pub struct GenericClient { sessions: SessionMessageSender, subscriber_tx: SubscriberSender, active_recordings: Arc, - credentials: CredentialService, + provisioning: ProvisioningStore, + synthetic_kdc_registry: SyntheticKdcRegistry, #[builder(default)] agent_tunnel_handle: Option>, } @@ -51,7 +53,8 @@ where sessions, subscriber_tx, active_recordings, - credentials, + provisioning, + synthetic_kdc_registry, agent_tunnel_handle, } = self; @@ -113,6 +116,32 @@ where RecordingPolicy::Proxy => anyhow::bail!("can't meet recording policy"), } + let is_rdp = claims.jet_ap == token::ApplicationProtocol::Known(token::Protocol::Rdp); + let mapping_status = if is_rdp { + provisioning.mapping_status(claims.jti) + } else { + MappingStatus::Absent + }; + let inject = match mapping_status { + MappingStatus::Available => true, + MappingStatus::Absent => false, + }; + + // Checkout before dialing so missing Kerberos material cannot open an upstream socket. + let credential_injection = if inject { + Some( + CredentialInjection::checkout(&provisioning, &synthetic_kdc_registry, claims.jti, token, true) + .with_context(|| { + format!( + "credential-injection material for {} is missing or expired; re-provision to retry", + claims.jti + ) + })?, + ) + } else { + None + }; + let ConnectedUpstream { leg: mut server_stream, server_addr, @@ -128,8 +157,6 @@ where span.record("target", selected_target.to_string()); - let is_rdp = claims.jet_ap == token::ApplicationProtocol::Known(token::Protocol::Rdp); - let info = SessionInfo::builder() .id(claims.jet_aid) .application_protocol(claims.jet_ap) @@ -143,23 +170,10 @@ where let disconnect_interest = DisconnectInterest::from_reconnection_policy(claims.jet_reuse); - // We support proxy-based credential injection for RDP. - // If a credential mapping has been pushed, we automatically switch to this mode. - // Otherwise, we continue the generic procedure. - // - // RdpProxy is generic over the server stream, so credential injection works - // regardless of whether the upstream is direct TCP or tunnelled via an agent. - // The credential store is keyed on the association token's JTI, so a direct - // lookup by `claims.jti` is the primary path. - if is_rdp - && let Some(entry) = credentials.get(claims.jti) - && entry.mapping.is_some() - { - anyhow::ensure!(token == entry.token, "token mismatch"); - let credential_injection_kdc = credentials.kdc_for(claims.jti)?; - + if let Some(credential_injection) = credential_injection { info!( - jti = %credential_injection_kdc.jti(), + jti = %credential_injection.jti(), + kerberos = credential_injection.uses_kerberos(), "RDP-TLS forwarding with credential injection" ); @@ -179,7 +193,7 @@ where .server_stream(server_stream) .sessions(sessions) .subscriber_tx(subscriber_tx) - .credential_injection_kdc(credential_injection_kdc) + .credential_injection(credential_injection) .client_stream_leftover_bytes(leftover_bytes) .server_dns_name(selected_target.host().to_owned()) .disconnect_interest(disconnect_interest) diff --git a/devolutions-gateway/src/lib.rs b/devolutions-gateway/src/lib.rs index ae5ef4190..1fb08ff85 100644 --- a/devolutions-gateway/src/lib.rs +++ b/devolutions-gateway/src/lib.rs @@ -16,7 +16,7 @@ pub mod api; pub mod cli; pub mod config; pub mod credential; -pub mod credential_injection_kdc; +pub mod credential_injection; pub mod extract; pub mod generic_client; pub mod http; @@ -62,7 +62,8 @@ pub struct DgwState { pub shutdown_signal: devolutions_gateway_task::ShutdownSignal, pub recordings: recording::RecordingMessageSender, pub job_queue_handle: job_queue::JobQueueHandle, - pub credentials: credential_injection_kdc::CredentialService, + pub provisioning: provisioning::ProvisioningStore, + pub synthetic_kdc_registry: credential_injection::SyntheticKdcRegistry, pub monitoring_state: Arc, pub traffic_audit_handle: traffic_audit::TrafficAuditHandle, pub agent_tunnel_handle: Option>, @@ -90,7 +91,8 @@ impl DgwState { let (shutdown_handle, shutdown_signal) = devolutions_gateway_task::ShutdownHandle::new(); let (job_queue_handle, job_queue_rx) = job_queue::JobQueueHandle::new(); let (traffic_audit_handle, traffic_audit_rx) = traffic_audit::TrafficAuditHandle::new(); - let credentials = credential_injection_kdc::CredentialService::new(conf_handle.clone()); + let provisioning = provisioning::ProvisioningStore::new(); + let synthetic_kdc_registry = credential_injection::SyntheticKdcRegistry::new(); let monitoring_state = Arc::new(network_monitor::State::new(Arc::new(MockMonitorsCache))?); let state = Self { @@ -103,7 +105,8 @@ impl DgwState { recordings: recording_manager_handle, job_queue_handle, traffic_audit_handle, - credentials, + provisioning, + synthetic_kdc_registry, monitoring_state, agent_tunnel_handle: None, }; diff --git a/devolutions-gateway/src/listener.rs b/devolutions-gateway/src/listener.rs index 5e23f5f8a..6dd0b179b 100644 --- a/devolutions-gateway/src/listener.rs +++ b/devolutions-gateway/src/listener.rs @@ -158,7 +158,8 @@ async fn handle_tcp_peer(stream: TcpStream, state: DgwState, peer_addr: SocketAd .sessions(state.sessions) .subscriber_tx(state.subscriber_tx) .active_recordings(state.recordings.active_recordings) - .credentials(state.credentials) + .provisioning(state.provisioning) + .synthetic_kdc_registry(state.synthetic_kdc_registry) .agent_tunnel_handle(state.agent_tunnel_handle) .build() .serve() diff --git a/devolutions-gateway/src/ngrok.rs b/devolutions-gateway/src/ngrok.rs index 9e2e846bd..9adb561a7 100644 --- a/devolutions-gateway/src/ngrok.rs +++ b/devolutions-gateway/src/ngrok.rs @@ -237,7 +237,8 @@ async fn run_tcp_tunnel(mut tunnel: ngrok::tunnel::TcpTunnel, state: DgwState) { .sessions(state.sessions) .subscriber_tx(state.subscriber_tx) .active_recordings(state.recordings.active_recordings) - .credentials(state.credentials) + .provisioning(state.provisioning) + .synthetic_kdc_registry(state.synthetic_kdc_registry) .agent_tunnel_handle(state.agent_tunnel_handle) .build() .serve() diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index 73b22bf60..151804a9e 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -393,7 +393,11 @@ struct PreflightOperation { /// /// Required for "resolve-host" kind. host_to_resolve: Option, - /// Minimum persistence duration in seconds for the data provisioned via this operation. + /// Retention duration in seconds for data provisioned by this operation. + /// + /// For "provision-credentials", this is the maximum staging time before the first credential + /// checkout. After checkout, Gateway retains the credentials for later connections authorized + /// for the same association. /// /// Optional parameter for "provision-token", "provision-credentials", and /// "provision-connection-options" kinds. diff --git a/devolutions-gateway/src/provisioning.rs b/devolutions-gateway/src/provisioning.rs index 06b21c40b..e581b6719 100644 --- a/devolutions-gateway/src/provisioning.rs +++ b/devolutions-gateway/src/provisioning.rs @@ -6,6 +6,7 @@ use anyhow::Context as _; use async_trait::async_trait; use devolutions_gateway_task::{ShutdownSignal, Task}; use parking_lot::Mutex; +use tokio::sync::Notify; use tracing::{debug, instrument, warn}; use uuid::Uuid; @@ -42,15 +43,23 @@ pub struct ProvisioningEntry { pub(crate) token: String, pub(crate) mapping: Option, pub(crate) connection_options: Option, + pub(crate) generation: u64, + pub(crate) kdc_expires_at: Option, } -pub type ArcProvisioningEntry = Arc; - #[derive(Debug, Clone)] struct CredentialsEntry { token: String, mapping: Option, expires_at: time::OffsetDateTime, + required_until: Option, + generation: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MappingStatus { + Available, + Absent, } #[derive(Debug, Clone)] @@ -72,6 +81,7 @@ struct ConnectionOptionsEntry { pub struct ProvisioningStore { credentials: Arc>>, connection_options: Arc>>, + cleanup_notify: Arc, } impl Default for ProvisioningStore { @@ -85,13 +95,17 @@ impl ProvisioningStore { Self { credentials: Arc::new(Mutex::new(HashMap::new())), connection_options: Arc::new(Mutex::new(HashMap::new())), + cleanup_notify: Arc::new(Notify::new()), } } /// Insert or replace the credentials half (token-only or with a mapping). /// - /// Same contract as master: `provision-token` passes `mapping = None`; - /// `provision-credentials` passes `Some(mapping)`. + /// `provision-token` passes `mapping = None`; `provision-credentials` passes `Some(mapping)`. + /// + /// For mapped rows, `time_to_live` is the staging wait for first checkout. + /// The first successful [`Self::get_mapping`] then keeps the mapping until the token acceptance + /// deadline. pub(crate) fn insert_credentials( &self, token: String, @@ -101,19 +115,46 @@ impl ProvisioningStore { let jti = crate::token::extract_jti(&token) .context("failed to extract token ID") .map_err(InsertError::InvalidToken)?; + let now = time::OffsetDateTime::now_utc(); + let staging_expires = now + time_to_live; + let required_until = if mapping.is_some() { + let exp = crate::token::extract_exp(&token) + .context("failed to extract token expiration") + .map_err(InsertError::InvalidToken)?; + Some( + crate::token::token_acceptance_deadline(exp) + .context("invalid token expiration") + .map_err(InsertError::InvalidToken)?, + ) + } else { + None + }; let mapping = mapping .map(CleartextAppCredentialMapping::encrypt) .transpose() .context("encrypt provisioned credentials") .map_err(InsertError::CredentialEncryption)?; - let entry = CredentialsEntry { - token, - mapping, - expires_at: time::OffsetDateTime::now_utc() + time_to_live, - }; - - Ok(self.credentials.lock().insert(jti, entry).is_some()) + let mut credentials = self.credentials.lock(); + let generation = credentials + .get(&jti) + .map_or(1, |entry| entry.generation.wrapping_add(1)); + let replaced = credentials + .insert( + jti, + CredentialsEntry { + token, + mapping, + expires_at: staging_expires, + required_until, + generation, + }, + ) + .is_some(); + + self.cleanup_notify.notify_one(); + + Ok(replaced) } /// Insert or replace the connection-options half. Returns whether a prior entry was replaced. @@ -123,48 +164,158 @@ impl ProvisioningStore { connection_options: TargetConnectionOptions, time_to_live: time::Duration, ) -> bool { + let now = time::OffsetDateTime::now_utc(); let entry = ConnectionOptionsEntry { connection_options, - expires_at: time::OffsetDateTime::now_utc() + time_to_live, + expires_at: now + time_to_live, }; - self.connection_options.lock().insert(jti, entry).is_some() + let replaced = self.connection_options.lock().insert(jti, entry).is_some(); + self.cleanup_notify.notify_one(); + replaced } - /// Assemble the provisioned view for a session. - /// - /// Returns `None` unless the credentials half (token and/or mapping) is present and live. - /// Folds in connection options when that half is also present and live. - pub(crate) fn get(&self, jti: Uuid) -> Option { + /// State of the credential-injection mapping for `jti`. + pub(crate) fn mapping_status(&self, jti: Uuid) -> MappingStatus { let now = time::OffsetDateTime::now_utc(); + let mut credentials = self.credentials.lock(); + + let Some(entry) = credentials.get(&jti) else { + return MappingStatus::Absent; + }; + if now >= entry.expires_at { + credentials.remove(&jti); + return MappingStatus::Absent; + } + + if entry.mapping.is_some() { + MappingStatus::Available + } else { + MappingStatus::Absent + } + } - let (token, mapping) = { - let entries = self.credentials.lock(); - let entry = entries.get(&jti)?; + /// Test helper that takes either a token-only or mapped entry. + #[cfg(test)] + pub(crate) fn take(&self, jti: Uuid) -> Option { + let now = time::OffsetDateTime::now_utc(); + + let (token, mapping, generation, kdc_expires_at) = { + let mut credentials = self.credentials.lock(); + let entry = credentials.remove(&jti)?; if now >= entry.expires_at { warn!(%jti, "Provisioned credentials expired before the connection arrived"); return None; } - (entry.token.clone(), entry.mapping.clone()) + (entry.token, entry.mapping, entry.generation, entry.required_until) }; - let connection_options = self.get_live_connection_options(jti, now); + let connection_options = { + let mut entries = self.connection_options.lock(); + match entries.remove(&jti) { + Some(entry) if now < entry.expires_at => Some(entry.connection_options), + Some(_) => { + warn!(%jti, "Provisioned connection options expired before the connection arrived"); + None + } + None => None, + } + }; - Some(Arc::new(ProvisioningEntry { + Some(ProvisioningEntry { token, mapping, connection_options, - })) + generation, + kdc_expires_at, + }) } - fn get_live_connection_options(&self, jti: Uuid, now: time::OffsetDateTime) -> Option { - let entries = self.connection_options.lock(); - let entry = entries.get(&jti)?; - if now >= entry.expires_at { - warn!(%jti, "Provisioned connection options expired before the connection arrived"); - return None; + /// Clone injection material for this `jti`. + /// + /// The first successful lookup extends retention to the token acceptance deadline so reconnects + /// authorized by `jet_reuse` can still inject. + pub(crate) fn get_mapping(&self, jti: Uuid, token: &str) -> anyhow::Result { + let now = time::OffsetDateTime::now_utc(); + + let (token, mapping, generation, required_until, expiry_changed) = { + let mut credentials = self.credentials.lock(); + let entry = credentials + .get_mut(&jti) + .context("provisioned credential-injection material is missing")?; + + anyhow::ensure!(token == entry.token, "token mismatch"); + let Some(deadline) = entry.required_until else { + anyhow::bail!("provisioned entry has no credential mapping"); + }; + anyhow::ensure!(entry.mapping.is_some(), "provisioned entry has no credential mapping"); + + if now >= entry.expires_at { + credentials.remove(&jti); + anyhow::bail!("credential-injection material for {jti} is missing or expired; re-provision to retry"); + } + + let expiry_changed = entry.expires_at != deadline; + entry.expires_at = deadline; + + ( + entry.token.clone(), + entry.mapping.clone(), + entry.generation, + entry.required_until, + expiry_changed, + ) + }; + + if expiry_changed { + self.cleanup_notify.notify_one(); } - Some(entry.connection_options.clone()) + + let connection_options = { + let entries = self.connection_options.lock(); + match entries.get(&jti) { + Some(entry) if now < entry.expires_at => Some(entry.connection_options.clone()), + Some(_) => { + warn!(%jti, "Provisioned connection options expired before the connection arrived"); + None + } + None => None, + } + }; + + Ok(ProvisioningEntry { + token, + mapping, + connection_options, + generation, + kdc_expires_at: required_until, + }) + } + + #[cfg(test)] + pub(crate) fn credentials_expires_at(&self, jti: Uuid) -> Option { + self.credentials.lock().get(&jti).map(|entry| entry.expires_at) + } + + #[cfg(test)] + pub(crate) fn connection_options_expires_at(&self, jti: Uuid) -> Option { + self.connection_options.lock().get(&jti).map(|entry| entry.expires_at) + } + + fn remove_expired(&self, now: time::OffsetDateTime) { + self.credentials.lock().retain(|_, entry| now < entry.expires_at); + self.connection_options.lock().retain(|_, entry| now < entry.expires_at); + } + + fn next_expiry(&self) -> Option { + let credentials_expiry = self.credentials.lock().values().map(|entry| entry.expires_at).min(); + let options_expiry = self + .connection_options + .lock() + .values() + .map(|entry| entry.expires_at) + .min(); + credentials_expiry.into_iter().chain(options_expiry).min() } } @@ -186,26 +337,28 @@ impl Task for CleanupTask { #[instrument(skip_all)] async fn cleanup_task(handle: ProvisioningStore, mut shutdown_signal: ShutdownSignal) { - use tokio::time::{Duration, sleep}; - - const TASK_INTERVAL: Duration = Duration::from_secs(60 * 15); - debug!("Task started"); loop { - tokio::select! { - _ = sleep(TASK_INTERVAL) => {} - _ = shutdown_signal.wait() => { - break; + let now = time::OffsetDateTime::now_utc(); + handle.remove_expired(now); + + match handle.next_expiry() { + Some(deadline) => { + let delay = (deadline - now).try_into().unwrap_or_default(); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = handle.cleanup_notify.notified() => {} + _ = shutdown_signal.wait() => break, + } + } + None => { + tokio::select! { + _ = handle.cleanup_notify.notified() => {} + _ = shutdown_signal.wait() => break, + } } } - - let now = time::OffsetDateTime::now_utc(); - handle.credentials.lock().retain(|_, entry| now < entry.expires_at); - handle - .connection_options - .lock() - .retain(|_, entry| now < entry.expires_at); } debug!("Task terminated"); @@ -232,14 +385,15 @@ mod tests { } } - fn association_token(jti: Uuid) -> String { + fn association_token_with_exp(jti: Uuid, exp: i64) -> String { use base64::Engine as _; let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; let header = engine.encode(r#"{"alg":"RS256"}"#); let payload = engine.encode( serde_json::to_vec(&serde_json::json!({ "jti": jti, - "dst_hst": "target.example:3389" + "dst_hst": "target.example:3389", + "exp": exp })) .expect("payload serializes"), ); @@ -247,54 +401,62 @@ mod tests { format!("{header}.{payload}.{signature}") } + fn association_token(jti: Uuid) -> String { + association_token_with_exp(jti, time::OffsetDateTime::now_utc().unix_timestamp() + 3600) + } + fn options() -> TargetConnectionOptions { serde_json::from_value(serde_json::json!({ "krb_kdc": "tcp://dc.example:88" })).expect("options") } #[test] - fn get_returns_token_only_entry() { + fn take_returns_token_only_entry() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); store .insert_credentials(association_token(jti), None, time::Duration::minutes(5)) .expect("insert"); - let entry = store.get(jti).expect("live entry"); + let entry = store.take(jti).expect("live entry"); assert!(entry.mapping.is_none()); assert!(entry.connection_options.is_none()); + assert!(store.take(jti).is_none(), "second take is empty"); } #[test] - fn get_returns_live_credentials_without_options() { + fn take_returns_live_credentials_without_options() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); store .insert_credentials(association_token(jti), Some(mapping()), time::Duration::minutes(5)) .expect("insert"); - let entry = store.get(jti).expect("live entry"); + let entry = store.take(jti).expect("live entry"); assert!(entry.mapping.is_some()); assert!(entry.connection_options.is_none()); } #[test] - fn get_folds_in_live_connection_options() { + fn take_folds_in_and_consumes_connection_options() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); store .insert_credentials(association_token(jti), Some(mapping()), time::Duration::minutes(5)) .expect("insert credentials"); assert!(!store.insert_connection_options(jti, options(), time::Duration::minutes(5))); - let entry = store.get(jti).expect("live entry"); + let entry = store.take(jti).expect("live entry"); assert!(entry.connection_options.is_some()); + assert!(store.take(jti).is_none()); + assert!(!store.insert_connection_options(jti, options(), time::Duration::minutes(5))); + assert!(store.take(jti).is_none()); } #[test] - fn get_treats_expired_credentials_as_absent() { + fn take_treats_expired_credentials_as_absent() { let store = ProvisioningStore::new(); let jti = Uuid::new_v4(); store .insert_credentials(association_token(jti), Some(mapping()), time::Duration::seconds(-1)) .expect("insert"); - assert!(store.get(jti).is_none()); + assert!(store.take(jti).is_none()); } #[test] @@ -315,4 +477,181 @@ mod tests { assert!(!store.insert_connection_options(jti, options(), time::Duration::minutes(5))); assert!(store.insert_connection_options(jti, options(), time::Duration::minutes(5))); } + + #[test] + fn reprovision_before_checkout_replaces_staged_mapping() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) + .expect("insert"); + + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) + .expect("re-provision"); + let first = store.get_mapping(jti, &token).expect("checkout replacement"); + assert_eq!(first.generation, 2); + } + + #[test] + fn checked_out_mapping_is_reusable() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) + .expect("insert"); + + let first = store.get_mapping(jti, &token).expect("first checkout"); + let second = store.get_mapping(jti, &token).expect("second checkout"); + assert_eq!(first.generation, second.generation); + } + + #[test] + fn token_mismatch_does_not_drop_mapping() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) + .expect("insert"); + + let error = store.get_mapping(jti, "different token").expect_err("mismatch"); + assert!(format!("{error:#}").contains("token mismatch")); + assert_eq!(store.mapping_status(jti), MappingStatus::Available); + store.get_mapping(jti, &token).expect("valid checkout"); + } + + #[test] + fn concurrent_mapping_checkout_all_succeed() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(5)) + .expect("insert"); + + let barrier = Arc::new(std::sync::Barrier::new(3)); + let handles: Vec<_> = (0..2) + .map(|_| { + let store = store.clone(); + let token = token.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + store.get_mapping(jti, &token) + }) + }) + .collect(); + barrier.wait(); + + let results: Vec<_> = handles + .into_iter() + .map(|handle| handle.join().expect("thread")) + .collect(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 2); + } + + #[test] + fn staging_expiry_before_first_use_removes_mapping() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::seconds(-1)) + .expect("insert"); + + assert_eq!(store.mapping_status(jti), MappingStatus::Absent); + assert!(store.credentials_expires_at(jti).is_none()); + let error = store.get_mapping(jti, &token).expect_err("expired staging"); + assert!(format!("{error:#}").contains("missing")); + } + + #[test] + fn first_get_extends_expiry_to_token_deadline() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let exp = time::OffsetDateTime::now_utc().unix_timestamp() + 3600; + let token = association_token_with_exp(jti, exp); + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::seconds(30)) + .expect("insert"); + + let before = store.credentials_expires_at(jti).expect("inserted"); + store.get_mapping(jti, &token).expect("activate"); + let after = store.credentials_expires_at(jti).expect("activated"); + assert!(after > before); + assert_eq!(after, crate::token::token_acceptance_deadline(exp).expect("deadline")); + } + + #[test] + fn staging_lifetime_uses_provisioning_ttl() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let exp = time::OffsetDateTime::now_utc().unix_timestamp(); + let token = association_token_with_exp(jti, exp); + store + .insert_credentials(token, Some(mapping()), time::Duration::hours(2)) + .expect("insert"); + + let expires_at = store.credentials_expires_at(jti).expect("inserted"); + let deadline = crate::token::token_acceptance_deadline(exp).expect("deadline"); + assert!(expires_at > deadline); + } + + #[test] + fn credential_lifetime_does_not_change_connection_options_lifetime() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token(jti); + store.insert_connection_options(jti, options(), time::Duration::minutes(5)); + let options_expires_at = store.connection_options_expires_at(jti).expect("options"); + + store + .insert_credentials(token.clone(), Some(mapping()), time::Duration::minutes(1)) + .expect("insert"); + assert_eq!(store.connection_options_expires_at(jti), Some(options_expires_at)); + + store.get_mapping(jti, &token).expect("checkout"); + assert_eq!(store.connection_options_expires_at(jti), Some(options_expires_at)); + } + + #[test] + fn mapped_insert_rejects_out_of_range_expiration() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + let token = association_token_with_exp(jti, i64::MAX); + + let error = store + .insert_credentials(token, Some(mapping()), time::Duration::minutes(5)) + .expect_err("invalid expiration"); + + assert!(format!("{error:#}").contains("supported timestamp range")); + assert_eq!(store.mapping_status(jti), MappingStatus::Absent); + } + + #[test] + fn mapped_insert_requires_exp() { + let store = ProvisioningStore::new(); + let jti = Uuid::new_v4(); + use base64::Engine as _; + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let token = format!( + "{}.{}.{}", + engine.encode(r#"{"alg":"RS256"}"#), + engine.encode( + serde_json::to_vec(&serde_json::json!({ + "jti": jti, + "dst_hst": "target.example:3389" + })) + .expect("payload") + ), + engine.encode(b"signature") + ); + let error = store + .insert_credentials(token, Some(mapping()), time::Duration::minutes(5)) + .expect_err("missing exp"); + assert!(format!("{error:#}").contains("exp")); + } } diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index ca7376bd8..e0e4eba44 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -16,7 +16,8 @@ use tracing::field; const PCB_TRANSMIT_DEADLINE: Duration = Duration::from_secs(10); use crate::config::Conf; -use crate::credential_injection_kdc::{CredentialInjectionKdc, CredentialService}; +use crate::credential_injection::{CredentialInjection, SyntheticKdcRegistry}; +use crate::provisioning::{MappingStatus, ProvisioningStore}; use crate::proxy::Proxy; use crate::recording::ActiveRecordings; use crate::session::{ConnectionModeDetails, DisconnectInterest, DisconnectedInfo, SessionInfo, SessionMessageSender}; @@ -425,23 +426,21 @@ async fn connect_rdp_server( }) } -/// Handle RDP connection with credential injection via CredSSP MITM +/// Handle RDP connection with credential injection via CredSSP MITM. #[expect(clippy::too_many_arguments)] async fn handle_with_credential_injection( mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send, client_addr: SocketAddr, conf: Arc, - token_cache: &TokenCache, - jrl: &CurrentJrl, sessions: SessionMessageSender, subscriber_tx: SubscriberSender, - active_recordings: &ActiveRecordings, cleanpath_pdu: RDCleanPathPdu, - credential_injection_kdc: CredentialInjectionKdc, + claims: AssociationTokenClaims, + provisioning: &ProvisioningStore, + synthetic_kdc_registry: &SyntheticKdcRegistry, agent_tunnel_handle: Option>, ) -> anyhow::Result<()> { let tls_conf = conf.credssp_tls.get().context("CredSSP TLS configuration")?; - let gateway_hostname = conf.hostname.clone(); let x224_req = cleanpath_pdu @@ -451,7 +450,6 @@ async fn handle_with_credential_injection( let received_connection_request: ironrdp_pdu::x224::X224 = ironrdp_core::decode(x224_req.as_bytes()).context("decode X224 connection request PDU from client")?; - // Choose the security protocol to use with the client. let received_connection_request_protocol = received_connection_request.0.protocol; let client_security_protocol = if received_connection_request_protocol.contains(nego::SecurityProtocol::HYBRID_EX) { nego::SecurityProtocol::HYBRID_EX @@ -468,18 +466,14 @@ async fn handle_with_credential_injection( ) }; - // Authorize and connect to the RDP server. - let CleanPathAuth { claims } = authorize_cleanpath( - &cleanpath_pdu, - client_addr, - &conf, - token_cache, - jrl, - active_recordings, - &sessions, - ) - .await - .context("RDCleanPath authorization failed")?; + let token = cleanpath_pdu + .proxy_auth + .clone() + .context("missing token in RDCleanPath PDU")?; + + let credential_injection = + CredentialInjection::checkout(provisioning, synthetic_kdc_registry, claims.jti, &token, true) + .context("checkout credential-injection material before connecting upstream")?; let ConnectedRdpServer { tls_stream: server_stream, @@ -491,13 +485,11 @@ async fn handle_with_credential_injection( .context("RDCleanPath connection failed")?; let x224_rsp = x224_rsp.context("RDCleanPath credential injection requires X.224")?; - // Retrieve the Gateway TLS public key that must be used for client-proxy CredSSP later on. let gateway_cert_chain_handle = tokio::spawn(crate::tls::get_cert_chain_for_acceptor_cached( - gateway_hostname.clone(), + gateway_hostname, tls_conf.acceptor.clone(), )); - // Extract server security protocol from X224 response (before x224_rsp is moved). let x224_confirm: ironrdp_pdu::x224::X224 = ironrdp_core::decode(&x224_rsp).context("decode X224 connection confirm")?; let server_security_protocol = match &x224_confirm.0 { @@ -521,8 +513,7 @@ async fn handle_with_credential_injection( let gateway_public_key = crate::tls::extract_public_key(gateway_cert_chain.first().context("no leaf")?) .context("extract Gateway public key")?; - // Send RDCleanPath response to client using Devolutions Gateway certification chain. - // (When performing credential injection, the client performs CredSSP against the Devolutions Gateway.) + // Client CredSSP runs against the Gateway certificate chain. trace!("Sending RDCleanPath response"); let rd_clean_path_rsp = RDCleanPathPdu::new_response( server_addr.to_string(), @@ -531,73 +522,8 @@ async fn handle_with_credential_injection( ) .context("couldn't build RDCleanPath response")?; send_clean_path_response(&mut client_stream, &rd_clean_path_rsp).await?; - debug!("RDCleanPath response sent, now performing CredSSP MITM"); - - // -- Perform the CredSSP authentication with the client (acting as a server) and the server (acting as a client) -- // - - let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream); - let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); - - let krb_configs = crate::rdp_proxy::credential_injection_kerberos_configs( - &conf, - client_addr, - &gateway_hostname, - &credential_injection_kdc, - )?; - - let kdc_connector = - crate::kdc_connector::KdcConnector::new(claims.jet_aid, claims.jet_agent_id, agent_tunnel_handle.clone()); - - let client_credssp_fut = crate::rdp_proxy::perform_credssp_as_server( - &mut client_framed, - client_addr.ip(), - gateway_public_key, - client_security_protocol, - credential_injection_kdc.proxy_credential(), - krb_configs.server, - &credential_injection_kdc, - &kdc_connector, - ); - - let server_credssp_fut = crate::rdp_proxy::perform_credssp_as_client( - &mut server_framed, - destination.host().to_owned(), - server_public_key, - server_security_protocol, - credential_injection_kdc.target_credential(), - krb_configs.client, - &kdc_connector, - ); - - let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); - client_credssp_res.context("CredSSP with client")?; - server_credssp_res.context("CredSSP with server")?; - - debug!("CredSSP MITM completed successfully"); - - // -- Intercept the Connect Confirm PDU, to override the server_security_protocol field -- // + debug!("RDCleanPath response sent, starting CredSSP MITM"); - crate::rdp_proxy::intercept_connect_confirm(&mut client_framed, &mut server_framed, server_security_protocol) - .await?; - - let (mut client_stream, client_leftover) = client_framed.into_inner(); - let (mut server_stream, server_leftover) = server_framed.into_inner(); - - // -- At this point, proceed to the usual two-way forwarding -- // - - info!("RDP-TLS forwarding (credential injection)"); - - client_stream - .write_all(&server_leftover) - .await - .context("write server leftover to client")?; - - server_stream - .write_all(&client_leftover) - .await - .context("write client leftover to server")?; - - // Build SessionInfo for forwarding let info = SessionInfo::builder() .id(claims.jet_aid) .application_protocol(claims.jet_ap) @@ -610,22 +536,32 @@ async fn handle_with_credential_injection( .build(); let disconnect_interest = DisconnectInterest::from_reconnection_policy(claims.jet_reuse); + let kdc_connector = + crate::kdc_connector::KdcConnector::new(claims.jet_aid, claims.jet_agent_id, agent_tunnel_handle.clone()); - // Plain forwarding for now - Proxy::builder() + let session = crate::rdp_proxy::CredsspSession::builder() .conf(conf) .session_info(info) - .address_a(client_addr) - .transport_a(client_stream) - .address_b(server_addr) - .transport_b(server_stream) + .client_addr(client_addr) + .server_addr(server_addr) + .credential_injection(credential_injection) .sessions(sessions) .subscriber_tx(subscriber_tx) + .server_dns_name(destination.host().to_owned()) .disconnect_interest(disconnect_interest) - .build() - .select_dissector_and_forward() - .await - .context("proxy failed") + .kdc_connector(kdc_connector) + .build(); + + let prepared = crate::rdp_proxy::PreparedCredssp::builder() + .client_stream(client_stream) + .server_stream(server_stream) + .gateway_public_key(gateway_public_key) + .server_public_key(server_public_key) + .client_security_protocol(client_security_protocol) + .server_security_protocol(server_security_protocol) + .build(); + + session.run(prepared).await } #[expect(clippy::too_many_arguments)] @@ -639,7 +575,8 @@ pub async fn handle( sessions: SessionMessageSender, subscriber_tx: SubscriberSender, active_recordings: &ActiveRecordings, - credentials: &CredentialService, + provisioning: &ProvisioningStore, + synthetic_kdc_registry: &SyntheticKdcRegistry, agent_tunnel_handle: Option>, ) -> anyhow::Result<()> { // Special handshake of our RDP extension @@ -650,71 +587,58 @@ pub async fn handle( .await .context("couldn't read cleanpath PDU")?; - // Early credential detection: check if we should use RdpProxy instead. - let token = cleanpath_pdu - .proxy_auth - .as_deref() - .context("missing token in RDCleanPath PDU")?; - - // If a credential mapping has been pushed, we automatically switch to - // proxy-based credential injection mode. Otherwise, we continue the usual - // clean path procedure. The credential store is keyed on the association token's JTI. - if let Some(jti) = crate::token::extract_jti(token).ok() - && let Some(entry) = credentials.get(jti) - && entry.mapping.is_some() + let auth = match authorize_cleanpath( + &cleanpath_pdu, + client_addr, + &conf, + token_cache, + jrl, + active_recordings, + &sessions, + ) + .await { - // VMConnect needs pre-X.224 CredSSP against the Hyper-V host cert on the client. - // Proxy CredSSP MITM is X.224-first and is not supported for this ordering. - if is_vmconnect_request(&cleanpath_pdu) { - let response = RDCleanPathPdu::new_http_error(400); + Ok(auth) => auth, + Err(error) => { + let response = RDCleanPathPdu::from(&error); send_clean_path_response(&mut client_stream, &response).await?; - anyhow::bail!("credential injection is not supported for VMConnect RDCleanPath"); + return anyhow::Error::new(error) + .context("an error occurred when processing cleanpath PDU") + .pipe(Err)?; } + }; - 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, - client_addr, - conf, - token_cache, - jrl, - sessions, - subscriber_tx, - active_recordings, - cleanpath_pdu, - credential_injection_kdc, - agent_tunnel_handle.clone(), - ) - .await; + let mapping_status = provisioning.mapping_status(auth.claims.jti); + if is_vmconnect_request(&cleanpath_pdu) && mapping_status == MappingStatus::Available { + let response = RDCleanPathPdu::new_http_error(400); + send_clean_path_response(&mut client_stream, &response).await?; + anyhow::bail!("credential injection is not supported for VMConnect RDCleanPath"); } - trace!("Processing RDCleanPath"); - - let (auth, connected) = match async { - let auth = authorize_cleanpath( - &cleanpath_pdu, - client_addr, - &conf, - token_cache, - jrl, - active_recordings, - &sessions, - ) - .await?; + match mapping_status { + MappingStatus::Available => { + debug!(jti = %auth.claims.jti, "Switching to RdpProxy for credential injection (WebSocket)"); + return handle_with_credential_injection( + client_stream, + client_addr, + conf, + sessions, + subscriber_tx, + cleanpath_pdu, + auth.claims, + provisioning, + synthetic_kdc_registry, + agent_tunnel_handle.clone(), + ) + .await; + } + MappingStatus::Absent => {} + } - let connected = connect_rdp_server(&auth.claims, cleanpath_pdu, agent_tunnel_handle.as_ref()).await?; + trace!("Processing RDCleanPath"); - Ok::<_, CleanPathError>((auth, connected)) - } - .await - { - Ok(result) => result, + let connected = match connect_rdp_server(&auth.claims, cleanpath_pdu, agent_tunnel_handle.as_ref()).await { + Ok(connected) => connected, Err(error) => { let response = RDCleanPathPdu::from(&error); send_clean_path_response(&mut client_stream, &response).await?; diff --git a/devolutions-gateway/src/rdp_proxy.rs b/devolutions-gateway/src/rdp_proxy.rs deleted file mode 100644 index 254a71df8..000000000 --- a/devolutions-gateway/src/rdp_proxy.rs +++ /dev/null @@ -1,846 +0,0 @@ -use std::net::{IpAddr, SocketAddr}; -use std::sync::Arc; - -use anyhow::Context as _; -use ironrdp_acceptor::credssp::CredsspProcessGenerator as CredsspServerProcessGenerator; -use ironrdp_connector::credssp::CredsspProcessGenerator as CredsspClientProcessGenerator; -use ironrdp_connector::sspi; -use ironrdp_connector::sspi::generator::GeneratorState; -use ironrdp_pdu::{mcs, nego, x224}; -use secrecy::ExposeSecret as _; -use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; -use typed_builder::TypedBuilder; - -use crate::config::Conf; -use crate::credential::AppCredential; -use crate::credential_injection_kdc::{ - CredentialInjectionClientAcceptorProtocol, CredentialInjectionKdc, CredentialInjectionKdcInterception, -}; -use crate::kdc_connector::KdcConnector; -use crate::proxy::Proxy; -use crate::session::{DisconnectInterest, SessionInfo, SessionMessageSender}; -use crate::subscriber::SubscriberSender; - -#[derive(TypedBuilder)] -pub struct RdpProxy { - conf: Arc, - session_info: SessionInfo, - client_stream: C, - client_addr: SocketAddr, - server_stream: S, - server_addr: SocketAddr, - credential_injection_kdc: CredentialInjectionKdc, - client_stream_leftover_bytes: bytes::BytesMut, - sessions: SessionMessageSender, - subscriber_tx: SubscriberSender, - server_dns_name: String, - disconnect_interest: Option, - /// Outbound dispatcher for CredSSP-originated KDC traffic. Encapsulates whether KDC - /// requests should attempt agent-tunnel routing (and any `jet_agent_id` pin from the - /// parent association token) or always go direct. - kdc_connector: KdcConnector, -} - -impl RdpProxy -where - A: AsyncWrite + AsyncRead + Unpin + Send, - B: AsyncWrite + AsyncRead + Unpin + Send, -{ - pub async fn run(self) -> anyhow::Result<()> { - handle(self).await - } -} - -#[instrument("rdp_proxy", skip_all, fields(session_id = proxy.session_info.id.to_string(), target = proxy.server_addr.to_string()))] -async fn handle(proxy: RdpProxy) -> anyhow::Result<()> -where - C: AsyncRead + AsyncWrite + Unpin + Send, - S: AsyncRead + AsyncWrite + Unpin + Send, -{ - let RdpProxy { - conf, - session_info, - client_stream, - client_addr, - server_stream, - server_addr, - credential_injection_kdc, - client_stream_leftover_bytes, - sessions, - subscriber_tx, - server_dns_name, - disconnect_interest, - kdc_connector, - } = proxy; - - let tls_conf = conf.credssp_tls.get().context("CredSSP TLS configuration")?; - let gateway_hostname = conf.hostname.clone(); - - // -- Retrieve the Gateway TLS public key that must be used for client-proxy CredSSP later on -- // - - let gateway_cert_chain_handle = tokio::spawn(crate::tls::get_cert_chain_for_acceptor_cached( - gateway_hostname.clone(), - tls_conf.acceptor.clone(), - )); - - // -- Dual handshake with the client and the server until the TLS security upgrade -- // - - let mut client_framed = - ironrdp_tokio::MovableTokioFramed::new_with_leftover(client_stream, client_stream_leftover_bytes); - let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); - - let handshake_result = dual_handshake_until_tls_upgrade( - &mut client_framed, - &mut server_framed, - credential_injection_kdc.target_credential(), - ) - .await?; - - let client_stream = client_framed.into_inner_no_leftover(); - let server_stream = server_framed.into_inner_no_leftover(); - - // -- Perform the TLS upgrading for both the client and the server, effectively acting as a man-in-the-middle -- // - - let client_tls_upgrade_fut = tls_conf.acceptor.accept(client_stream); - let server_tls_upgrade_fut = crate::tls::dangerous_connect(server_dns_name.clone(), server_stream); - - let (client_stream, server_stream) = tokio::join!(client_tls_upgrade_fut, server_tls_upgrade_fut); - - let client_stream = client_stream.context("TLS upgrade with client failed")?; - let server_stream = server_stream.context("TLS upgrade with server failed")?; - - let server_public_key = - crate::tls::extract_stream_peer_public_key(&server_stream).context("extract target server TLS public key")?; - - let gateway_cert_chain = gateway_cert_chain_handle.await??; - let gateway_public_key = crate::tls::extract_public_key(gateway_cert_chain.first().context("no leaf")?) - .context("extract Gateway public key")?; - - // -- Perform the CredSSP authentication with the client (acting as a server) and the server (acting as a client) -- // - - let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream); - let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); - - let krb_configs = - credential_injection_kerberos_configs(&conf, client_addr, &gateway_hostname, &credential_injection_kdc)?; - - let client_credssp_fut = perform_credssp_as_server( - &mut client_framed, - client_addr.ip(), - gateway_public_key, - handshake_result.client_security_protocol, - credential_injection_kdc.proxy_credential(), - krb_configs.server, - &credential_injection_kdc, - &kdc_connector, - ); - - let server_credssp_fut = perform_credssp_as_client( - &mut server_framed, - server_dns_name, - server_public_key, - handshake_result.server_security_protocol, - credential_injection_kdc.target_credential(), - krb_configs.client, - &kdc_connector, - ); - - let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); - client_credssp_res.context("CredSSP with client")?; - server_credssp_res.context("CredSSP with server")?; - - // -- Intercept the Connect Confirm PDU, to override the server_security_protocol field -- // - - intercept_connect_confirm( - &mut client_framed, - &mut server_framed, - handshake_result.server_security_protocol, - ) - .await?; - - let (mut client_stream, client_leftover) = client_framed.into_inner(); - let (mut server_stream, server_leftover) = server_framed.into_inner(); - - // -- At this point, proceed to the usual two-way forwarding -- // - - info!("RDP-TLS forwarding (credential injection)"); - - client_stream - .write_all(&server_leftover) - .await - .context("write server leftover to client")?; - - server_stream - .write_all(&client_leftover) - .await - .context("write client leftover to server")?; - - Proxy::builder() - .conf(conf) - .session_info(session_info) - .address_a(client_addr) - .transport_a(client_stream) - .address_b(server_addr) - .transport_b(server_stream) - .sessions(sessions) - .subscriber_tx(subscriber_tx) - .disconnect_interest(disconnect_interest) - .build() - .select_dissector_and_forward() - .await - .context("RDP-TLS traffic proxying failed")?; - - Ok(()) -} - -#[derive(Debug)] -struct HandshakeResult { - client_security_protocol: nego::SecurityProtocol, - server_security_protocol: nego::SecurityProtocol, -} - -#[instrument(level = "debug", ret, skip_all)] -pub(crate) async fn intercept_connect_confirm( - client_framed: &mut ironrdp_tokio::MovableTokioFramed, - server_framed: &mut ironrdp_tokio::MovableTokioFramed, - server_security_protocol: nego::SecurityProtocol, -) -> anyhow::Result<()> -where - C: AsyncWrite + AsyncRead + Unpin + Send, - S: AsyncWrite + AsyncRead + Unpin + Send, -{ - let (_, received_frame) = client_framed - .read_pdu() - .await - .context("read MCS Connect Initial from client")?; - let received_connect_initial: x224::X224> = - ironrdp_core::decode(&received_frame).context("decode PDU from client")?; - let mut received_connect_initial: mcs::ConnectInitial = - ironrdp_core::decode(&received_connect_initial.0.data).context("decode Connect Initial PDU")?; - trace!(message = ?received_connect_initial, "Received Connect Initial PDU from client"); - - let mut gcc_blocks = received_connect_initial.conference_create_request.into_gcc_blocks(); - gcc_blocks.core.optional_data.server_selected_protocol = Some(server_security_protocol); - // Update the conference request with modified gcc_blocks. - received_connect_initial.conference_create_request = ironrdp_pdu::gcc::ConferenceCreateRequest::new(gcc_blocks)?; - trace!(message = ?received_connect_initial, "Send Connection Request PDU to server"); - let x224_msg_buf = ironrdp_core::encode_vec(&received_connect_initial)?; - let pdu = x224::X224Data { - data: std::borrow::Cow::Owned(x224_msg_buf), - }; - send_pdu(server_framed, &x224::X224(pdu)) - .await - .context("send connection request to server")?; - - Ok(()) -} - -#[instrument(name = "dual_handshake", level = "debug", ret, skip_all)] -async fn dual_handshake_until_tls_upgrade( - client_framed: &mut ironrdp_tokio::MovableTokioFramed, - server_framed: &mut ironrdp_tokio::MovableTokioFramed, - target_credential: &AppCredential, -) -> anyhow::Result -where - C: AsyncWrite + AsyncRead + Unpin + Send, - S: AsyncWrite + AsyncRead + Unpin + Send, -{ - let (_, received_frame) = client_framed.read_pdu().await.context("read PDU from client")?; - let received_connection_request: x224::X224 = - ironrdp_core::decode(&received_frame).context("decode PDU from client")?; - trace!(message = ?received_connection_request, "Received Connection Request PDU from client"); - - // Choose the security protocol to use with the client. - let received_connection_request_protocol = received_connection_request.0.protocol; - let client_security_protocol = if received_connection_request_protocol.contains(nego::SecurityProtocol::HYBRID_EX) { - nego::SecurityProtocol::HYBRID_EX - } else if received_connection_request - .0 - .protocol - .contains(nego::SecurityProtocol::HYBRID) - { - nego::SecurityProtocol::HYBRID - } else { - anyhow::bail!( - "client does not support CredSSP (received {})", - received_connection_request.0.protocol - ) - }; - - let connection_request_to_send = nego::ConnectionRequest { - nego_data: match target_credential { - AppCredential::UsernamePassword { username, .. } => { - Some(nego::NegoRequestData::cookie(username.to_owned())) - } - }, - flags: received_connection_request.0.flags, - // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/902b090b-9cb3-4efc-92bf-ee13373371e3 - // - // The spec states that `PROTOCOL_SSL` "SHOULD" also be set when using `PROTOCOL_HYBRID`: - // - // > PROTOCOL_HYBRID (0x00000002) - // > Credential Security Support Provider protocol (CredSSP) (section 5.4.5.2). - // > If this flag is set, then the PROTOCOL_SSL (0x00000001) flag SHOULD also be set - // > because Transport Layer Security (TLS) is a subset of CredSSP. - // - // However, in practice `mstsc` is picky about these flags: it expects the - // SupportedProtocol bits in the ConnectionRequestPDU that reach the target - // server to match what the client originally sent. If the proxy modifies - // them (for example, forcing HYBRID | HYBRID_EX and/or clearing SSL), - // the connection can fail with an authentication error (Code: 0x609). - // - // We therefore *do not* synthesize a new protocol bitmask here anymore. - // Instead, we forward the client's SupportedProtocol flags as-is and - // enforce our policy by validating them: if HYBRID / HYBRID_EX are not - // present (i.e. NLA is not negotiated), we fail the connection rather - // than trying to "fix" the flags ourselves. - // - // See also: https://serverfault.com/a/720161 - protocol: received_connection_request_protocol, - }; - trace!(?connection_request_to_send, "Send Connection Request PDU to server"); - send_pdu(server_framed, &x224::X224(connection_request_to_send)) - .await - .context("send connection request to server")?; - - let (_, received_frame) = server_framed.read_pdu().await.context("read PDU from server")?; - let received_connection_confirm: x224::X224 = - ironrdp_core::decode(&received_frame).context("decode PDU from server")?; - trace!(message = ?received_connection_confirm, "Received Connection Confirm PDU from server"); - - let (connection_confirm_to_send, handshake_result) = match &received_connection_confirm.0 { - nego::ConnectionConfirm::Response { - flags, - protocol: server_security_protocol, - } => { - debug!(?server_security_protocol, ?flags, "Server confirmed connection"); - - let result = if !server_security_protocol - .intersects(nego::SecurityProtocol::HYBRID | nego::SecurityProtocol::HYBRID_EX) - { - Err(anyhow::anyhow!( - "server selected security protocol {server_security_protocol}, which is not supported for credential injection" - )) - } else { - Ok(HandshakeResult { - client_security_protocol, - server_security_protocol: *server_security_protocol, - }) - }; - - ( - x224::X224(nego::ConnectionConfirm::Response { - flags: *flags, - protocol: client_security_protocol, - }), - result, - ) - } - nego::ConnectionConfirm::Failure { code } => ( - x224::X224(received_connection_confirm.0.clone()), - Err(anyhow::anyhow!("RDP session initiation failed with code {code}")), - ), - }; - - trace!(?connection_confirm_to_send, "Send Connection Request PDU to client"); - send_pdu(client_framed, &connection_confirm_to_send) - .await - .context("send connection confirm to client")?; - - handshake_result -} - -/// Kerberos configs for the two CredSSP legs of a credential-injection session. -/// -/// `server` drives the client-facing acceptor (Gateway-as-server); `client` drives the -/// target-facing leg (Gateway-as-client). `None` on a leg means that leg authenticates over NTLM. -pub(crate) struct CredentialInjectionKerberosConfigs { - pub server: Option, - pub client: Option, -} - -/// Whether a credential-injection session speaks Kerberos (vs NTLM). Decided once so both CredSSP -/// legs agree — sspi's acceptor and initiator must speak the same package or the handshake fails -/// reading one as the other. Kerberos needs the experimental opt-in AND a domain-qualified target -/// (a domainless account can't get a ticket). -fn injection_uses_kerberos( - enable_unstable: bool, - kerberos_credential_injection: bool, - protocol: CredentialInjectionClientAcceptorProtocol, -) -> bool { - enable_unstable - && kerberos_credential_injection - && matches!(protocol, CredentialInjectionClientAcceptorProtocol::Kerberos) -} - -/// Build the Kerberos config for both CredSSP legs from the single [`injection_uses_kerberos`] -/// decision. Everything else is NTLM on both legs. -pub(crate) fn credential_injection_kerberos_configs( - conf: &Conf, - client_addr: SocketAddr, - gateway_hostname: &str, - credential_injection_kdc: &CredentialInjectionKdc, -) -> anyhow::Result { - let protocol = credential_injection_kdc.client_acceptor_protocol()?; - - if !injection_uses_kerberos( - conf.debug.enable_unstable, - conf.debug.kerberos_credential_injection, - protocol, - ) { - return Ok(CredentialInjectionKerberosConfigs { - server: None, - client: None, - }); - } - - let krb_kdc = credential_injection_kdc - .krb_kdc() - .context("kerberos credential injection requires the krb_kdc target connection option")?; - - Ok(CredentialInjectionKerberosConfigs { - server: Some(credential_injection_kdc.server_kerberos_config(client_addr)?), - client: Some(ironrdp_connector::credssp::KerberosConfig { - kdc_proxy_url: Some(krb_kdc.clone()), - hostname: gateway_hostname.to_owned(), - }), - }) -} - -#[instrument(name = "server_credssp", level = "debug", ret, skip_all)] -pub(crate) async fn perform_credssp_as_client( - framed: &mut ironrdp_tokio::Framed, - server_name: String, - server_public_key: Vec, - security_protocol: nego::SecurityProtocol, - credentials: &AppCredential, - kerberos_config: Option, - kdc_connector: &KdcConnector, -) -> anyhow::Result<()> -where - S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, -{ - use ironrdp_tokio::FramedWrite as _; - - // Decrypt password into short-lived buffer. - let (username, decrypted_password) = credentials - .decrypt_password() - .context("failed to decrypt credentials")?; - - let credentials = ironrdp_connector::Credentials::UsernamePassword { - username, - password: decrypted_password.expose_secret().to_owned(), - }; - // decrypted_password drops here, zeroizing its buffer; note: a copy of the plaintext - // remains in `credentials` above, which is a regular String (downstream API limitation). - - let (mut sequence, mut ts_request) = ironrdp_connector::credssp::CredsspSequence::init( - credentials, - None, - security_protocol, - ironrdp_connector::ServerName::new(server_name.clone()), - server_public_key, - kerberos_config, - )?; - - let mut buf = ironrdp_pdu::WriteBuf::new(); - - loop { - let client_state = { - let mut generator = sequence.process_ts_request(ts_request); - resolve_client_generator(&mut generator, kdc_connector).await? - }; // drop generator - - buf.clear(); - let written = sequence.handle_process_result(client_state, &mut buf)?; - - if let Some(response_len) = written.size() { - let response = &buf[..response_len]; - framed - .write_all(response) - .await - .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; - } - - let Some(next_pdu_hint) = sequence.next_pdu_hint() else { - break; - }; - - let pdu = framed.read_by_hint(next_pdu_hint).await.context("read frame by hint")?; - - if let Some(next_request) = sequence.decode_server_message(&pdu)? { - ts_request = next_request; - } else { - break; - } - } - - Ok(()) -} - -async fn resolve_server_generator( - generator: &mut CredsspServerProcessGenerator<'_>, - credential_injection_kdc: &CredentialInjectionKdc, - kdc_connector: &KdcConnector, -) -> Result { - let mut state = generator.start(); - - loop { - match state { - GeneratorState::Suspended(request) => { - let response = match credential_injection_kdc.intercept_network_request(&request) { - Ok(CredentialInjectionKdcInterception::Intercepted(response)) => Ok(response), - Ok(CredentialInjectionKdcInterception::NotInjectionRequest) => { - kdc_connector.send_network_request(&request).await - } - Ok(CredentialInjectionKdcInterception::NotInjectionRealm(mismatch)) => Err(anyhow::anyhow!( - "kdc request realm does not match credential-injection session realm: {mismatch}" - )), - Err(error) => Err(error), - } - .map_err(|err| sspi::credssp::ServerError { - ts_request: None, - error: sspi::Error::new(sspi::ErrorKind::InternalError, err), - })?; - - state = generator.resume(Ok(response)); - } - GeneratorState::Completed(client_state) => { - break client_state; - } - } - } -} - -async fn resolve_client_generator( - generator: &mut CredsspClientProcessGenerator<'_>, - kdc_connector: &KdcConnector, -) -> anyhow::Result { - let mut state = generator.start(); - - loop { - match state { - GeneratorState::Suspended(request) => { - let response = kdc_connector.send_network_request(&request).await?; - state = generator.resume(Ok(response)); - } - GeneratorState::Completed(client_state) => { - break Ok(client_state.map_err(|e| { - ironrdp_connector::ConnectorError::new("CredSSP", ironrdp_connector::ConnectorErrorKind::Credssp(e)) - })?); - } - }; - } -} - -#[expect(clippy::too_many_arguments)] -#[instrument(name = "client_credssp", level = "debug", ret, skip_all)] -pub(crate) async fn perform_credssp_as_server( - framed: &mut ironrdp_tokio::Framed, - client_addr: IpAddr, - gateway_public_key: Vec, - security_protocol: nego::SecurityProtocol, - credentials: &AppCredential, - kerberos_server_config: Option, - credential_injection_kdc: &CredentialInjectionKdc, - kdc_connector: &KdcConnector, -) -> anyhow::Result<()> -where - S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, -{ - use ironrdp_connector::sspi::credssp::EarlyUserAuthResult; - use ironrdp_tokio::FramedWrite as _; - - let mut buf = ironrdp_pdu::WriteBuf::new(); - - // Are we supposed to use the actual computer name of the client? - // But this does not seem to matter so far, so we stringify the IP address of the client instead. - let client_computer_name = ironrdp_connector::ServerName::new(client_addr.to_string()); - - let result = credssp_loop( - framed, - &mut buf, - client_computer_name, - gateway_public_key, - credentials, - kerberos_server_config, - credential_injection_kdc, - kdc_connector, - ) - .await; - - if security_protocol.intersects(nego::SecurityProtocol::HYBRID_EX) { - trace!(?result, "HYBRID_EX"); - - let result = if result.is_ok() { - EarlyUserAuthResult::Success - } else { - EarlyUserAuthResult::AccessDenied - }; - - buf.clear(); - result.to_buffer(&mut buf).context("write early user auth result")?; - let response = &buf[..result.buffer_len()]; - framed.write_all(response).await.context("write_all")?; - } - - return result; - - async fn credssp_loop( - framed: &mut ironrdp_tokio::Framed, - buf: &mut ironrdp_pdu::WriteBuf, - client_computer_name: ironrdp_connector::ServerName, - public_key: Vec, - credentials: &AppCredential, - kerberos_server_config: Option, - credential_injection_kdc: &CredentialInjectionKdc, - kdc_connector: &KdcConnector, - ) -> anyhow::Result<()> - where - S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, - { - // Decrypt password into short-lived buffer. - let (username, decrypted_password) = credentials - .decrypt_password() - .context("failed to decrypt credentials")?; - - let username = sspi::Username::parse(&username).context("invalid username")?; - - let identity = sspi::AuthIdentity { - username, - password: decrypted_password.expose_secret().to_owned().into(), - }; - // decrypted_password drops here, zeroizing its buffer; note: a copy of the plaintext - // remains in `identity` above (downstream API limitation). - - let mut sequence = ironrdp_acceptor::credssp::CredsspSequence::init( - &identity, - client_computer_name, - public_key, - kerberos_server_config, - )?; - - loop { - let Some(next_pdu_hint) = sequence.next_pdu_hint()? else { - break; - }; - - let pdu = framed - .read_by_hint(next_pdu_hint) - .await - .map_err(|e| ironrdp_connector::custom_err!("read frame by hint", e))?; - - let Some(ts_request) = sequence.decode_client_message(&pdu)? else { - break; - }; - - let result = { - let mut generator = sequence.process_ts_request(ts_request); - resolve_server_generator(&mut generator, credential_injection_kdc, kdc_connector).await - }; // drop generator - - buf.clear(); - let written = sequence.handle_process_result(result, buf)?; - - if let Some(response_len) = written.size() { - let response = &buf[..response_len]; - framed - .write_all(response) - .await - .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; - } - } - - Ok(()) - } -} - -async fn send_pdu(framed: &mut ironrdp_tokio::MovableTokioFramed, pdu: &P) -> anyhow::Result<()> -where - S: AsyncWrite + Unpin + Send, - P: ironrdp_core::Encode, -{ - use ironrdp_tokio::FramedWrite as _; - - let payload = ironrdp_core::encode_vec(pdu).context("failed to encode PDU")?; - framed.write_all(&payload).await.context("failed to write PDU")?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::net::{Ipv4Addr, SocketAddr}; - use std::sync::Arc; - - use base64::Engine as _; - use secrecy::SecretString; - use uuid::Uuid; - - use super::*; - use crate::config::ConfHandle; - use crate::credential::{CleartextAppCredential, CleartextAppCredentialMapping}; - use crate::credential_injection_kdc::CredentialService; - use crate::target_connection_options::TargetConnectionOptions; - - const TEST_CONFIG: &str = r#"{ - "Hostname": "dgateway.localhost.com", - "ProvisionerPublicKeyData": { - "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" - }, - "Listeners": [ - { "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" } - ], - "__debug__": { "disable_token_validation": true } - }"#; - - const KERBEROS_CONFIG: &str = r#"{ - "Hostname": "dgateway.localhost.com", - "ProvisionerPublicKeyData": { - "Value": "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB" - }, - "Listeners": [ - { "InternalUrl": "http://*:7171", "ExternalUrl": "https://*:7171" } - ], - "__debug__": { - "disable_token_validation": true, - "enable_unstable": true, - "kerberos_credential_injection": true - } - }"#; - - fn conf(json: &str) -> Arc { - ConfHandle::mock(json).expect("test config is valid").get_conf() - } - - fn client_addr() -> SocketAddr { - SocketAddr::from((Ipv4Addr::LOCALHOST, 33_889)) - } - - fn association_token(jti: Uuid) -> String { - let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; - let header = engine.encode(r#"{"alg":"RS256"}"#); - let payload = engine.encode( - serde_json::to_vec(&serde_json::json!({ - "jti": jti, - "dst_hst": "target.example:3389" - })) - .expect("payload serializes"), - ); - let signature = engine.encode(b"signature"); - format!("{header}.{payload}.{signature}") - } - - fn mapping(target_username: &str) -> CleartextAppCredentialMapping { - CleartextAppCredentialMapping { - proxy: CleartextAppCredential::UsernamePassword { - username: "proxy@example.invalid".to_owned(), - password: SecretString::from("pwd"), - }, - target: CleartextAppCredential::UsernamePassword { - username: target_username.to_owned(), - password: SecretString::from("pwd"), - }, - } - } - - /// Provision credentials (and optional `krb_kdc`) then resolve the injection KDC — the - /// in-process path RDP takes before building CredSSP Kerberos configs. - fn provisioned_kdc(target_username: &str, krb_kdc: Option<&str>) -> CredentialInjectionKdc { - let service = CredentialService::new(ConfHandle::mock(TEST_CONFIG).expect("test config is valid")); - let jti = Uuid::new_v4(); - service - .insert_credentials( - association_token(jti), - Some(mapping(target_username)), - time::Duration::minutes(5), - ) - .expect("credentials insert"); - if let Some(krb_kdc) = krb_kdc { - let options = TargetConnectionOptions::new(Some(krb_kdc)).expect("valid krb_kdc"); - service.insert_connection_options(jti, options, time::Duration::minutes(5)); - } - service.kdc_for(jti).expect("kdc_for resolves provisioned state") - } - - // The two CredSSP legs are built from this single decision, so agreement is guaranteed by - // construction. These cases pin the decision itself (the bug was the two legs deciding - // independently): Kerberos requires BOTH opt-in flags AND a domain-qualified target. - #[test] - fn injection_uses_kerberos_requires_optin_and_domain_qualified_target() { - use CredentialInjectionClientAcceptorProtocol::{Kerberos, Ntlm}; - - assert!(injection_uses_kerberos(true, true, Kerberos)); - - // Either opt-in off => NTLM, even for a Kerberos-capable target. - assert!(!injection_uses_kerberos(false, true, Kerberos)); - assert!(!injection_uses_kerberos(true, false, Kerberos)); - - // Domainless target can't get a ticket => NTLM regardless of the flags. - assert!(!injection_uses_kerberos(true, true, Ntlm)); - assert!(!injection_uses_kerberos(false, false, Ntlm)); - } - - #[test] - fn provisioned_krb_kdc_becomes_client_kdc_proxy_url() { - let conf = conf(KERBEROS_CONFIG); - let kdc = provisioned_kdc("administrator@example.invalid", Some("tcp://dc.example.com:88")); - - let configs = - credential_injection_kerberos_configs(conf.as_ref(), client_addr(), "dgateway.localhost.com", &kdc) - .expect("kerberos configs build when krb_kdc is provisioned"); - - let client = configs.client.expect("client leg speaks Kerberos"); - assert_eq!( - client.kdc_proxy_url.as_ref().map(url::Url::as_str), - Some("tcp://dc.example.com:88"), - "target-side CredSSP must use the provisioned KDC URL", - ); - assert_eq!(client.hostname, "dgateway.localhost.com"); - assert!(configs.server.is_some(), "both CredSSP legs must agree on Kerberos"); - } - - #[test] - fn kerberos_path_requires_provisioned_krb_kdc() { - let conf = conf(KERBEROS_CONFIG); - let kdc = provisioned_kdc("administrator@example.invalid", None); - - let error = - match credential_injection_kerberos_configs(conf.as_ref(), client_addr(), "dgateway.localhost.com", &kdc) { - Ok(_) => panic!("Kerberos without krb_kdc must fail before CredSSP starts"), - Err(error) => error, - }; - - assert!( - format!("{error:#}").contains("krb_kdc"), - "error should name the missing connection option, got: {error:#}", - ); - } - - #[test] - fn ntlm_path_does_not_require_krb_kdc() { - // Domainless target → NTLM decision even with Kerberos feature flags on. - let conf = conf(KERBEROS_CONFIG); - let kdc = provisioned_kdc("Administrator", None); - - let configs = - credential_injection_kerberos_configs(conf.as_ref(), client_addr(), "dgateway.localhost.com", &kdc) - .expect("NTLM path succeeds without connection options"); - - assert!(configs.client.is_none()); - assert!(configs.server.is_none()); - } - - #[test] - fn kerberos_flags_off_does_not_require_krb_kdc() { - // Domain-qualified target but feature flags off → NTLM on both legs. - let conf = conf(TEST_CONFIG); - let kdc = provisioned_kdc("administrator@example.invalid", None); - - let configs = - credential_injection_kerberos_configs(conf.as_ref(), client_addr(), "dgateway.localhost.com", &kdc) - .expect("flags off means NTLM without needing krb_kdc"); - - assert!(configs.client.is_none()); - assert!(configs.server.is_none()); - } -} diff --git a/devolutions-gateway/src/rdp_proxy/credssp.rs b/devolutions-gateway/src/rdp_proxy/credssp.rs new file mode 100644 index 000000000..94e36c6f1 --- /dev/null +++ b/devolutions-gateway/src/rdp_proxy/credssp.rs @@ -0,0 +1,577 @@ +//! CredSSP MITM for proxy-based RDP credential injection. +//! +//! Enclosed here so [`super::RdpProxy`] only orchestrates handshake and TLS upgrade. +//! The dual CredSSP legs, Kerberos config derivation, Connect Confirm intercept, and the +//! post-auth forward all live in [`CredsspSession::run`]. + +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::Context as _; +use ironrdp_acceptor::credssp::CredsspProcessGenerator as CredsspServerProcessGenerator; +use ironrdp_connector::credssp::CredsspProcessGenerator as CredsspClientProcessGenerator; +use ironrdp_connector::sspi; +use ironrdp_connector::sspi::generator::GeneratorState; +use ironrdp_pdu::{mcs, nego, x224}; +use secrecy::ExposeSecret as _; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _}; +use typed_builder::TypedBuilder; + +use super::send_pdu; +use crate::config::Conf; +use crate::credential::AppCredential; +use crate::credential_injection::{CredentialInjection, CredentialInjectionKdc, CredentialInjectionKdcInterception}; +use crate::kdc_connector::KdcConnector; +use crate::proxy::Proxy; +use crate::session::{DisconnectInterest, SessionInfo, SessionMessageSender}; +use crate::subscriber::SubscriberSender; + +/// Long-lived inputs for the CredSSP MITM + forward phase. +#[derive(TypedBuilder)] +pub(crate) struct CredsspSession { + conf: Arc, + session_info: SessionInfo, + client_addr: SocketAddr, + server_addr: SocketAddr, + credential_injection: CredentialInjection, + sessions: SessionMessageSender, + subscriber_tx: SubscriberSender, + server_dns_name: String, + disconnect_interest: Option, + kdc_connector: KdcConnector, +} + +/// Streams and keys collected after TLS upgrade, ready for CredSSP. +#[derive(TypedBuilder)] +pub(crate) struct PreparedCredssp { + client_stream: C, + server_stream: S, + gateway_public_key: Vec, + server_public_key: Vec, + client_security_protocol: nego::SecurityProtocol, + server_security_protocol: nego::SecurityProtocol, +} + +impl CredsspSession { + pub(super) fn conf(&self) -> &Conf { + &self.conf + } + + pub(super) fn server_dns_name(&self) -> &str { + &self.server_dns_name + } + + pub(super) fn target_credential(&self) -> &AppCredential { + self.credential_injection.target_credential() + } + + /// Run both CredSSP legs, fix Connect Confirm, then forward RDP-TLS. + pub(crate) async fn run(self, prepared: PreparedCredssp) -> anyhow::Result<()> + where + C: AsyncRead + AsyncWrite + Unpin + Send, + S: AsyncRead + AsyncWrite + Unpin + Send, + { + let Self { + conf, + session_info, + client_addr, + server_addr, + credential_injection, + sessions, + subscriber_tx, + server_dns_name, + disconnect_interest, + kdc_connector, + } = self; + let PreparedCredssp { + client_stream, + server_stream, + gateway_public_key, + server_public_key, + client_security_protocol, + server_security_protocol, + } = prepared; + + let mut client_framed = ironrdp_tokio::MovableTokioFramed::new(client_stream); + let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); + + let client_credssp_fut = perform_credssp_as_server( + &mut client_framed, + client_addr, + gateway_public_key, + client_security_protocol, + &credential_injection, + &kdc_connector, + ); + + let server_credssp_fut = perform_credssp_as_client( + &mut server_framed, + server_dns_name, + server_public_key, + server_security_protocol, + &credential_injection, + &kdc_connector, + ); + + let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); + client_credssp_res.context("CredSSP with client")?; + server_credssp_res.context("CredSSP with server")?; + drop(credential_injection); + + intercept_connect_confirm(&mut client_framed, &mut server_framed, server_security_protocol).await?; + + let (mut client_stream, client_leftover) = client_framed.into_inner(); + let (mut server_stream, server_leftover) = server_framed.into_inner(); + + info!("RDP-TLS forwarding (credential injection)"); + + client_stream + .write_all(&server_leftover) + .await + .context("write server leftover to client")?; + + server_stream + .write_all(&client_leftover) + .await + .context("write client leftover to server")?; + + Proxy::builder() + .conf(conf) + .session_info(session_info) + .address_a(client_addr) + .transport_a(client_stream) + .address_b(server_addr) + .transport_b(server_stream) + .sessions(sessions) + .subscriber_tx(subscriber_tx) + .disconnect_interest(disconnect_interest) + .build() + .select_dissector_and_forward() + .await + .context("RDP-TLS traffic proxying failed")?; + + Ok(()) + } +} + +async fn intercept_connect_confirm( + client_framed: &mut ironrdp_tokio::MovableTokioFramed, + server_framed: &mut ironrdp_tokio::MovableTokioFramed, + server_security_protocol: nego::SecurityProtocol, +) -> anyhow::Result<()> +where + C: AsyncWrite + AsyncRead + Unpin + Send, + S: AsyncWrite + AsyncRead + Unpin + Send, +{ + let (_, received_frame) = client_framed + .read_pdu() + .await + .context("read MCS Connect Initial from client")?; + let received_connect_initial: x224::X224> = + ironrdp_core::decode(&received_frame).context("decode PDU from client")?; + let mut received_connect_initial: mcs::ConnectInitial = + ironrdp_core::decode(&received_connect_initial.0.data).context("decode Connect Initial PDU")?; + trace!(message = ?received_connect_initial, "Received Connect Initial PDU from client"); + + let mut gcc_blocks = received_connect_initial.conference_create_request.into_gcc_blocks(); + gcc_blocks.core.optional_data.server_selected_protocol = Some(server_security_protocol); + // Update the conference request with modified gcc_blocks. + received_connect_initial.conference_create_request = ironrdp_pdu::gcc::ConferenceCreateRequest::new(gcc_blocks)?; + trace!(message = ?received_connect_initial, "Send Connection Request PDU to server"); + let x224_msg_buf = ironrdp_core::encode_vec(&received_connect_initial)?; + let pdu = x224::X224Data { + data: std::borrow::Cow::Owned(x224_msg_buf), + }; + send_pdu(server_framed, &x224::X224(pdu)) + .await + .context("send connection request to server")?; + + Ok(()) +} + +fn server_kerberos_setup( + client_addr: SocketAddr, + injection: &CredentialInjection, +) -> anyhow::Result<(Option, Option<&CredentialInjectionKdc>)> { + let Some(kerberos) = injection.as_kerberos() else { + return Ok((None, None)); + }; + let synthetic = kerberos.synthetic_kdc(); + Ok((Some(synthetic.server_kerberos_config(client_addr)?), Some(synthetic))) +} + +fn client_kerberos_config( + injection: &CredentialInjection, +) -> anyhow::Result> { + let Some(kerberos) = injection.as_kerberos() else { + return Ok(None); + }; + // Target-leg Kerberos uses the same session destination as the synthetic KDC (association + // `dst_hst`). conf.hostname is Gateway identity only and is not the RDP destination. + Ok(Some(ironrdp_connector::credssp::KerberosConfig { + kdc_proxy_url: Some(kerberos.target_kdc().clone()), + hostname: kerberos.synthetic_kdc().target_hostname().to_owned(), + })) +} + +#[instrument(name = "server_credssp", level = "debug", ret, skip_all)] +async fn perform_credssp_as_client( + framed: &mut ironrdp_tokio::Framed, + server_name: String, + server_public_key: Vec, + security_protocol: nego::SecurityProtocol, + injection: &CredentialInjection, + kdc_connector: &KdcConnector, +) -> anyhow::Result<()> +where + S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, +{ + use ironrdp_tokio::FramedWrite as _; + + let credentials = injection.target_credential(); + let kerberos_config = client_kerberos_config(injection)?; + + let (username, decrypted_password) = credentials + .decrypt_password() + .context("failed to decrypt credentials")?; + + // TODO: Pass a zeroizing password type once ironrdp-connector accepts one, so this temporary + // plaintext allocation is cleared before release. + let credentials = ironrdp_connector::Credentials::UsernamePassword { + username, + password: decrypted_password.expose_secret().to_owned(), + }; + + let (mut sequence, mut ts_request) = ironrdp_connector::credssp::CredsspSequence::init( + credentials, + None, + security_protocol, + ironrdp_connector::ServerName::new(server_name.clone()), + server_public_key, + kerberos_config, + )?; + + let mut buf = ironrdp_pdu::WriteBuf::new(); + + loop { + let client_state = { + let mut generator = sequence.process_ts_request(ts_request); + resolve_client_generator(&mut generator, kdc_connector).await? + }; // drop generator + + buf.clear(); + let written = sequence.handle_process_result(client_state, &mut buf)?; + + if let Some(response_len) = written.size() { + let response = &buf[..response_len]; + framed + .write_all(response) + .await + .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; + } + + let Some(next_pdu_hint) = sequence.next_pdu_hint() else { + break; + }; + + let pdu = framed.read_by_hint(next_pdu_hint).await.context("read frame by hint")?; + + if let Some(next_request) = sequence.decode_server_message(&pdu)? { + ts_request = next_request; + } else { + break; + } + } + + Ok(()) +} + +async fn resolve_server_generator( + generator: &mut CredsspServerProcessGenerator<'_>, + credential_injection_kdc: Option<&CredentialInjectionKdc>, + kdc_connector: &KdcConnector, +) -> Result { + let mut state = generator.start(); + + loop { + match state { + GeneratorState::Suspended(request) => { + let kdc = credential_injection_kdc.ok_or_else(|| sspi::credssp::ServerError { + ts_request: None, + error: sspi::Error::new( + sspi::ErrorKind::InternalError, + "Kerberos CredSSP generator requires a synthetic KDC", + ), + })?; + let response = match kdc.intercept_network_request(&request) { + Ok(CredentialInjectionKdcInterception::Intercepted(response)) => Ok(response), + Ok(CredentialInjectionKdcInterception::NotInjectionRequest) => { + kdc_connector.send_network_request(&request).await + } + Ok(CredentialInjectionKdcInterception::NotInjectionRealm(mismatch)) => Err(anyhow::anyhow!( + "kdc request realm does not match credential-injection session realm: {mismatch}" + )), + Err(error) => Err(error), + } + .map_err(|err| sspi::credssp::ServerError { + ts_request: None, + error: sspi::Error::new(sspi::ErrorKind::InternalError, err), + })?; + + state = generator.resume(Ok(response)); + } + GeneratorState::Completed(client_state) => { + break client_state; + } + } + } +} + +async fn resolve_client_generator( + generator: &mut CredsspClientProcessGenerator<'_>, + kdc_connector: &KdcConnector, +) -> anyhow::Result { + let mut state = generator.start(); + + loop { + match state { + GeneratorState::Suspended(request) => { + let response = kdc_connector.send_network_request(&request).await?; + state = generator.resume(Ok(response)); + } + GeneratorState::Completed(client_state) => { + break Ok(client_state.map_err(|e| { + ironrdp_connector::ConnectorError::new("CredSSP", ironrdp_connector::ConnectorErrorKind::Credssp(e)) + })?); + } + }; + } +} + +#[instrument(name = "client_credssp", level = "debug", ret, skip_all)] +async fn perform_credssp_as_server( + framed: &mut ironrdp_tokio::Framed, + client_addr: SocketAddr, + gateway_public_key: Vec, + security_protocol: nego::SecurityProtocol, + injection: &CredentialInjection, + kdc_connector: &KdcConnector, +) -> anyhow::Result<()> +where + S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, +{ + use ironrdp_connector::sspi::credssp::EarlyUserAuthResult; + use ironrdp_tokio::FramedWrite as _; + + let mut buf = ironrdp_pdu::WriteBuf::new(); + + // Are we supposed to use the actual computer name of the client? + // But this does not seem to matter so far, so we stringify the IP address of the client instead. + let client_computer_name = ironrdp_connector::ServerName::new(client_addr.ip().to_string()); + + let (kerberos_server_config, synthetic_kdc) = server_kerberos_setup(client_addr, injection)?; + let credentials = injection.proxy_credential(); + + let result = credssp_loop( + framed, + &mut buf, + client_computer_name, + gateway_public_key, + credentials, + kerberos_server_config, + synthetic_kdc, + kdc_connector, + ) + .await; + + if security_protocol.intersects(nego::SecurityProtocol::HYBRID_EX) { + trace!(?result, "HYBRID_EX"); + + let result = if result.is_ok() { + EarlyUserAuthResult::Success + } else { + EarlyUserAuthResult::AccessDenied + }; + + buf.clear(); + result.to_buffer(&mut buf).context("write early user auth result")?; + let response = &buf[..result.buffer_len()]; + framed.write_all(response).await.context("write_all")?; + } + + return result; + + #[expect( + clippy::too_many_arguments, + reason = "CredSSP loop needs framed IO, identity, optional synthetic KDC, and KdcConnector together" + )] + async fn credssp_loop( + framed: &mut ironrdp_tokio::Framed, + buf: &mut ironrdp_pdu::WriteBuf, + client_computer_name: ironrdp_connector::ServerName, + public_key: Vec, + credentials: &AppCredential, + kerberos_server_config: Option, + credential_injection_kdc: Option<&CredentialInjectionKdc>, + kdc_connector: &KdcConnector, + ) -> anyhow::Result<()> + where + S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, + { + // Decrypt password into short-lived buffer. + let (username, decrypted_password) = credentials + .decrypt_password() + .context("failed to decrypt credentials")?; + + let username = sspi::Username::parse(&username).context("invalid username")?; + + let identity = sspi::AuthIdentity { + username, + password: decrypted_password.expose_secret().to_owned().into(), + }; + // decrypted_password drops here, zeroizing its buffer; note: a copy of the plaintext + // remains in `identity` above (downstream API limitation). + + let mut sequence = ironrdp_acceptor::credssp::CredsspSequence::init( + &identity, + client_computer_name, + public_key, + kerberos_server_config, + )?; + + loop { + let Some(next_pdu_hint) = sequence.next_pdu_hint()? else { + break; + }; + + let pdu = framed + .read_by_hint(next_pdu_hint) + .await + .map_err(|e| ironrdp_connector::custom_err!("read frame by hint", e))?; + + let Some(ts_request) = sequence.decode_client_message(&pdu)? else { + break; + }; + + let result = { + let mut generator = sequence.process_ts_request(ts_request); + resolve_server_generator(&mut generator, credential_injection_kdc, kdc_connector).await + }; // drop generator + + buf.clear(); + let written = sequence.handle_process_result(result, buf)?; + + if let Some(response_len) = written.size() { + let response = &buf[..response_len]; + framed + .write_all(response) + .await + .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use secrecy::SecretString; + use uuid::Uuid; + + use super::*; + use crate::credential::{CleartextAppCredential, CleartextAppCredentialMapping}; + use crate::credential_injection::{CredentialInjection, SyntheticKdcRegistry}; + use crate::provisioning::ProvisioningStore; + use crate::target_connection_options::TargetConnectionOptions; + + fn association_token(jti: Uuid) -> String { + use base64::Engine as _; + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode(r#"{"alg":"RS256"}"#); + let payload = engine.encode( + serde_json::to_vec(&serde_json::json!({ + "jti": jti, + "dst_hst": "target.example:3389", + "exp": time::OffsetDateTime::now_utc().unix_timestamp() + 3600 + })) + .expect("payload serializes"), + ); + let signature = engine.encode(b"signature"); + format!("{header}.{payload}.{signature}") + } + + fn kerberos_injection() -> CredentialInjection { + let jti = Uuid::new_v4(); + let store = ProvisioningStore::new(); + store + .insert_credentials( + association_token(jti), + Some(CleartextAppCredentialMapping { + proxy: CleartextAppCredential::UsernamePassword { + username: "proxy@example.invalid".to_owned(), + password: SecretString::from("pwd"), + }, + target: CleartextAppCredential::UsernamePassword { + username: "administrator@example.invalid".to_owned(), + password: SecretString::from("pwd"), + }, + }), + time::Duration::minutes(5), + ) + .expect("credentials"); + let options = TargetConnectionOptions::new(Some("tcp://dc.example.com:88")).expect("kdc"); + store.insert_connection_options(jti, options, time::Duration::minutes(5)); + let entry = store.take(jti).expect("entry"); + CredentialInjection::from_provisioned(jti, entry, true) + .expect("prepared") + .register_if_kerberos(&SyntheticKdcRegistry::new(), 1) + } + + #[test] + fn client_kerberos_config_uses_provisioned_target_kdc_url_and_dst_hst() { + let injection = kerberos_injection(); + let config = client_kerberos_config(&injection) + .expect("config builds") + .expect("kerberos client leg"); + + assert_eq!( + config.kdc_proxy_url.as_ref().map(url::Url::as_str), + Some("tcp://dc.example.com:88"), + "CredSSP kdc_proxy_url must be the provisioned krb_kdc", + ); + assert_eq!( + config.hostname, "target.example", + "target-leg Kerberos hostname is association dst_hst, not conf.hostname", + ); + } + + #[test] + fn client_kerberos_config_is_none_for_ntlm() { + let jti = Uuid::new_v4(); + let store = ProvisioningStore::new(); + store + .insert_credentials( + association_token(jti), + Some(CleartextAppCredentialMapping { + proxy: CleartextAppCredential::UsernamePassword { + username: "proxy@example.invalid".to_owned(), + password: SecretString::from("pwd"), + }, + target: CleartextAppCredential::UsernamePassword { + username: "Administrator".to_owned(), + password: SecretString::from("pwd"), + }, + }), + time::Duration::minutes(5), + ) + .expect("credentials"); + let entry = store.take(jti).expect("entry"); + let injection = CredentialInjection::from_provisioned(jti, entry, true) + .expect("ntlm prepared") + .register_if_kerberos(&SyntheticKdcRegistry::new(), 1); + + let config = client_kerberos_config(&injection).expect("ntlm ok"); + assert!(config.is_none()); + } +} diff --git a/devolutions-gateway/src/rdp_proxy/mod.rs b/devolutions-gateway/src/rdp_proxy/mod.rs new file mode 100644 index 000000000..3eae2e84e --- /dev/null +++ b/devolutions-gateway/src/rdp_proxy/mod.rs @@ -0,0 +1,269 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::Context as _; +use ironrdp_pdu::{nego, x224}; +use tokio::io::{AsyncRead, AsyncWrite}; +use typed_builder::TypedBuilder; + +mod credssp; + +pub(crate) use credssp::{CredsspSession, PreparedCredssp}; + +use crate::config::Conf; +use crate::credential::AppCredential; +use crate::credential_injection::CredentialInjection; +use crate::kdc_connector::KdcConnector; +use crate::session::{DisconnectInterest, SessionInfo, SessionMessageSender}; +use crate::subscriber::SubscriberSender; + +/// RDP proxy for credential-injection sessions. +/// +/// The main path only orchestrates handshake and TLS upgrade. CredSSP MITM and the subsequent +/// forward live in [`CredsspSession`] / [`PreparedCredssp`]. +#[derive(TypedBuilder)] +pub struct RdpProxy { + conf: Arc, + session_info: SessionInfo, + client_stream: C, + client_addr: SocketAddr, + server_stream: S, + server_addr: SocketAddr, + credential_injection: CredentialInjection, + client_stream_leftover_bytes: bytes::BytesMut, + sessions: SessionMessageSender, + subscriber_tx: SubscriberSender, + server_dns_name: String, + disconnect_interest: Option, + kdc_connector: KdcConnector, +} + +impl RdpProxy +where + A: AsyncWrite + AsyncRead + Unpin + Send, + B: AsyncWrite + AsyncRead + Unpin + Send, +{ + pub async fn run(self) -> anyhow::Result<()> { + handle(self).await + } +} + +#[instrument("rdp_proxy", skip_all, fields(session_id = proxy.session_info.id.to_string(), target = proxy.server_addr.to_string()))] +async fn handle(proxy: RdpProxy) -> anyhow::Result<()> +where + C: AsyncRead + AsyncWrite + Unpin + Send, + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + let RdpProxy { + conf, + session_info, + client_stream, + client_addr, + server_stream, + server_addr, + credential_injection, + client_stream_leftover_bytes, + sessions, + subscriber_tx, + server_dns_name, + disconnect_interest, + kdc_connector, + } = proxy; + + let session = CredsspSession::builder() + .conf(conf) + .session_info(session_info) + .client_addr(client_addr) + .server_addr(server_addr) + .credential_injection(credential_injection) + .sessions(sessions) + .subscriber_tx(subscriber_tx) + .server_dns_name(server_dns_name.clone()) + .disconnect_interest(disconnect_interest) + .kdc_connector(kdc_connector) + .build(); + + let tls_conf = session.conf().credssp_tls.get().context("CredSSP TLS configuration")?; + let gateway_hostname = session.conf().hostname.clone(); + + // -- Retrieve the Gateway TLS public key that must be used for client-proxy CredSSP later on -- // + + let gateway_cert_chain_handle = tokio::spawn(crate::tls::get_cert_chain_for_acceptor_cached( + gateway_hostname.clone(), + tls_conf.acceptor.clone(), + )); + + // -- Dual handshake with the client and the server until the TLS security upgrade -- // + + let mut client_framed = + ironrdp_tokio::MovableTokioFramed::new_with_leftover(client_stream, client_stream_leftover_bytes); + let mut server_framed = ironrdp_tokio::MovableTokioFramed::new(server_stream); + + let handshake_result = + dual_handshake_until_tls_upgrade(&mut client_framed, &mut server_framed, session.target_credential()).await?; + + let client_stream = client_framed.into_inner_no_leftover(); + let server_stream = server_framed.into_inner_no_leftover(); + + // -- Perform the TLS upgrading for both the client and the server, effectively acting as a man-in-the-middle -- // + + let client_tls_upgrade_fut = tls_conf.acceptor.accept(client_stream); + let server_tls_upgrade_fut = crate::tls::dangerous_connect(session.server_dns_name().to_owned(), server_stream); + + let (client_stream, server_stream) = tokio::join!(client_tls_upgrade_fut, server_tls_upgrade_fut); + + let client_stream = client_stream.context("TLS upgrade with client failed")?; + let server_stream = server_stream.context("TLS upgrade with server failed")?; + + let server_public_key = + crate::tls::extract_stream_peer_public_key(&server_stream).context("extract target server TLS public key")?; + + let gateway_cert_chain = gateway_cert_chain_handle.await??; + let gateway_public_key = crate::tls::extract_public_key(gateway_cert_chain.first().context("no leaf")?) + .context("extract Gateway public key")?; + + let prepared = PreparedCredssp::builder() + .client_stream(client_stream) + .server_stream(server_stream) + .gateway_public_key(gateway_public_key) + .server_public_key(server_public_key) + .client_security_protocol(handshake_result.client_security_protocol) + .server_security_protocol(handshake_result.server_security_protocol) + .build(); + + // CredSSP MITM + Connect Confirm intercept + bidirectional forward: owned by CredsspSession. + session.run(prepared).await +} + +#[derive(Debug)] +struct HandshakeResult { + client_security_protocol: nego::SecurityProtocol, + server_security_protocol: nego::SecurityProtocol, +} + +#[instrument(name = "dual_handshake", level = "debug", ret, skip_all)] +async fn dual_handshake_until_tls_upgrade( + client_framed: &mut ironrdp_tokio::MovableTokioFramed, + server_framed: &mut ironrdp_tokio::MovableTokioFramed, + target_credential: &AppCredential, +) -> anyhow::Result +where + C: AsyncWrite + AsyncRead + Unpin + Send, + S: AsyncWrite + AsyncRead + Unpin + Send, +{ + let (_, received_frame) = client_framed.read_pdu().await.context("read PDU from client")?; + let received_connection_request: x224::X224 = + ironrdp_core::decode(&received_frame).context("decode PDU from client")?; + trace!(message = ?received_connection_request, "Received Connection Request PDU from client"); + + // Choose the security protocol to use with the client. + let received_connection_request_protocol = received_connection_request.0.protocol; + let client_security_protocol = if received_connection_request_protocol.contains(nego::SecurityProtocol::HYBRID_EX) { + nego::SecurityProtocol::HYBRID_EX + } else if received_connection_request + .0 + .protocol + .contains(nego::SecurityProtocol::HYBRID) + { + nego::SecurityProtocol::HYBRID + } else { + anyhow::bail!( + "client does not support CredSSP (received {})", + received_connection_request.0.protocol + ) + }; + + let connection_request_to_send = nego::ConnectionRequest { + nego_data: match target_credential { + AppCredential::UsernamePassword { username, .. } => { + Some(nego::NegoRequestData::cookie(username.to_owned())) + } + }, + flags: received_connection_request.0.flags, + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/902b090b-9cb3-4efc-92bf-ee13373371e3 + // + // The spec states that `PROTOCOL_SSL` "SHOULD" also be set when using `PROTOCOL_HYBRID`: + // + // > PROTOCOL_HYBRID (0x00000002) + // > Credential Security Support Provider protocol (CredSSP) (section 5.4.5.2). + // > If this flag is set, then the PROTOCOL_SSL (0x00000001) flag SHOULD also be set + // > because Transport Layer Security (TLS) is a subset of CredSSP. + // + // However, in practice `mstsc` is picky about these flags: it expects the + // SupportedProtocol bits in the ConnectionRequestPDU that reach the target + // server to match what the client originally sent. If the proxy modifies + // them (for example, forcing HYBRID | HYBRID_EX and/or clearing SSL), + // the connection can fail with an authentication error (Code: 0x609). + // + // We therefore *do not* synthesize a new protocol bitmask here anymore. + // Instead, we forward the client's SupportedProtocol flags as-is and + // enforce our policy by validating them: if HYBRID / HYBRID_EX are not + // present (i.e. NLA is not negotiated), we fail the connection rather + // than trying to "fix" the flags ourselves. + // + // See also: https://serverfault.com/a/720161 + protocol: received_connection_request_protocol, + }; + trace!(?connection_request_to_send, "Send Connection Request PDU to server"); + send_pdu(server_framed, &x224::X224(connection_request_to_send)) + .await + .context("send connection request to server")?; + + let (_, received_frame) = server_framed.read_pdu().await.context("read PDU from server")?; + let received_connection_confirm: x224::X224 = + ironrdp_core::decode(&received_frame).context("decode PDU from server")?; + trace!(message = ?received_connection_confirm, "Received Connection Confirm PDU from server"); + + let (connection_confirm_to_send, handshake_result) = match &received_connection_confirm.0 { + nego::ConnectionConfirm::Response { + flags, + protocol: server_security_protocol, + } => { + debug!(?server_security_protocol, ?flags, "Server confirmed connection"); + + let result = if !server_security_protocol + .intersects(nego::SecurityProtocol::HYBRID | nego::SecurityProtocol::HYBRID_EX) + { + Err(anyhow::anyhow!( + "server selected security protocol {server_security_protocol}, which is not supported for credential injection" + )) + } else { + Ok(HandshakeResult { + client_security_protocol, + server_security_protocol: *server_security_protocol, + }) + }; + + ( + x224::X224(nego::ConnectionConfirm::Response { + flags: *flags, + protocol: client_security_protocol, + }), + result, + ) + } + nego::ConnectionConfirm::Failure { code } => ( + x224::X224(received_connection_confirm.0.clone()), + Err(anyhow::anyhow!("RDP session initiation failed with code {code}")), + ), + }; + + trace!(?connection_confirm_to_send, "Send Connection Request PDU to client"); + send_pdu(client_framed, &connection_confirm_to_send) + .await + .context("send connection confirm to client")?; + + handshake_result +} + +pub(super) async fn send_pdu(framed: &mut ironrdp_tokio::MovableTokioFramed, pdu: &P) -> anyhow::Result<()> +where + S: AsyncWrite + Unpin + Send, + P: ironrdp_core::Encode, +{ + use ironrdp_tokio::FramedWrite as _; + + let payload = ironrdp_core::encode_vec(pdu).context("failed to encode PDU")?; + framed.write_all(&payload).await.context("failed to write PDU")?; + Ok(()) +} diff --git a/devolutions-gateway/src/service.rs b/devolutions-gateway/src/service.rs index 6602c9746..e4c9a2d7d 100644 --- a/devolutions-gateway/src/service.rs +++ b/devolutions-gateway/src/service.rs @@ -267,7 +267,8 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { .await .context("failed to initialize traffic audit manager")?; - let credentials = devolutions_gateway::credential_injection_kdc::CredentialService::new(conf_handle.clone()); + let provisioning = devolutions_gateway::provisioning::ProvisioningStore::new(); + let synthetic_kdc_registry = devolutions_gateway::credential_injection::SyntheticKdcRegistry::new(); let filesystem_monitor_config_cache = devolutions_gateway::api::monitoring::FilesystemConfigCache::new( config::get_data_dir().join("monitors_cache.json"), @@ -315,7 +316,8 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { shutdown_signal: tasks.shutdown_signal.clone(), recordings: recording_manager_handle.clone(), job_queue_handle: job_queue_ctx.job_queue_handle.clone(), - credentials: credentials.clone(), + provisioning: provisioning.clone(), + synthetic_kdc_registry: synthetic_kdc_registry.clone(), monitoring_state, traffic_audit_handle: traffic_audit_task.handle(), agent_tunnel_handle, @@ -350,11 +352,11 @@ async fn spawn_tasks(conf_handle: ConfHandle) -> anyhow::Result { tasks.register(devolutions_gateway::token::CleanupTask { token_cache }); - tasks.register(devolutions_gateway::provisioning::CleanupTask { - handle: credentials.credential_store().clone(), - }); + tasks.register(devolutions_gateway::provisioning::CleanupTask { handle: provisioning }); - tasks.register(devolutions_gateway::credential_injection_kdc::CleanupTask { service: credentials }); + tasks.register(devolutions_gateway::credential_injection::CleanupTask { + handle: synthetic_kdc_registry, + }); tasks.register(devolutions_log::LogDeleterTask::::new( conf.log_file.clone(), diff --git a/devolutions-gateway/src/token.rs b/devolutions-gateway/src/token.rs index a3f6e0e7f..10f7beb91 100644 --- a/devolutions-gateway/src/token.rs +++ b/devolutions-gateway/src/token.rs @@ -1279,6 +1279,24 @@ pub fn extract_jti(token: &str) -> anyhow::Result { extract_uuid(token, "jti").context("extract jti") } +/// Extract the JWT `exp` claim without verifying the signature. +pub fn extract_exp(token: &str) -> anyhow::Result { + let payload = extract_payload(token)?; + let exp = payload.get("exp").context("exp is missing from the token")?; + exp.as_i64() + .or_else(|| exp.as_u64().and_then(|value| i64::try_from(value).ok())) + .context("exp is malformed") +} + +/// Latest instant at which Gateway will still accept a token with this `exp`. +/// +/// Includes the hardcoded JWT clock-skew leeway. +pub(crate) fn token_acceptance_deadline(exp: i64) -> anyhow::Result { + let timestamp = exp.saturating_add(i64::from(LEEWAY_SECS)); + time::OffsetDateTime::from_unix_timestamp(timestamp) + .context("token expiration is outside the supported timestamp range") +} + pub fn extract_session_id(token: &str) -> anyhow::Result { extract_uuid(token, "jet_aid").context("extract jet_aid") } diff --git a/devolutions-gateway/tests/preflight.rs b/devolutions-gateway/tests/preflight.rs index 1d0de8866..1cbcf8480 100644 --- a/devolutions-gateway/tests/preflight.rs +++ b/devolutions-gateway/tests/preflight.rs @@ -46,6 +46,10 @@ fn preflight_request(operations: serde_json::Value) -> anyhow::Result i64 { + time::OffsetDateTime::now_utc().unix_timestamp() + 3600 +} + fn unsigned_jws(payload: serde_json::Value) -> anyhow::Result { let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; let header = engine.encode(r#"{"alg":"RS256"}"#); @@ -84,7 +88,8 @@ async fn test_provision_credentials_success() -> anyhow::Result<()> { let jti = Uuid::new_v4(); let token = unsigned_jws(json!({ "jti": jti, - "dst_hst": "target.example:3389" + "dst_hst": "target.example:3389", + "exp": token_exp() }))?; let op_id = Uuid::new_v4(); @@ -124,7 +129,8 @@ async fn test_provision_credentials_success_when_unstable_disabled() -> anyhow:: let jti = Uuid::new_v4(); let token = unsigned_jws(json!({ "jti": jti, - "dst_hst": "target.example:3389" + "dst_hst": "target.example:3389", + "exp": token_exp() }))?; let op_id = Uuid::new_v4(); @@ -318,7 +324,8 @@ async fn test_provision_credentials_and_connection_options_fold() -> anyhow::Res let jti = Uuid::new_v4(); let token = unsigned_jws(json!({ "jti": jti, - "dst_hst": "target.example:3389" + "dst_hst": "target.example:3389", + "exp": token_exp() }))?; let ops = json!([ diff --git a/testsuite/Cargo.toml b/testsuite/Cargo.toml index 7961358b6..1a0b7c3a3 100644 --- a/testsuite/Cargo.toml +++ b/testsuite/Cargo.toml @@ -39,6 +39,13 @@ devolutions-gateway-task = { path = "../crates/devolutions-gateway-task" } devolutions-gateway = { path = "../devolutions-gateway" } futures-util = "0.3" ipnetwork = "0.20" +ironrdp-connector = "0.10" +ironrdp-core = { version = "0.2", features = ["std"] } +ironrdp-pdu = { version = "0.9", features = ["std"] } +ironrdp-tokio = "0.10" +kdc = "0.1" +picky-asn1-der = "0.5" +picky-krb = "0.12" libsql = { version = "0.9", default-features = false, features = ["core"] } mcp-proxy.path = "../crates/mcp-proxy" network-scanner = { path = "../crates/network-scanner", features = ["test-utils"] } @@ -59,6 +66,7 @@ tempfile = "3" test-utils.path = "../crates/test-utils" tokio-rustls = { version = "0.26", features = ["ring"] } uuid = { version = "1", features = ["v4"] } +x509-cert = { version = "0.3", default-features = false, features = ["std"] } [target.'cfg(unix)'.dev-dependencies] sysevent-syslog.path = "../crates/sysevent-syslog" diff --git a/testsuite/tests/cli/dgw/cred_injection.rs b/testsuite/tests/cli/dgw/cred_injection.rs new file mode 100644 index 000000000..ca7b6bd1d --- /dev/null +++ b/testsuite/tests/cli/dgw/cred_injection.rs @@ -0,0 +1,695 @@ +//! Process-level tests for RDP credential injection, reconnect, and fail-closed routing. +//! +//! These tests start a real Gateway, provision credentials over `/jet/preflight` the way DVLS +//! does, then connect to the TCP listener with an RDP preconnection blob. A loopback peer stands +//! in for the destination RDP server and records the X.224 Connection Request the proxy forwards. +//! Injection is observed from Gateway logs and from the rewritten mstshash cookie. CredSSP is not +//! completed: the contract under test is checkout, reconnect, and fail-closed routing. + +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use base64::Engine as _; +use ironrdp_pdu::nego::{ConnectionRequest, NegoRequestData}; +use ironrdp_pdu::x224::X224; +use ironrdp_tokio::TokioFramed; +use testsuite::cli::{dgw_tokio_cmd, wait_for_tcp_port}; +use testsuite::dgw_config::{DgwConfig, DgwConfigHandle, VerbosityProfile}; +use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::process::Child; + +pub(crate) const CLIENT_COOKIE: &str = "client-cookie-user"; +pub(crate) const TARGET_USER: &str = "injected-target-user"; +pub(crate) const PROXY_USER: &str = "injected-proxy-user"; +pub(crate) const PROXY_PASSWORD: &str = "proxy-secret"; +pub(crate) const TARGET_PASSWORD: &str = "target-secret"; +pub(crate) const KERBEROS_TARGET_USER: &str = "administrator@example.invalid"; +pub(crate) const INJECT_LOG: &str = "RDP-TLS forwarding with credential injection"; +pub(crate) const FORWARD_LOG: &str = "Upstream forwarding"; +pub(crate) const MISSING_LOG: &str = "missing or expired; re-provision to retry"; +pub(crate) const PROXY_KERBEROS_USER: &str = "injected-proxy-user@example.invalid"; +const PUBLISHED_KDC_LOG: &str = "Published synthetic KDC"; +const REGISTERED_KDC_LOG: &str = "Registered synthetic KDC for credential-injection session"; + +pub(crate) fn next_id() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(1); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("00000000-0000-4000-a000-{n:012x}") +} + +pub(crate) fn unsigned_jws(header: serde_json::Value, payload: serde_json::Value) -> anyhow::Result { + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = engine.encode(serde_json::to_vec(&header).context("serialize JWT header")?); + let payload = engine.encode(serde_json::to_vec(&payload).context("serialize JWT payload")?); + Ok(format!("{header}.{payload}.ZHVtbXlfc2lnbmF0dXJl")) +} + +fn preflight_scope_token() -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"SCOPE"}), + serde_json::json!({ + "scope": "gateway.preflight", + "exp": 9_999_999_999i64, + "jti": next_id(), + }), + ) +} + +pub(crate) fn association_token(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"ASSOCIATION"}), + serde_json::json!({ + "dst_hst": format!("127.0.0.1:{dest_port}"), + "exp": 9_999_999_999i64, + "jet_aid": jet_aid, + "jet_ap": "rdp", + "jet_cm": "fwd", + "jet_rec": "none", + "jet_reuse": jet_reuse, + "jti": jti, + "nbf": 0, + }), + ) +} + +pub(crate) fn encode_pcb(token: &str) -> anyhow::Result> { + let pcb = ironrdp_pdu::pcb::PreconnectionBlob { + version: ironrdp_pdu::pcb::PcbVersion::V2, + id: 0, + v2_payload: Some(token.to_owned()), + }; + ironrdp_core::encode_vec(&pcb).context("encode preconnection blob") +} + +fn encode_connection_request(cookie: &str) -> anyhow::Result> { + use ironrdp_pdu::nego::{ConnectionRequest, NegoRequestData, RequestFlags, SecurityProtocol}; + use ironrdp_pdu::x224::X224; + + let pdu = X224(ConnectionRequest { + nego_data: Some(NegoRequestData::cookie(cookie.to_owned())), + flags: RequestFlags::empty(), + protocol: SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX | SecurityProtocol::SSL, + }); + ironrdp_core::encode_vec(&pdu).context("encode X.224 connection request") +} + +fn strip_ansi(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' && chars.peek() == Some(&'[') { + chars.next(); + for next in chars.by_ref() { + if next.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(c); + } + } + out +} + +pub(crate) struct LogBuffer(Arc>); + +impl LogBuffer { + fn new() -> Self { + Self(Arc::new(Mutex::new(String::new()))) + } + + pub(crate) fn snapshot(&self) -> String { + strip_ansi(&self.0.lock().expect("log mutex")) + } + + pub(crate) async fn wait_contains(&self, needle: &str) -> anyhow::Result { + self.wait_count(needle, 1).await + } + + pub(crate) async fn wait_count(&self, needle: &str, count: usize) -> anyhow::Result { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let snapshot = self.snapshot(); + if snapshot.matches(needle).count() >= count { + return Ok(snapshot); + } + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for {count} occurrence(s) of {needle:?}; logs:\n{snapshot}"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +struct FakeRdpTarget { + port: u16, + accepted: Arc, + cookies: Arc>>, +} + +impl FakeRdpTarget { + async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind fake RDP target")?; + let port = listener.local_addr().context("fake RDP local_addr")?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let cookies = Arc::new(Mutex::new(Vec::new())); + let accepted_task = Arc::clone(&accepted); + let cookies_task = Arc::clone(&cookies); + + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + accepted_task.fetch_add(1, Ordering::SeqCst); + let cookies = Arc::clone(&cookies_task); + tokio::spawn(async move { + let mut framed = TokioFramed::new(stream); + // CredSSP cert generation can delay the rewritten X.224 CR. + if let Ok(Ok((_, request))) = tokio::time::timeout(Duration::from_secs(30), framed.read_pdu()).await + && let Some(cookie) = decode_x224_cookie(&request) + { + cookies.lock().expect("cookie mutex").push(cookie); + } + // Keep the accepted socket open so the proxy can finish writing the CR. + tokio::time::sleep(Duration::from_secs(30)).await; + }); + } + }); + + Ok(Self { + port, + accepted, + cookies, + }) + } + + fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) + } + + async fn wait_cookies(&self, count: usize) -> anyhow::Result> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + { + let cookies = self.cookies.lock().expect("cookie mutex"); + if cookies.len() >= count { + return Ok(cookies.clone()); + } + } + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for {count} decoded X.224 cookie(s); accepted={}", + self.accepted() + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +fn decode_x224_cookie(payload: &[u8]) -> Option { + let cr: X224 = ironrdp_core::decode(payload).ok()?; + match cr.0.nego_data { + Some(NegoRequestData::Cookie(cookie)) => Some(cookie.0), + _ => None, + } +} + +pub(crate) struct GatewayProc { + pub(crate) config: DgwConfigHandle, + pub(crate) process: Child, + pub(crate) logs: LogBuffer, +} + +impl GatewayProc { + pub(crate) async fn start() -> anyhow::Result { + let config = DgwConfig::builder() + .disable_token_validation(true) + .verbosity_profile(VerbosityProfile::DEBUG) + .build() + .init() + .context("init gateway config")?; + + let mut process = dgw_tokio_cmd() + .env("DGATEWAY_CONFIG_PATH", config.config_dir()) + .env("RUST_LOG", "devolutions_gateway=debug") + .env("NO_COLOR", "1") + .kill_on_drop(true) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .context("start Devolutions Gateway")?; + + let logs = LogBuffer::new(); + spawn_stdio_collector(process.stdout.take(), Arc::clone(&logs.0)); + spawn_stdio_collector(process.stderr.take(), Arc::clone(&logs.0)); + + wait_for_tcp_port(config.http_port()) + .await + .context("wait for gateway HTTP port")?; + + Ok(Self { config, process, logs }) + } +} + +fn spawn_stdio_collector(stream: Option, logs: Arc>) +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, +{ + let Some(stream) = stream else { + return; + }; + tokio::spawn(async move { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => logs.lock().expect("log mutex").push_str(&line), + } + } + }); +} + +async fn post_preflight(http_port: u16, operations: serde_json::Value) -> anyhow::Result { + let bearer = preflight_scope_token()?; + let body = serde_json::to_string(&operations).context("serialize preflight body")?; + let request = format!( + "POST /jet/preflight HTTP/1.1\r\n\ + Host: 127.0.0.1:{http_port}\r\n\ + Content-Type: application/json\r\n\ + Authorization: Bearer {bearer}\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len() + ); + + let mut stream = TcpStream::connect(("127.0.0.1", http_port)) + .await + .context("connect to gateway HTTP")?; + stream.write_all(request.as_bytes()).await.context("write preflight")?; + stream.flush().await.context("flush preflight")?; + + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + reader + .read_line(&mut status_line) + .await + .context("read preflight status")?; + anyhow::ensure!(status_line.contains("200"), "preflight HTTP status was {status_line:?}"); + + let mut content_length = None; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.context("read preflight header")?; + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().to_owned()) + { + content_length = Some(value.parse::().context("parse Content-Length")?); + } + } + + let response_body = if let Some(len) = content_length { + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf).await.context("read preflight body")?; + String::from_utf8(buf).context("preflight body utf-8")? + } else { + let mut buf = String::new(); + reader + .read_to_string(&mut buf) + .await + .context("read preflight eof body")?; + buf + }; + + let json: serde_json::Value = + serde_json::from_str(&response_body).with_context(|| format!("parse preflight JSON: {response_body}"))?; + let outputs = json.as_array().context("preflight response is not an array")?; + // Re-provisioning the same JTI emits an info alert, then still acks. + let mut acked = 0usize; + for output in outputs { + match output["kind"].as_str() { + Some("ack") => acked += 1, + Some("alert") if output["alert_status"] == "info" => {} + _ => anyhow::bail!("preflight operation was not ack: {output}"), + } + } + anyhow::ensure!(acked > 0, "preflight returned no ack: {json}"); + Ok(json) +} + +pub(crate) async fn provision_credentials( + http_port: u16, + token: &str, + target_username: &str, + time_to_live: u32, + krb_kdc: Option<&str>, +) -> anyhow::Result<()> { + provision_mapping( + http_port, + token, + PROXY_USER, + target_username, + TARGET_PASSWORD, + time_to_live, + krb_kdc, + ) + .await +} + +pub(crate) async fn provision_mapping( + http_port: u16, + token: &str, + proxy_username: &str, + target_username: &str, + target_password: &str, + time_to_live: u32, + krb_kdc: Option<&str>, +) -> anyhow::Result<()> { + let mut operations = vec![serde_json::json!({ + "id": next_id(), + "kind": "provision-credentials", + "token": token, + "proxy_credential": { + "kind": "username-password", + "username": proxy_username, + "password": PROXY_PASSWORD + }, + "target_credential": { + "kind": "username-password", + "username": target_username, + "password": target_password + }, + "time_to_live": time_to_live + })]; + + if let Some(krb_kdc) = krb_kdc { + operations.push(serde_json::json!({ + "id": next_id(), + "kind": "provision-connection-options", + "token": token, + "connection_options": { "krb_kdc": krb_kdc }, + "time_to_live": time_to_live + })); + } + + post_preflight(http_port, serde_json::Value::Array(operations)).await?; + Ok(()) +} + +async fn connect_rdp_client(gateway_tcp: u16, association_jwt: &str) -> anyhow::Result { + let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) + .await + .context("connect to gateway TCP")?; + stream + .write_all(&encode_pcb(association_jwt)?) + .await + .context("write preconnection blob")?; + stream + .write_all(&encode_connection_request(CLIENT_COOKIE)?) + .await + .context("write connection request")?; + stream.flush().await.context("flush RDP client")?; + Ok(stream) +} + +#[tokio::test] +async fn first_rdp_connection_injects_ntlm() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + assert!( + logs.contains("kerberos=false"), + "expected NTLM injection; logs:\n{logs}" + ); + assert!( + !logs.contains(FORWARD_LOG), + "injection must not fall back to ordinary forward; logs:\n{logs}" + ); + + let cookies = target.wait_cookies(1).await?; + assert_eq!( + cookies, + vec![TARGET_USER], + "decoded X.224 cookie must be the injected user" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn reconnect_same_jwt_still_injects() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + + let first = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + gateway.logs.wait_count(INJECT_LOG, 1).await?; + target.wait_cookies(1).await?; + drop(first); + + let _second = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 2).await?; + assert_eq!( + logs.matches(INJECT_LOG).count(), + 2, + "reconnect must inject exactly twice; logs:\n{logs}" + ); + assert!( + !logs.contains(FORWARD_LOG), + "reconnect must keep injecting, not ordinary-forward; logs:\n{logs}" + ); + + let cookies = target.wait_cookies(2).await?; + assert_eq!( + cookies, + vec![TARGET_USER, TARGET_USER], + "both decoded X.224 cookies must be the injected user" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn expired_staging_uses_ordinary_forward() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 1, None).await?; + tokio::time::sleep(Duration::from_secs(2)).await; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_contains(FORWARD_LOG).await?; + assert!( + !logs.contains(INJECT_LOG), + "evicted staging credentials must not inject; logs:\n{logs}" + ); + + let cookies = target.wait_cookies(1).await?; + assert_eq!( + cookies, + vec![CLIENT_COOKIE], + "ordinary forward must keep the decoded client cookie" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn unprovisioned_rdp_uses_ordinary_forward() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_contains(FORWARD_LOG).await?; + assert!( + !logs.contains(INJECT_LOG), + "absent mapping should ordinary-forward; logs:\n{logs}" + ); + + let cookies = target.wait_cookies(1).await?; + assert_eq!( + cookies, + vec![CLIENT_COOKIE], + "ordinary forward must keep the decoded client cookie" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_reconnect_reuses_generation_until_reprovision() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + // Keep overlapping reconnect sockets so the same-generation KDC lease stays live. + let _first = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 1).await?; + assert!( + logs.contains("kerberos=true"), + "expected Kerberos injection; logs:\n{logs}" + ); + gateway.logs.wait_count(PUBLISHED_KDC_LOG, 1).await?; + + let _second = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 2).await?; + assert_eq!( + logs.matches("kerberos=true").count(), + 2, + "same generation reconnect should still inject Kerberos; logs:\n{logs}" + ); + gateway.logs.wait_count(REGISTERED_KDC_LOG, 2).await?; + assert_eq!( + logs.matches(PUBLISHED_KDC_LOG).count(), + 1, + "same provisioning generation must reuse the interned synthetic KDC; logs:\n{logs}" + ); + + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + let _third = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_count(INJECT_LOG, 3).await?; + assert_eq!( + logs.matches("kerberos=true").count(), + 3, + "newer provisioning generation should replace and still inject; logs:\n{logs}" + ); + let logs = gateway.logs.wait_count(PUBLISHED_KDC_LOG, 2).await?; + assert_eq!( + logs.matches(PUBLISHED_KDC_LOG).count(), + 2, + "re-provision must publish exactly one extra synthetic KDC; logs:\n{logs}" + ); + assert_eq!( + logs.matches(REGISTERED_KDC_LOG).count(), + 3, + "each connection should register a synthetic KDC lease; logs:\n{logs}" + ); + assert!( + !logs.contains(FORWARD_LOG), + "Kerberos injection must not ordinary-forward; logs:\n{logs}" + ); + + let cookies = target.wait_cookies(3).await?; + assert_eq!( + cookies, + vec![ + KERBEROS_TARGET_USER.to_owned(), + KERBEROS_TARGET_USER.to_owned(), + KERBEROS_TARGET_USER.to_owned() + ], + "each decoded X.224 cookie must be the Kerberos target user" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn domainless_target_stays_ntlm_even_with_krb_kdc() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + assert!( + logs.contains("kerberos=false"), + "username without a realm must stay NTLM even if krb_kdc is provisioned; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_injection_does_not_need_debug_flags() -> anyhow::Result<()> { + let target = FakeRdpTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token(&jti, &jet_aid, target.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some("tcp://127.0.0.1:88"), + ) + .await?; + + let _client = connect_rdp_client(gateway.config.tcp_port(), &token).await?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + assert!( + logs.contains("kerberos=true"), + "Kerberos injection must run without debug flags; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} diff --git a/testsuite/tests/cli/dgw/cred_injection_kdc.rs b/testsuite/tests/cli/dgw/cred_injection_kdc.rs new file mode 100644 index 000000000..409ee8ef1 --- /dev/null +++ b/testsuite/tests/cli/dgw/cred_injection_kdc.rs @@ -0,0 +1,1633 @@ +//! Kerberos credential injection against a mock KDC and IronRDP CredSSP server. +//! +//! Proves the target-leg path: Gateway fetches tickets from a TCP KDC (`kdc` crate from +//! sspi-rs) and completes CredSSP with a fake RDP acceptor. The Gateway-facing client uses +//! NTLM so the test does not depend on the in-process synthetic KDC. +//! +//! RDCleanPath coverage drives the public `ironrdp-agent` 0.1.0 CLI (`cargo install +//! ironrdp-agent --version 0.1.0`) over `ws://127.0.0.1/jet/rdp`. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use ironrdp_connector::sspi; +use ironrdp_connector::sspi::generator::GeneratorState; +use ironrdp_pdu::nego::{ + ConnectionConfirm, ConnectionRequest, NegoRequestData, RequestFlags, ResponseFlags, SecurityProtocol, +}; +use ironrdp_pdu::x224::X224; +use ironrdp_tokio::{FramedWrite as _, TokioFramed}; +use picky_krb::data_types::PrincipalName; +use picky_krb::messages::{AsRep, AsReq, KdcProxyMessage, KrbError, TgsRep, TgsReq}; +use rustls::pki_types::pem::PemObject as _; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use rustls::{ClientConfig, ServerConfig}; +use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}; +use tokio::net::{TcpListener, TcpStream}; +use x509_cert::der::Decode as _; + +use super::cred_injection::{ + FORWARD_LOG, GatewayProc, INJECT_LOG, KERBEROS_TARGET_USER, MISSING_LOG, PROXY_KERBEROS_USER, PROXY_PASSWORD, + PROXY_USER, TARGET_PASSWORD, TARGET_USER, encode_pcb, next_id, provision_credentials, provision_mapping, + unsigned_jws, +}; +use super::tls_fixtures::{CERT_PEM, KEY_PEM}; + +const REALM: &str = "EXAMPLE.INVALID"; +// sspi-rs downgrades Negotiate to NTLM when the SPN host is an IP address. +const SERVICE_HOST: &str = "localhost"; +const KRBTGT_KEY: [u8; 32] = [0x11; 32]; +const TERMSRV_KEY: [u8; 32] = [0x22; 32]; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ObservedKdcReq { + As { cname: String, realm: String }, + Tgs { sname: Vec, realm: String }, + Other, +} + +struct MockKdc { + port: u16, + exchanges: Arc, + requests: Arc>>, +} + +impl MockKdc { + async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind mock KDC")?; + let port = listener.local_addr().context("mock KDC local_addr")?.port(); + let exchanges = Arc::new(AtomicUsize::new(0)); + let requests = Arc::new(Mutex::new(Vec::new())); + let exchanges_task = Arc::clone(&exchanges); + let requests_task = Arc::clone(&requests); + let config = kdc_config(); + + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let config = config.clone(); + let exchanges = Arc::clone(&exchanges_task); + let requests = Arc::clone(&requests_task); + tokio::spawn(async move { + match serve_kdc_exchange(stream, &config, &requests).await { + Ok(()) => { + exchanges.fetch_add(1, Ordering::SeqCst); + } + Err(error) => eprintln!("mock KDC exchange failed: {error:#}"), + } + }); + } + }); + + Ok(Self { + port, + exchanges, + requests, + }) + } + + fn url(&self) -> String { + format!("tcp://127.0.0.1:{}", self.port) + } + + fn exchanges(&self) -> usize { + self.exchanges.load(Ordering::SeqCst) + } + + fn requests(&self) -> Vec { + self.requests.lock().expect("kdc request mutex").clone() + } +} + +fn kdc_config() -> kdc::config::KerberosServer { + let username = format!("administrator@{REALM}"); + kdc::config::KerberosServer { + realm: REALM.to_owned(), + users: vec![kdc::config::DomainUser { + username, + password: TARGET_PASSWORD.to_owned(), + salt: format!("{}administrator", REALM.to_ascii_uppercase()), + }], + max_time_skew: 300, + krbtgt_key: KRBTGT_KEY.to_vec(), + ticket_decryption_key: Some(TERMSRV_KEY.to_vec()), + service_user: None, + } +} + +async fn serve_kdc_exchange( + mut stream: TcpStream, + config: &kdc::config::KerberosServer, + requests: &Mutex>, +) -> anyhow::Result<()> { + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.context("read KDC length")?; + let len = usize::try_from(u32::from_be_bytes(len_buf)).context("KDC length")?; + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.context("read KDC body")?; + + requests.lock().expect("kdc request mutex").push(observe_kdc_req(&body)); + + let mut raw = Vec::with_capacity(4 + len); + raw.extend_from_slice(&len_buf); + raw.extend_from_slice(&body); + + let request = KdcProxyMessage::from_raw_kerb_message(&raw).context("wrap KDC TCP payload")?; + let reply = kdc::handle_kdc_proxy_message(request, config, SERVICE_HOST).context("handle KDC message")?; + stream + .write_all(&reply.kerb_message.0.0) + .await + .context("write KDC reply")?; + Ok(()) +} + +fn principal_strings(name: &PrincipalName) -> Vec { + name.name_string.0.0.iter().map(|part| part.0.to_string()).collect() +} + +fn observe_kdc_req(body: &[u8]) -> ObservedKdcReq { + if let Ok(as_req) = picky_asn1_der::from_bytes::(body) { + let req = &as_req.0.req_body.0; + let cname = req + .cname + .0 + .as_ref() + .map(|name| principal_strings(&name.0).join("/")) + .unwrap_or_default(); + return ObservedKdcReq::As { + cname, + realm: req.realm.0.to_string(), + }; + } + if let Ok(tgs_req) = picky_asn1_der::from_bytes::(body) { + let req = &tgs_req.0.req_body.0; + let sname = req + .sname + .0 + .as_ref() + .map(|name| principal_strings(&name.0)) + .unwrap_or_default(); + return ObservedKdcReq::Tgs { + sname, + realm: req.realm.0.to_string(), + }; + } + ObservedKdcReq::Other +} + +fn observe_kdc_reply(body: &[u8]) -> ObservedKdcReply { + let krb = body.get(4..).unwrap_or(body); + if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::AsRep + } else if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::TgsRep + } else if picky_asn1_der::from_bytes::(krb).is_ok() { + ObservedKdcReply::KrbError + } else { + ObservedKdcReply::Other + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ObservedKdcReply { + AsRep, + TgsRep, + KrbError, + Other, +} + +#[derive(Clone)] +enum MockRdpMode { + Kerberos { kdc_url: Option }, + Ntlm, +} + +struct MockRdp { + port: u16, + credssp_ok: Arc, + finished_account: Arc>>, + cookies: Arc>>, +} + +impl MockRdp { + async fn start_kerberos(kdc_url: String) -> anyhow::Result { + Self::start(MockRdpMode::Kerberos { kdc_url: Some(kdc_url) }).await + } + + async fn start_ntlm() -> anyhow::Result { + Self::start(MockRdpMode::Ntlm).await + } + + async fn start(mode: MockRdpMode) -> anyhow::Result { + install_crypto_provider(); + // Dual-stack so Windows `localhost` (IPv6 first) still hits the fake server. + let listener = match TcpListener::bind("[::]:0").await { + Ok(listener) => listener, + Err(_) => TcpListener::bind("127.0.0.1:0").await.context("bind mock RDP")?, + }; + let port = listener.local_addr().context("mock RDP local_addr")?.port(); + let credssp_ok = Arc::new(AtomicBool::new(false)); + let finished_account = Arc::new(Mutex::new(None)); + let cookies = Arc::new(Mutex::new(Vec::new())); + let credssp_ok_task = Arc::clone(&credssp_ok); + let finished_account_task = Arc::clone(&finished_account); + let cookies_task = Arc::clone(&cookies); + let acceptor = tls_acceptor()?; + let public_key = server_public_key()?; + + tokio::spawn(async move { + loop { + let Ok((stream, peer)) = listener.accept().await else { + break; + }; + let acceptor = acceptor.clone(); + let public_key = public_key.clone(); + let credssp_ok = Arc::clone(&credssp_ok_task); + let finished_account = Arc::clone(&finished_account_task); + let cookies = Arc::clone(&cookies_task); + let mode = mode.clone(); + tokio::spawn(async move { + let result = match &mode { + MockRdpMode::Kerberos { kdc_url } => { + accept_kerberos_rdp( + stream, + peer, + acceptor, + public_key, + kdc_url.as_deref(), + &cookies, + &finished_account, + ) + .await + } + MockRdpMode::Ntlm => { + accept_ntlm_rdp(stream, peer, acceptor, public_key, &cookies, &finished_account).await + } + }; + match result { + Ok(()) => credssp_ok.store(true, Ordering::SeqCst), + Err(error) => eprintln!("mock RDP CredSSP failed: {error:#}"), + } + }); + } + }); + + Ok(Self { + port, + credssp_ok, + finished_account, + cookies, + }) + } + + fn credssp_ok(&self) -> bool { + self.credssp_ok.load(Ordering::SeqCst) + } + + fn finished_account(&self) -> Option { + self.finished_account.lock().expect("finished account mutex").clone() + } + + fn cookies(&self) -> Vec { + self.cookies.lock().expect("cookie mutex").clone() + } + + async fn wait_credssp(&self) -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if self.credssp_ok() { + return Ok(()); + } + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for Kerberos CredSSP on mock RDP"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } +} + +async fn accept_kerberos_rdp( + stream: TcpStream, + peer: std::net::SocketAddr, + acceptor: tokio_rustls::TlsAcceptor, + public_key: Vec, + kdc_url: Option<&str>, + cookies: &Mutex>, + finished_account: &Mutex>, +) -> anyhow::Result<()> { + let mut framed = TokioFramed::new(stream); + let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; + let cr: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + record_cookie(&cr, cookies); + + let confirm = X224(ConnectionConfirm::Response { + flags: ResponseFlags::empty(), + protocol: SecurityProtocol::HYBRID, + }); + framed + .write_all(&ironrdp_core::encode_vec(&confirm).context("encode X.224 CC")?) + .await + .context("write X.224 CC")?; + + let tcp = framed.into_inner_no_leftover(); + let tls = acceptor.accept(tcp).await.context("TLS accept")?; + let mut framed = TokioFramed::new(tls); + + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(KERBEROS_TARGET_USER).context("parse target username")?, + password: TARGET_PASSWORD.to_owned().into(), + }; + let kerberos_config = sspi::KerberosServerConfig { + kerberos_config: sspi::KerberosConfig { + kdc_url: kdc_url + .map(|url| url.parse()) + .transpose() + .context("parse mock KDC URL")?, + client_computer_name: peer.to_string(), + }, + server_properties: sspi::kerberos::ServerProperties::new( + &["TERMSRV", SERVICE_HOST], + Some(sspi::CredentialsBuffers::AuthIdentity( + sspi::AuthIdentityBuffers::from_utf8(identity.username.account_name(), REALM, TARGET_PASSWORD), + )), + Duration::from_secs(300), + Some(sspi::Secret::new(TERMSRV_KEY.to_vec())), + ) + .context("Kerberos server properties")?, + }; + + let mut server = sspi::credssp::CredSspServer::new( + public_key, + IdentityProxy(identity), + sspi::credssp::ServerMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(kerberos_config), + Some("kerberos,!ntlm".to_owned()), + peer.to_string(), + )), + ) + .context("init Kerberos-only CredSSP server")?; + + let hint = TsRequestHint; + let mut buf = ironrdp_pdu::WriteBuf::new(); + for _ in 0..6 { + let pdu = framed.read_by_hint(&hint).await.context("read CredSSP TSRequest")?; + let ts_request = sspi::credssp::TsRequest::from_buffer(&pdu).context("decode CredSSP")?; + let result = { + let mut generator = server.process(ts_request); + resolve_sspi_server(&mut generator) + .await + .map_err(|error| anyhow::anyhow!("mock RDP CredSSP: {error:?}"))? + }; + match result { + sspi::credssp::ServerState::ReplyNeeded(outbound) => { + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode server TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write CredSSP")?; + } + sspi::credssp::ServerState::Finished(identity) => { + *finished_account.lock().expect("finished account mutex") = + Some(identity.username.account_name().to_owned()); + return Ok(()); + } + } + } + anyhow::bail!("mock RDP CredSSP exceeded 6 round trips") +} + +async fn accept_ntlm_rdp( + stream: TcpStream, + peer: std::net::SocketAddr, + acceptor: tokio_rustls::TlsAcceptor, + public_key: Vec, + cookies: &Mutex>, + finished_account: &Mutex>, +) -> anyhow::Result<()> { + let mut framed = TokioFramed::new(stream); + let (_, request) = framed.read_pdu().await.context("read X.224 CR")?; + let cr: X224 = ironrdp_core::decode(&request).context("decode X.224 CR")?; + record_cookie(&cr, cookies); + + let confirm = X224(ConnectionConfirm::Response { + flags: ResponseFlags::empty(), + protocol: SecurityProtocol::HYBRID, + }); + framed + .write_all(&ironrdp_core::encode_vec(&confirm).context("encode X.224 CC")?) + .await + .context("write X.224 CC")?; + + let tcp = framed.into_inner_no_leftover(); + let tls = acceptor.accept(tcp).await.context("TLS accept")?; + let mut framed = TokioFramed::new(tls); + + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(TARGET_USER).context("parse NTLM target username")?, + password: TARGET_PASSWORD.to_owned().into(), + }; + let mut server = sspi::credssp::CredSspServer::new( + public_key, + IdentityProxy(identity), + sspi::credssp::ServerMode::Ntlm(sspi::ntlm::NtlmConfig { + client_computer_name: Some(peer.to_string()), + }), + ) + .context("init NTLM CredSSP server")?; + + let hint = TsRequestHint; + let mut buf = ironrdp_pdu::WriteBuf::new(); + for _ in 0..6 { + let pdu = framed.read_by_hint(&hint).await.context("read CredSSP TSRequest")?; + let ts_request = sspi::credssp::TsRequest::from_buffer(&pdu).context("decode CredSSP")?; + let result = { + let mut generator = server.process(ts_request); + resolve_sspi_server(&mut generator) + .await + .map_err(|error| anyhow::anyhow!("mock RDP NTLM CredSSP: {error:?}"))? + }; + match result { + sspi::credssp::ServerState::ReplyNeeded(outbound) => { + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode server TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write CredSSP")?; + } + sspi::credssp::ServerState::Finished(identity) => { + *finished_account.lock().expect("finished account mutex") = + Some(identity.username.account_name().to_owned()); + return Ok(()); + } + } + } + anyhow::bail!("mock RDP NTLM CredSSP exceeded 6 round trips") +} + +fn record_cookie(cr: &X224, cookies: &Mutex>) { + if let Some(NegoRequestData::Cookie(cookie)) = &cr.0.nego_data { + cookies.lock().expect("cookie mutex").push(cookie.0.clone()); + } +} + +async fn resolve_sspi_server( + generator: &mut sspi::generator::Generator< + '_, + sspi::generator::NetworkRequest, + sspi::Result>, + Result, + >, +) -> Result { + let mut state = generator.start(); + loop { + match state { + GeneratorState::Suspended(request) => { + let reply = send_kdc_tcp(&request) + .await + .map_err(|error| sspi::credssp::ServerError { + ts_request: None, + error: sspi::Error::new(sspi::ErrorKind::NoAuthenticatingAuthority, error), + })?; + state = generator.resume(Ok(reply)); + } + GeneratorState::Completed(result) => break result, + } + } +} + +async fn send_kdc_tcp(request: &sspi::generator::NetworkRequest) -> anyhow::Result> { + let host = request.url.host_str().context("KDC host")?; + let port = request.url.port().unwrap_or(88); + let mut stream = TcpStream::connect((host, port)).await.context("connect mock KDC")?; + stream.write_all(&request.data).await.context("write KDC request")?; + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.context("read KDC length")?; + let len = usize::try_from(u32::from_be_bytes(len_buf)).context("KDC length")?; + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.context("read KDC body")?; + let mut reply = Vec::with_capacity(4 + len); + reply.extend_from_slice(&len_buf); + reply.extend_from_slice(&body); + Ok(reply) +} + +struct IdentityProxy(sspi::AuthIdentity); + +impl sspi::credssp::CredentialsProxy for IdentityProxy { + type AuthenticationData = sspi::AuthIdentity; + + fn auth_data_by_user(&mut self, username: &sspi::Username) -> std::io::Result { + if username.account_name() != self.0.username.account_name() { + return Err(std::io::Error::other("invalid username")); + } + let mut data = self.0.clone(); + data.username = username.clone(); + Ok(data) + } + + fn auth_data(&mut self) -> Result, std::io::Error> { + Ok(vec![self.0.clone()]) + } +} + +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20); + +async fn connect_ntlm_client( + gateway_tcp: u16, + association_jwt: &str, +) -> anyhow::Result> { + tokio::time::timeout( + HANDSHAKE_TIMEOUT, + connect_ntlm_client_inner(gateway_tcp, association_jwt), + ) + .await + .context("timed out connecting NTLM client to Gateway")? +} + +async fn connect_ntlm_client_inner( + gateway_tcp: u16, + association_jwt: &str, +) -> anyhow::Result> { + let mut stream = TcpStream::connect(("127.0.0.1", gateway_tcp)) + .await + .context("connect gateway TCP")?; + stream + .write_all(&encode_pcb(association_jwt)?) + .await + .context("write PCB")?; + stream.write_all(&encode_hybrid_cr()?).await.context("write X.224 CR")?; + stream.flush().await.context("flush CR")?; + + let mut framed = TokioFramed::new(stream); + let (_, confirm) = framed.read_pdu().await.context("read X.224 CC")?; + let confirm: X224 = ironrdp_core::decode(&confirm).context("decode X.224 CC")?; + anyhow::ensure!( + matches!(confirm.0, ConnectionConfirm::Response { protocol, .. } if protocol.contains(SecurityProtocol::HYBRID)), + "gateway did not confirm CredSSP: {confirm:?}" + ); + + let tcp = framed.into_inner_no_leftover(); + let connector = dangerous_tls_connector(); + let server_name = ServerName::try_from("localhost").map_err(|error| anyhow::anyhow!("{error}"))?; + connector.connect(server_name, tcp).await.context("TLS to gateway") +} + +async fn complete_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { + complete_client_credssp(tls, PROXY_USER, None, false, None, None).await +} + +async fn complete_raw_ntlm_credssp(tls: tokio_rustls::client::TlsStream) -> anyhow::Result<()> { + complete_client_credssp(tls, PROXY_USER, None, true, None, None).await +} + +async fn complete_client_credssp( + tls: tokio_rustls::client::TlsStream, + username: &str, + kdc_proxy_url: Option<&str>, + raw_ntlm: bool, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result<()> { + tokio::time::timeout( + HANDSHAKE_TIMEOUT, + complete_client_credssp_inner(tls, username, kdc_proxy_url, raw_ntlm, proxy_replies, proxy_requests), + ) + .await + .context("timed out completing client CredSSP")? +} + +async fn complete_client_credssp_inner( + tls: tokio_rustls::client::TlsStream, + username: &str, + kdc_proxy_url: Option<&str>, + raw_ntlm: bool, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result<()> { + use sspi::credssp::{ClientMode, ClientState, CredSspClient, CredSspMode, TsRequest}; + use sspi::ntlm::NtlmConfig; + + let public_key = peer_public_key(&tls)?; + let mut framed = TokioFramed::new(tls); + let identity = sspi::AuthIdentity { + username: sspi::Username::parse(username).context("parse client username")?, + password: PROXY_PASSWORD.to_owned().into(), + }; + let client_mode = if let Some(kdc_url) = kdc_proxy_url { + ClientMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(sspi::KerberosConfig { + kdc_url: Some(kdc_url.parse().context("parse KDC proxy URL")?), + client_computer_name: "cred-injection-e2e".to_owned(), + }), + Some("kerberos,!ntlm".to_owned()), + "cred-injection-e2e".to_owned(), + )) + } else if raw_ntlm { + // Gateway NTLM injection uses ServerMode::Ntlm, which rejects SPNEGO. + ClientMode::Ntlm(NtlmConfig { + client_computer_name: Some("cred-injection-e2e".to_owned()), + }) + } else { + ClientMode::Negotiate(sspi::NegotiateConfig::new( + Box::new(NtlmConfig { + client_computer_name: Some("cred-injection-e2e".to_owned()), + }), + Some("ntlm,!kerberos,!pku2u".to_owned()), + "cred-injection-e2e".to_owned(), + )) + }; + let mut client = CredSspClient::new( + public_key, + identity.into(), + CredSspMode::WithCredentials, + client_mode, + format!("TERMSRV/{SERVICE_HOST}"), + ) + .context("init CredSSP client")?; + + let mut ts_request = TsRequest::default(); + let mut buf = ironrdp_pdu::WriteBuf::new(); + let hint = TsRequestHint; + + for _ in 0..8 { + let client_state = { + let mut generator = client.process(std::mem::take(&mut ts_request)); + resolve_sspi_client(&mut generator, proxy_replies, proxy_requests).await? + }; + let (outbound, finished) = match client_state { + ClientState::ReplyNeeded(request) => (request, false), + ClientState::FinalMessage(request) => (request, true), + }; + buf.clear(); + let length = usize::from(outbound.buffer_len()); + outbound + .encode_ts_request(buf.unfilled_to(length)) + .context("encode client TSRequest")?; + buf.advance(length); + framed.write_all(&buf[..length]).await.context("write client CredSSP")?; + if finished { + return Ok(()); + } + let pdu = framed.read_by_hint(&hint).await.context("read server CredSSP")?; + ts_request = TsRequest::from_buffer(&pdu).context("decode server TSRequest")?; + } + + anyhow::bail!("CredSSP exceeded 8 round trips") +} + +async fn resolve_sspi_client( + generator: &mut sspi::generator::Generator< + '_, + sspi::generator::NetworkRequest, + sspi::Result>, + sspi::Result, + >, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result { + let mut state = generator.start(); + loop { + match state { + GeneratorState::Suspended(request) => { + let reply = match request.url.scheme() { + "tcp" | "udp" => send_kdc_tcp(&request).await?, + "http" | "https" => send_kdc_http(&request, proxy_replies, proxy_requests).await?, + other => anyhow::bail!("unsupported KDC scheme {other}: {}", request.url), + }; + state = generator.resume(Ok(reply)); + } + GeneratorState::Completed(result) => { + break result.map_err(|error| anyhow::anyhow!("client CredSSP: {error}")); + } + } + } +} + +async fn send_kdc_http( + request: &sspi::generator::NetworkRequest, + proxy_replies: Option<&Mutex>>, + proxy_requests: Option<&Mutex>>, +) -> anyhow::Result> { + let host = request.url.host_str().context("KDC proxy host")?; + let port = request.url.port_or_known_default().unwrap_or(80); + let path = if request.url.query().is_some() { + format!("{}?{}", request.url.path(), request.url.query().unwrap_or_default()) + } else { + request.url.path().to_owned() + }; + let mut stream = TcpStream::connect((host, port)).await.context("connect KDC proxy")?; + let header = format!( + "POST {path} HTTP/1.1\r\n\ + Host: {host}:{port}\r\n\ + Content-Type: application/octet-stream\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + request.data.len() + ); + stream + .write_all(header.as_bytes()) + .await + .context("write KDC proxy headers")?; + stream.write_all(&request.data).await.context("write KDC proxy body")?; + stream.flush().await.context("flush KDC proxy")?; + if let Ok(message) = KdcProxyMessage::from_raw(&request.data) + && let Some(log) = proxy_requests + { + let kerb = message.kerb_message.0.0.get(4..).unwrap_or(&message.kerb_message.0.0); + log.lock().expect("proxy request mutex").push(observe_kdc_req(kerb)); + } + + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + reader + .read_line(&mut status_line) + .await + .context("read KDC proxy status")?; + anyhow::ensure!( + status_line.starts_with("HTTP/1.1 200") || status_line.starts_with("HTTP/1.0 200"), + "KDC proxy HTTP status was {status_line:?}" + ); + + let mut content_length = None; + loop { + let mut line = String::new(); + reader.read_line(&mut line).await.context("read KDC proxy header")?; + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(value) = line + .split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().to_owned()) + { + content_length = Some(value.parse::().context("parse KDC proxy Content-Length")?); + } + } + + let buf = if let Some(len) = content_length { + let mut buf = vec![0u8; len]; + tokio::io::AsyncReadExt::read_exact(&mut reader, &mut buf) + .await + .context("read KDC proxy body")?; + buf + } else { + let mut buf = Vec::new(); + tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut buf) + .await + .context("read KDC proxy eof body")?; + buf + }; + if let Ok(message) = KdcProxyMessage::from_raw(&buf) + && let Some(log) = proxy_replies + { + log.lock() + .expect("proxy reply mutex") + .push(observe_kdc_reply(&message.kerb_message.0.0)); + } + Ok(buf) +} + +fn kdc_inject_token(association_jti: &str) -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"KDC"}), + serde_json::json!({ + "exp": 9_999_999_999i64, + "jet_cred_id": association_jti, + "jti": next_id(), + }), + ) +} + +fn kdc_proxy_url(http_port: u16, association_jti: &str) -> anyhow::Result { + let token = kdc_inject_token(association_jti)?; + Ok(format!("http://127.0.0.1:{http_port}/jet/KdcProxy/{token}")) +} + +#[derive(Debug)] +struct TsRequestHint; + +impl ironrdp_pdu::PduHint for TsRequestHint { + fn find_size(&self, bytes: &[u8]) -> ironrdp_core::DecodeResult> { + match sspi::credssp::TsRequest::read_length(bytes) { + Ok(length) => Ok(Some((true, length))), + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(None), + Err(error) => Err(ironrdp_core::other_err!("TsRequestHint", source: error)), + } + } +} + +fn association_token_for_host(jti: &str, jet_aid: &str, dest_port: u16, jet_reuse: u32) -> anyhow::Result { + unsigned_jws( + serde_json::json!({"alg":"RS256","typ":"JWT","cty":"ASSOCIATION"}), + serde_json::json!({ + "dst_hst": format!("{SERVICE_HOST}:{dest_port}"), + "exp": 9_999_999_999i64, + "jet_aid": jet_aid, + "jet_ap": "rdp", + "jet_cm": "fwd", + "jet_rec": "none", + "jet_reuse": jet_reuse, + "jti": jti, + "nbf": 0, + }), + ) +} + +fn encode_hybrid_cr() -> anyhow::Result> { + let pdu = X224(ConnectionRequest { + nego_data: Some(NegoRequestData::cookie(super::cred_injection::CLIENT_COOKIE.to_owned())), + flags: RequestFlags::empty(), + protocol: SecurityProtocol::HYBRID | SecurityProtocol::SSL, + }); + ironrdp_core::encode_vec(&pdu).context("encode hybrid CR") +} + +fn peer_public_key(tls: &tokio_rustls::client::TlsStream) -> anyhow::Result> { + let cert = tls + .get_ref() + .1 + .peer_certificates() + .and_then(|certs| certs.first()) + .context("gateway TLS certificate missing")?; + extract_public_key(cert) +} + +fn server_public_key() -> anyhow::Result> { + let cert = CertificateDer::from_pem_slice(CERT_PEM.as_bytes()).context("parse mock RDP cert")?; + extract_public_key(&cert) +} + +fn extract_public_key(cert: &CertificateDer<'_>) -> anyhow::Result> { + let cert = x509_cert::Certificate::from_der(cert.as_ref()).context("parse X509")?; + let public_key = cert + .tbs_certificate() + .subject_public_key_info() + .subject_public_key + .as_bytes() + .context("unaligned subject public key")? + .to_owned(); + Ok(public_key) +} + +fn tls_acceptor() -> anyhow::Result { + let cert = CertificateDer::from_pem_slice(CERT_PEM.as_bytes()).context("parse cert PEM")?; + let key = PrivateKeyDer::from_pem_slice(KEY_PEM.as_bytes()).context("parse key PEM")?; + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert], key) + .context("TLS server config")?; + Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config))) +} + +fn dangerous_tls_connector() -> tokio_rustls::TlsConnector { + let mut config = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) + .with_no_client_auth(); + config.resumption = rustls::client::Resumption::disabled(); + tokio_rustls::TlsConnector::from(Arc::new(config)) +} + +fn install_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +#[derive(Debug)] +struct NoCertificateVerification; + +impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _: &CertificateDer<'_>, + _: &[CertificateDer<'_>], + _: &ServerName<'_>, + _: &[u8], + _: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::ED25519, + ] + } +} + +fn assert_target_kdc_as_and_tgs(kdc: &MockKdc) -> anyhow::Result<()> { + let reqs = kdc.requests(); + anyhow::ensure!( + reqs.iter().any(|req| matches!( + req, + ObservedKdcReq::As { cname, realm } + if cname.eq_ignore_ascii_case("administrator") && realm.eq_ignore_ascii_case(REALM) + )), + "KDC must see AS-REQ cname=administrator realm={REALM}; requests={reqs:?}" + ); + anyhow::ensure!( + reqs.iter().any(|req| matches!( + req, + ObservedKdcReq::Tgs { sname, realm } + if *sname == ["TERMSRV", SERVICE_HOST] && realm.eq_ignore_ascii_case(REALM) + )), + "KDC must see TGS-REQ sname=TERMSRV/{SERVICE_HOST} realm={REALM}; requests={reqs:?}" + ); + Ok(()) +} + +#[tokio::test] +async fn kerberos_injection_completes_credssp_against_mock_kdc() -> anyhow::Result<()> { + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some(&kdc.url()), + ) + .await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + complete_ntlm_credssp(tls) + .await + .context("Gateway-facing NTLM CredSSP")?; + rdp.wait_credssp() + .await + .with_context(|| format!("gateway logs:\n{}", gateway.logs.snapshot()))?; + anyhow::ensure!( + kdc.exchanges() >= 2, + "expected AS-REQ and TGS-REQ against the mock KDC; exchanges={}; gateway logs:\n{}", + kdc.exchanges(), + gateway.logs.snapshot() + ); + assert_target_kdc_as_and_tgs(&kdc)?; + anyhow::ensure!( + rdp.finished_account().as_deref() == Some("administrator"), + "RDP CredSSP Finished account must be administrator; got={:?}; cookies={:?}", + rdp.finished_account(), + rdp.cookies() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "RDP X.224 cookie must be {KERBEROS_TARGET_USER}; cookies={:?}", + rdp.cookies() + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_client_and_target_legs_complete_credssp() -> anyhow::Result<()> { + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_mapping( + gateway.config.http_port(), + &token, + PROXY_KERBEROS_USER, + KERBEROS_TARGET_USER, + TARGET_PASSWORD, + 300, + Some(&kdc.url()), + ) + .await?; + + let kdc_proxy = kdc_proxy_url(gateway.config.http_port(), &jti)?; + let proxy_replies = Mutex::new(Vec::new()); + let proxy_requests = Mutex::new(Vec::new()); + let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + complete_client_credssp( + tls, + PROXY_KERBEROS_USER, + Some(&kdc_proxy), + false, + Some(&proxy_replies), + Some(&proxy_requests), + ) + .await + .with_context(|| { + format!( + "client-leg Kerberos CredSSP; gateway logs:\n{}", + gateway.logs.snapshot() + ) + })?; + rdp.wait_credssp().await.with_context(|| { + format!( + "target-leg Kerberos CredSSP; gateway logs:\n{}", + gateway.logs.snapshot() + ) + })?; + anyhow::ensure!( + kdc.exchanges() >= 2, + "target-leg must talk to the mock KDC; exchanges={}; logs:\n{}", + kdc.exchanges(), + gateway.logs.snapshot() + ); + assert_target_kdc_as_and_tgs(&kdc)?; + let replies = proxy_replies.lock().expect("proxy reply mutex").clone(); + anyhow::ensure!( + replies.contains(&ObservedKdcReply::AsRep) && replies.contains(&ObservedKdcReply::TgsRep), + "/jet/KdcProxy must return AS-REP and TGS-REP (PREAUTH KRB-ERROR is allowed first); replies={replies:?}" + ); + let requests = proxy_requests.lock().expect("proxy request mutex").clone(); + anyhow::ensure!( + requests.iter().any(|req| matches!( + req, + ObservedKdcReq::As { cname, realm } + if cname.eq_ignore_ascii_case("injected-proxy-user") && realm.eq_ignore_ascii_case(REALM) + )), + "synthetic KDC AS-REQ must be proxy user injected-proxy-user@{REALM}; requests={requests:?}" + ); + anyhow::ensure!( + requests.iter().any(|req| matches!( + req, + ObservedKdcReq::Tgs { sname, realm } + if *sname == ["TERMSRV", SERVICE_HOST] && realm.eq_ignore_ascii_case(REALM) + )), + "synthetic KDC TGS-REQ must be TERMSRV/{SERVICE_HOST}; requests={requests:?}" + ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some("administrator"), + "RDP CredSSP Finished account must be administrator; got={:?}", + rdp.finished_account() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "RDP X.224 cookie must be {KERBEROS_TARGET_USER}; cookies={:?}", + rdp.cookies() + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_wrong_target_password_fails_closed() -> anyhow::Result<()> { + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_mapping( + gateway.config.http_port(), + &token, + PROXY_USER, + KERBEROS_TARGET_USER, + "wrong-target-password", + 300, + Some(&kdc.url()), + ) + .await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + let _ = complete_ntlm_credssp(tls).await; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + anyhow::ensure!( + logs.contains("kerberos=true"), + "wrong password must still start Kerberos injection; logs:\n{logs}" + ); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if kdc.requests().iter().any(|req| { + matches!( + req, + ObservedKdcReq::As { cname, realm } + if cname.eq_ignore_ascii_case("administrator") && realm.eq_ignore_ascii_case(REALM) + ) + }) { + break; + } + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for AS-REQ as administrator; requests={:?}", + kdc.requests() + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + tokio::time::sleep(Duration::from_secs(2)).await; + anyhow::ensure!( + !rdp.credssp_ok() && rdp.finished_account().is_none(), + "wrong target password must not complete Kerberos CredSSP; account={:?}; logs:\n{}", + rdp.finished_account(), + gateway.logs.snapshot() + ); + anyhow::ensure!( + !kdc.requests() + .iter() + .any(|req| matches!(req, ObservedKdcReq::Tgs { .. })), + "wrong password must not obtain a TGS; requests={:?}", + kdc.requests() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "wrong password still rewrites the X.224 cookie; cookies={:?}", + rdp.cookies() + ); + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + !logs.contains(FORWARD_LOG), + "wrong password must not ordinary-forward; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +struct RefusingKdc { + port: u16, + accepted: Arc, +} + +impl RefusingKdc { + async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind refusing KDC")?; + let port = listener.local_addr()?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_task = Arc::clone(&accepted); + tokio::spawn(async move { + loop { + let Ok((_stream, _)) = listener.accept().await else { + break; + }; + accepted_task.fetch_add(1, Ordering::SeqCst); + } + }); + Ok(Self { port, accepted }) + } + + fn url(&self) -> String { + format!("tcp://127.0.0.1:{}", self.port) + } + + fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) + } +} + +#[tokio::test] +async fn kerberos_kdc_down_fails_closed() -> anyhow::Result<()> { + install_crypto_provider(); + let kdc = RefusingKdc::start().await?; + let rdp_kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(rdp_kdc.url()).await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some(&kdc.url()), + ) + .await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + let _ = complete_ntlm_credssp(tls).await; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + anyhow::ensure!( + logs.contains("kerberos=true"), + "KDC down must still start Kerberos injection; logs:\n{logs}" + ); + let deadline = Instant::now() + Duration::from_secs(10); + while kdc.accepted() == 0 { + if Instant::now() >= deadline { + anyhow::bail!("Gateway never TCP-connected the provisioned KDC"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + tokio::time::sleep(Duration::from_secs(1)).await; + anyhow::ensure!( + !rdp.credssp_ok() && rdp.finished_account().is_none(), + "unreachable KDC must not complete CredSSP; account={:?}; logs:\n{}", + rdp.finished_account(), + gateway.logs.snapshot() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == KERBEROS_TARGET_USER), + "KDC down still rewrites the X.224 cookie; cookies={:?}", + rdp.cookies() + ); + anyhow::ensure!( + kdc.accepted() >= 1, + "Gateway must TCP-connect the provisioned KDC; accepted={}", + kdc.accepted() + ); + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + !logs.contains(FORWARD_LOG), + "KDC down must not ordinary-forward; logs:\n{logs}" + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn kerberos_missing_krb_kdc_fails_closed() -> anyhow::Result<()> { + let rdp = FakeClosedTarget::start().await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, KERBEROS_TARGET_USER, 300, None).await?; + + let mut stream = TcpStream::connect(("127.0.0.1", gateway.config.tcp_port())) + .await + .context("connect gateway TCP")?; + stream.write_all(&encode_pcb(&token)?).await.context("write PCB")?; + stream.write_all(&encode_hybrid_cr()?).await.context("write CR")?; + stream.flush().await.context("flush CR")?; + let logs = gateway.logs.wait_contains(MISSING_LOG).await?; + anyhow::ensure!( + !logs.contains(FORWARD_LOG) && !logs.contains(INJECT_LOG), + "missing krb_kdc must fail closed; logs:\n{logs}" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + anyhow::ensure!( + rdp.accepted() == 0, + "missing krb_kdc must not dial the target; accepted={}", + rdp.accepted() + ); + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn ntlm_injection_completes_credssp_both_legs() -> anyhow::Result<()> { + install_crypto_provider(); + let rdp = MockRdp::start_ntlm().await?; + let mut gateway = GatewayProc::start().await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + + let tls = connect_ntlm_client(gateway.config.tcp_port(), &token).await?; + complete_raw_ntlm_credssp(tls) + .await + .with_context(|| format!("client-leg NTLM CredSSP; logs:\n{}", gateway.logs.snapshot()))?; + rdp.wait_credssp() + .await + .with_context(|| format!("target-leg NTLM CredSSP; logs:\n{}", gateway.logs.snapshot()))?; + let logs = gateway.logs.wait_contains(INJECT_LOG).await?; + anyhow::ensure!( + logs.contains("kerberos=false"), + "expected NTLM injection; logs:\n{logs}" + ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some(TARGET_USER), + "RDP NTLM CredSSP Finished account must be {TARGET_USER}; got={:?}", + rdp.finished_account() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == TARGET_USER), + "RDP X.224 cookie must be {TARGET_USER}; cookies={:?}", + rdp.cookies() + ); + + let _ = gateway.process.start_kill(); + Ok(()) +} + +struct FakeClosedTarget { + port: u16, + accepted: Arc, +} + +impl FakeClosedTarget { + async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await.context("bind closed target")?; + let port = listener.local_addr()?.port(); + let accepted = Arc::new(AtomicUsize::new(0)); + let accepted_task = Arc::clone(&accepted); + tokio::spawn(async move { + loop { + let Ok((_stream, _)) = listener.accept().await else { + break; + }; + accepted_task.fetch_add(1, Ordering::SeqCst); + } + }); + Ok(Self { port, accepted }) + } + + fn accepted(&self) -> usize { + self.accepted.load(Ordering::SeqCst) + } +} + +const IRONRDP_AGENT_VERSION: &str = "0.1.0"; +const RDCLEANPATH_INJECT_LOG: &str = "Switching to RdpProxy for credential injection (WebSocket)"; +const RDCLEANPATH_FORWARD_LOG: &str = "RDP-TLS forwarding (RDCleanPath)"; + +fn ironrdp_agent_bin() -> Option { + if let Ok(path) = std::env::var("IRONRDP_AGENT") { + return Some(PathBuf::from(path)); + } + let name = if cfg!(windows) { + "ironrdp-agent.exe" + } else { + "ironrdp-agent" + }; + if let Ok(home) = std::env::var("CARGO_HOME") { + let path = PathBuf::from(home).join("bin").join(name); + if path.is_file() { + return Some(path); + } + } + let cargo_home = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .map(|home| home.join(".cargo").join("bin").join(name)); + if let Some(path) = cargo_home + && path.is_file() + { + return Some(path); + } + if let Ok(path) = std::env::var("PATH") { + for dir in std::env::split_paths(&path) { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +fn require_ironrdp_agent() -> anyhow::Result> { + let Some(bin) = ironrdp_agent_bin() else { + eprintln!( + "skipping RDCleanPath ironrdp-agent test: cargo install ironrdp-agent --version {IRONRDP_AGENT_VERSION}" + ); + return Ok(None); + }; + let output = std::process::Command::new(&bin) + .arg("--version") + .output() + .with_context(|| format!("run {} --version", bin.display()))?; + let version = String::from_utf8_lossy(&output.stdout); + anyhow::ensure!( + version.contains(IRONRDP_AGENT_VERSION), + "expected ironrdp-agent {IRONRDP_AGENT_VERSION}, got {version:?} from {}", + bin.display() + ); + Ok(Some(bin)) +} + +fn ironrdp_agent_endpoint() -> String { + let name = format!("ironrdp-e2e-{}", next_id().replace('-', "")); + if cfg!(windows) { + format!(r"\\.\pipe\{name}") + } else { + std::env::temp_dir().join(format!("{name}.sock")).display().to_string() + } +} + +async fn start_ironrdp_daemon(bin: &Path, endpoint: &str) -> anyhow::Result { + let child = tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "daemon-start"]) + .kill_on_drop(true) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("start ironrdp-agent daemon")?; + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let status = tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "status"]) + .output() + .await + .context("ironrdp-agent status")?; + if status.status.success() { + return Ok(child); + } + if Instant::now() >= deadline { + anyhow::bail!( + "ironrdp-agent daemon not ready at {endpoint}: {}", + String::from_utf8_lossy(&status.stderr) + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn connect_ironrdp_rdcleanpath( + bin: &Path, + endpoint: &str, + server: &str, + token: &str, + http_port: u16, +) -> anyhow::Result { + let url = format!("ws://127.0.0.1:{http_port}/jet/rdp"); + tokio::process::Command::new(bin) + .args([ + "--endpoint", + endpoint, + "connect", + "--server", + server, + "--username", + PROXY_USER, + "--password", + PROXY_PASSWORD, + "--prop", + &format!("ironrdp_rdcleanpathurl:s:{url}"), + "--prop", + &format!("ironrdp_rdcleanpathtoken:s:{token}"), + ]) + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("start ironrdp-agent connect") +} + +async fn agent_query_logs(bin: &Path, endpoint: &str) -> String { + tokio::process::Command::new(bin) + .args(["--endpoint", endpoint, "query-logs"]) + .output() + .await + .ok() + .map(|output| String::from_utf8_lossy(&output.stdout).into_owned()) + .unwrap_or_default() +} + +#[tokio::test] +async fn ironrdp_agent_rdcleanpath_ntlm_injection() -> anyhow::Result<()> { + let Some(bin) = require_ironrdp_agent()? else { + return Ok(()); + }; + install_crypto_provider(); + let rdp = MockRdp::start_ntlm().await?; + let mut gateway = GatewayProc::start().await?; + let endpoint = ironrdp_agent_endpoint(); + let mut daemon = start_ironrdp_daemon(&bin, &endpoint).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials(gateway.config.http_port(), &token, TARGET_USER, 300, None).await?; + + let mut connect = connect_ironrdp_rdcleanpath( + &bin, + &endpoint, + &format!("{SERVICE_HOST}:{}", rdp.port), + &token, + gateway.config.http_port(), + ) + .await?; + let wait = rdp.wait_credssp().await; + let agent_logs = agent_query_logs(&bin, &endpoint).await; + wait.with_context(|| { + format!( + "RDCleanPath NTLM target CredSSP; gateway logs:\n{}\nagent logs:\n{agent_logs}", + gateway.logs.snapshot() + ) + })?; + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + logs.contains(RDCLEANPATH_INJECT_LOG), + "RDCleanPath must take the injection path; logs:\n{logs}" + ); + anyhow::ensure!( + !logs.contains(RDCLEANPATH_FORWARD_LOG), + "RDCleanPath injection must not ordinary-forward; logs:\n{logs}" + ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some(TARGET_USER), + "RDP NTLM CredSSP Finished account must be {TARGET_USER}; got={:?}", + rdp.finished_account() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == PROXY_USER), + "RDCleanPath forwards the client X.224 cookie; cookies={:?}", + rdp.cookies() + ); + + let _ = connect.start_kill(); + let _ = daemon.start_kill(); + let _ = gateway.process.start_kill(); + Ok(()) +} + +#[tokio::test] +async fn ironrdp_agent_rdcleanpath_kerberos_injection() -> anyhow::Result<()> { + let Some(bin) = require_ironrdp_agent()? else { + return Ok(()); + }; + install_crypto_provider(); + let kdc = MockKdc::start().await?; + let rdp = MockRdp::start_kerberos(kdc.url()).await?; + let mut gateway = GatewayProc::start().await?; + let endpoint = ironrdp_agent_endpoint(); + let mut daemon = start_ironrdp_daemon(&bin, &endpoint).await?; + + let jti = next_id(); + let jet_aid = next_id(); + let token = association_token_for_host(&jti, &jet_aid, rdp.port, 60)?; + provision_credentials( + gateway.config.http_port(), + &token, + KERBEROS_TARGET_USER, + 300, + Some(&kdc.url()), + ) + .await?; + + let mut connect = connect_ironrdp_rdcleanpath( + &bin, + &endpoint, + &format!("{SERVICE_HOST}:{}", rdp.port), + &token, + gateway.config.http_port(), + ) + .await?; + let wait = rdp.wait_credssp().await; + let agent_logs = agent_query_logs(&bin, &endpoint).await; + wait.with_context(|| { + format!( + "RDCleanPath Kerberos target CredSSP; gateway logs:\n{}\nagent logs:\n{agent_logs}", + gateway.logs.snapshot() + ) + })?; + assert_target_kdc_as_and_tgs(&kdc)?; + let logs = gateway.logs.snapshot(); + anyhow::ensure!( + logs.contains(RDCLEANPATH_INJECT_LOG), + "RDCleanPath must take the injection path; logs:\n{logs}" + ); + anyhow::ensure!( + !logs.contains(RDCLEANPATH_FORWARD_LOG), + "RDCleanPath injection must not ordinary-forward; logs:\n{logs}" + ); + anyhow::ensure!( + rdp.finished_account().as_deref() == Some("administrator"), + "RDP CredSSP Finished account must be administrator; got={:?}", + rdp.finished_account() + ); + anyhow::ensure!( + rdp.cookies().iter().any(|cookie| cookie == PROXY_USER), + "RDCleanPath forwards the client X.224 cookie; cookies={:?}", + rdp.cookies() + ); + + let _ = connect.start_kill(); + let _ = daemon.start_kill(); + let _ = gateway.process.start_kill(); + Ok(()) +} diff --git a/testsuite/tests/cli/dgw/mod.rs b/testsuite/tests/cli/dgw/mod.rs index c6cc1226b..aadf2cefa 100644 --- a/testsuite/tests/cli/dgw/mod.rs +++ b/testsuite/tests/cli/dgw/mod.rs @@ -1,6 +1,9 @@ mod benign_disconnect; mod cli_args; +mod cred_injection; +mod cred_injection_kdc; mod heartbeat; mod preflight; mod tls_anchoring; +mod tls_fixtures; mod traffic_audit; diff --git a/testsuite/tests/cli/dgw/tls_anchoring.rs b/testsuite/tests/cli/dgw/tls_anchoring.rs index b45669ad2..79f355cf5 100644 --- a/testsuite/tests/cli/dgw/tls_anchoring.rs +++ b/testsuite/tests/cli/dgw/tls_anchoring.rs @@ -168,56 +168,7 @@ async fn start_dummy_tls_server() -> anyhow::Result { } mod tls { - /// Self-signed certificate for localhost (valid for 100 years). - pub(super) const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- -MIIDCzCCAfOgAwIBAgIUPRJa8i280unV3/kW6TE2fSUw8PwwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTEyNTA5NDAzMFoYDzIxMjUx -MTAxMDk0MDMwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQDHpBlyRgUx/V9cQGw/eqDFc6odxB2hvnbudi67LvEj -cNIWOU79R1e/NswME4oecqT9W05n4UyxkABfm2qjODO0nDf47W0DsgbEA87qE715 -RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgWa8rhPc7E6In1uDRak0lWKQPQSBbc -34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEvXp+g9WsuMaU913ecacWDjyWDkf80 -pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsgUbK+TMWvkF9bcxAQDnJsrZRL7Jfh -ofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1jrU3cPA7dAgMBAAGjUzBRMB0GA1Ud -DgQWBBTT+m6dyc/c3mXF3JAsZr9OqUwgWTAfBgNVHSMEGDAWgBTT+m6dyc/c3mXF -3JAsZr9OqUwgWTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBB -i/yonZY3ztaeGElzD8xkI+rJ+daJ5WzdfKnzudJllg/Ht8m7wO5SdQnMt2T44gbH -05uekc1zXnXb7fJKqs3R6DacctG0nQ3acuI+IMtTaBbbAcf3PJJlo0Pap0ypVC0R -IUiUhJGFNi4cCBOvJqsly0d3T5xqOXU1Q5j3mIwRBY68+m9btwwuZWvASRADtCyZ -RpisBzS4a6jSeHXa4iG/VhskbiZkcnfHNTw7yNJJdv125y2zQkWWF9wlLbYwWr40 -x9Ba6YbssOz6epATKhvt80yclO34AzUyimssvViIUpgFEyaPhZZTw46Q/6X3ixK4 -/v4eYM0cCHN0h+rynSor ------END CERTIFICATE-----"#; - - /// Private key for the self-signed certificate. - pub(super) const KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHpBlyRgUx/V9c -QGw/eqDFc6odxB2hvnbudi67LvEjcNIWOU79R1e/NswME4oecqT9W05n4UyxkABf -m2qjODO0nDf47W0DsgbEA87qE715RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgW -a8rhPc7E6In1uDRak0lWKQPQSBbc34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEv -Xp+g9WsuMaU913ecacWDjyWDkf80pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsg -UbK+TMWvkF9bcxAQDnJsrZRL7JfhofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1j -rU3cPA7dAgMBAAECggEAKh7KK5zwTaq6atlAvWfe8anEk4EkC1MG/qq6k02FHMgZ -2wx+SNu7fKFQDaA1vNTNUJLqCOq05qWOHp3IsuURq6JmAMP/Aw+Vc9el2ScPC74E -Dt09MmlZKl77H3fxPYwoFx5RHrbIuvoSH/DgHgOPU2YIbWpOyWlXyLDgmBoNkM3N -fXYLXJONpStPHeQLhh7LcHO3CZgn6kycJyByEO2NtcchS5zITiJuwL+qR5/QIlvD -Yo7jdCjelJat38MZ9dE1us8xlIjQtsYF/acZZtcpYho+7ZpDCNcb+xF8KStKei+B -MMpWISsa+Zh9g7lPYTnG/i1dSMMT100XCEw8o4rBoQKBgQDnptz8acp7DB2wJH4L -c0xuw8IlrSl3BGUEj8H+RyFlpH3+//i6/fE9MrtF8b4FSYUp5AG4NVFGcRbwJVGW -jeL13YwIKMdXjmx8fDIylCgBB1tzBS9T/0ws3HS8avxhKvjgoXIZm6D3XDcBslrH -c9/LojT8YGI1wx7jWI2qKj8yeQKBgQDcn+kQ1QjzgIz6bAVWY3t1jr5uHHyaS+5G -ihY/mx4Mn3DURgPXZHz/HrN9rZkax0zuq9wuIlqgZ2KI37iCF49M4aZxC788LyDo -Hp0Cak3wt3g0Tj6J7SJiQe8h/6VBS4R5dRD2vhEc3xPAOf7WIFdlLYBOOvE/LmOt -N6ChkfgGhQKBgQDSiDqLRPJ7BjXtIh1T9sPeXxeR+mCXBG1yydx7ZtYZdHf2S1kZ -STX4cqT1GpGiaIEX41sUuZBWPu2j76bI98bvwRxFRhp1nsFGGfHdOf1pgfBBBtNO -udXXZ7zIiUs6XD24mcIDOAgBB9QOPLR4VP1uKsuRG1/mkKD/6jlGEANDsQKBgQDC -AoEygxQnBVFz2c/rwvnLS+Zb8AMGsGTtdPrRnjeThBX1JUi1fbGJq1bN2v27Fa2q -aEjr7NvjGGcG1C1tgQhL5Fa4LEtTwmHenSUW/aJiXwR+gpvuMDC/VRnTvPp2a9En -+XEcedGUoPq+XIGjjLctyxB8Osrw83tF1JgV3MXN/QKBgQC83B54rYDd4QmVH5nL -WLw834fgr+Z1hA6UqJIaahlD/bDwzbbJEv0pHCBxe01ywQFivqWBdVbuoy9YSeLS -KKEklzh+L0SorrYoBA5F63qx0zy05bba0ASplgDUEUNZn7oIFi7x5pVsNNaNxZpR -bQGM8UrNQvWQ+tutRmp7PM6VuQ== ------END PRIVATE KEY-----"#; + pub(super) use super::super::tls_fixtures::{CERT_PEM, KEY_PEM}; /// SHA-256 thumbprint of the certificate. pub(super) const CERT_THUMBPRINT: &str = "bce13f257b9d856404c51b46f2420eff6d01b3a4c99fe3d0e11e4517c2291b70"; diff --git a/testsuite/tests/cli/dgw/tls_fixtures.rs b/testsuite/tests/cli/dgw/tls_fixtures.rs new file mode 100644 index 000000000..7d4d2f4b0 --- /dev/null +++ b/testsuite/tests/cli/dgw/tls_fixtures.rs @@ -0,0 +1,52 @@ +//! Shared localhost TLS material for process-level Gateway tests. + +/// Self-signed certificate for localhost (valid for 100 years). +pub(crate) const CERT_PEM: &str = r#"-----BEGIN CERTIFICATE----- +MIIDCzCCAfOgAwIBAgIUPRJa8i280unV3/kW6TE2fSUw8PwwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI1MTEyNTA5NDAzMFoYDzIxMjUx +MTAxMDk0MDMwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDHpBlyRgUx/V9cQGw/eqDFc6odxB2hvnbudi67LvEj +cNIWOU79R1e/NswME4oecqT9W05n4UyxkABfm2qjODO0nDf47W0DsgbEA87qE715 +RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgWa8rhPc7E6In1uDRak0lWKQPQSBbc +34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEvXp+g9WsuMaU913ecacWDjyWDkf80 +pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsgUbK+TMWvkF9bcxAQDnJsrZRL7Jfh +ofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1jrU3cPA7dAgMBAAGjUzBRMB0GA1Ud +DgQWBBTT+m6dyc/c3mXF3JAsZr9OqUwgWTAfBgNVHSMEGDAWgBTT+m6dyc/c3mXF +3JAsZr9OqUwgWTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBB +i/yonZY3ztaeGElzD8xkI+rJ+daJ5WzdfKnzudJllg/Ht8m7wO5SdQnMt2T44gbH +05uekc1zXnXb7fJKqs3R6DacctG0nQ3acuI+IMtTaBbbAcf3PJJlo0Pap0ypVC0R +IUiUhJGFNi4cCBOvJqsly0d3T5xqOXU1Q5j3mIwRBY68+m9btwwuZWvASRADtCyZ +RpisBzS4a6jSeHXa4iG/VhskbiZkcnfHNTw7yNJJdv125y2zQkWWF9wlLbYwWr40 +x9Ba6YbssOz6epATKhvt80yclO34AzUyimssvViIUpgFEyaPhZZTw46Q/6X3ixK4 +/v4eYM0cCHN0h+rynSor +-----END CERTIFICATE-----"#; + +/// Private key for the self-signed certificate. +pub(crate) const KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHpBlyRgUx/V9c +QGw/eqDFc6odxB2hvnbudi67LvEjcNIWOU79R1e/NswME4oecqT9W05n4UyxkABf +m2qjODO0nDf47W0DsgbEA87qE715RWg8AtC529CZAazqTV3gqYyRMsCuVKzPVxgW +a8rhPc7E6In1uDRak0lWKQPQSBbc34nxMOVIusZNlkAEar8/aYPr/YWvdEqkobEv +Xp+g9WsuMaU913ecacWDjyWDkf80pPPtf+uet7WMysKMhzGQtpbgilT8XCo8uTsg +UbK+TMWvkF9bcxAQDnJsrZRL7JfhofsFfQbTIvbvpn+4J4kmHN36BTohlNL8TX1j +rU3cPA7dAgMBAAECggEAKh7KK5zwTaq6atlAvWfe8anEk4EkC1MG/qq6k02FHMgZ +2wx+SNu7fKFQDaA1vNTNUJLqCOq05qWOHp3IsuURq6JmAMP/Aw+Vc9el2ScPC74E +Dt09MmlZKl77H3fxPYwoFx5RHrbIuvoSH/DgHgOPU2YIbWpOyWlXyLDgmBoNkM3N +fXYLXJONpStPHeQLhh7LcHO3CZgn6kycJyByEO2NtcchS5zITiJuwL+qR5/QIlvD +Yo7jdCjelJat38MZ9dE1us8xlIjQtsYF/acZZtcpYho+7ZpDCNcb+xF8KStKei+B +MMpWISsa+Zh9g7lPYTnG/i1dSMMT100XCEw8o4rBoQKBgQDnptz8acp7DB2wJH4L +c0xuw8IlrSl3BGUEj8H+RyFlpH3+//i6/fE9MrtF8b4FSYUp5AG4NVFGcRbwJVGW +jeL13YwIKMdXjmx8fDIylCgBB1tzBS9T/0ws3HS8avxhKvjgoXIZm6D3XDcBslrH +c9/LojT8YGI1wx7jWI2qKj8yeQKBgQDcn+kQ1QjzgIz6bAVWY3t1jr5uHHyaS+5G +ihY/mx4Mn3DURgPXZHz/HrN9rZkax0zuq9wuIlqgZ2KI37iCF49M4aZxC788LyDo +Hp0Cak3wt3g0Tj6J7SJiQe8h/6VBS4R5dRD2vhEc3xPAOf7WIFdlLYBOOvE/LmOt +N6ChkfgGhQKBgQDSiDqLRPJ7BjXtIh1T9sPeXxeR+mCXBG1yydx7ZtYZdHf2S1kZ +STX4cqT1GpGiaIEX41sUuZBWPu2j76bI98bvwRxFRhp1nsFGGfHdOf1pgfBBBtNO +udXXZ7zIiUs6XD24mcIDOAgBB9QOPLR4VP1uKsuRG1/mkKD/6jlGEANDsQKBgQDC +AoEygxQnBVFz2c/rwvnLS+Zb8AMGsGTtdPrRnjeThBX1JUi1fbGJq1bN2v27Fa2q +aEjr7NvjGGcG1C1tgQhL5Fa4LEtTwmHenSUW/aJiXwR+gpvuMDC/VRnTvPp2a9En ++XEcedGUoPq+XIGjjLctyxB8Osrw83tF1JgV3MXN/QKBgQC83B54rYDd4QmVH5nL +WLw834fgr+Z1hA6UqJIaahlD/bDwzbbJEv0pHCBxe01ywQFivqWBdVbuoy9YSeLS +KKEklzh+L0SorrYoBA5F63qx0zy05bba0ASplgDUEUNZn7oIFi7x5pVsNNaNxZpR +bQGM8UrNQvWQ+tutRmp7PM6VuQ== +-----END PRIVATE KEY-----"#;