Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ab9d1a7
refactor(dgw): CredSSP enclosure, supermarket store, thin synthetic KDC
irvingoujAtDevolution Jul 30, 2026
2a86354
fix(dgw): address Copilot review on CredSSP/provisioning refactor
irvingoujAtDevolution Jul 31, 2026
dc7c482
style(dgw): rustfmt expect attribute
irvingoujAtDevolution Jul 31, 2026
b34472f
fix(dgw): pin injection destination to dst_hst
irvingoujAtDevolution Aug 14, 2026
038ca3d
ci(package): retry Windows tool installs
irvingoujAtDevolution Aug 17, 2026
731ce31
chore: remove unrelated package CI changes
irvingoujAtDevolution Aug 17, 2026
2e10e60
fix(dgw): make injection checkout fail closed
irvingoujAtDevolution Aug 17, 2026
e855268
docs(openapi): document one-shot provisioning TTL
irvingoujAtDevolution Aug 17, 2026
6991021
docs(dgw): clarify Kerberos SPN contract
irvingoujAtDevolution Aug 17, 2026
180aa0c
revert: remove one-shot OpenAPI documentation
irvingoujAtDevolution Aug 17, 2026
8eb632d
fix(dgw): keep injection mappings across reconnects
irvingoujAtDevolution Aug 20, 2026
bdc3382
test(dgw): require exp on provision-credentials fixtures
irvingoujAtDevolution Aug 20, 2026
e826133
fix(dgw): checkout injection before connecting upstream
irvingoujAtDevolution Aug 21, 2026
a2c9dd6
docs(dgw): document credential injection intent
irvingoujAtDevolution Aug 25, 2026
8951d37
docs(dgw): clarify credential injection intent
irvingoujAtDevolution Aug 25, 2026
bda124d
fix(dgw): align credential injection lifecycle
irvingoujAtDevolution Aug 25, 2026
85545af
fix(dgw): release credentials after CredSSP
irvingoujAtDevolution Aug 25, 2026
b6c2173
Merge branch 'master' into refactor/rdp-proxy-credssp-extract
irvingoujAtDevolution Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion devolutions-gateway/openapi/doc/index.adoc

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion devolutions-gateway/openapi/gateway-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 9 additions & 20 deletions devolutions-gateway/src/api/kdc_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ use picky_krb::messages::KdcProxyMessage;
use uuid::Uuid;

use crate::DgwState;
use crate::credential_injection_kdc::{
CredentialInjectionKdcInterception, CredentialInjectionKdcRequest, CredentialInjectionKdcResolveError,
kdc_proxy_message_realm,
use crate::credential_injection::{
CredentialInjectionKdcInterception, CredentialInjectionKdcRequest, kdc_proxy_message_realm,
};
use crate::extract::KdcToken;
use crate::http::HttpError;
Expand All @@ -22,7 +21,7 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
async fn kdc_proxy(
State(DgwState {
conf_handle,
credentials,
synthetic_kdc_registry,
agent_tunnel_handle,
..
}): State<DgwState>,
Expand All @@ -47,7 +46,9 @@ async fn kdc_proxy(
KdcDestination::Inject { jti } => {
enforce_credential_injection_enabled(jti, conf.debug.enable_unstable)?;

let kdc = credentials.kdc_for(jti).map_err(credential_injection_resolve_error)?;
let kdc = synthetic_kdc_registry
Comment thread
irvingoujAtDevolution marked this conversation as resolved.
.get(jti)
.ok_or_else(|| HttpError::bad_request().msg("no live synthetic KDC published for this session"))?;

debug!(
jti = %kdc.jti(),
Expand Down Expand Up @@ -92,17 +93,6 @@ async fn kdc_proxy(
}
}

fn credential_injection_resolve_error(error: CredentialInjectionKdcResolveError) -> HttpError {
match error {
CredentialInjectionKdcResolveError::BuildKdcConfig { .. } => HttpError::internal()
.with_msg("credential-injection KDC could not be initialized")
.build(error),
_ => HttpError::bad_request()
.with_msg("credential-injection state is not available")
.build(error),
}
}

// Forwards the request to the real KDC indicated by the token (or by the debug override) and
// returns the response wrapped as a `KdcProxyMessage`.
//
Expand Down Expand Up @@ -193,10 +183,9 @@ mod tests {

#[test]
fn enforce_realm_mismatch_passes_under_bypass() {
// `bypass=true` is the `__debug__.disable_token_validation` downgrade. CBenoit asked
// for explicit coverage of this branch because it is the only place the realm
// authorization is intentionally weakened, and slipping the gate (e.g. by inverting the
// condition) would only surface in production.
// `bypass=true` is the `__debug__.disable_token_validation` downgrade.
// This is the only branch where realm authorization is intentionally weakened, so pin it
// explicitly to catch an inverted gate.
assert!(enforce_realm_token_match("ad.example", "evil.example", true).is_ok());
}

Expand Down
15 changes: 7 additions & 8 deletions devolutions-gateway/src/api/preflight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -204,7 +203,7 @@ pub(super) async fn post_preflight(
State(DgwState {
conf_handle,
sessions,
credentials,
provisioning,
..
}): State<DgwState>,
_scope: PreflightScope,
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 8 additions & 4 deletions devolutions-gateway/src/api/rdp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ pub async fn handler(
subscriber_tx,
recordings,
shutdown_signal,
credentials,
provisioning,
synthetic_kdc_registry,
agent_tunnel_handle,
..
}): State<DgwState>,
Expand All @@ -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)
Expand All @@ -66,7 +68,8 @@ async fn handle_socket(
subscriber_tx: SubscriberSender,
active_recordings: Arc<ActiveRecordings>,
source_addr: SocketAddr,
credentials: crate::credential_injection_kdc::CredentialService,
provisioning: crate::provisioning::ProvisioningStore,
synthetic_kdc_registry: crate::credential_injection::SyntheticKdcRegistry,
agent_tunnel_handle: Option<Arc<agent_tunnel::AgentTunnelHandle>>,
) {
let (stream, close_handle) = crate::ws::handle(
Expand All @@ -84,7 +87,8 @@ async fn handle_socket(
sessions,
subscriber_tx,
&active_recordings,
&credentials,
&provisioning,
&synthetic_kdc_registry,
agent_tunnel_handle,
)
.await;
Expand Down
56 changes: 56 additions & 0 deletions devolutions-gateway/src/credential/INTENT.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading