Skip to content

feat(dgw): Kerberos credential injection via explicit jet_cred_id#1768

Open
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 21 commits into
masterfrom
dgw-378-explicit-identity
Open

feat(dgw): Kerberos credential injection via explicit jet_cred_id#1768
irvingouj@Devolutions (irvingoujAtDevolution) wants to merge 21 commits into
masterfrom
dgw-378-explicit-identity

Conversation

@irvingoujAtDevolution
Copy link
Copy Markdown
Contributor

@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) commented Apr 28, 2026

Summary

Implements DGW-378: proxy-based Kerberos credential injection for Web Access. A new cred_injection_id (UUID) is minted at /jet/preflight provision-credentials and propagated through DVLS-issued association and KDC tokens as the JWT claim jet_cred_id — the gateway uses it as the primary credential-store key for /rdp and /jet/KdcProxy. Replaces the heuristic AS-REQ router from the experimental dgw-378-session-redesign branch.

Coupled with DVLS-13821; old DVLS still works for NTLM injection via the JTI fallback (no regression).

Highlights

  • Per-session SessionKerberos (krbtgt + service long-term keys, secrecy-wrapped) with a CRED-{uuid}.INVALID synthetic realm for bare-UUID proxy usernames.
  • Gateway-as-CredSSP-server self-AS-REQ uses an in-process loopback at http://cred.invalid/{cred_injection_id} (RFC 6761 reserved TLD) — no HTTP, no token, no middleware.
  • SPN derived from dst_hst (TERMSRV/<target>) so client AP-REQ tickets validate against the impersonated server identity. Required at insert time — missing/malformed dst_hst is rejected at preflight, not mid-CredSSP.

Verification

  • 5 unit tests in credential/tests.rs (insert / lookup / eviction / synthetic realm).
  • E2E against an AD-joined RDP server via DVLS web → Gateway → target: full CredSSP MITM + KdcProxy completes in ~1.4s, no errors.

Test plan

  • CI green
  • DVLS-side PR (DVLS-13821, RDM repo) reviewed in lockstep
  • Release pipeline regenerates Gateway.Client SDK from OpenAPI (currently hand-edited to match)

New design that replaces the heuristic AS-REQ session router from the
dgw-378-session-redesign experimental branch with an explicit
cred_injection_id (JWT claim jet_cred_id) propagated from
/jet/preflight provision-credentials through DVLS-issued association
and KDC tokens.

