Skip to content

Commit 8ceb2ad

Browse files
feat(dgw): Kerberos-based credential injection (#1768)
1 parent 2cee955 commit 8ceb2ad

19 files changed

Lines changed: 1656 additions & 290 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dist/
99
/output/
1010
/config/
1111
*.DotSettings
12+
*.csproj.lscache
1213

1314
# Downloaded build dependencies
1415
tun2socks.exe

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

devolutions-gateway/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32
140140
embed-resource = "3.0"
141141

142142
[dev-dependencies]
143+
base64 = "0.22"
143144
tokio-test = "0.4"
144145
proptest = "1.7"
145146
tempfile = "3"

devolutions-gateway/src/api/kdc_proxy.rs

Lines changed: 149 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
use std::io;
2-
use std::net::SocketAddr;
32

43
use axum::Router;
5-
use axum::extract::{self, ConnectInfo, State};
4+
use axum::extract::State;
65
use axum::http::StatusCode;
76
use axum::routing::post;
8-
use kdc::handle_kdc_proxy_message;
97
use picky_krb::messages::KdcProxyMessage;
108
use tokio::io::{AsyncReadExt, AsyncWriteExt};
119
use tokio::net::{TcpStream, UdpSocket};
1210

1311
use crate::DgwState;
12+
use crate::credential_injection_kdc::{
13+
CredentialInjectionKdcInterception, CredentialInjectionKdcRequest, CredentialInjectionKdcResolveError,
14+
kdc_proxy_message_realm,
15+
};
16+
use crate::extract::KdcToken;
1417
use crate::http::{HttpError, HttpErrorBuilder};
1518
use crate::target_addr::TargetAddr;
16-
use crate::token::{AccessTokenClaims, KdcDestination};
19+
use crate::token::{KdcDestination, KdcTokenClaims};
1720

1821
pub fn make_router<S>(state: DgwState) -> Router<S> {
1922
Router::new().route("/{token}", post(kdc_proxy)).with_state(state)
@@ -22,106 +25,144 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
2225
async fn kdc_proxy(
2326
State(DgwState {
2427
conf_handle,
25-
token_cache,
26-
jrl,
27-
recordings,
28+
credentials,
2829
..
2930
}): State<DgwState>,
30-
extract::Path(token): extract::Path<String>,
31-
ConnectInfo(source_addr): ConnectInfo<SocketAddr>,
31+
KdcToken(KdcTokenClaims { destination }): KdcToken,
3232
body: axum::body::Bytes,
3333
) -> Result<Vec<u8>, HttpError> {
3434
let conf = conf_handle.get_conf();
3535

36-
let claims = crate::middleware::auth::authenticate(
37-
source_addr,
38-
&token,
39-
&conf,
40-
&token_cache,
41-
&jrl,
42-
&recordings.active_recordings,
43-
None,
44-
)
45-
.map_err(HttpError::unauthorized().err())?;
46-
47-
let AccessTokenClaims::Kdc(claims) = claims else {
48-
return Err(HttpError::forbidden().msg("token not allowed (expected KDC token)"));
49-
};
50-
5136
let kdc_proxy_message = KdcProxyMessage::from_raw(&body).map_err(HttpError::bad_request().err())?;
5237

5338
trace!(?kdc_proxy_message, "Received KDC message");
54-
5539
debug!(
5640
?kdc_proxy_message.target_domain,
5741
?kdc_proxy_message.dclocator_hint,
5842
"KDC message",
5943
);
6044

61-
let realm = if let Some(realm) = &kdc_proxy_message.target_domain.0 {
62-
realm.0.to_string()
63-
} else {
64-
return Err(HttpError::bad_request().msg("realm is missing from KDC request"));
65-
};
66-
67-
debug!("Request is for realm (target_domain): {realm}");
45+
match destination {
46+
KdcDestination::Inject { jti } => {
47+
enforce_credential_injection_enabled(jti, conf.debug.enable_unstable)?;
6848

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-
};
49+
let kdc = credentials.kdc_for(jti).map_err(credential_injection_resolve_error)?;
7750

78-
if !claims_realm.eq_ignore_ascii_case(&realm) {
79-
if conf.debug.disable_token_validation {
80-
warn!(
81-
token_realm = %claims_realm,
82-
request_realm = %realm,
83-
"**DEBUG OPTION** Allowed a KDC request towards a KDC whose Kerberos realm differs from what's inside the KDC token"
51+
debug!(
52+
jti = %kdc.jti(),
53+
"Proxy-based credential injection with Kerberos. Processing KdcProxy message internally"
8454
);
85-
} else {
86-
let error_message = format!("expected: {}, got: {}", claims_realm, realm);
8755

88-
return Err(HttpError::bad_request()
89-
.with_msg("requested domain is not allowed")
90-
.err()(error_message));
56+
match kdc
57+
.handle_kdc_proxy_request(CredentialInjectionKdcRequest::from_token(kdc_proxy_message))
58+
.map_err(HttpError::internal().err())?
59+
{
60+
CredentialInjectionKdcInterception::Intercepted(reply) => Ok(reply),
61+
CredentialInjectionKdcInterception::NotInjectionRealm(mismatch) => {
62+
Err(HttpError::bad_request()
63+
.with_msg("requested domain is not allowed")
64+
.err()(mismatch))
65+
}
66+
CredentialInjectionKdcInterception::NotInjectionRequest => {
67+
Err(HttpError::internal().msg("credential-injection KDC did not handle the KDC proxy request"))
68+
}
69+
}
70+
}
71+
KdcDestination::Real { krb_realm, krb_kdc } => {
72+
let envelope_realm = kdc_proxy_message_realm(&kdc_proxy_message);
73+
forward_to_real_kdc(
74+
kdc_proxy_message,
75+
envelope_realm,
76+
&krb_realm,
77+
&krb_kdc,
78+
conf.debug.override_kdc.as_ref(),
79+
conf.debug.disable_token_validation,
80+
)
81+
.await
9182
}
9283
}
84+
}
9385

94-
let gateway_id = conf
95-
.id
96-
.ok_or_else(|| HttpError::internal().build("Gateway ID is missing"))?;
97-
if let Some(krb_config) = &conf.debug.kerberos
98-
&& realm.eq_ignore_ascii_case(&krb_config.kerberos_server.realm(gateway_id))
99-
&& conf.debug.enable_unstable
100-
{
101-
debug!("Proxy-based credential injection with Kerberos. Processing KdcProxy message internally...");
102-
103-
let config = krb_config.kerberos_server.clone().into_kdc_kerberos_config(gateway_id);
104-
let kdc_reply_message = handle_kdc_proxy_message(kdc_proxy_message, &config, &conf.hostname)
105-
.map_err(HttpError::internal().err())?;
106-
107-
return kdc_reply_message.to_vec().map_err(HttpError::internal().err());
86+
fn credential_injection_resolve_error(error: CredentialInjectionKdcResolveError) -> HttpError {
87+
match error {
88+
CredentialInjectionKdcResolveError::BuildKdcConfig { .. } => HttpError::internal()
89+
.with_msg("credential-injection KDC could not be initialized")
90+
.build(error),
91+
_ => HttpError::bad_request()
92+
.with_msg("credential-injection state is not available")
93+
.build(error),
10894
}
95+
}
10996

110-
let kdc_addr = if let Some(kdc_addr) = &conf.debug.override_kdc {
111-
warn!("**DEBUG OPTION** KDC address has been overridden with {kdc_addr}");
112-
kdc_addr
113-
} else {
114-
claims_kdc
97+
// Forwards the request to the real KDC indicated by the token (or by the debug override) and
98+
// returns the response wrapped as a `KdcProxyMessage`.
99+
//
100+
// The forward path requires the envelope realm to be set: there is no fallback since this is
101+
// not a credential-injection session. After resolving, validates the realm against the
102+
// token's `krb_realm` claim before forwarding anything.
103+
async fn forward_to_real_kdc(
104+
kdc_proxy_message: KdcProxyMessage,
105+
envelope_realm: Option<String>,
106+
token_realm: &str,
107+
token_kdc_addr: &TargetAddr,
108+
override_kdc: Option<&TargetAddr>,
109+
bypass_realm_check: bool,
110+
) -> Result<Vec<u8>, HttpError> {
111+
let realm = envelope_realm.ok_or_else(|| HttpError::bad_request().msg("realm is missing from KDC request"))?;
112+
debug!(resolved_realm = %realm, "Forward-to-real-KDC realm resolved");
113+
enforce_realm_token_match(token_realm, &realm, bypass_realm_check)?;
114+
115+
let kdc_addr = match override_kdc {
116+
Some(override_addr) => {
117+
warn!(%override_addr, "**DEBUG OPTION** KDC address has been overridden");
118+
override_addr
119+
}
120+
None => token_kdc_addr,
115121
};
116122

117-
let kdc_reply_message = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?;
123+
let kdc_reply_bytes = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?;
118124

119-
let kdc_reply_message = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_message)
125+
let reply = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_bytes)
120126
.map_err(HttpError::internal().with_msg("couldn't create KDC proxy reply").err())?;
121127

122-
trace!(?kdc_reply_message, "Sending back KDC reply");
128+
trace!(?reply, "Sending back KDC reply");
129+
130+
reply.to_vec().map_err(HttpError::internal().err())
131+
}
132+
133+
fn enforce_credential_injection_enabled(jet_cred_id: uuid::Uuid, enable_unstable: bool) -> Result<(), HttpError> {
134+
if enable_unstable {
135+
return Ok(());
136+
}
137+
138+
warn!(
139+
%jet_cred_id,
140+
"Credential-injection KDC token rejected because unstable Kerberos injection is disabled"
141+
);
142+
Err(HttpError::bad_request().msg("credential-injection KDC proxy is not enabled"))
143+
}
144+
145+
/// Refuses to forward a KDC request whose realm disagrees with the realm the token was issued for.
146+
///
147+
/// `bypass=true` (only when `__debug__.disable_token_validation` is on) downgrades the mismatch
148+
/// to a warning. Production never opts into this.
149+
fn enforce_realm_token_match(token_realm: &str, request_realm: &str, bypass: bool) -> Result<(), HttpError> {
150+
if token_realm.eq_ignore_ascii_case(request_realm) {
151+
return Ok(());
152+
}
153+
154+
if bypass {
155+
warn!(
156+
%token_realm,
157+
%request_realm,
158+
"**DEBUG OPTION** Allowed a KDC request towards a KDC whose Kerberos realm differs from what's inside the KDC token"
159+
);
160+
return Ok(());
161+
}
123162

124-
kdc_reply_message.to_vec().map_err(HttpError::internal().err())
163+
Err(HttpError::bad_request()
164+
.with_msg("requested domain is not allowed")
165+
.err()(format!("expected: {token_realm}, got: {request_realm}")))
125166
}
126167

127168
async fn read_kdc_reply_message(connection: &mut TcpStream) -> io::Result<Vec<u8>> {
@@ -221,3 +262,37 @@ pub async fn send_krb_message(kdc_addr: &TargetAddr, message: &[u8]) -> Result<V
221262
Ok(reply_buf)
222263
}
223264
}
265+
266+
#[cfg(test)]
267+
mod tests {
268+
use super::*;
269+
270+
#[test]
271+
fn enforce_realm_match_accepts_case_insensitive_match() {
272+
assert!(enforce_realm_token_match("ad.example", "AD.EXAMPLE", false).is_ok());
273+
}
274+
275+
#[test]
276+
fn enforce_realm_mismatch_rejects_without_bypass() {
277+
assert!(enforce_realm_token_match("ad.example", "evil.example", false).is_err());
278+
}
279+
280+
#[test]
281+
fn enforce_realm_mismatch_passes_under_bypass() {
282+
// `bypass=true` is the `__debug__.disable_token_validation` downgrade. CBenoit asked
283+
// for explicit coverage of this branch because it is the only place the realm
284+
// authorization is intentionally weakened, and slipping the gate (e.g. by inverting the
285+
// condition) would only surface in production.
286+
assert!(enforce_realm_token_match("ad.example", "evil.example", true).is_ok());
287+
}
288+
289+
#[test]
290+
fn credential_injection_gate_allows_jet_cred_id_when_enabled() {
291+
assert!(enforce_credential_injection_enabled(uuid::Uuid::new_v4(), true).is_ok());
292+
}
293+
294+
#[test]
295+
fn credential_injection_gate_rejects_jet_cred_id_when_disabled() {
296+
assert!(enforce_credential_injection_enabled(uuid::Uuid::new_v4(), false).is_err());
297+
}
298+
}

0 commit comments

Comments
 (0)