Skip to content

fix(dgw): reuse credential injection across reconnects - #1900

Open
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 18 commits into
masterfrom
refactor/rdp-proxy-credssp-extract
Open

fix(dgw): reuse credential injection across reconnects#1900
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 18 commits into
masterfrom
refactor/rdp-proxy-credssp-extract

Conversation

@irvingoujAtDevolution

@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Keep provisioned injection mappings alive for as long as Gateway still accepts the association token, so native RDP reconnects reuse the same JWT and get injected again. If injection was provisioned but the material is gone, checkout fails closed before we dial the target.

Generic RDP and RDCleanPath now share one CredSSP session path. Kerberos uses the token dst_hst for the client-facing TERMSRV SPN.

I wrote the lifecycle contract down in src/credential/INTENT.md (following intent-files.md) — that's what this PR implements.

Why this design

Credentials live in two phases. Before the first connect, the preflight time_to_live bounds how long staged secrets wait in memory. After the first checkout, retention switches to the token acceptance deadline (exp + leeway). If Gateway accepts the token for a reconnect, the mapping has to still be there — the old one-shot take_mapping broke exactly that. jet_reuse starts at disconnect and the TTL starts at preflight, so neither can cap the other; the token deadline is the one clock every connection attempt shares.

Where to look hardest

  • Generation-guarded KDC publication: re-provisioning the same JTI bumps a generation, and an older in-flight lease can never overwrite a newer published KDC, so the newest provisioning wins deterministically.
  • Deadline-driven cleanup: expired session KDC material is removed by a background task, not lazily on the next checkout, and secrets are zeroized on drop.
  • Release after CredSSP: the per-connection mapping and KDC lease drop as soon as both CredSSP legs finish, so plain forwarding never holds secret material.

Tests cover staging expiry, reconnect reuse, generation replacement, and physical removal without a later checkout. Manual run: 22-case DVLS matrix with live NTLM and Kerberos CredSSP through Gateway. E2E hops are stacked on #1938.

Issue: DVLS-14697

@irvingoujAtDevolution
irvingouj@Devolutions (irvingoujAtDevolution) force-pushed the refactor/rdp-proxy-credssp-extract branch 2 times, most recently from 604d476 to 4d54764 Compare July 30, 2026 02:56
Comment thread devolutions-gateway/src/generic_client.rs Outdated
Comment thread devolutions-gateway/src/generic_client.rs Outdated
Comment thread devolutions-gateway/src/api/kdc_proxy.rs
@irvingoujAtDevolution
irvingouj@Devolutions (irvingoujAtDevolution) marked this pull request as ready for review July 30, 2026 15:45
Copilot AI review requested due to automatic review settings July 30, 2026 15:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors RDP credential injection by separating provisioning, synthetic KDC registration, and CredSSP orchestration.

Changes:

  • Splits RDP proxy and CredSSP logic into dedicated modules.
  • Replaces CredentialService with a one-shot provisioning store and live KDC registry.
  • Updates all connection and API paths to use the new architecture.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
devolutions-gateway/src/service.rs Initializes and wires the new stores.
devolutions-gateway/src/rdp_proxy/mod.rs Adds RDP handshake and TLS orchestration.
devolutions-gateway/src/rdp_proxy/credssp.rs Encapsulates CredSSP MITM behavior.
devolutions-gateway/src/rdp_proxy.rs Removes the former monolithic proxy.
devolutions-gateway/src/rd_clean_path.rs Integrates new credential-injection state.
devolutions-gateway/src/provisioning.rs Makes provisioning retrieval one-shot.
devolutions-gateway/src/ngrok.rs Updates state wiring.
devolutions-gateway/src/listener.rs Updates TCP client construction.
devolutions-gateway/src/lib.rs Replaces credential-service state fields.
devolutions-gateway/src/generic_client.rs Builds session-scoped credential injection.
devolutions-gateway/src/credential_injection.rs Adds credential and synthetic KDC runtime.
devolutions-gateway/src/credential_injection_kdc.rs Removes the previous combined service.
devolutions-gateway/src/api/rdp.rs Passes new state into RDP handling.
devolutions-gateway/src/api/preflight.rs Writes directly to provisioning storage.
devolutions-gateway/src/api/kdc_proxy.rs Resolves only live registered KDCs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread devolutions-gateway/src/credential_injection.rs
Comment thread devolutions-gateway/src/rd_clean_path.rs Outdated
Comment thread devolutions-gateway/src/credential_injection.rs
Comment thread devolutions-gateway/src/rdp_proxy/credssp.rs Outdated
Comment thread devolutions-gateway/src/generic_client.rs Outdated