Covers protocol mechanics (Kerberos AS/TGS/AP, U2U vs standard,
two-leg CredSSP MITM), wire contracts (preflight DTO, JWT claims,
NuGet builder API), internal structures (CredentialStore reduced to
two indices, SessionKerberos simplified), in-process loopback for U2U
(inproc:// scheme in send_network_request), backward compatibility
matrix, and an implementation outline.

This is a design-only commit. The branch dgw-378-explicit-identity
forks from master and will receive the implementation in subsequent
commits. The dgw-378-session-redesign branch is preserved as
experimental record.
…ion_id

Working end-to-end Kerberos credential-injection demo against AD-joined RDP server.
DVLS-side counterpart: dvls-13821-jet-cred-id branch in RDM repo.

Changes (matches docs/plans/2026-04-27-dgw-378-explicit-identity-design.md):

Token + JWT claims (token.rs):
- AssociationTokenClaims.jet_cred_id: Option<Uuid>
- KdcTokenClaims.jet_cred_id: Option<Uuid>
- extract_jet_cred_id, extract_dst_hst helpers

Credential store (credential/mod.rs):
- CredentialEntry primary key renamed session_id -> cred_injection_id
- New SessionKerberos { krbtgt_key, service_long_term_key, service_user_*, realm }
- Synthetic per-session realm CRED-{uuid}.INVALID when proxy username is bare UUID
- target_hostname captured from association token dst_hst at insert time
- 5 unit tests covering insert/lookup/eviction/synthetic-realm

Preflight (api/preflight.rs):
- ProvisionCredentialsParams gains optional cred_injection_id
- New PreflightOutputKind::ProvisionedCredentials returns the final id

KDC proxy (api/kdc_proxy.rs):
- Direct lookup by claims.jet_cred_id, fall through to forward-to-real-KDC
- Empty target_domain envelope falls back to entry.kerberos.realm
- Pass entry.target_hostname to handle_kdc_proxy_message for TGS-REQ sname check

CredSSP MITM (rdp_proxy.rs, rd_clean_path.rs, generic_client.rs):
- /rdp lookup prefers jet_cred_id, falls back to get_by_token (NTLM compat)
- build_credential_injection_server_kerberos_config uses target hostname for SPN
  (TERMSRV/<target>, not Gateway hostname) so client AP-REQ tickets validate
- In-process loopback URL http://cred.invalid/{cred_injection_id} dispatches to
  fake-KDC without HTTP, no token issuance, no auth middleware

NuGet (utils/dotnet/Devolutions.Gateway.Utils):
- AssociationClaims.JetCredId: Guid? (nullable, JsonIgnore-when-null)
- KdcClaims.JetCredId: Guid? (same)
- New ProvisionCredentialsRequest.cs with non-nullable CredInjectionId

OpenAPI (gateway-api.yaml + dotnet-client SDK):
- cred_injection_id added to PreflightOperation request shape
- cred_injection_id added to PreflightOutput response shape
- ProvisionedCredentials output kind variant
- PreflightOperation.cs SDK model hand-edited to add CredInjectionId property
  (matches what openapi-generator would regenerate from the YAML)

tokengen tooling:
- --jet-cred-id flag on RdpTls and Kdc subcommands

Local-dev wiring (intentionally kept; clean up before PR):
- Gateway.Utils Version bumped 2025.10.1.0 -> 2025.10.2 (local pack)
- Gateway.Client Version bumped 2025.12.2 -> 2025.12.3 (local pack)
- review.md captures the round-3 review state

Backward compatibility (Gateway side):
- Old DVLS without jet_cred_id in preflight: Gateway auto-generates UUID, NTLM
  injection works through JTI fallback. Kerberos injection requires new DVLS.
- Empty target_domain envelope tolerated when entry exists (defensive against
  iron-remote-desktop sspi-rs not populating it).
- Old conf.debug.kerberos / enable_unstable left in for now (review.md §1.5);
  production behaviour is correct, dead branch deletion is a follow-up commit.
…y_hostname plumbing

Round-4 follow-up review feedback (review-followup.md):

F1 — credential/mod.rs: pass DEFAULT_DST_PORT (3389) to TargetAddr::parse so a
bare-hostname dst_hst (no scheme/port) doesn't silently degrade target_hostname
to None and trigger the SPN-mismatch error this commit was supposed to fix.

F2 — kdc_proxy.rs / send_in_process_kdc_request: drop the
`unwrap_or(&conf.hostname)` fallback and error out cleanly when
entry.target_hostname is missing. The previous fallback would silently revert
to Gateway's hostname — exactly the bug the prior commit fixed — making future
regressions invisible.

Plumbing cleanup: gateway_hostname is no longer threaded through
send_network_request → resolve_server_generator / resolve_client_generator /
credssp_loop / perform_credssp_with_client. The in-process loopback resolves
the Kerberos hostname per session from entry.target_hostname; nothing else on
this chain ever needed Gateway's own hostname.

End-to-end Kerberos credential injection still works after this cleanup;
verified against an AD-joined RDP server via DVLS web client.

Tracking: review-followup.md
…st layout)

Round-5 review feedback from codex:

1. cargo +nightly fmt --all --check now passes. Previous commits left three
   blocks unformatted (kdc_proxy.rs realm match, kdc_proxy.rs handle_kdc_proxy
   call site, rdp_proxy.rs::send_in_process_kdc_request). cargo fmt
   auto-applied.

2. cargo clippy -p devolutions-gateway --tests -- -D warnings now passes.
   Removed `#[expect(clippy::too_many_arguments)]` on
   `perform_credssp_with_client` — the previous commit dropped the
   `gateway_hostname` parameter from the chain, so the function is below
   clippy's threshold and the lint expectation is now unfulfilled.

3. Generated .NET OpenAPI client (`Devolutions.Gateway.Client`) was missing
   the new response shape entirely. Added:
   - `PreflightOutput.CredInjectionId: Guid?`
   - `PreflightOutputKind.ProvisionedCredentials = 7`
   plus the corresponding `ToValue` extension case and constructor parameter.
   The OpenAPI YAML already exposes both. Without these the package can't
   deserialize the new preflight `provisioned-credentials` response.

4. Moved `credential/mod.rs`'s test module to its own file at
   `credential/tests.rs`. Replaced the inline `#[cfg(test)] mod tests { ... }`
   block with `#[cfg(test)] mod tests;`. Tests still see private items via
   `use super::*;` because `tests` is a child module of `credential`.

Verification:
- `cargo +nightly fmt --all --check` ✓ clean
- End-to-end demo still works: AD-joined RDP server via DVLS web client →
  Gateway (proxy MITM with Kerberos credential injection) → target.
…esponse

`test_provision_credentials_success` expected `ack` for the `provision-credentials`
operation. After this branch the operation returns the new
`PreflightOutputKind::ProvisionedCredentials` variant carrying the freshly
minted `cred_injection_id`. Update the assertion accordingly and switch
the credential-store lookup to use the returned id (the previous test used
the token JTI as the primary key, which no longer is).

This comment was marked as resolved.

…String

