From 258236e519e343bd0e7b232c1e03da75a564b590 Mon Sep 17 00:00:00 2001 From: Ki Hyun Park Date: Thu, 11 Jun 2026 09:30:38 +0900 Subject: [PATCH 1/3] fix(credssp): complete the SPNEGO mechListMIC exchange in CredSspServer When NTLM runs inside SPNEGO (ClientMode::Negotiate / ServerMode::Negotiate - the pairing RDP requires), the client always includes an NTLM MIC, which makes the SPNEGO mechListMIC exchange mandatory per [MS-SPNG]. The client therefore defers pubKeyAuth until it has received and verified the server's final SPNEGO token (accept-completed + mechListMIC). CredSspServer did not implement that leg: when the acceptor completed on the AUTHENTICATE message it dropped its own final SPNEGO token (ts_request.nego_tokens = None) and demanded pubKeyAuth in that same TSRequest, failing the handshake with "CredSSP server expected an encrypted public key" - so CredSspServer could never authenticate sspi's own CredSspClient in Negotiate-NTLM mode (#687). Fix: when the security context completes but the incoming TSRequest carries no pubKeyAuth and the acceptor produced a final token, send that token as ReplyNeeded and accept pubKeyAuth on the following leg - matching the client's behavior and what Windows RDP servers do. Pairs where pubKeyAuth arrives together with the last nego token (bare NTLM, Kerberos) keep the existing single-leg path. The new credssp_negotiate_ntlm test pins the four-leg handshake (NEGOTIATE, AUTHENTICATE+mechListMIC, pubKeyAuth, authInfo); the existing credssp_ntlm and credssp_kerberos tests cover the unchanged paths. Closes #687 --- src/credssp/mod.rs | 124 ++++++++++++++++++++-------- tests/sspi/client_server/credssp.rs | 48 +++++++++++ 2 files changed, 137 insertions(+), 35 deletions(-) diff --git a/src/credssp/mod.rs b/src/credssp/mod.rs index 4e8ffe9b..2e871fc1 100644 --- a/src/credssp/mod.rs +++ b/src/credssp/mod.rs @@ -454,6 +454,10 @@ pub struct CredSspServer> credentials_handle: Option, ts_request_version: u32, context_config: Option, + /// Set when the security context completed but the final SPNEGO token (accept-completed + + /// `mechListMIC`) still had to be sent to the client: the client cannot send `pubKeyAuth` + /// until it has verified that token, so `pubKeyAuth` arrives on the *next* leg. + awaiting_pub_key_auth: bool, } impl + Send> CredSspServer { @@ -466,6 +470,7 @@ impl + Send> CredSspServe credentials_handle: None, ts_request_version: TS_REQUEST_VERSION, context_config: Some(client_mode), + awaiting_pub_key_auth: false, }) } @@ -483,9 +488,34 @@ impl + Send> CredSspServe credentials_handle: None, ts_request_version, context_config: Some(client_mode), + awaiting_pub_key_auth: false, }) } + /// Decrypts and verifies the client's `pubKeyAuth`, returning the server-side `pubKeyAuth` + /// to send back. + fn exchange_pub_key_auth( + &mut self, + pub_key_auth: Vec, + client_nonce: &Option<[u8; NONCE_SIZE]>, + ) -> crate::Result> { + let peer_version = self + .context + .as_ref() + .unwrap() + .peer_version + .expect("an decrypt public key server function cannot be fired without any incoming TSRequest"); + let context = self.context.as_mut().unwrap(); + context.decrypt_public_key( + self.public_key.as_ref(), + pub_key_auth.as_ref(), + EndpointType::Server, + client_nonce, + peer_version, + )?; + context.encrypt_public_key(self.public_key.as_ref(), EndpointType::Server, client_nonce, peer_version) + } + #[instrument(fields(state = ?self.state), skip_all)] pub fn process( &mut self, @@ -576,6 +606,34 @@ impl + Send> CredSspServe Ok(ServerState::Finished(auth_identity)) } CredSspState::NegoToken => { + if self.awaiting_pub_key_auth { + // The SPNEGO `mechListMIC` exchange deferred the client's `pubKeyAuth` by one + // leg: the security context is already established and the final SPNEGO token + // has been sent, so this TSRequest carries `pubKeyAuth` alone. Calling the + // acceptor again would be out-of-sequence. + let pub_key_auth = try_cred_ssp_server!( + ts_request.pub_key_auth.take().ok_or_else(|| { + Error::new( + ErrorKind::InvalidToken, + String::from("CredSSP server expected an encrypted public key"), + ) + }), + ts_request + ); + let client_nonce = ts_request.client_nonce; + let pub_key_auth = try_cred_ssp_server!( + self.exchange_pub_key_auth(pub_key_auth, &client_nonce), + ts_request + ); + ts_request.nego_tokens = None; + ts_request.pub_key_auth = Some(pub_key_auth); + + self.awaiting_pub_key_auth = false; + self.state = CredSspState::AuthInfo; + + return Ok(ServerState::ReplyNeeded(ts_request)); + } + let input = ts_request.nego_tokens.take().unwrap_or_default(); let mut input_token = vec![SecurityBuffer::new(input, BufferType::Token)]; let mut output_token = vec![SecurityBuffer::new(Vec::with_capacity(1024), BufferType::Token)]; @@ -628,42 +686,38 @@ impl + Send> CredSspServe self.context.as_mut().unwrap().sspi_context.complete_auth_token(&mut []), ts_request ); - ts_request.nego_tokens = None; - - let pub_key_auth = try_cred_ssp_server!( - ts_request.pub_key_auth.take().ok_or_else(|| { - Error::new( - ErrorKind::InvalidToken, - String::from("CredSSP server expected an encrypted public key"), - ) - }), - ts_request - ); - let peer_version = self.context.as_ref().unwrap().peer_version.expect( - "an decrypt public key server function cannot be fired without any incoming TSRequest", - ); - try_cred_ssp_server!( - self.context.as_mut().unwrap().decrypt_public_key( - self.public_key.as_ref(), - pub_key_auth.as_ref(), - EndpointType::Server, - &ts_request.client_nonce, - peer_version, - ), - ts_request - ); - let pub_key_auth = try_cred_ssp_server!( - self.context.as_mut().unwrap().encrypt_public_key( - self.public_key.as_ref(), - EndpointType::Server, - &ts_request.client_nonce, - peer_version, - ), - ts_request - ); - ts_request.pub_key_auth = Some(pub_key_auth); - self.state = CredSspState::AuthInfo; + let final_token = output_token.remove(0).buffer; + if ts_request.pub_key_auth.is_none() && !final_token.is_empty() { + // SPNEGO `mechListMIC` exchange ([MS-SPNG] 3.3.5.5 / RFC 4178 §5): the + // security context is established, but the client cannot send + // `pubKeyAuth` until it has received and verified our final SPNEGO + // token (accept-completed + `mechListMIC`), so that token must reach + // the wire instead of being dropped. `pubKeyAuth` arrives on the next + // leg. + ts_request.nego_tokens = Some(final_token); + self.awaiting_pub_key_auth = true; + } else { + ts_request.nego_tokens = None; + + let pub_key_auth = try_cred_ssp_server!( + ts_request.pub_key_auth.take().ok_or_else(|| { + Error::new( + ErrorKind::InvalidToken, + String::from("CredSSP server expected an encrypted public key"), + ) + }), + ts_request + ); + let client_nonce = ts_request.client_nonce; + let pub_key_auth = try_cred_ssp_server!( + self.exchange_pub_key_auth(pub_key_auth, &client_nonce), + ts_request + ); + ts_request.pub_key_auth = Some(pub_key_auth); + + self.state = CredSspState::AuthInfo; + } } result => { try_cred_ssp_server!( diff --git a/tests/sspi/client_server/credssp.rs b/tests/sspi/client_server/credssp.rs index 23dc8db6..cb869fe5 100644 --- a/tests/sspi/client_server/credssp.rs +++ b/tests/sspi/client_server/credssp.rs @@ -109,6 +109,54 @@ fn credssp_ntlm() { run_credssp(&mut client, &mut server, &auth_identity, &mut network_client); } +#[test] +fn credssp_negotiate_ntlm() { + // NTLM wrapped in SPNEGO on both sides — the pairing RDP requires (Windows RDP servers + // reject bare NTLM inside CredSSP). Because the client always includes an NTLM MIC, the + // SPNEGO `mechListMIC` exchange is mandatory ([MS-SPNG]): the client defers `pubKeyAuth` + // until it has verified the server's final SPNEGO token, so the handshake takes four + // client legs (NEGOTIATE, AUTHENTICATE+mechListMIC, pubKeyAuth, authInfo) instead of + // NTLM-only's three. Regression test for #687: the server used to drop its final SPNEGO + // token and demand `pubKeyAuth` on the AUTHENTICATE leg, failing the handshake. + let auth_identity = AuthIdentity { + username: Username::parse("test_user").unwrap(), + password: Secret::from("test_password".to_owned()), + }; + let credentials = Credentials::AuthIdentity(auth_identity.clone()); + + let mut client = CredSspClient::new( + PUBLIC_KEY.to_vec(), + credentials.clone(), + CredSspMode::WithCredentials, + ClientMode::Negotiate(NegotiateConfig::new( + Box::new(NtlmConfig { + client_computer_name: Some("DESKTOP-3D83IAN.example.com".to_owned()), + }), + Some("ntlm,!kerberos,!pku2u".to_owned()), + "DESKTOP-3D83IAN.example.com".to_owned(), + )), + TARGET_NAME.to_owned(), + ) + .unwrap(); + + let mut server = CredSspServer::new( + PUBLIC_KEY.to_vec(), + CredentialsProxyImpl::new(&auth_identity), + ServerMode::Negotiate(NegotiateConfig::new( + Box::new(NtlmConfig { + client_computer_name: Some("DESKTOP-3D83IAN.example.com".to_owned()), + }), + Some("ntlm,!kerberos,!pku2u".to_owned()), + "SERVER.example.com".to_owned(), + )), + ) + .unwrap(); + + let mut network_client = NetworkClientMock { kdc: KdcMock::empty() }; + + run_credssp(&mut client, &mut server, &auth_identity, &mut network_client); +} + #[test] fn credssp_kerberos() { // CredSSP with Kerberos inside requires SPNEGO. We cannot use Kerberos inside CredSSP without SPNEGO. From 25e54d1881a589fb9cfb14afcfbee1b574b452b4 Mon Sep 17 00:00:00 2001 From: Ki Hyun Park Date: Thu, 11 Jun 2026 09:50:16 +0900 Subject: [PATCH 2/3] style(credssp): apply rustfmt --- src/credssp/mod.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/credssp/mod.rs b/src/credssp/mod.rs index 2e871fc1..b162f9d9 100644 --- a/src/credssp/mod.rs +++ b/src/credssp/mod.rs @@ -513,7 +513,12 @@ impl + Send> CredSspServe client_nonce, peer_version, )?; - context.encrypt_public_key(self.public_key.as_ref(), EndpointType::Server, client_nonce, peer_version) + context.encrypt_public_key( + self.public_key.as_ref(), + EndpointType::Server, + client_nonce, + peer_version, + ) } #[instrument(fields(state = ?self.state), skip_all)] @@ -621,10 +626,8 @@ impl + Send> CredSspServe ts_request ); let client_nonce = ts_request.client_nonce; - let pub_key_auth = try_cred_ssp_server!( - self.exchange_pub_key_auth(pub_key_auth, &client_nonce), - ts_request - ); + let pub_key_auth = + try_cred_ssp_server!(self.exchange_pub_key_auth(pub_key_auth, &client_nonce), ts_request); ts_request.nego_tokens = None; ts_request.pub_key_auth = Some(pub_key_auth); From f8959ca980e2308b35917b2564d8e16d73504379 Mon Sep 17 00:00:00 2001 From: Ki Hyun Park Date: Thu, 11 Jun 2026 09:54:34 +0900 Subject: [PATCH 3/3] ci: enable __test-data so the client_server suite runs in CI The tests/sspi/client_server module is gated on all(network_client, __test-data), but no test-matrix row enabled __test-data - so the client<->server pairing tests (credssp_ntlm, credssp_kerberos, and the credssp_negotiate_ntlm regression test added in this PR) never ran in CI. That gap is how #687 shipped unnoticed. The suite is hermetic (KdcMock / NetworkClientMock, no real network), verified locally with the exact win-row feature combo (network_client,dns_resolver,scard,tsssp,__test-data) and the shared minimal combo (network_client,__test-data): all green. --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64c76f3f..112843d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,16 +101,18 @@ jobs: - manifest: crates/dpapi-native-transport/Cargo.toml crate-name: dpapi-native-transport - # Per-OS feature overrides for specific manifests + # Per-OS feature overrides for specific manifests. + # `__test-data` enables the `tests/sspi/client_server` suite (gated on + # `all(network_client, __test-data)`), which otherwise never runs in CI. - os: win manifest: Cargo.toml - additional-args: --features network_client,dns_resolver,scard,tsssp + additional-args: --features network_client,dns_resolver,scard,tsssp,__test-data - os: osx manifest: Cargo.toml - additional-args: --features network_client,scard + additional-args: --features network_client,scard,__test-data - os: linux manifest: Cargo.toml - additional-args: --features network_client,dns_resolver,scard + additional-args: --features network_client,dns_resolver,scard,__test-data - os: win manifest: ffi/Cargo.toml