use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use zeroize::Zeroizing;
use crate::auth::AuthTransport;
use crate::error::WinrmError;
use crate::ntlm;
pub(crate) struct NtlmAuth {
pub(crate) username: String,
pub(crate) password: Zeroizing<String>,
pub(crate) domain: String,
pub(crate) cert_handle: Option<crate::tls::CertHandle>,
}
const ENCRYPTED_BOUNDARY: &str = "--Encrypted Boundary";
const ENCRYPTED_CONTENT_TYPE: &str = "multipart/encrypted;protocol=\"application/HTTP-SPNEGO-session-encrypted\";boundary=\"Encrypted Boundary\"";
pub(crate) fn seal_body(session: &mut ntlm::NtlmSession, body: &str) -> (String, Vec<u8>) {
let sealed = session.seal(body.as_bytes());
let sig_len = 16u32;
let mut payload = Vec::new();
payload.extend_from_slice(&sig_len.to_le_bytes());
payload.extend_from_slice(&sealed);
let header_part = format!(
"{ENCRYPTED_BOUNDARY}\r\n\
\tContent-Type: application/HTTP-SPNEGO-session-encrypted\r\n\
\tOriginalContent: type=application/soap+xml;charset=UTF-8;Length={}\r\n\
{ENCRYPTED_BOUNDARY}\r\n\
\tContent-Type: application/octet-stream\r\n",
body.len()
);
let mut mime_body = header_part.into_bytes();
mime_body.extend_from_slice(&payload);
mime_body.extend_from_slice(format!("\r\n{ENCRYPTED_BOUNDARY}--\r\n").as_bytes());
(ENCRYPTED_CONTENT_TYPE.to_string(), mime_body)
}
pub(crate) fn unseal_body(
session: &mut ntlm::NtlmSession,
data: &[u8],
) -> Result<String, WinrmError> {
let marker = b"application/octet-stream\r\n";
let pos = data
.windows(marker.len())
.position(|w| w == marker)
.ok_or_else(|| {
WinrmError::AuthFailed("sealed response: missing octet-stream marker".into())
})?;
let encrypted_start = pos + marker.len();
if encrypted_start + 4 > data.len() {
return Err(WinrmError::AuthFailed("sealed response: truncated".into()));
}
let sig_len = u32::from_le_bytes([
data[encrypted_start],
data[encrypted_start + 1],
data[encrypted_start + 2],
data[encrypted_start + 3],
]) as usize;
let sealed_start = encrypted_start + 4;
let end_marker = format!("\r\n{ENCRYPTED_BOUNDARY}--").into_bytes();
let sealed_end = data[sealed_start..]
.windows(end_marker.len())
.position(|w| w == end_marker.as_slice())
.map_or(data.len(), |p| sealed_start + p);
let sealed_data = &data[sealed_start..sealed_end];
if sealed_data.len() < sig_len {
return Err(WinrmError::AuthFailed(
"sealed response: data too short for signature".into(),
));
}
let plaintext = session.unseal(sealed_data).map_err(WinrmError::Ntlm)?;
String::from_utf8(plaintext)
.map_err(|e| WinrmError::AuthFailed(format!("sealed response: invalid UTF-8: {e}")))
}
impl NtlmAuth {
pub(crate) async fn handshake_and_send(
&self,
http: &reqwest::Client,
url: &str,
body: &str,
) -> Result<(String, [u8; 16]), WinrmError> {
let (response, session_key) = self.do_handshake(http, url, body, false).await?;
Ok((response, session_key))
}
async fn do_handshake(
&self,
http: &reqwest::Client,
url: &str,
body: &str,
seal: bool,
) -> Result<(String, [u8; 16]), WinrmError> {
let type1 = ntlm::create_negotiate_message();
let auth_header = ntlm::encode_authorization(&type1);
let resp = http
.post(url)
.header(CONTENT_TYPE, "application/soap+xml;charset=UTF-8")
.header(AUTHORIZATION, &auth_header)
.header("Content-Length", "0")
.send()
.await
.map_err(WinrmError::Http)?;
if resp.status().as_u16() != 401 {
return Err(WinrmError::AuthFailed(format!(
"expected 401 for NTLM negotiate, got {}",
resp.status()
)));
}
let www_auth = resp
.headers()
.get("WWW-Authenticate")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| WinrmError::AuthFailed("missing WWW-Authenticate header in 401".into()))?
.to_string();
let _ = resp.bytes().await;
let type2_raw = {
let token = www_auth.strip_prefix("Negotiate ").ok_or_else(|| {
WinrmError::AuthFailed("missing Negotiate prefix in challenge".into())
})?;
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(token.trim_ascii())
.map_err(|e| WinrmError::AuthFailed(format!("base64 decode: {e}")))?
};
let challenge = ntlm::decode_challenge_header(&www_auth).map_err(WinrmError::Ntlm)?;
let domain = self.domain.clone();
let host_part = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.and_then(|s| s.split('/').next())
.unwrap_or(url);
let target_name = format!("http/{host_part}");
let (type3, session_key) =
if let Some(cert_der) = self.cert_handle.as_ref().and_then(|h| h.get()) {
let cbt = crate::ntlm::crypto::compute_channel_bindings(&cert_der);
ntlm::create_authenticate_message_with_cbt_and_key(
&challenge,
&self.username,
&self.password,
&domain,
cbt,
)
} else {
ntlm::create_authenticate_message_with_key_and_mic(
&challenge,
&self.username,
&self.password,
&domain,
&type1,
&type2_raw,
&target_name,
)
};
let auth_header = ntlm::encode_authorization(&type3);
let (content_type, request_body) = if seal {
let mut session = ntlm::NtlmSession::from_auth(&session_key);
let (ct, sealed) = seal_body(&mut session, body);
(ct, sealed)
} else {
(
"application/soap+xml;charset=UTF-8".to_string(),
body.as_bytes().to_vec(),
)
};
let resp = http
.post(url)
.header(CONTENT_TYPE, &content_type)
.header(AUTHORIZATION, &auth_header)
.body(request_body)
.send()
.await
.map_err(WinrmError::Http)?;
if resp.status().as_u16() == 401 {
return Err(WinrmError::AuthFailed(
"NTLM authentication rejected (bad credentials or CBT mismatch)".into(),
));
}
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.as_u16() == 500
&& let Err(soap_err) = crate::soap::parser::check_soap_fault(&text)
{
return Err(WinrmError::Soap(soap_err));
}
return Err(WinrmError::AuthFailed(format!("HTTP {status}: {text}")));
}
let response_text = resp.text().await.map_err(WinrmError::Http)?;
Ok((response_text, session_key))
}
}
impl AuthTransport for NtlmAuth {
async fn send_authenticated(
&self,
http: &reqwest::Client,
url: &str,
body: String,
) -> Result<String, WinrmError> {
let (response, _session_key) = self.do_handshake(http, url, &body, false).await?;
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ntlm::NtlmSession;
#[test]
fn seal_body_produces_multipart_format() {
let mut session = NtlmSession::from_auth(&[0xAB; 16]);
let body = "<soap>hello</soap>";
let (content_type, mime_body) = seal_body(&mut session, body);
assert!(content_type.contains("multipart/encrypted"));
assert!(content_type.contains("HTTP-SPNEGO-session-encrypted"));
assert!(content_type.contains("Encrypted Boundary"));
let body_str = String::from_utf8_lossy(&mime_body);
assert!(body_str.contains("--Encrypted Boundary"));
assert!(body_str.contains("application/HTTP-SPNEGO-session-encrypted"));
assert!(body_str.contains("application/octet-stream"));
assert!(body_str.contains(&format!("Length={}", body.len())));
}
#[test]
fn seal_body_includes_signature_length_prefix() {
let mut session = NtlmSession::from_auth(&[0xCD; 16]);
let (_, mime_body) = seal_body(&mut session, "test");
let marker = b"application/octet-stream\r\n";
let pos = mime_body
.windows(marker.len())
.position(|w| w == marker)
.expect("octet-stream marker present");
let sig_len_bytes = &mime_body[pos + marker.len()..pos + marker.len() + 4];
let sig_len = u32::from_le_bytes([
sig_len_bytes[0],
sig_len_bytes[1],
sig_len_bytes[2],
sig_len_bytes[3],
]);
assert_eq!(sig_len, 16, "NTLM signature is always 16 bytes");
}
#[test]
fn seal_body_ends_with_closing_boundary() {
let mut session = NtlmSession::from_auth(&[0xEF; 16]);
let (_, mime_body) = seal_body(&mut session, "x");
let body_str = String::from_utf8_lossy(&mime_body);
assert!(body_str.trim_end().ends_with("--Encrypted Boundary--"));
}
#[test]
fn unseal_body_sig_len_parsing_is_correct() {
let mut session = NtlmSession::from_auth(&[0x55; 16]);
let (_, sealed) = seal_body(&mut session, "test");
let marker = b"application/octet-stream\r\n";
let pos = sealed
.windows(marker.len())
.position(|w| w == marker)
.unwrap();
let sig_len_offset = pos + marker.len();
let sig_len = u32::from_le_bytes([
sealed[sig_len_offset],
sealed[sig_len_offset + 1],
sealed[sig_len_offset + 2],
sealed[sig_len_offset + 3],
]);
assert_eq!(sig_len, 16);
}
#[test]
fn unseal_body_rejects_short_data_with_exact_boundary() {
let mut session = NtlmSession::from_auth(&[0u8; 16]);
let mut bad = b"application/octet-stream\r\n".to_vec();
bad.extend_from_slice(&100u32.to_le_bytes()); bad.extend_from_slice(&[0xAA; 10]); bad.extend_from_slice(b"\r\n--Encrypted Boundary--\r\n");
let err = unseal_body(&mut session, &bad).unwrap_err();
assert!(format!("{err}").contains("too short"));
}
#[test]
fn unseal_body_rejects_missing_octet_stream_marker() {
let mut session = NtlmSession::from_auth(&[0u8; 16]);
let bad = b"no marker here";
let result = unseal_body(&mut session, bad);
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(err.contains("octet-stream marker"));
}
#[test]
fn unseal_body_rejects_truncated_signature_after_marker() {
let mut session = NtlmSession::from_auth(&[0u8; 16]);
let bad = b"application/octet-stream\r\n\x10\x00\x00";
let err = unseal_body(&mut session, bad).unwrap_err();
assert!(format!("{err}").contains("truncated"));
}
#[test]
fn unseal_body_rejects_data_too_short_for_signature() {
let mut session = NtlmSession::from_auth(&[0u8; 16]);
let mut data = b"application/octet-stream\r\n".to_vec();
data.extend_from_slice(&100u32.to_le_bytes());
data.extend_from_slice(format!("\r\n{ENCRYPTED_BOUNDARY}--").as_bytes());
let err = unseal_body(&mut session, &data).unwrap_err();
assert!(format!("{err}").contains("too short"));
}
#[test]
fn unseal_body_rejects_truncated_data() {
let mut session = NtlmSession::from_auth(&[0u8; 16]);
let bad = b"application/octet-stream\r\n\x01\x02";
let result = unseal_body(&mut session, bad);
assert!(result.is_err());
}
}