Previous shape on `SessionKerberos`:

    #[derive(Debug)]
    pub struct SessionKerberos {
        pub krbtgt_key: Vec<u8>,
        pub service_long_term_key: Vec<u8>,
        pub service_user_name: String,
        pub service_user_password: String,
        pub realm: String,
    }

`#[derive(Debug)]` would render the AES-256 key bytes and the
PA_ENC_TIMESTAMP password verbatim into any `tracing::debug!(?kerberos, …)`
call. Nobody currently does that, but it is the kind of leak that is hard
to spot in review and easy to introduce by accident later. The rest of the
codebase already treats secrets through `secrecy::SecretString` / a custom
`Debug`-redacted wrapper (`crypto::EncryptedPassword`), so it is worth
bringing this struct in line with that pattern.

Two layers of protection now apply:

1. Type-level. The two AES-256 keys are wrapped in `SecretBox<Vec<u8>>`
   and the service-user password in `SecretString`. Access requires
   `expose_secret()`, which is greppable and reviewable. SecretBox /
   SecretString implement a `Debug` that prints the redacted form.

2. Struct-level. `SessionKerberos` no longer derives `Debug`; it has a
   manual `impl fmt::Debug` that explicitly prints `<redacted>` for the
   three secret fields. This keeps the redaction working even if a future
   refactor changes the field types back to plain `Vec<u8>` / `String`.

Use sites updated to call `expose_secret()` at the boundary where the
fake-KDC config (`kdc::config::KerberosServer`) and sspi-rs's
`AuthIdentityBuffers` need raw bytes / strings:

- `credential/mod.rs::build_session_kdc_config` (3 fields, plus the
  shared service-user password reused twice — extracted into a local).
- `rdp_proxy.rs::build_credential_injection_server_kerberos_config`
  (`service_user_password` for sspi-rs `AuthIdentityBuffers`,
  `service_long_term_key` for `ServerProperties::new`).

The `realm` and `service_user_name` fields stay as plain `String` —
neither is sensitive: `service_user_name` is a fixed `"jet"`, and
`realm` is either an explicit Kerberos realm parsed from the proxy
username or the synthetic `CRED-{uuid}.INVALID` fallback.

Tests in `credential/tests.rs` only read `kerberos.realm`; no test
changes needed.
The handler had grown to ~115 lines doing six distinct things in one
top-to-bottom flow: token auth, message decode, realm resolution
(envelope + entry fallback), realm-vs-token check, fake-KDC processing
for credential-injection sessions, and forward-to-real-KDC for everything
else. The control flow was a mix of `let`-deconstruction, in-place
matching, and an early `return` on the injection branch — readable on
first pass, hard to skim on the third.

Split into the orchestrator + five helpers, each with a one-line doc:

- `authenticate_kdc_token` — runs the auth middleware, unwraps `Kdc`.
- `lookup_credential_injection` — resolves `claims.jet_cred_id` against
  the credential store, returning `None` for the forward-to-real-KDC path
  (legacy DVLS or expired entries).
- `resolve_request_realm` — picks the realm from the envelope; falls back
  to `entry.kerberos.realm` when the envelope is empty and we have an
  injection entry; errors out otherwise.
- `enforce_realm_token_match` — refuses forwarding when realms disagree;
  the `disable_token_validation` debug option downgrades to a warning.
- `process_credential_injection` — builds the per-session fake-KDC config
  and runs `handle_kdc_proxy_message` synchronously.
- `forward_to_real_kdc` — async, sends to the real KDC and wraps the
  response back as a `KdcProxyMessage`.

`kdc_proxy` itself shrinks to ~30 lines that read top-to-bottom as the
flow: auth, decode, resolve realm, enforce token/realm match, dispatch.

No behavioral change. Same error codes, same trace/debug log fields,
same realm-resolution semantics.
…d path

`resolve_request_realm` was taking `injection_entry` to compute its
fallback. That mixed two concerns in one shared helper: the envelope
extraction (common to both paths) and the per-session realm fallback
(specific to credential injection). The forward-to-real-KDC path doesn't
care about that fallback at all, but it had to know about it because the
realm-resolution flow ran in the orchestrator before dispatch.

Restructure so injection-specific logic lives entirely inside the
injection branch:

- `resolve_request_realm` -> `extract_envelope_realm`. Now a pure data
  extractor: read `target_domain` and treat empty string as absent.
  No policy.

- The orchestrator stops resolving / validating the realm. It just
  extracts the envelope value and dispatches.

- `process_credential_injection` owns the injection fallback:
  if envelope is empty, fall back to `entry.kerberos.realm` (captured
  at preflight). Then runs `enforce_realm_token_match` itself before
  doing any KDC work.

- `forward_to_real_kdc` owns its own (simpler) rule: envelope realm is
  required, no fallback. Then `enforce_realm_token_match` itself.

