fix(dgw): reuse credential injection across reconnects - #1900
fix(dgw): reuse credential injection across reconnects#1900irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 13 commits into
Conversation
604d476 to
4d54764
Compare
4d54764 to
3e9b3b6
Compare
3e9b3b6 to
751129f
Compare
There was a problem hiding this comment.
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
CredentialServicewith 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.
dcf1ce4 to
7c1a9ba
Compare
7881ed0 to
45839d6
Compare
45839d6 to
53726c5
Compare
53726c5 to
f815e99
Compare
f815e99 to
866675e
Compare
866675e to
63e17d0
Compare
Benoît Cortier (CBenoit)
left a comment
There was a problem hiding this comment.
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).
-
Blocking - the existing SPN regression thread remains unresolved.
credential_injection.rs:222,260,351,416now derives the synthetic-KDC service hostname from the association token'sdst_hst. The base branch deliberately usedconf.hostnameafter merged fix #1856, and this PR deletesservice_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. -
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 currenttime_to_livewording inopenapi.rs:396-400("Minimum persistence duration") and is not a behavior-preservingrefactor:. 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. -
The CredSSP enclosure is incomplete and leaves two orchestration implementations.
credssp.rs:73-153owns both CredSSP legs, Connect Confirm interception, leftover flushing, and forwarding, butrd_clean_path.rs:551-634open-codes the same sequence.rdp_proxy/mod.rs:12-14then re-exports three internals solely to sustain that duplicate path. BuildCredsspSession/PreparedCredsspfrom RDCleanPath too and callrun; that removes roughly 80 duplicated lines and makes the stated module boundary real. -
generic_clientdestructively reads entries it does not use. Atgeneric_client.rs:157-160,takeruns beforeentry.mapping.is_some(), so an RDP connection consumes a token-onlyprovision-tokenentry and discards it. Match the safer RDCleanPath shape: checkhas_mappingfirst, thentakeonly for the injection path. -
Protocol gating was duplicated while its regression coverage was deleted. The two-flag condition is repeated at
generic_client.rs:162andrd_clean_path.rs:495, while the prior flag-matrix test was removed. The empty test module atrdp_proxy/mod.rs:274-278incorrectly claims protocol selection remains covered. Centralize the predicate, restore the opt-in matrix test, and remove the empty scaffolding. -
The kitchen metaphor makes the new boundaries harder to learn.
credential_injection.rs:1-5,101,108,205,524-531andrd_clean_path.rs:668-669require readers to translate "supermarket/groceries/chef/dish/pass window" back intoProvisioningStore,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).
63e17d0 to
79b523d
Compare
|
Implementation notes:
Note Human-tuned, LLM-assisted content. |
Keep PR #1900 scoped to the Gateway provisioning and CredSSP refactor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Final review follow-up:
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. |
|
Benoît Cortier (@CBenoit), ready for review Note Human-tuned, LLM-assisted content. |
|
(Reviewed and feedback sent on Slack) |
|
Benoît Cortier (@CBenoit), ready for review. Credential injection now keeps the mapping until the JWT can still authenticate, so Note LLM-assisted content (no human feedback). |
|
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>
7935d8c to
e826133
Compare
|
The clocks are separate on purpose. We stopped trying to make one duration cover all of them. Token reuse is only the token cache. 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 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));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;So: unused secrets die with staging TTL. Once injected, “Gateway accepted this association JWT” implies the mapping is still there. We do not compare 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_upstreamif now >= deadline {
MappingStatus::Absent
} else if entry.mapping.is_some() && now < entry.expires_at {
MappingStatus::Available
} else {
MappingStatus::RequiredMissing
}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);
}
}We kept the preflight TTL field. It only covers the wait before first connect. After that, Stacked hop tests are on #1938. Signed Note Human-tuned, LLM-assisted content. |

Keep provisioned injection mappings until the association token acceptance deadline so native reconnects can reuse the same JWT. Checkout fails closed when required material is missing, and it happens before the upstream dial so a missing Kerberos KDC never opens a target socket.
Generic RDP and RDCleanPath share the CredSSP session path. Kerberos injection uses the token
dst_hstfor the client-facing TERMSRV SPN.Issue: DVLS-14697