@CBenoit Benoît Cortier (CBenoit) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Automated review. Human insight is required, especially to resolve the conflicting SPN assumptions described below.

Requesting changes on this stacked delta (chore/regenerate-openapi-provisioning -> refactor/rdp-proxy-credssp-extract).

  1. Blocking - the existing SPN regression thread remains unresolved. credential_injection.rs:222,260,351,416 now derives the synthetic-KDC service hostname from the association token's dst_hst. The base branch deliberately used conf.hostname after merged fix #1856, and this PR deletes service_kdc_for_uses_gateway_hostname_for_spn, the regression test that pinned that behavior. The author has supplied contrary product-path evidence in the existing thread, so this needs a human protocol decision rather than another automated guess. Whichever hostname is correct must be explicit, tested with different Gateway and destination hostnames, and described as a behavior change if #1856 is intentionally being reversed.

  2. The new one-shot store changes observable retry behavior without documenting it. ProvisioningStore::take (provisioning.rs:143-197) removes credentials and connection options before target connection, TLS, or CredSSP can succeed (generic_client.rs:158-197, rd_clean_path.rs:490-580). A transient failure consumes the data, and a retry inside the advertised TTL can silently fall through to non-injected forwarding. This conflicts with the current time_to_live wording in openapi.rs:396-400 ("Minimum persistence duration") and is not a behavior-preserving refactor:. Either retain entries across failed attempts, or document the single-use contract in OpenAPI/PR history and make skipped injection explicit instead of silently falling back.

  3. The CredSSP enclosure is incomplete and leaves two orchestration implementations. credssp.rs:73-153 owns both CredSSP legs, Connect Confirm interception, leftover flushing, and forwarding, but rd_clean_path.rs:551-634 open-codes the same sequence. rdp_proxy/mod.rs:12-14 then re-exports three internals solely to sustain that duplicate path. Build CredsspSession/PreparedCredssp from RDCleanPath too and call run; that removes roughly 80 duplicated lines and makes the stated module boundary real.

  4. generic_client destructively reads entries it does not use. At generic_client.rs:157-160, take runs before entry.mapping.is_some(), so an RDP connection consumes a token-only provision-token entry and discards it. Match the safer RDCleanPath shape: check has_mapping first, then take only for the injection path.

  5. Protocol gating was duplicated while its regression coverage was deleted. The two-flag condition is repeated at generic_client.rs:162 and rd_clean_path.rs:495, while the prior flag-matrix test was removed. The empty test module at rdp_proxy/mod.rs:274-278 incorrectly claims protocol selection remains covered. Centralize the predicate, restore the opt-in matrix test, and remove the empty scaffolding.

  6. The kitchen metaphor makes the new boundaries harder to learn. credential_injection.rs:1-5,101,108,205,524-531 and rd_clean_path.rs:668-669 require readers to translate "supermarket/groceries/chef/dish/pass window" back into ProvisioningStore, ProvisioningEntry, preparation, and live KDC registration. Use those technical terms directly; this module's purpose is separation of concerns, so it should not introduce a second vocabulary for them.

Non-blocking cleanup: CredentialInjectionKdcResolveError no longer has a discriminating consumer (credential_injection.rs:55-72), so its four variants and duplicated pre-return warnings should either regain a matching caller or collapse to contextual anyhow errors. Also capitalize the newly added log messages and terminate sentence comments per repository conventions.

Note

LLM-assisted content (no human feedback).

@irvingoujAtDevolution

Copy link
Copy Markdown
Contributor Author

Final review follow-up:

  1. SPN decision: dst_hst is the explicit service-name contract for supported credential-injection clients. Synthetic KDC tickets, CredSSP acceptor properties, and the target Kerberos leg use the same dynamic destination. Gateway-addressed clients that derive TERMSRV/<conf.hostname> from the transport endpoint are explicitly outside this unstable feature contract.
  2. One-shot behavior: checkout is atomic; a consumed-JTI tombstone remains until the original expiry, so reuse and concurrent losers fail closed instead of falling through to non-injected forwarding. Re-provisioning clears the tombstone.
  3. CredSSP enclosure: RDCleanPath constructs CredsspSession / PreparedCredssp and calls the single run implementation.
  4. Token-only provisioning: both entry points inspect MappingStatus before checkout; token-only rows are preserved.
  5. Protocol gating: both flags are centralized and covered by the full 2x2 matrix, with target username selection tested separately.
  6. Terminology: kitchen/supermarket metaphors are removed from source and docs.

Additional cleanup: removed the unused typed KDC resolve error, centralized checkout validation, kept generation-guarded KDC leases for explicit same-JTI re-provision overlap, and regenerated OpenAPI artifacts.