Realm validation (`enforce_realm_token_match`) stays a small shared
helper because the rule is identical on both paths. Each dispatch path
calls it as part of its own setup.

Also destructure `KdcTokenClaims` once at the top of the orchestrator
so helpers receive the individual fields they need (`token_realm: &str`,
`token_kdc_addr: &TargetAddr`) instead of a borrowed claims struct that
they'd have to repeatedly access through `claims.*`. Drops the trivial
`lookup_credential_injection` helper — `jet_cred_id.and_then(|id|
store.get(id))` is clearer inline now that it's a one-liner.

No behavioral change. Same error codes, log fields, and realm semantics.
…ingle-use)

IronRDP's STYLE.md is the closer-fit guide for this codebase. Two rules
were violated by the prior refactor:

- "Avoid creating single-use helper functions" (exception: needs `?` /
  `return`). `extract_envelope_realm` did neither, so it should be a
  block. Same for `realm_from_username` in `credential/mod.rs`.

- "Local helper functions ... at the end of the enclosing function (this
  requires using a return statement)". When a helper does qualify (uses
  `?`), the convention is to nest it inside the parent function and
  trigger via `return` rather than place it as a sibling at module level.

`api/kdc_proxy.rs`:

- Inlined `extract_envelope_realm` into the orchestrator (5-line method
  chain). The naming `envelope_realm` keeps the intent self-documenting.
- Moved `authenticate_kdc_token`, `process_credential_injection`, and
  `forward_to_real_kdc` inside `kdc_proxy`. The orchestrator now ends
  with `return match injection_entry { … };`, after which the three
  nested fns are defined.
- `enforce_realm_token_match` stays at module level — multi-use, so the
  rule does not apply.

`credential/mod.rs`:

- Inlined `realm_from_username` into `encrypt_with_kerberos`. The
  inline form (split_once + filter + map) is short and the comment
  above it explains the why.
- `synthetic_realm` stays at module level — referenced from the test
  module via `super::synthetic_realm`, so it has more than one caller.

No behavioral change. `cargo fmt --check` clean.
The KDC proxy route is `/jet/KdcProxy/{token}` — the token sits in the URL
path, not the standard `Authorization: Bearer` header or `?token=` query
parameter the global auth middleware (`middleware/auth.rs`) handles. As a
result the route is listed in `AUTH_EXCEPTIONS`, the middleware skips it,
and the handler had to call `authenticate()` itself.

That works but it makes the handler look different from every other
authenticated endpoint, which use the `FromRequestParts` extractor pattern
(`AssociationToken`, `JmuxToken`, `JrlToken`, `JrecToken`, `ScopeToken`).

Add a dedicated `KdcToken` extractor in `extract.rs` that:

- pulls the token out of the path with `Path::<String>`,
- pulls `ConnectInfo<SocketAddr>` for the source address the auth routine needs,
- runs `crate::middleware::auth::authenticate()` exactly the way the
  global middleware would,
- and unwraps the `Kdc` variant before returning.

Unlike the other extractors in the file, this one is concrete on
`DgwState` rather than generic over `<S>` because it actually performs
authentication and needs `state.token_cache`, `state.jrl`, etc. — the
others just pull `AccessTokenClaims` out of request extensions that the
middleware already populated.

`api/kdc_proxy.rs`:

- Handler signature now takes `KdcToken(KdcTokenClaims { krb_realm,
  krb_kdc, jet_cred_id }): KdcToken` directly. Path/source-addr
  extraction and the `match` on `AccessTokenClaims::Kdc` move into the
  extractor.
- The nested `authenticate_kdc_token` helper goes away.
- `AUTH_EXCEPTIONS` configuration in `middleware/auth.rs` is unchanged —
  the global middleware still does not know how to find a path-bound
  token, so the route still has to be exempted there. The extractor is
  the route-local replacement.

No behavioural change. Same error codes (401 from middleware, 403 from
the type mismatch), same auth path, same logged events. The handler
just looks like every other authenticated endpoint now.
…tate>

The credential entry's mapping / kerberos / target_hostname triplet was three
separate Option fields, which let the type system represent illegal combinations
(e.g. mapping set without kerberos). Bundle them into a single InjectionState so
the "all set or all unset" invariant lives in the type system rather than in
defensive checks scattered across consumers.

target_hostname becomes a required String inside InjectionState: credential
injection only fires for RDP and the fake-KDC's TGS-REQ sname check needs it,
so missing/malformed dst_hst is now rejected at insert time (preflight) instead
of surfacing mid-CredSSP.
- token.rs: add `skip_serializing_if = "Option::is_none"` on `jet_cred_id`
  in both AssociationClaimsHelper and KdcClaimsHelper so absent claims are
  omitted from the wire instead of serializing as explicit nulls (matches
  `jet_agent_id` and tokengen).
