Skip to content

Commit 2cee955

Browse files
feat(dgw): add jet_cred_id KDC claim (#1786)
Introduces the `jet_cred_id` KDC claim and a typed `KdcDestination` wrapper at the token-claims layer, plumbed through the Gateway, `tokengen` and the `Devolutions.Gateway.Utils` NuGet package. A KDC session token now has two mutually exclusive shapes: * `{ krb_realm, krb_kdc }` — forward traffic to a real upstream KDC (existing behavior, no wire change). * `{ jet_cred_id }` — serve KDC traffic locally using the credentials provisioned at session establishment, identified by the JTI of the access token that carries them. Co-authored-by: irving ou <jou@devolutions.net>
1 parent b696ea1 commit 2cee955

8 files changed

Lines changed: 193 additions & 60 deletions

File tree

devolutions-gateway/src/api/kdc_proxy.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use tokio::net::{TcpStream, UdpSocket};
1313
use crate::DgwState;
1414
use crate::http::{HttpError, HttpErrorBuilder};
1515
use crate::target_addr::TargetAddr;
16-
use crate::token::AccessTokenClaims;
16+
use crate::token::{AccessTokenClaims, KdcDestination};
1717

1818
pub fn make_router<S>(state: DgwState) -> Router<S> {
1919
Router::new().route("/{token}", post(kdc_proxy)).with_state(state)
@@ -66,15 +66,24 @@ async fn kdc_proxy(
6666

6767
debug!("Request is for realm (target_domain): {realm}");
6868

69-
if !claims.krb_realm.eq_ignore_ascii_case(&realm) {
69+
let (claims_realm, claims_kdc) = match &claims.destination {
70+
KdcDestination::Real { krb_realm, krb_kdc } => (krb_realm, krb_kdc),
71+
KdcDestination::Inject { .. } => {
72+
// TODO(DGW-378): dispatch credential-injection KDC requests to the in-process
73+
// sspi-rs server backed by the credentials provisioned at session establishment.
74+
return Err(HttpError::internal().msg("credential injection KDC dispatch is not implemented yet"));
75+
}
76+
};
77+
78+
if !claims_realm.eq_ignore_ascii_case(&realm) {
7079
if conf.debug.disable_token_validation {
7180
warn!(
72-
token_realm = %claims.krb_realm,
81+
token_realm = %claims_realm,
7382
request_realm = %realm,
7483
"**DEBUG OPTION** Allowed a KDC request towards a KDC whose Kerberos realm differs from what's inside the KDC token"
7584
);
7685
} else {
77-
let error_message = format!("expected: {}, got: {}", claims.krb_realm, realm);
86+
let error_message = format!("expected: {}, got: {}", claims_realm, realm);
7887

7988
return Err(HttpError::bad_request()
8089
.with_msg("requested domain is not allowed")
@@ -102,7 +111,7 @@ async fn kdc_proxy(
102111
warn!("**DEBUG OPTION** KDC address has been overridden with {kdc_addr}");
103112
kdc_addr
104113
} else {
105-
&claims.krb_kdc
114+
claims_kdc
106115
};
107116

108117
let kdc_reply_message = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?;

devolutions-gateway/src/api/webapp.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,8 +386,10 @@ pub(crate) async fn sign_session_token(
386386

387387
SessionTokenContentType::Kdc { krb_realm, krb_kdc } => (
388388
KdcTokenClaims {
389-
krb_realm: krb_realm.into(),
390-
krb_kdc: krb_kdc.clone(),
389+
destination: crate::token::KdcDestination::Real {
390+
krb_realm: krb_realm.into(),
391+
krb_kdc: krb_kdc.clone(),
392+
},
391393
}
392394
.pipe(serde_json::to_value)
393395
.map(|mut claims| {

devolutions-gateway/src/token.rs

Lines changed: 79 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -604,18 +604,36 @@ pub struct JrecTokenClaims {
604604

605605
#[derive(Clone)]
606606
pub struct KdcTokenClaims {
607-
/// Kerberos realm.
608-
///
609-
/// E.g.: `ad.it-help.ninja`
610-
/// Should be lowercased (actual validation is case-insensitive though).
611-
pub krb_realm: SmolStr,
607+
/// Where the KDC traffic for this session is routed.
608+
pub destination: KdcDestination,
609+
}
612610

613-
/// Kerberos KDC address.
611+
/// Destination for a KDC session token.
612+
///
613+
/// Either a real upstream KDC (the typical case, with `krb_realm` + `krb_kdc` on the wire) or
614+
/// a credential-injection placeholder pointing at the JTI of the access token whose credentials
615+
/// must be used to forge Kerberos tickets locally (the `jet_cred_id` claim on the wire).
616+
#[derive(Clone)]
617+
pub enum KdcDestination {
618+
/// Forward to an upstream KDC.
619+
Real {
620+
/// Kerberos realm.
621+
///
622+
/// E.g.: `ad.it-help.ninja`
623+
/// Should be lowercased (actual validation is case-insensitive though).
624+
krb_realm: SmolStr,
625+
626+
/// Kerberos KDC address.
627+
///
628+
/// E.g.: `tcp://IT-HELP-DC.ad.it-help.ninja:88`
629+
/// Default scheme is `tcp`.
630+
/// Default port is `88`.
631+
krb_kdc: TargetAddr,
632+
},
633+
/// Serve the request locally using credentials injected at session establishment.
614634
///
615-
/// E.g.: `tcp://IT-HELP-DC.ad.it-help.ninja:88`
616-
/// Default scheme is `tcp`.
617-
/// Default port is `88`.
618-
pub krb_kdc: TargetAddr,
635+
/// `jti` is the JTI of the access token that provisioned the credentials to use.
636+
Inject { jti: Uuid },
619637
}
620638

621639
// ----- jrl claims ----- //
@@ -1389,8 +1407,12 @@ mod serde_impl {
13891407

13901408
#[derive(Serialize, Deserialize)]
13911409
struct KdcClaimsHelper {
1392-
krb_realm: SmolStr,
1393-
krb_kdc: SmolStr,
1410+
#[serde(default, skip_serializing_if = "Option::is_none")]
1411+
krb_realm: Option<SmolStr>,
1412+
#[serde(default, skip_serializing_if = "Option::is_none")]
1413+
krb_kdc: Option<SmolStr>,
1414+
#[serde(default, skip_serializing_if = "Option::is_none")]
1415+
jet_cred_id: Option<Uuid>,
13941416
}
13951417

13961418
impl ser::Serialize for SessionTtl {
@@ -1637,11 +1659,20 @@ mod serde_impl {
16371659
where
16381660
S: serde::Serializer,
16391661
{
1640-
KdcClaimsHelper {
1641-
krb_realm: self.krb_realm.clone(),
1642-
krb_kdc: SmolStr::new(self.krb_kdc.as_str()),
1643-
}
1644-
.serialize(serializer)
1662+
let helper = match &self.destination {
1663+
KdcDestination::Real { krb_realm, krb_kdc } => KdcClaimsHelper {
1664+
krb_realm: Some(krb_realm.clone()),
1665+
krb_kdc: Some(SmolStr::new(krb_kdc.as_str())),
1666+
jet_cred_id: None,
1667+
},
1668+
KdcDestination::Inject { jti } => KdcClaimsHelper {
1669+
krb_realm: None,
1670+
krb_kdc: None,
1671+
jet_cred_id: Some(*jti),
1672+
},
1673+
};
1674+
1675+
helper.serialize(serializer)
16451676
}
16461677
}
16471678

@@ -1654,28 +1685,42 @@ mod serde_impl {
16541685

16551686
let claims = KdcClaimsHelper::deserialize(deserializer)?;
16561687

1657-
// Validate krb_realm value
1688+
let destination = match (claims.krb_realm, claims.krb_kdc, claims.jet_cred_id) {
1689+
(Some(krb_realm), Some(krb_kdc), None) => {
1690+
// Validate krb_realm value
16581691

1659-
if claims.krb_realm.chars().any(char::is_uppercase) {
1660-
return Err(de::Error::custom("krb_realm field contains uppercases"));
1661-
}
1692+
if krb_realm.chars().any(char::is_uppercase) {
1693+
return Err(de::Error::custom("krb_realm field contains uppercases"));
1694+
}
1695+
1696+
// Validate krb_kdc field
16621697

1663-
// Validate krb_kdc field
1698+
let krb_kdc = TargetAddr::parse(&krb_kdc, DEFAULT_KDC_PORT).map_err(de::Error::custom)?;
1699+
match krb_kdc.scheme() {
1700+
"tcp" | "udp" => { /* supported! */ }
1701+
unsupported_scheme => {
1702+
return Err(de::Error::custom(format!(
1703+
"unsupported protocol for KDC proxy: {unsupported_scheme}"
1704+
)));
1705+
}
1706+
}
16641707

1665-
let krb_kdc = TargetAddr::parse(&claims.krb_kdc, DEFAULT_KDC_PORT).map_err(de::Error::custom)?;
1666-
match krb_kdc.scheme() {
1667-
"tcp" | "udp" => { /* supported! */ }
1668-
unsupported_scheme => {
1669-
return Err(de::Error::custom(format!(
1670-
"unsupported protocol for KDC proxy: {unsupported_scheme}"
1671-
)));
1708+
KdcDestination::Real { krb_realm, krb_kdc }
16721709
}
1673-
}
1710+
(None, None, Some(jti)) => KdcDestination::Inject { jti },
1711+
(None, None, None) => {
1712+
return Err(de::Error::custom(
1713+
"missing KDC destination: expected `krb_realm`+`krb_kdc` or `jet_cred_id`",
1714+
));
1715+
}
1716+
_ => {
1717+
return Err(de::Error::custom(
1718+
"conflicting KDC destination fields: `jet_cred_id` is mutually exclusive with `krb_realm`+`krb_kdc`",
1719+
));
1720+
}
1721+
};
16741722

1675-
Ok(Self {
1676-
krb_realm: claims.krb_realm,
1677-
krb_kdc,
1678-
})
1723+
Ok(Self { destination })
16791724
}
16801725
}
16811726
}

tools/tokengen/src/lib.rs

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,12 @@ pub struct JrecClaims {
9999

100100
#[derive(Clone, Serialize)]
101101
pub struct KdcClaims<'a> {
102-
pub krb_realm: &'a str,
103-
pub krb_kdc: &'a str,
102+
#[serde(skip_serializing_if = "Option::is_none")]
103+
pub krb_realm: Option<&'a str>,
104+
#[serde(skip_serializing_if = "Option::is_none")]
105+
pub krb_kdc: Option<&'a str>,
106+
#[serde(skip_serializing_if = "Option::is_none")]
107+
pub jet_cred_id: Option<Uuid>,
104108
#[serde(skip_serializing_if = "Option::is_none")]
105109
pub jet_gw_id: Option<Uuid>,
106110
pub exp: i64,
@@ -257,8 +261,9 @@ pub enum SubCommandArgs {
257261
jet_reuse: Option<u32>,
258262
},
259263
Kdc {
260-
krb_realm: String,
261-
krb_kdc: String,
264+
krb_realm: Option<String>,
265+
krb_kdc: Option<String>,
266+
jet_cred_id: Option<Uuid>,
262267
},
263268
Jrl {
264269
revoked_jti_list: Vec<Uuid>,
@@ -452,14 +457,36 @@ pub fn generate_token(
452457
};
453458
("JREC", serde_json::to_value(claims)?)
454459
}
455-
SubCommandArgs::Kdc { krb_realm, krb_kdc } => {
456-
let claims = KdcClaims {
457-
exp,
458-
nbf,
459-
krb_realm: &krb_realm,
460-
krb_kdc: &krb_kdc,
461-
jet_gw_id,
462-
jti,
460+
SubCommandArgs::Kdc {
461+
krb_realm,
462+
krb_kdc,
463+
jet_cred_id,
464+
} => {
465+
let claims = match (krb_realm.as_deref(), krb_kdc.as_deref(), jet_cred_id) {
466+
(Some(krb_realm), Some(krb_kdc), None) => KdcClaims {
467+
exp,
468+
nbf,
469+
krb_realm: Some(krb_realm),
470+
krb_kdc: Some(krb_kdc),
471+
jet_cred_id: None,
472+
jet_gw_id,
473+
jti,
474+
},
475+
(None, None, Some(jet_cred_id)) => KdcClaims {
476+
exp,
477+
nbf,
478+
krb_realm: None,
479+
krb_kdc: None,
480+
jet_cred_id: Some(jet_cred_id),
481+
jet_gw_id,
482+
jti,
483+
},
484+
_ => {
485+
return Err(
486+
"KDC subcommand requires either both --krb-realm and --krb-kdc, or only --jet-cred-id (mutually exclusive)"
487+
.into(),
488+
);
489+
}
463490
};
464491
("KDC", serde_json::to_value(claims)?)
465492
}

tools/tokengen/src/main.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,15 @@ fn sign(
122122
jet_aid,
123123
jet_reuse,
124124
},
125-
SignSubCommand::Kdc { krb_realm, krb_kdc } => SubCommandArgs::Kdc { krb_realm, krb_kdc },
125+
SignSubCommand::Kdc {
126+
krb_realm,
127+
krb_kdc,
128+
jet_cred_id,
129+
} => SubCommandArgs::Kdc {
130+
krb_realm,
131+
krb_kdc,
132+
jet_cred_id,
133+
},
126134
SignSubCommand::Jrl { jti } => SubCommandArgs::Jrl { revoked_jti_list: jti },
127135
SignSubCommand::NetScan {} => SubCommandArgs::NetScan {},
128136
};
@@ -255,9 +263,11 @@ enum SignSubCommand {
255263
},
256264
Kdc {
257265
#[clap(long)]
258-
krb_realm: String,
266+
krb_realm: Option<String>,
267+
#[clap(long)]
268+
krb_kdc: Option<String>,
259269
#[clap(long)]
260-
krb_kdc: String,
270+
jet_cred_id: Option<Uuid>,
261271
},
262272
Jrl {
263273
#[clap(long)]

tools/tokengen/src/server/server_impl.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ pub(crate) async fn kdc_handler(
212212
SubCommandArgs::Kdc {
213213
krb_realm: request.krb_realm,
214214
krb_kdc: request.krb_kdc,
215+
jet_cred_id: request.jet_cred_id,
215216
},
216217
)
217218
.await
@@ -339,8 +340,12 @@ pub(crate) struct JrecRequest {
339340
pub(crate) struct KdcRequest {
340341
#[serde(flatten)]
341342
common: CommonRequest,
342-
krb_realm: String,
343-
krb_kdc: String,
343+
#[serde(default)]
344+
krb_realm: Option<String>,
345+
#[serde(default)]
346+
krb_kdc: Option<String>,
347+
#[serde(default)]
348+
jet_cred_id: Option<Uuid>,
344349
}
345350

346351
#[derive(Deserialize)]

utils/dotnet/Devolutions.Gateway.Utils.Tests/JsonSerializationTests.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ public void KdcClaims()
1717
Assert.Equal(EXPECTED, result);
1818
}
1919

20+
[Fact]
21+
public void KdcClaimsForCredentialInjection()
22+
{
23+
const string EXPECTED = """{"jet_cred_id":"2dd6fb87-5340-4a85-9e96-d383ebef8a41","jet_gw_id":"ccbaad3f-4627-4666-8bb5-cb6a1a7db815"}""";
24+
25+
var claims = Devolutions.Gateway.Utils.KdcClaims.ForCredentialInjection(gatewayId, Guid.Parse("2dd6fb87-5340-4a85-9e96-d383ebef8a41"));
26+
string result = JsonSerializer.Serialize(claims);
27+
Assert.Equal(EXPECTED, result);
28+
}
29+
2030
[Fact]
2131
public void JmuxClaims()
2232
{

utils/dotnet/Devolutions.Gateway.Utils/src/KdcClaims.cs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@ namespace Devolutions.Gateway.Utils;
55
public class KdcClaims : IGatewayClaims
66
{
77
[JsonPropertyName("krb_kdc")]
8-
public TargetAddr KrbKdc { get; set; }
8+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
9+
public TargetAddr? KrbKdc { get; set; }
10+
911
[JsonPropertyName("krb_realm")]
10-
public string KrbRealm { get; set; }
12+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
13+
public string? KrbRealm { get; set; }
14+
15+
[JsonPropertyName("jet_cred_id")]
16+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
17+
public Guid? JetCredId { get; set; }
18+
1119
[JsonPropertyName("jet_gw_id")]
1220
public Guid ScopeGatewayId { get; set; }
1321

@@ -18,6 +26,23 @@ public KdcClaims(Guid scopeGatewayId, TargetAddr krbKdc, string krbRealm)
1826
this.ScopeGatewayId = scopeGatewayId;
1927
}
2028

29+
private KdcClaims(Guid scopeGatewayId, Guid jetCredId)
30+
{
31+
this.JetCredId = jetCredId;
32+
this.ScopeGatewayId = scopeGatewayId;
33+
}
34+
35+
/// <summary>
36+
/// Build a KDC claims set whose KDC traffic is served locally using credentials provisioned
37+
/// at session establishment, rather than forwarded to an upstream KDC.
38+
/// </summary>
39+
/// <param name="scopeGatewayId">Target Gateway identifier.</param>
40+
/// <param name="jetCredId">JTI of the access token whose credentials must be used.</param>
41+
public static KdcClaims ForCredentialInjection(Guid scopeGatewayId, Guid jetCredId)
42+
{
43+
return new KdcClaims(scopeGatewayId, jetCredId);
44+
}
45+
2146
public string GetContentType()
2247
{
2348
return "KDC";

0 commit comments

Comments
 (0)