Validation: full workspace Clippy; workspace lib/bin/test targets plus colliding examples run separately; generated .NET client build; 22-case DVLS matrix, including live NTLM and Kerberos CredSSP over RDCleanPath.

Note

Human-tuned, LLM-assisted content.

@irvingoujAtDevolution

Copy link
Copy Markdown
Contributor Author

Benoît Cortier (@CBenoit), ready for review

Note

Human-tuned, LLM-assisted content.

@CBenoit

Copy link
Copy Markdown
Member

(Reviewed and feedback sent on Slack)

@irvingoujAtDevolution

Copy link
Copy Markdown
Contributor Author

Benoît Cortier (@CBenoit), ready for review.

Credential injection now keeps the mapping until the JWT can still authenticate, so jet_reuse reconnects inject again instead of consuming on first checkout. Required-but-missing material fails closed. Manual RDM/DVLS Kerberos and NTLM launches against IT-HELP-DC succeeded on this HEAD.

Note

LLM-assisted content (no human feedback).

@irvingoujAtDevolution

Copy link
Copy Markdown
Contributor Author

Benoît Cortier (@CBenoit) ready for review.

Credential injection now keeps mappings across token-accepted reconnects (get_mapping, fail-closed if required material is missing). Stacked E2E is #1938.

Note

LLM-assisted content (no human feedback).

Split rdp_proxy CredSSP into its own module. Delete CredentialService: DgwState holds ProvisioningStore + SyntheticKdcRegistry. from_provisioned builds PreparedCredentialInjection; register_if_kerberos publishes. take() consumes groceries once. Synthetic KDC keeps only fake-KDC runtime; credentials and target_kdc stay on the dish.
Authorize CleanPath tokens before one-shot take, use a registry-wide generation counter, and replace bare clippy allow with expect. SPN remains association-token dst_hst for client-facing CredSSP.
Use association dst_hst for synthetic KDC SPN and target-leg Kerberos
hostname instead of conf.hostname. Route RDCleanPath through
CredsspSession, peek before one-shot take, and document checkout TTL.

Issue: DGW review #1900

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep the preinstalled WiX toolset until Chocolatey successfully installs the pinned version. Retry transient feed failures, validate candle.exe, and expose WIXSHARP_WIXDIR so installer builds cannot continue with an empty WiX path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep PR #1900 scoped to the Gateway provisioning and CredSSP refactor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep consumed credential mappings visible until their original expiry so a reused JTI fails explicitly instead of silently falling back to ordinary forwarding.

Centralize atomic checkout and CredSSP orchestration, preserve token-only provisioning, and simplify KDC error handling. Record the one-shot contract in PR history without regenerating unchanged OpenAPI artifacts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe time_to_live as the first-checkout window for credential-injection mappings and state that failed attempts require re-provisioning. Regenerate the published specification and clients.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
State that supported credential-injection clients retain association dst_hst as their logical TERMSRV service name even when the transport endpoint is a Gateway listener.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Restore the pre-existing provisioning TTL wording and generated artifacts. The OpenAPI documentation update was outside the requested PR scope.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Token validation already accepts the same association JWT inside jet_reuse, but checkout consumed the mapping on first use. Native RDM reconnects reuse that JWT without DVLS, so injection failed or silently forwarded.

Keep encrypted mappings until the token acceptance deadline, authorize before choosing injection, fail closed when required material is gone, and reuse one synthetic KDC per provisioning generation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mapped insert now caps retention to the association token acceptance deadline, so unsigned preflight fixtures without exp fail as invalid-parameters.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Missing Kerberos krb_kdc must fail closed without dialing the target.

Issue: DGW-1900

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) changed the title fix(dgw): separate credential injection runtime fix(dgw): reuse credential injection across reconnects Aug 21, 2026
@irvingoujAtDevolution

Copy link
Copy Markdown
Contributor Author

The clocks are separate on purpose. We stopped trying to make one duration cover all of them.

preflight time_to_live  = unused mapping wait (staging)
jet_reuse / RDP 10s     = can this JWT be replayed
exp + 5 min             = hard cap after first successful inject
jet_ttl                 = already-running session (unchanged)

Token reuse is only the token cache. jet_reuse is the optional reconnect window after disconnect. RDP still has the built-in ~10s handshake allowance even when jet_reuse is off (mstsc opens several sockets):

match (disconnected_info, jet_reuse) {
    (Some(info), ReconnectionPolicy::Allowed { window_in_seconds }) => {
        // not killed, inside window since disconnect, reconnect count <= 10
    }
    _ => {
        if matches!(jet_ap, ApplicationProtocol::Known(Protocol::Rdp)) {
            // ~10 seconds since last token use, not disconnect
            if now > bucket.get().last_use_timestamp + RDP_MAX_REUSE_INTERVAL_SECS {
                return Err(TokenError::UnexpectedReplay { ... });
            }
        }
    }
}