- Revert local-dev `<Version>` bumps on Devolutions.Gateway.Utils.csproj
  (2025.10.2 → 2025.10.1.0) and Devolutions.Gateway.Client.csproj
  (2025.12.3 → 2025.12.2); the release pipeline owns publishing.
@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) changed the title feat(DGW-378): Kerberos credential injection via explicit cred_injection_id feat(dgw): Kerberos credential injection via explicit cred_injection_id Apr 28, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements DGW-378 by introducing an explicit cred_injection_id minted at preflight and propagated as jet_cred_id in JWTs, then used to route Kerberos credential-injection traffic deterministically (not heuristically) for /rdp and /jet/KdcProxy.

Changes:

  • Add cred_injection_id to preflight provision-credentials requests/responses and thread jet_cred_id through Association/KDC token claims.
  • Refactor credential store to key entries by cred_injection_id, attach per-session Kerberos material, and require dst_hst to capture the target hostname for SPN validation.
  • Update Gateway KDC proxy handling and RDP proxy Kerberos loopback dispatch to use the explicit ID, and update tooling/.NET helpers accordingly.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
utils/dotnet/Devolutions.Gateway.Utils/src/ProvisionCredentialsRequest.cs Adds a typed DTO for provision-credentials including cred_injection_id.
utils/dotnet/Devolutions.Gateway.Utils/src/KdcClaims.cs Adds optional jet_cred_id claim for KDC tokens.
utils/dotnet/Devolutions.Gateway.Utils/src/AssociationClaims.cs Adds optional jet_cred_id claim for association tokens.
tools/tokengen/src/server/server_impl.rs Accepts/forwards jet_cred_id in token generation server endpoints.
tools/tokengen/src/main.rs Adds --jet-cred-id CLI option for relevant subcommands.
tools/tokengen/src/lib.rs Serializes jet_cred_id into Association/KDC token claims.
devolutions-gateway/src/token.rs Adds jet_cred_id to claims + helper extractors (extract_jet_cred_id, extract_dst_hst).
devolutions-gateway/src/credential/mod.rs Refactors credential store indexing to cred_injection_id, adds injection state + per-session Kerberos material, enforces dst_hst presence for injection sessions.
devolutions-gateway/src/credential/tests.rs Adds unit tests for insert/lookup/eviction and synthetic realm behavior.
devolutions-gateway/src/api/preflight.rs Adds optional cred_injection_id input and returns provisioned-credentials output kind with the final id.
devolutions-gateway/tests/preflight.rs Updates preflight test expectations to the new output kind and returned id.
devolutions-gateway/src/api/kdc_proxy.rs Routes injection sessions via jet_cred_id lookup; forwards otherwise; factors realm enforcement.
devolutions-gateway/src/extract.rs Adds KdcToken extractor to authenticate path-bound /jet/KdcProxy/{token} tokens.
devolutions-gateway/src/rdp_proxy.rs Adds in-process KDC dispatch via http://cred.invalid/{id} and threads credential store into CredSSP server generator.
devolutions-gateway/src/rd_clean_path.rs Switches injection lookup to jet_cred_id first, fallback to JTI index; threads credential store into proxy path.
devolutions-gateway/src/generic_client.rs Same injection lookup precedence and threads credential store into RdpProxy.
devolutions-gateway/src/openapi.rs Updates OpenAPI schema representation for cred_injection_id and new output kind.
devolutions-gateway/openapi/gateway-api.yaml Adds cred_injection_id to preflight models and new provisioned-credentials kind.
devolutions-gateway/openapi/dotnet-client/.../PreflightOutputKind.cs Adds provisioned-credentials enum variant.
devolutions-gateway/openapi/dotnet-client/.../PreflightOutput.cs Adds cred_injection_id field to generated model.
devolutions-gateway/openapi/dotnet-client/.../PreflightOperation.cs Adds cred_injection_id to generated model.
devolutions-gateway/src/api/webapp.rs Initializes new claims fields (jet_cred_id: None) when signing tokens.
docs/plans/2026-04-27-dgw-378-explicit-identity-design.md Adds design doc for explicit identity routing.
review.md Adds an internal review write-up (likely not intended for merge).
review-followup.md Adds follow-up internal review notes (likely not intended for merge).

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

Comment thread devolutions-gateway/src/rdp_proxy.rs Outdated
Comment thread review.md Outdated
Comment thread review-followup.md Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.


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

Comment thread devolutions-gateway/src/rdp_proxy.rs Outdated
Comment thread devolutions-gateway/src/credential/mod.rs Outdated
Comment thread devolutions-gateway/src/api/preflight.rs Outdated
- credential store: reject `cred_injection_id` reuse across different association
  tokens (`InsertError::Conflict`); same-JTI idempotent re-provision still works.
