use crate::error::Error;
const NTLMSSP_SIGNATURE: &[u8; 8] = b"NTLMSSP\0";
const MAX_TYPE2_SIZE: usize = 64 * 1024;
const NEGOTIATE_MESSAGE: u32 = 1;
const CHALLENGE_MESSAGE: u32 = 2;
const AUTHENTICATE_MESSAGE: u32 = 3;
#[allow(dead_code)] const NTLMSSP_NEGOTIATE_UNICODE: u32 = 0x0000_0001;
const NTLMSSP_NEGOTIATE_OEM: u32 = 0x0000_0002;
const NTLMSSP_REQUEST_TARGET: u32 = 0x0000_0004;
const NTLMSSP_NEGOTIATE_NTLM: u32 = 0x0000_0200;
const NTLMSSP_NEGOTIATE_ALWAYS_SIGN: u32 = 0x0000_8000;
const NTLMSSP_NEGOTIATE_NTLM2: u32 = 0x0008_0000;
const WORKSTATION: &str = "WORKSTATION";
#[derive(Debug, Clone)]
pub struct NtlmChallenge {
pub server_challenge: [u8; 8],
pub flags: u32,
pub target_info: Option<Vec<u8>>,
}
#[must_use]
pub fn create_type1_message() -> String {
use base64::Engine as _;
let flags = NTLMSSP_NEGOTIATE_OEM
| NTLMSSP_REQUEST_TARGET
| NTLMSSP_NEGOTIATE_NTLM
| NTLMSSP_NEGOTIATE_ALWAYS_SIGN
| NTLMSSP_NEGOTIATE_NTLM2;
let mut msg = Vec::with_capacity(32);
msg.extend_from_slice(NTLMSSP_SIGNATURE); msg.extend_from_slice(&NEGOTIATE_MESSAGE.to_le_bytes()); msg.extend_from_slice(&flags.to_le_bytes()); msg.extend_from_slice(&[0u8; 8]);
msg.extend_from_slice(&[0u8; 8]);
base64::engine::general_purpose::STANDARD.encode(&msg)
}
pub fn parse_type2_message(base64_msg: &str) -> Result<NtlmChallenge, Error> {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD
.decode(base64_msg.trim())
.map_err(|e| Error::Http(format!("NTLM Type 2 base64 decode failed: {e}")))?;
if data.len() < 32 {
return Err(Error::Http(format!("NTLM Type 2 message too short: {} bytes", data.len())));
}
if data.len() > MAX_TYPE2_SIZE {
return Err(Error::Protocol(100));
}
if &data[0..8] != NTLMSSP_SIGNATURE {
return Err(Error::Http("NTLM Type 2 invalid signature".to_string()));
}
let msg_type = u32::from_le_bytes([data[8], data[9], data[10], data[11]]);
if msg_type != CHALLENGE_MESSAGE {
return Err(Error::Http(format!("expected NTLM Type 2 (challenge), got type {msg_type}")));
}
let flags = u32::from_le_bytes([data[20], data[21], data[22], data[23]]);
let mut server_challenge = [0u8; 8];
server_challenge.copy_from_slice(&data[24..32]);
let target_info = if data.len() >= 48 {
let ti_len = u16::from_le_bytes([data[40], data[41]]) as usize;
let ti_offset = u32::from_le_bytes([data[44], data[45], data[46], data[47]]) as usize;
if ti_len > 0 && ti_offset + ti_len <= data.len() {
Some(data[ti_offset..ti_offset + ti_len].to_vec())
} else {
None
}
} else {
None
};
Ok(NtlmChallenge { server_challenge, flags, target_info })
}
const NTLM_BUFSIZE: usize = 1024;
const NTLM_TYPE3_HEADER_SIZE: usize = 64;
pub fn create_type3_message(
challenge: &NtlmChallenge,
username: &str,
password: &str,
domain: &str,
) -> Result<String, Error> {
use base64::Engine as _;
let nt_hash = compute_nt_hash(password);
let lm_hash = compute_lm_hash(password);
let lm_response = des_encrypt_challenge(&lm_hash, &challenge.server_challenge);
let nt_response = des_encrypt_challenge(&nt_hash, &challenge.server_challenge);
let domain_bytes = domain.as_bytes().to_vec();
let username_bytes = username.as_bytes().to_vec();
let workstation_bytes = WORKSTATION.as_bytes().to_vec();
let payload_size = NTLM_TYPE3_HEADER_SIZE
+ lm_response.len()
+ nt_response.len()
+ domain_bytes.len()
+ username_bytes.len()
+ workstation_bytes.len();
if payload_size >= NTLM_BUFSIZE {
return Err(Error::Transfer {
code: 100,
message: "user + domain + hostname too big for NTLM".to_string(),
});
}
let base_offset: u32 = 64;
let lm_offset = base_offset;
#[allow(clippy::cast_possible_truncation)]
let lm_len = lm_response.len() as u16;
let nt_offset = lm_offset + u32::from(lm_len);
#[allow(clippy::cast_possible_truncation)]
let nt_len = nt_response.len() as u16;
let domain_offset = nt_offset + u32::from(nt_len);
#[allow(clippy::cast_possible_truncation)]
let domain_len = domain_bytes.len() as u16;
let username_offset = domain_offset + u32::from(domain_len);
#[allow(clippy::cast_possible_truncation)]
let username_len = username_bytes.len() as u16;
let workstation_offset = username_offset + u32::from(username_len);
#[allow(clippy::cast_possible_truncation)]
let workstation_len = workstation_bytes.len() as u16;
let flags = challenge.flags | NTLMSSP_NEGOTIATE_OEM | NTLMSSP_NEGOTIATE_NTLM;
let mut msg = Vec::with_capacity(256);
msg.extend_from_slice(NTLMSSP_SIGNATURE);
msg.extend_from_slice(&AUTHENTICATE_MESSAGE.to_le_bytes());
msg.extend_from_slice(&lm_len.to_le_bytes());
msg.extend_from_slice(&lm_len.to_le_bytes());
msg.extend_from_slice(&lm_offset.to_le_bytes());
msg.extend_from_slice(&nt_len.to_le_bytes());
msg.extend_from_slice(&nt_len.to_le_bytes());
msg.extend_from_slice(&nt_offset.to_le_bytes());
msg.extend_from_slice(&domain_len.to_le_bytes());
msg.extend_from_slice(&domain_len.to_le_bytes());
msg.extend_from_slice(&domain_offset.to_le_bytes());
msg.extend_from_slice(&username_len.to_le_bytes());
msg.extend_from_slice(&username_len.to_le_bytes());
msg.extend_from_slice(&username_offset.to_le_bytes());
msg.extend_from_slice(&workstation_len.to_le_bytes());
msg.extend_from_slice(&workstation_len.to_le_bytes());
msg.extend_from_slice(&workstation_offset.to_le_bytes());
msg.extend_from_slice(&[0u8; 8]);
msg.extend_from_slice(&flags.to_le_bytes());
msg.extend_from_slice(&lm_response);
msg.extend_from_slice(&nt_response);
msg.extend_from_slice(&domain_bytes);
msg.extend_from_slice(&username_bytes);
msg.extend_from_slice(&workstation_bytes);
Ok(base64::engine::general_purpose::STANDARD.encode(&msg))
}
fn compute_nt_hash(password: &str) -> [u8; 16] {
use md4::{Digest as _, Md4};
let password_utf16 = to_utf16le(password);
let mut hasher = Md4::new();
hasher.update(&password_utf16);
let result = hasher.finalize();
let mut hash = [0u8; 16];
hash.copy_from_slice(&result);
hash
}
fn compute_lm_hash(password: &str) -> [u8; 16] {
let magic = b"KGS!@#$%";
let mut pwd_bytes = [0u8; 14];
for (i, &b) in password.as_bytes().iter().take(14).enumerate() {
pwd_bytes[i] = b.to_ascii_uppercase();
}
let key1 = des_key_from_7(&pwd_bytes[0..7]);
let key2 = des_key_from_7(&pwd_bytes[7..14]);
let mut hash = [0u8; 16];
hash[..8].copy_from_slice(&des_ecb_encrypt(&key1, magic));
hash[8..].copy_from_slice(&des_ecb_encrypt(&key2, magic));
hash
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn des_encrypt_challenge(hash: &[u8; 16], challenge: &[u8; 8]) -> Vec<u8> {
let mut padded = [0u8; 21];
padded[..16].copy_from_slice(hash);
let key1 = des_key_from_7(&padded[0..7]);
let key2 = des_key_from_7(&padded[7..14]);
let key3 = des_key_from_7(&padded[14..21]);
let mut response = Vec::with_capacity(24);
response.extend_from_slice(&des_ecb_encrypt(&key1, challenge));
response.extend_from_slice(&des_ecb_encrypt(&key2, challenge));
response.extend_from_slice(&des_ecb_encrypt(&key3, challenge));
response
}
fn des_key_from_7(src: &[u8]) -> [u8; 8] {
[
src[0],
(src[0] << 7) | (src[1] >> 1),
(src[1] << 6) | (src[2] >> 2),
(src[2] << 5) | (src[3] >> 3),
(src[3] << 4) | (src[4] >> 4),
(src[4] << 3) | (src[5] >> 5),
(src[5] << 2) | (src[6] >> 6),
src[6] << 1,
]
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn des_ecb_encrypt(key: &[u8; 8], plaintext: &[u8; 8]) -> [u8; 8] {
use cipher::{BlockEncrypt as _, KeyInit as _};
use des::Des;
#[allow(clippy::expect_used)]
let cipher = Des::new_from_slice(key).expect("DES accepts 8-byte keys");
let mut block = cipher::generic_array::GenericArray::clone_from_slice(plaintext);
cipher.encrypt_block(&mut block);
let mut out = [0u8; 8];
out.copy_from_slice(&block);
out
}
fn to_utf16le(s: &str) -> Vec<u8> {
s.encode_utf16().flat_map(u16::to_le_bytes).collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn type1_message_matches_curl() {
use base64::Engine as _;
let msg = create_type1_message();
let data = base64::engine::general_purpose::STANDARD.decode(&msg).unwrap();
assert_eq!(&data[0..8], NTLMSSP_SIGNATURE);
let msg_type = u32::from_le_bytes([data[8], data[9], data[10], data[11]]);
assert_eq!(msg_type, NEGOTIATE_MESSAGE);
let flags = u32::from_le_bytes([data[12], data[13], data[14], data[15]]);
assert_eq!(flags, 0x0008_8206, "Type1 flags must match curl");
assert_eq!(flags & NTLMSSP_NEGOTIATE_UNICODE, 0);
assert_ne!(flags & NTLMSSP_NEGOTIATE_OEM, 0);
assert_ne!(flags & NTLMSSP_REQUEST_TARGET, 0);
assert_ne!(flags & NTLMSSP_NEGOTIATE_NTLM, 0);
assert_ne!(flags & NTLMSSP_NEGOTIATE_ALWAYS_SIGN, 0);
assert_ne!(flags & NTLMSSP_NEGOTIATE_NTLM2, 0);
assert_eq!(data.len(), 32);
}
#[test]
fn type1_base64_matches_curl_test() {
let msg = create_type1_message();
let expected = "TlRMTVNTUAABAAAABoIIAAAAAAAAAAAAAAAAAAAAAAA=";
assert_eq!(msg, expected, "Type1 base64 must match curl test expectation");
}
#[test]
fn type2_parse_valid_message() {
use base64::Engine as _;
let mut msg = Vec::new();
msg.extend_from_slice(NTLMSSP_SIGNATURE); msg.extend_from_slice(&CHALLENGE_MESSAGE.to_le_bytes()); msg.extend_from_slice(&[0u8; 8]);
let flags: u32 = NTLMSSP_NEGOTIATE_NTLM | NTLMSSP_NEGOTIATE_UNICODE;
msg.extend_from_slice(&flags.to_le_bytes());
msg.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
let encoded = base64::engine::general_purpose::STANDARD.encode(&msg);
let challenge = parse_type2_message(&encoded).unwrap();
assert_eq!(challenge.server_challenge, [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
assert_ne!(challenge.flags & NTLMSSP_NEGOTIATE_NTLM, 0);
}
#[test]
fn type2_parse_too_short() {
use base64::Engine as _;
let data = vec![0u8; 16]; let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
assert!(parse_type2_message(&encoded).is_err());
}
#[test]
fn type2_parse_bad_signature() {
use base64::Engine as _;
let mut data = vec![0u8; 32];
data[0..8].copy_from_slice(b"BADSSIG\0");
let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
assert!(parse_type2_message(&encoded).is_err());
}
#[test]
fn type2_parse_wrong_message_type() {
use base64::Engine as _;
let mut msg = Vec::new();
msg.extend_from_slice(NTLMSSP_SIGNATURE);
msg.extend_from_slice(&NEGOTIATE_MESSAGE.to_le_bytes()); msg.extend_from_slice(&[0u8; 20]); let encoded = base64::engine::general_purpose::STANDARD.encode(&msg);
assert!(parse_type2_message(&encoded).is_err());
}
#[test]
fn type3_message_is_valid() {
use base64::Engine as _;
let challenge = NtlmChallenge {
server_challenge: [0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef],
flags: NTLMSSP_NEGOTIATE_NTLM | NTLMSSP_NEGOTIATE_OEM,
target_info: None,
};
let msg = create_type3_message(&challenge, "user", "password", "DOMAIN").unwrap();
let data = base64::engine::general_purpose::STANDARD.decode(&msg).unwrap();
assert_eq!(&data[0..8], NTLMSSP_SIGNATURE);
let msg_type = u32::from_le_bytes([data[8], data[9], data[10], data[11]]);
assert_eq!(msg_type, AUTHENTICATE_MESSAGE);
let lm_len = u16::from_le_bytes([data[12], data[13]]);
assert_eq!(lm_len, 24, "LM response must be 24 bytes (NTLMv1)");
let nt_len = u16::from_le_bytes([data[20], data[21]]);
assert_eq!(nt_len, 24, "NT response must be 24 bytes (NTLMv1)");
assert!(data.len() > 72);
}
#[test]
fn type3_uses_oem_encoding() {
use base64::Engine as _;
let challenge = NtlmChallenge {
server_challenge: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08],
flags: NTLMSSP_NEGOTIATE_NTLM | NTLMSSP_NEGOTIATE_OEM,
target_info: None,
};
let msg = create_type3_message(&challenge, "testuser", "testpass", "").unwrap();
let data = base64::engine::general_purpose::STANDARD.decode(&msg).unwrap();
let usr_len = u16::from_le_bytes([data[36], data[37]]) as usize;
let usr_off = u32::from_le_bytes([data[40], data[41], data[42], data[43]]) as usize;
let usr = &data[usr_off..usr_off + usr_len];
assert_eq!(usr, b"testuser", "Username must be OEM-encoded ASCII");
let ws_len = u16::from_le_bytes([data[44], data[45]]) as usize;
let ws_off = u32::from_le_bytes([data[48], data[49], data[50], data[51]]) as usize;
let ws = &data[ws_off..ws_off + ws_len];
assert_eq!(ws, b"WORKSTATION");
}
#[test]
fn type3_matches_curl_test_vector() {
use base64::Engine as _;
let expected_b64 = "TlRMTVNTUAADAAAAGAAYAEAAAAAYABgAWAAAAAAAAABwAAAACAAIAHAAAAALAAsAeAAAAAAAAAAAAAAAhoIBAFpkQwKRCZFMhjj0tw47wEjKHRHlvzfxQamFcheMuv8v+xeqphEO5V41xRd7R9deOXRlc3R1c2VyV09SS1NUQVRJT04=";
let expected = base64::engine::general_purpose::STANDARD.decode(expected_b64).unwrap();
let type2_b64 = "TlRMTVNTUAACAAAAAgACADAAAACGggEAc51AYVDgyNcAAAAAAAAAAG4AbgAyAAAAQ0MCAAQAQwBDAAEAEgBFAEwASQBTAEEAQgBFAFQASAAEABgAYwBjAC4AaQBjAGUAZABlAHYALgBuAHUAAwAsAGUAbABpAHMAYQBiAGUAdABoAC4AYwBjAC4AaQBjAGUAZABlAHYALgBuAHUAAAAAAA==";
let challenge = parse_type2_message(type2_b64).unwrap();
let msg = create_type3_message(&challenge, "testuser", "testpass", "").unwrap();
let actual = base64::engine::general_purpose::STANDARD.decode(&msg).unwrap();
assert_eq!(actual.len(), expected.len(), "Type3 message length must match");
assert_eq!(&actual[60..64], &expected[60..64], "Flags must match");
assert_eq!(&actual[28..36], &expected[28..36], "Domain fields must match");
assert_eq!(&actual[36..44], &expected[36..44], "Username fields must match");
assert_eq!(&actual[44..52], &expected[44..52], "Workstation fields must match");
assert_eq!(&actual[112..120], b"testuser", "Username data must be 'testuser'");
assert_eq!(&actual[120..131], b"WORKSTATION", "Workstation data must be 'WORKSTATION'");
assert_eq!(
&actual[64..88],
&expected[64..88],
"LM response must match (NTLMv1 is deterministic)"
);
assert_eq!(
&actual[88..112],
&expected[88..112],
"NT response must match (NTLMv1 is deterministic)"
);
}
#[test]
fn type3_with_domain_matches_curl_test91() {
use base64::Engine as _;
let expected_b64 = "TlRMTVNTUAADAAAAGAAYAEAAAAAYABgAWAAAAAgACABwAAAABgAGAHgAAAALAAsAfgAAAAAAAAAAAAAAhoIBAMIyJpR5mHpg2FZha5kRaFZ9436GAxPu0C5llxexSQ5QzVkiLSfkcpVyRgCXXqR+Am15ZG9tYWlubXlzZWxmV09SS1NUQVRJT04=";
let expected = base64::engine::general_purpose::STANDARD.decode(expected_b64).unwrap();
let type2_b64 = "TlRMTVNTUAACAAAAAgACADAAAACGggEAc51AYVDgyNcAAAAAAAAAAG4AbgAyAAAAQ0MCAAQAQwBDAAEAEgBFAEwASQBTAEEAQgBFAFQASAAEABgAYwBjAC4AaQBjAGUAZABlAHYALgBuAHUAAwAsAGUAbABpAHMAYQBiAGUAdABoAC4AYwBjAC4AaQBjAGUAZABlAHYALgBuAHUAAAAAAA==";
let challenge = parse_type2_message(type2_b64).unwrap();
let msg = create_type3_message(&challenge, "myself", "secret", "mydomain").unwrap();
let actual = base64::engine::general_purpose::STANDARD.decode(&msg).unwrap();
assert_eq!(actual.len(), expected.len(), "Type3 length must match for test 91");
assert_eq!(actual, expected, "Type3 must match curl test 91 exactly");
}
#[test]
fn type3_different_credentials_differ() {
let challenge = NtlmChallenge {
server_challenge: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08],
flags: NTLMSSP_NEGOTIATE_NTLM,
target_info: None,
};
let msg1 = create_type3_message(&challenge, "user1", "pass", "DOM").unwrap();
let msg2 = create_type3_message(&challenge, "user2", "pass", "DOM").unwrap();
assert_ne!(msg1, msg2);
}
#[test]
fn utf16le_encoding() {
let bytes = to_utf16le("AB");
assert_eq!(bytes, vec![0x41, 0x00, 0x42, 0x00]);
}
#[test]
fn utf16le_empty() {
let bytes = to_utf16le("");
assert!(bytes.is_empty());
}
#[test]
fn lm_hash_known_value() {
let hash = compute_lm_hash("");
assert_eq!(hash.len(), 16);
assert_eq!(
hex::encode(&hash[..8]),
"aad3b435b51404ee",
"First half of LM hash for empty password"
);
}
#[test]
fn des_key_expansion() {
let input = [0xFF_u8; 7];
let key = des_key_from_7(&input);
assert_eq!(key.len(), 8);
}
#[test]
fn des_encrypt_produces_24_bytes() {
let hash = compute_nt_hash("password");
let challenge = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
let response = des_encrypt_challenge(&hash, &challenge);
assert_eq!(response.len(), 24, "NTLMv1 response must be 24 bytes");
}
#[test]
fn roundtrip_type1_type2_type3() {
use base64::Engine as _;
let type1 = create_type1_message();
let type1_data = base64::engine::general_purpose::STANDARD.decode(&type1).unwrap();
assert_eq!(
u32::from_le_bytes([type1_data[8], type1_data[9], type1_data[10], type1_data[11]]),
NEGOTIATE_MESSAGE
);
let challenge = NtlmChallenge {
server_challenge: [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11],
flags: NTLMSSP_NEGOTIATE_NTLM | NTLMSSP_NEGOTIATE_OEM,
target_info: None,
};
let type3 = create_type3_message(&challenge, "admin", "secret", "WORKGROUP").unwrap();
let type3_data = base64::engine::general_purpose::STANDARD.decode(&type3).unwrap();
assert_eq!(
u32::from_le_bytes([type3_data[8], type3_data[9], type3_data[10], type3_data[11]]),
AUTHENTICATE_MESSAGE
);
}
#[test]
fn type3_too_long_username_returns_error() {
let challenge = NtlmChallenge {
server_challenge: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08],
flags: NTLMSSP_NEGOTIATE_NTLM | NTLMSSP_NEGOTIATE_OEM,
target_info: None,
};
let long_user = format!("testuser{}", "A".repeat(1100));
let result = create_type3_message(&challenge, &long_user, "testpass", "");
assert!(result.is_err(), "Too-long username must return an error");
}
#[test]
fn nt_hash_known_value() {
let hash = compute_nt_hash("Password");
assert_eq!(hash.len(), 16);
assert_ne!(hash, [0u8; 16]);
}
#[test]
fn nt_hash_empty_password() {
let hash = compute_nt_hash("");
assert_eq!(hash.len(), 16);
assert_ne!(hash, [0u8; 16]);
}
}