at token.rs

That layer never looks at the credential store. If reuse is refused, we never reach injection.

Credentials have two phases. Staging uses preflight time_to_live (default 15 min, max 2 h), capped to the token acceptance deadline (exp + 5 min leeway). After the first successful checkout, retention is that deadline — not jet_reuse, and not the original staging TTL:

let staging_expires = now + time_to_live;
let required_until = if mapping.is_some() {
    Some(crate::token::token_acceptance_deadline(exp))
} else {
    None
};
let expires_at = required_until.map_or(staging_expires, |deadline| staging_expires.min(deadline));

at provisioning.rs

pub(crate) fn token_acceptance_deadline(exp: i64) -> time::OffsetDateTime {
    let timestamp = exp.saturating_add(i64::from(LEEWAY_SECS));
    // ...
}

at token.rs

// First successful lookup keeps the mapping until the token deadline.
if now >= deadline || now >= entry.expires_at {
    anyhow::bail!("credential-injection material for {jti} is missing or expired; re-provision to retry");
}
entry.expires_at = deadline;

at provisioning.rs

So: unused secrets die with staging TTL. Once injected, “Gateway accepted this association JWT” implies the mapping is still there. We do not compare time_to_live to jet_reuse — they start at different events.

If the token is still acceptable and the mapping is gone, we fail closed (no ordinary forward, no upstream dial):

MappingStatus::RequiredMissing => anyhow::bail!(...),
MappingStatus::Available => true,
MappingStatus::Absent => false,
// checkout before connect_upstream

at generic_client.rs

if now >= deadline {
    MappingStatus::Absent
} else if entry.mapping.is_some() && now < entry.expires_at {
    MappingStatus::Available
} else {
    MappingStatus::RequiredMissing
}

at provisioning.rs

Kerberos KDC follows the same activated deadline, interned per JTI + provisioning generation so reconnects keep the same synthetic realm:

/// Reuse the synthetic KDC for this provisioning generation until `expires_at`.
pub(crate) fn intern_session_kdc(...) -> Arc<CredentialInjectionKdc> {
    if existing.provision_generation == provision_generation {
        return Arc::clone(&existing.kdc);
    }
}

at credential_injection.rs

We kept the preflight TTL field. It only covers the wait before first connect. After that, exp is the cap. A long jet_reuse still needs a token exp that covers it; Gateway will not keep credentials past the token.

Stacked hop tests are on #1938. Signed jet_reuse token-cache is not covered there (disable_token_validation + unsigned JWTs).

Note

Human-tuned, LLM-assisted content.

@irvingoujAtDevolution

irvingouj@Devolutions (irvingoujAtDevolution) commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author
lifetime-timeline

@irvingoujAtDevolution

irvingouj@Devolutions (irvingoujAtDevolution) commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Summary of Timing & Lifetime Parameters

Attribute Starts At Deadline / Rule Core Role
preflight.time_to_live (preflight.ttl) Preflight API call min(now + ttl, association.exp + 5m) (promoted on first connect) Staging wait time before initial connect.
How long provisioned credentials stay in memory if no client connects.
association.exp Fixed wall-clock timestamp (issued by DVLS) association.exp + 5m (with leeway) Global absolute ceiling for token & credential lifetime.
Hard deadline for credential retention in memory after first checkout; requests past this are rejected and credentials evicted.
association.jet_reuse Client disconnection timestamp (T_disc) T_disc + jet_reuse (and reconnect count <= 10) Relative sliding window for fast reconnects after disconnect.
Mitigates token replay; restricts reconnects to a bounded window post-disconnect.

Key Takeaway & Interaction

  • preflight.time_to_live governs how long credentials wait in staging before the first connection arrives.
  • association.jet_reuse governs the allowed reconnect window once disconnected.
  • association.exp is the hard absolute ceiling that cannot be exceeded by either staging or reconnect windows.

Note

Human-tuned, LLM-assisted content.

Capture the lifecycle invariants that guide the credential injection refactor before further implementation changes.
Define checkout, staging, credential replacement, cleanup, and synthetic KDC lifetime expectations.
Keep checked-out credentials and Kerberos session material available
for every association-token use that Gateway accepts.

Use provisioning TTL only for staging, remove expired material at its
deadline, and keep connection-option retention independent.

Issue: DVLS-14697
Drop the per-connection credential mapping and synthetic KDC lease as
soon as both CredSSP legs finish. RDP forwarding no longer retains
secret material for the full session.

Issue: DVLS-14697

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

5 participants