- credential store: filter expired entries at lookup so `time_to_live` is a hard
  limit rather than depending on the 15-minute cleanup sweep.
- rdp_proxy: cache the KDC-proxy HTTP client and apply connect/total timeouts so
  a stalled external KDC proxy cannot hang CredSSP indefinitely.
Mirror the external KDC proxy handler's behavior (`api/kdc_proxy.rs:55`):
treat an empty `target_domain` from the wire as missing and fall back to the
session's stored realm. Prevents a malformed ASN.1 zero-length OCTET STRING
from producing an empty-realm KDC config that would later fail with a
cryptic salt/principal mismatch deep in CredSSP.
@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) changed the title feat(dgw): Kerberos credential injection via explicit cred_injection_id feat(dgw): Kerberos credential injection via explicit jet_cred_id May 12, 2026
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Wow, amazing feature 🤩. Per-session Kerberos material is a great improvement.

I reviewed the PR. LGTM.

Copy link
Copy Markdown
Member

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

Choose a reason for hiding this comment

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

Thanks for tackling this! This is clearly moving in the right direction, and the test coverage you added on CredentialStore is appreciated. That said, I'd like us to be more conservative on three architectural points before this lands:

  1. The credential module should not own Kerberos. SessionKerberos, build_session_kdc_config, principal_for_realm, kerberos_salt, synthetic_realm, encrypt_with_kerberos, and the dst_hst extraction inside insert all turn what used to be a protocol-neutral store into a Kerberos-aware one. They belong in a new rdp_proxy::kerberos_session (or similar) submodule, with InjectionState reduced to { mapping, target_hostname } and the target hostname passed to insert as a typed parameter from preflight (which already has the validated token).

  2. The new cred_injection_id claim/field is avoidable. The PR's own backwards-compat fallback (get_by_token keyed by JTI) demonstrates that the association-token JTI is already a sufficient identifier. I'd like us to take the minimal-surface variant: keep a jet_cred_id claim only on KdcTokenClaims, defined as "the JTI of the associated session token", i.e. it's a reference to another token's ID, not a reuse of one. With that, we drop:

    • jet_cred_id on AssociationTokenClaims
    • cred_injection_id request field on ProvisionCredentialsParams
    • PreflightOutputKind::ProvisionedCredentials (revert to Ack)
    • InsertError::Conflict and the whole "caller proposes an ID" threat model
    • association_token_index + get_by_token
    • All the OpenAPI / .NET client additions tied to those
  3. The enable_unstable gate was silently removed. Whether proxy-based Kerberos injection is ready to be promoted out of the unstable gate is a separate decision that deserves its own PR and CHANGELOG entry. Please keep the gate around build_credential_injection_server_kerberos_config here. Most likely we’ll simply open a PR once this lands.

A few smaller items in line comments.

