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