/// `tracing::debug!(?kerberos, ...)`. Access requires an explicit `expose_secret()` call,
/// which is greppable and reviewable. The custom [`fmt::Debug`] adds a second layer that
/// stays correct even if a future refactor changes the field types.
pub struct SessionKerberos {
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.

Move all of this out of credential/. SessionKerberos, build_session_kdc_config (line 179), principal_for_realm, kerberos_salt, synthetic_realm, encrypt_with_kerberos, random_32_bytes, plus the hardcoded service_user_name: "jet" and max_time_skew: 300 — all of this is Kerberos/CredSSP session glue that has no business inside what used to be a protocol-neutral credential store.

Suggested layout: a new rdp_proxy::kerberos_session (or credssp_session) submodule owns the SessionKerberos type and the KDC config builder. InjectionState in credential/ becomes just { mapping, target_hostname }. The Arc<SessionKerberos> is either lazily derived on first CredSSP use, or stored in a parallel HashMap<Uuid, Arc<SessionKerberos>> owned by the RDP layer (keyed by the same UUID — see point 2 in the summary, that UUID should be the association-token JTI).

This also lets CleartextAppCredentialMapping::encrypt_with_kerberos go back to a plain encrypt, so non-Kerberos use of the store stops minting AES-256 keys it never uses.

// (`token.rs::parse_target_address`). Missing/malformed `dst_hst` is fatal
// here so the failure surfaces at preflight rather than mid-CredSSP.
const DEFAULT_DST_PORT: u16 = 3389;
let raw = crate::token::extract_dst_hst(&token)
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.

Extracting dst_hst from the JWT inside the credential store is a layering violation. The preflight handler in api/preflight.rs already has the validated token in hand and could resolve target_hostname: String itself, then pass it to insert as a typed parameter. That removes the credential module's dependency on JWT shape beyond jti, and removes the second discard_signature() call on a token whose validation invariant is documented elsewhere.

A short doc comment on CredentialStore::insert stating the upstream-validation invariant for the existing JTI extraction would also be welcome, regardless.

Comment thread devolutions-gateway/src/token.rs Outdated
pub jet_agent_id: Option<Uuid>,

/// Reference to a credential-injection record provisioned via /jet/preflight.
pub jet_cred_id: Option<Uuid>,
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.

Please drop jet_cred_id from AssociationTokenClaims. The association token already has a jti, and that JTI uniquely identifies the credential-store entry — every existing call site in this PR (generic_client.rs, rd_clean_path.rs) already falls back to credential_store.get_by_token(token) for old DVLS, which proves a JTI-keyed lookup suffices.

Keep jet_cred_id only on KdcTokenClaims (line 624), defined as "the JTI of the associated session token." That preserves your invariant that JTIs are not reused across tokens — the KDC token has its own jti, and jet_cred_id is a reference to a different token's ID. The KDC handler then does credential_store.get(claims.jet_cred_id) against the same JTI-keyed store.

This change cascades into removing extract_jet_cred_id/extract_optional_uuid callers on the association path, the preflight request field, the provisioned-credentials output kind, the Conflict error, the reverse association_token_index, and the get_by_token helper.

#[serde(flatten)]
mapping: crate::credential::CleartextAppCredentialMapping,
#[serde(default)]
cred_injection_id: Option<Uuid>,
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.

Revert this addition (and the PreflightOutputKind::ProvisionedCredentials variant at line 120, plus the provisioned_cred_injection_id plumbing at line 348 and the response shaping at line 372). With the JTI-keyed approach, the preflight contract is unchanged: Ack stays, no new request field, no new output kind. DVLS already knows the association-token JTI it minted; that's the identifier.

allOf:
- $ref: '#/components/schemas/AppCredential'
nullable: true
cred_injection_id:
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.

Revert this cred_injection_id field (and the matching one at line 1361 on PreflightOutput, plus the provisioned-credentials enum value). Falls out of the preflight revert. The .NET client regen will follow automatically — please don't hand-edit it.

Comment thread devolutions-gateway/src/rdp_proxy.rs Outdated
kerberos.service_user_password.expose_secret(),
));

let kdc_url = Url::parse(&format!("http://cred.invalid/{}", credential_entry.cred_injection_id))
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.

About the http://cred.invalid/<id> trampoline: I see why you reached for it (sspi-rs's CredSSP server config exposes kdc_url: Option<Url>, and you needed a way to dispatch a synthetic in-process "KDC"), but the implementation is heavier than the idea. Three concrete asks:

  1. Verify whether sspi-rs offers a custom KDC dispatcher API (trait object, async closure, KdcClient-like abstraction). If yes — use it; the URL trick goes away and CredentialStoreHandle no longer needs to be threaded through perform_credssp_with_clientprocess_authentication_with_clientresolve_server_generatorsend_network_request.

  2. If no clean API exists in sspi-rs, that's fine for now (sspi-rs is ours — Devolutions/sspi-rs), but please open a GitHub issue against sspi-rs asking for a pluggable KDC dispatcher and link it from a // TODO(sspi-rs#NNN) comment here. We can keep a workaround in this PR to unblock; the cleanup lands when sspi-rs ships the API.

  3. Even with the workaround, please localize the interception. Right now send_network_request is a generic Kerberos-network helper that knows about "cred.invalid". Push the loopback dispatch into a non-generic function (closed over Arc<SessionKerberos> + mapping + target_hostname already in scope at build_credential_injection_server_kerberos_config), and keep the generic helper unaware of the magic hostname.

Also related: kdc_proxy_http_client() (line 653) and the "http" | "https" non-loopback branch (line 681) appear to have no real caller in this PR — sspi-rs only emits tcp:///udp:// in the non-injection path and http://cred.invalid/... in the injection path. Either delete this branch or document the external use case it's meant to serve (an undocumented "session token can point at arbitrary HTTP host" capability would be a security concern).

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.

Comment thread devolutions-gateway/src/rdp_proxy.rs Outdated
None
};
let krb_server_config =
build_credential_injection_server_kerberos_config(&credential_entry, &server_dns_name, client_addr)?;
Copy link
Copy Markdown
Member

@CBenoit Benoît Cortier (CBenoit) May 14, 2026

Choose a reason for hiding this comment

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

The previous code in both rdp_proxy.rs and rd_clean_path.rs only built the Kerberos server config when conf.debug.enable_unstable && conf.debug.kerberos.is_some(). The new build_credential_injection_server_kerberos_config is unconditional — every provision-credentials session now goes through the fake-KDC path.

That's a real product-surface change not called out in the PR description. Please keep the enable_unstable. Promotion to stable should be a separate, deliberate PR with its own CHANGELOG entry under feat(dgw).

}

#[derive(Debug)]
pub struct CredentialEntry {
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.

Nit: CredentialEntry, InjectionState, SessionKerberos, and build_session_kdc_config are all pub but the only consumer is rdp_proxy in the same crate. Restrict to pub(crate) (or pub(super)) to keep refactor flexibility. Same for the new fields you've added on CredentialEntry.

store.get_by_token(&token).is_none(),
"expired entry must not be returned by token"
);
}
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.

Test coverage on CredentialStore mechanics is good. Three branches in this PR are still uncovered and I think are reachable from in-process tests (no real RDP client needed):

  • kdc_proxy::enforce_realm_token_match bypass under disable_token_validation.
  • kdc_proxy::process_credential_injection realm-fallback when the envelope realm is missing.
  • rdp_proxy::send_in_process_kdc_request parses the URL path into a UUID, looks up the entry, and dispatches.

Full end-to-end CredSSP testing would need an actual RDP client and isn't the goal here — that's a welcome future improvement. Please add the easy-to-write ones if time permits; otherwise a tracked follow-up PR is fine.

…e on JTI

Per review feedback: the association token's JTI already uniquely identifies a
credential-injection session, so a separate `cred_injection_id` is redundant.
Restructured to that minimal surface:

- AssociationTokenClaims no longer carries `jet_cred_id`. The credential store
  is keyed directly on `extract_jti(token)` / `claims.jti`; the routing layers
  in `generic_client` and `rd_clean_path` drop the claim-level reference and
  the `get_by_token` fallback that existed for backwards compatibility.
- KdcTokenClaims keeps `jet_cred_id`, redefined as "JTI of the associated
  session token" — a reference to another token's id, not a shared identity.
  KDC proxy still resolves it through `CredentialStoreHandle::get`.
- CredentialStoreHandle::insert no longer accepts a caller-proposed UUID,
  no longer returns one, and InsertError::Conflict / association_token_index /
  get_by_token are gone. CredentialEntry exposes a single `jti` field.
- Preflight reverts to `Ack`: ProvisionedCredentials output kind dropped,
  cred_injection_id request field dropped, OpenAPI schema follows.
- tokengen drops --jet-cred-id from the RdpTls subcommand (association). The
  KDC subcommand keeps it.
- .NET utils: AssociationClaims.JetCredId removed; KdcClaims.JetCredId kept;
  ProvisionCredentialsRequest.CredInjectionId removed.
- Hand-edited dotnet-client model classes reverted to master so the release
  pipeline regenerates them from the updated OpenAPI.
Bundles the remaining structural feedback from the PR review:

- Kerberos out of `credential/`. New `credssp_session` module owns
  `SessionKerberos`, `build_session_kdc_config`, principal/salt/synthetic-realm
  helpers, and a parallel JTI-keyed `SessionKerberosStoreHandle` with its own
  cleanup task. `InjectionState` in the credential store shrinks to
  `{mapping, target_hostname}`. `target_hostname` is now resolved at preflight
  (where the validated token is) and passed to `insert` as a typed parameter;
  the store no longer touches JWT shape. `CredentialStoreHandle::insert`
  returns `anyhow::Result` — `InsertError::InvalidToken` no longer applies and
  `InsertError::Conflict` is already gone. The two stores are paired at
  preflight and threaded through to the RDP layer as direct refs, so no
  store lookup happens during CredSSP/KdcProxy.

- `enable_unstable` gate restored. The preflight handler now rejects
  `provision-credentials` operations with `unsupported-operation` unless
  `__debug__.enable_unstable` is set. Promotion to stable is left to a
  follow-up PR with its own CHANGELOG entry.

- `cred.invalid` trampoline localized. `send_network_request` is now a
  generic tcp/udp helper that has no knowledge of the credential-injection
  loopback; the magic hostname lives entirely inside
  `InProcessKdcDispatch::try_handle`, which the server-side CredSSP resolver
  consults first. `kdc_proxy_http_client()` and the unused `http`/`https`
  non-loopback branch are deleted. The dispatcher also verifies the URL JTI
  matches the entry's JTI as defense-in-depth. Tagged with a
  `TODO(sspi-rs)` note for the future pluggable-KDC-dispatcher API.

- Visibility tightened. `CleartextAppCredential[Mapping]`, internal helpers,
  `CredentialStoreHandle::insert`, and the new `credssp_session` internals are
  `pub(crate)`. Types reachable through the public `DgwState` graph stay
  `pub` (without breaking the integration test).

- Tests for the three branches CBenoit flagged as reachable without a real
  RDP client: `enforce_realm_token_match` (case-insensitive match,
  with/without bypass), `resolve_injection_realm` (extracted helper covering
  the envelope-realm fallback), and `InProcessKdcDispatch` URL parsing
  (non-loopback host, malformed UUID path, JTI mismatch).
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.

4 participants