Skip to main content

winrm_rs/ntlm/
mod.rs

1//! NTLMv2 authentication for WinRM.
2//!
3//! Implements NTLM challenge/response per MS-NLMP. Structured as:
4//! - `crypto` -- hash functions, RC4, AV_PAIR parsing
5//! - `messages` -- Type 1/2/3 message construction and parsing
6
7pub(crate) mod crypto;
8pub(crate) mod messages;
9
10// Re-export crate-internal API
11pub(crate) use messages::{
12    create_authenticate_message_with_cbt_and_key, create_authenticate_message_with_key_and_mic,
13    create_negotiate_message, decode_challenge_header, encode_authorization,
14};
15// `parse_challenge` is only reached from outside `ntlm::messages` by the
16// CredSSP path and by fuzz targets via the internal feature. Tests inside
17// the module reach it through `super::`, so the reexport is feature-gated.
18#[cfg(any(feature = "credssp", feature = "__internal"))]
19#[allow(unreachable_pub)]
20// re-exported via lib.rs under `__internal`; used in-crate under `credssp`
21pub use messages::parse_challenge;
22#[cfg(feature = "credssp")]
23#[allow(unreachable_pub)] // used in-crate by auth/credssp.rs
24pub use messages::{create_authenticate_message_credssp, create_negotiate_message_credssp};
25
26// NtlmSession uses crypto internals
27use crate::error::NtlmError;
28use crypto::{Rc4State, hmac_md5};
29
30/// NTLM session state for message encryption/decryption after authentication.
31///
32/// Derived from the NTLMv2 authentication exchange per MS-NLMP section 3.4.4.
33/// Provides seal (encrypt+sign) and unseal (decrypt+verify) for WinRM
34/// message-level encryption over HTTP.
35///
36/// # Usage
37///
38/// After completing the NTLM handshake with
39/// `create_authenticate_message_with_key`, use the returned exported
40/// session key to create an `NtlmSession`:
41///
42/// ```ignore
43/// let (type3_msg, session_key) = create_authenticate_message_with_key(...);
44/// let mut session = NtlmSession::from_auth(&session_key);
45/// let sealed = session.seal(b"plaintext payload");
46/// ```
47///
48/// The actual integration into the HTTP transport (MIME multipart framing for
49/// encrypted payloads) is deferred to a future release.
50pub struct NtlmSession {
51    client_sign_key: [u8; 16],
52    #[allow(dead_code)] // Used for full checksum verification (future)
53    server_sign_key: [u8; 16],
54    client_seq_num: u32,
55    server_seq_num: u32,
56    client_seal_handle: Rc4State,
57    server_seal_handle: Rc4State,
58}
59
60impl NtlmSession {
61    /// Derive a session from the exported session key produced during
62    /// the NTLMv2 authentication exchange.
63    ///
64    /// Computes the four session keys (client/server seal/sign) per
65    /// MS-NLMP section 3.4.4 and initializes the RC4 cipher handles.
66    pub fn from_auth(exported_session_key: &[u8; 16]) -> Self {
67        let client_seal_key = Self::derive_key(
68            exported_session_key,
69            b"session key to client-to-server sealing key magic constant\0",
70        );
71        let client_sign_key = Self::derive_key(
72            exported_session_key,
73            b"session key to client-to-server signing key magic constant\0",
74        );
75        let server_seal_key = Self::derive_key(
76            exported_session_key,
77            b"session key to server-to-client sealing key magic constant\0",
78        );
79        let server_sign_key = Self::derive_key(
80            exported_session_key,
81            b"session key to server-to-client signing key magic constant\0",
82        );
83
84        Self {
85            client_sign_key,
86            server_sign_key,
87            client_seq_num: 0,
88            server_seq_num: 0,
89            client_seal_handle: Rc4State::new(&client_seal_key),
90            server_seal_handle: Rc4State::new(&server_seal_key),
91        }
92    }
93
94    fn derive_key(session_key: &[u8; 16], magic: &[u8]) -> [u8; 16] {
95        use md5::Digest;
96        let mut hasher = md5::Md5::new();
97        hasher.update(session_key);
98        hasher.update(magic);
99        let result = hasher.finalize();
100        let mut key = [0u8; 16];
101        key.copy_from_slice(&result);
102        key
103    }
104
105    /// Seal (encrypt + sign) a message for sending to the server.
106    ///
107    /// Returns `signature (16 bytes) || ciphertext`. The signature contains:
108    /// - Version (4 bytes, always 1)
109    /// - Encrypted HMAC-MD5 checksum (8 bytes)
110    /// - Sequence number (4 bytes, little-endian)
111    pub fn seal(&mut self, plaintext: &[u8]) -> Vec<u8> {
112        // 1. Compute signature: HMAC_MD5(sign_key, seq_num + plaintext)[0..8]
113        let mut sig_input = Vec::with_capacity(4 + plaintext.len());
114        sig_input.extend_from_slice(&self.client_seq_num.to_le_bytes());
115        sig_input.extend_from_slice(plaintext);
116        let checksum = hmac_md5(&self.client_sign_key, &sig_input);
117        let mut checksum_8 = [0u8; 8];
118        checksum_8.copy_from_slice(&checksum[..8]);
119
120        // 2. Encrypt the plaintext with RC4 FIRST (MS-NLMP 3.4.3 / _mac_with_ess
121        //    in pyspnego: seal() does rc4(plaintext) then sign() does rc4(checksum)).
122        let mut ciphertext = plaintext.to_vec();
123        self.client_seal_handle.process(&mut ciphertext);
124
125        // 3. Then encrypt the checksum with RC4 (consumes next bytes of keystream).
126        self.client_seal_handle.process(&mut checksum_8);
127
128        // 4. Build signature: Version(4) + Checksum(8) + SeqNum(4) = 16 bytes
129        let mut result = Vec::with_capacity(16 + ciphertext.len());
130        result.extend_from_slice(&1u32.to_le_bytes()); // version
131        result.extend_from_slice(&checksum_8);
132        result.extend_from_slice(&self.client_seq_num.to_le_bytes());
133
134        self.client_seq_num += 1;
135
136        result.extend_from_slice(&ciphertext);
137        result
138    }
139
140    /// Compute an NTLM signature over `data` (no encryption of payload).
141    /// Returns the 16-byte NTLMSSP_MESSAGE_SIGNATURE per MS-NLMP 3.4.4.1
142    /// (with extended session security + key exchange). Consumes 8 bytes of
143    /// the client RC4 keystream and increments the client sequence number.
144    pub fn sign(&mut self, data: &[u8]) -> [u8; 16] {
145        let mut sig_input = Vec::with_capacity(4 + data.len());
146        sig_input.extend_from_slice(&self.client_seq_num.to_le_bytes());
147        sig_input.extend_from_slice(data);
148        let checksum = hmac_md5(&self.client_sign_key, &sig_input);
149        let mut checksum_8 = [0u8; 8];
150        checksum_8.copy_from_slice(&checksum[..8]);
151        // KEY_EXCH path: encrypt the checksum with the client RC4 stream.
152        self.client_seal_handle.process(&mut checksum_8);
153
154        let mut sig = [0u8; 16];
155        sig[0..4].copy_from_slice(&1u32.to_le_bytes());
156        sig[4..12].copy_from_slice(&checksum_8);
157        sig[12..16].copy_from_slice(&self.client_seq_num.to_le_bytes());
158        self.client_seq_num += 1;
159        sig
160    }
161
162    /// Unseal (decrypt + verify) a message received from the server.
163    ///
164    /// Expects `sealed` to be `signature (16 bytes) || ciphertext`.
165    /// Verifies the signature version and sequence number. Returns the
166    /// decrypted plaintext.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`NtlmError::InvalidMessage`] if:
171    /// - The message is shorter than 16 bytes
172    /// - The signature version is not 1
173    /// - The sequence number does not match the expected value
174    /// - The HMAC-MD5 checksum does not match the expected value
175    pub fn unseal(&mut self, sealed: &[u8]) -> Result<Vec<u8>, NtlmError> {
176        if sealed.len() < 16 {
177            return Err(NtlmError::InvalidMessage("sealed message too short".into()));
178        }
179
180        let signature = &sealed[..16];
181        let ciphertext = &sealed[16..];
182
183        // Verify signature version (unencrypted field)
184        let version = u32::from_le_bytes([signature[0], signature[1], signature[2], signature[3]]);
185        if version != 1 {
186            return Err(NtlmError::InvalidMessage("bad signature version".into()));
187        }
188
189        // Verify sequence number (unencrypted field)
190        let sig_seq =
191            u32::from_le_bytes([signature[12], signature[13], signature[14], signature[15]]);
192        if sig_seq != self.server_seq_num {
193            return Err(NtlmError::InvalidMessage("sequence number mismatch".into()));
194        }
195
196        // Decrypt payload first (matches seal order: rc4(plaintext) then rc4(checksum)).
197        let mut plaintext = ciphertext.to_vec();
198        self.server_seal_handle.process(&mut plaintext);
199
200        // Then decrypt the checksum.
201        let mut sig_checksum = [0u8; 8];
202        sig_checksum.copy_from_slice(&signature[4..12]);
203        self.server_seal_handle.process(&mut sig_checksum);
204
205        // Verify HMAC-MD5 checksum against decrypted plaintext
206        let mut expected_sig_input = Vec::with_capacity(4 + plaintext.len());
207        expected_sig_input.extend_from_slice(&self.server_seq_num.to_le_bytes());
208        expected_sig_input.extend_from_slice(&plaintext);
209        let expected_checksum = hmac_md5(&self.server_sign_key, &expected_sig_input);
210        if sig_checksum != expected_checksum[..8] {
211            return Err(NtlmError::InvalidMessage("checksum mismatch".into()));
212        }
213
214        self.server_seq_num += 1;
215        Ok(plaintext)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn unhex(s: &str) -> Vec<u8> {
224        (0..s.len())
225            .step_by(2)
226            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
227            .collect()
228    }
229
230    #[test]
231    fn mic_hmac_md5_matches_pywinrm_vector() {
232        // Vector captured from pywinrm oracle:
233        let session_key = unhex("101112131415161718191a1b1c1d1e1f");
234        let neg = unhex(
235            "4e544c4d5353500001000000378208e200000000280000000000000028000000000c01000000000f",
236        );
237        let chal = unhex(
238            "4e544c4d53535000020000001e001e003800000035828ae28dc091106adfffd0000000000000000098009800560000000a00f4650000000f570049004e002d00540054005300540041004e005500510030003800530002001e00570049004e002d00540054005300540041004e005500510030003800530001001e00570049004e002d00540054005300540041004e005500510030003800530004001e00570049004e002d00540054005300540041004e005500510030003800530003001e00570049004e002d00540054005300540041004e00550051003000380053000700080000f8c4ba9dc7dc0100000000",
239        );
240        let auth = unhex(
241            "4e544c4d53535000030000001800180058000000f600f6007000000000000000660100000e000e00660100001e001e0074010000100010009201000035828ae2000c01000000000f000000000000000000000000000000000000000000000000000000000000000000000000000000008d3613113b1608b1afb92a5f0eb02477010100000000000000f8c4ba9dc7dc0120212223242526270000000002001e00570049004e002d00540054005300540041004e005500510030003800530001001e00570049004e002d00540054005300540041004e005500510030003800530004001e00570049004e002d00540054005300540041004e005500510030003800530003001e00570049004e002d00540054005300540041004e00550051003000380053000700080000f8c4ba9dc7dc010900220048005400540050002f003100390032002e003100360038002e00390036002e00310006000400020000000000000000000000760061006700720061006e00740050004f005300540045002d0046004900580045002d004c004f004900430017dc1c37061dbbea1b965421d8311908",
242        );
243        let expected = unhex("a235369e56d2a0fad48a755b6b4c63e6");
244        let mut input = Vec::new();
245        input.extend_from_slice(&neg);
246        input.extend_from_slice(&chal);
247        input.extend_from_slice(&auth);
248        let key: [u8; 16] = session_key.try_into().unwrap();
249        let mic = crypto::hmac_md5(&key, &input);
250        assert_eq!(mic.to_vec(), expected, "MIC mismatch");
251    }
252
253    #[test]
254    fn ntlm_session_keys_match_pywinrm_vector() {
255        // Vector captured from pywinrm/spnego with exported_session_key = 0x10..0x1f
256        let key: [u8; 16] = [
257            0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d,
258            0x1e, 0x1f,
259        ];
260        let cli_seal = NtlmSession::derive_key(
261            &key,
262            b"session key to client-to-server sealing key magic constant\0",
263        );
264        let srv_seal = NtlmSession::derive_key(
265            &key,
266            b"session key to server-to-client sealing key magic constant\0",
267        );
268        let cli_sign = NtlmSession::derive_key(
269            &key,
270            b"session key to client-to-server signing key magic constant\0",
271        );
272        let srv_sign = NtlmSession::derive_key(
273            &key,
274            b"session key to server-to-client signing key magic constant\0",
275        );
276        let h = |b: &[u8]| b.iter().map(|x| format!("{:02x}", x)).collect::<String>();
277        assert_eq!(
278            h(&cli_seal),
279            "af22a2127a4b090cccdfa26c427969c7",
280            "client seal key"
281        );
282        assert_eq!(
283            h(&srv_seal),
284            "b9e4af6ccd5f5edeb067d13815036db5",
285            "server seal key"
286        );
287        assert_eq!(
288            h(&cli_sign),
289            "a14c3d1e1b365279873f7dcf51aed29d",
290            "client sign key"
291        );
292        assert_eq!(
293            h(&srv_sign),
294            "dbfeaa5883b889757ff1d849f31d6d53",
295            "server sign key"
296        );
297    }
298
299    #[test]
300    fn sign_matches_pyspnego_mech_list_mic() {
301        let key: [u8; 16] = [
302            0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d,
303            0x1e, 0x1f,
304        ];
305        let mut s = NtlmSession::from_auth(&key);
306        let mech = unhex("300c060a2b06010401823702020a");
307        let sig = s.sign(&mech);
308        assert_eq!(
309            sig.to_vec(),
310            unhex("0100000002f81117bb3953f700000000"),
311            "mechListMIC mismatch"
312        );
313    }
314
315    #[test]
316    fn seal_matches_pywinrm_vector() {
317        let key: [u8; 16] = [
318            0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d,
319            0x1e, 0x1f,
320        ];
321        let mut session = NtlmSession::from_auth(&key);
322        let plaintext: Vec<u8> = (0u8..32).collect();
323        let sealed = session.seal(&plaintext);
324        let expected = unhex(
325            "010000000f62d40713d158d4000000001b23173031109ef42a884e223417c37909fada44f3180048ab67dc2d64ea9c41",
326        );
327        assert_eq!(sealed, expected, "seal output mismatch");
328    }
329
330    #[test]
331    fn ntlm_session_seal_unseal_roundtrip() {
332        let session_key: [u8; 16] = [
333            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
334            0x0F, 0x10,
335        ];
336
337        let mut session = NtlmSession::from_auth(&session_key);
338        let plaintext = b"Hello, WinRM! This is a test SOAP message.";
339        let sealed = session.seal(plaintext);
340
341        assert_eq!(sealed.len(), 16 + plaintext.len());
342
343        let version = u32::from_le_bytes(sealed[0..4].try_into().unwrap());
344        assert_eq!(version, 1);
345
346        let seq_num = u32::from_le_bytes(sealed[12..16].try_into().unwrap());
347        assert_eq!(seq_num, 0);
348
349        assert_ne!(&sealed[16..], &plaintext[..]);
350    }
351
352    #[test]
353    fn ntlm_session_seal_increments_sequence() {
354        let session_key: [u8; 16] = [0xAA; 16];
355        let mut session = NtlmSession::from_auth(&session_key);
356
357        let sealed1 = session.seal(b"message 1");
358        let sealed2 = session.seal(b"message 2");
359
360        let seq1 = u32::from_le_bytes(sealed1[12..16].try_into().unwrap());
361        let seq2 = u32::from_le_bytes(sealed2[12..16].try_into().unwrap());
362        assert_eq!(seq1, 0);
363        assert_eq!(seq2, 1);
364    }
365
366    #[test]
367    fn ntlm_session_unseal_too_short() {
368        let session_key: [u8; 16] = [0xBB; 16];
369        let mut session = NtlmSession::from_auth(&session_key);
370        let result = session.unseal(&[0u8; 10]);
371        assert!(result.is_err());
372        let err = format!("{}", result.unwrap_err());
373        assert!(err.contains("too short"));
374    }
375
376    #[test]
377    fn ntlm_session_unseal_bad_version() {
378        let session_key: [u8; 16] = [0xCC; 16];
379        let mut session = NtlmSession::from_auth(&session_key);
380        let mut fake = vec![0u8; 32];
381        fake[0..4].copy_from_slice(&2u32.to_le_bytes());
382        fake[12..16].copy_from_slice(&0u32.to_le_bytes());
383        let result = session.unseal(&fake);
384        assert!(result.is_err());
385        let err = format!("{}", result.unwrap_err());
386        assert!(err.contains("bad signature version"));
387    }
388
389    #[test]
390    fn ntlm_session_unseal_bad_sequence() {
391        let session_key: [u8; 16] = [0xDD; 16];
392        let mut session = NtlmSession::from_auth(&session_key);
393        let mut fake = vec![0u8; 32];
394        fake[0..4].copy_from_slice(&1u32.to_le_bytes());
395        fake[12..16].copy_from_slice(&99u32.to_le_bytes());
396        let result = session.unseal(&fake);
397        assert!(result.is_err());
398        let err = format!("{}", result.unwrap_err());
399        assert!(err.contains("sequence number mismatch"));
400    }
401
402    #[test]
403    fn ntlm_session_seal_unseal_symmetric() {
404        let session_key: [u8; 16] = [
405            0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0,
406            0xF0, 0x00,
407        ];
408
409        let mut client = NtlmSession::from_auth(&session_key);
410        let plaintext = b"SOAP envelope data for WinRM";
411        let sealed = client.seal(plaintext);
412
413        assert!(sealed.len() > 16);
414        let version = u32::from_le_bytes(sealed[0..4].try_into().unwrap());
415        assert_eq!(version, 1);
416
417        let mut client2 = NtlmSession::from_auth(&session_key);
418        let sealed2 = client2.seal(plaintext);
419        assert_eq!(sealed, sealed2);
420    }
421
422    #[test]
423    fn ntlm_session_unseal_exact_16_bytes() {
424        // Build a properly sealed empty message using client keys, then unseal
425        // by constructing the "server side" manually. Since seal uses client
426        // keys and unseal uses server keys, we simulate by sealing with
427        // a session where client keys match the other session's server keys.
428        // For simplicity, test that an invalid checksum on a 16-byte message
429        // is now correctly rejected.
430        let session_key: [u8; 16] = [0xEE; 16];
431        let mut session = NtlmSession::from_auth(&session_key);
432        let mut msg = vec![0u8; 16];
433        msg[0..4].copy_from_slice(&1u32.to_le_bytes());
434        msg[12..16].copy_from_slice(&0u32.to_le_bytes());
435        let result = session.unseal(&msg);
436        assert!(result.is_err(), "fake checksum should be rejected");
437        let err = format!("{}", result.unwrap_err());
438        assert!(err.contains("checksum mismatch"));
439    }
440
441    #[test]
442    fn ntlm_session_derive_key_is_deterministic() {
443        let key1 = [0x42u8; 16];
444        let key2 = [0x42u8; 16];
445        let mut s1 = NtlmSession::from_auth(&key1);
446        let mut s2 = NtlmSession::from_auth(&key2);
447
448        let sealed1 = s1.seal(b"test");
449        let sealed2 = s2.seal(b"test");
450        assert_eq!(
451            sealed1, sealed2,
452            "same key must produce identical sealed output"
453        );
454
455        let mut s3 = NtlmSession::from_auth(&[0u8; 16]);
456        let sealed3 = s3.seal(b"test");
457        assert_ne!(
458            sealed1, sealed3,
459            "different keys must produce different sealed output"
460        );
461
462        let mut s4 = NtlmSession::from_auth(&[1u8; 16]);
463        let sealed4 = s4.seal(b"test");
464        assert_ne!(
465            sealed1, sealed4,
466            "different keys must produce different sealed output"
467        );
468    }
469
470    #[test]
471    fn ntlm_session_multiple_seal_sequence_numbers() {
472        let key = [0xAA; 16];
473        let mut sealer = NtlmSession::from_auth(&key);
474
475        let msg1 = sealer.seal(b"first");
476        let msg2 = sealer.seal(b"second");
477
478        let seq1 = u32::from_le_bytes(msg1[12..16].try_into().unwrap());
479        let seq2 = u32::from_le_bytes(msg2[12..16].try_into().unwrap());
480        assert_eq!(seq1, 0);
481        assert_eq!(seq2, 1);
482    }
483
484    #[test]
485    fn ntlm_session_unseal_rejects_stale_sequence() {
486        let key = [0xAA; 16];
487        let mut session = NtlmSession::from_auth(&key);
488        // Fake message with seq_num=99 (expected 0)
489        let mut fake = vec![0u8; 20];
490        fake[0..4].copy_from_slice(&1u32.to_le_bytes());
491        fake[12..16].copy_from_slice(&99u32.to_le_bytes());
492        let result = session.unseal(&fake);
493        assert!(result.is_err(), "stale seq_num should be rejected");
494    }
495
496    #[test]
497    fn ntlm_session_unseal_rejects_tampered_checksum() {
498        let key = [0xBB; 16];
499        let mut session = NtlmSession::from_auth(&key);
500        // Fake message with valid version and seq_num but wrong checksum
501        let mut fake = vec![0u8; 32];
502        fake[0..4].copy_from_slice(&1u32.to_le_bytes());
503        fake[4..12].copy_from_slice(&[0xFF; 8]); // garbage checksum
504        fake[12..16].copy_from_slice(&0u32.to_le_bytes());
505        let result = session.unseal(&fake);
506        assert!(result.is_err(), "tampered checksum should be rejected");
507        let err = format!("{}", result.unwrap_err());
508        assert!(err.contains("checksum mismatch"));
509    }
510
511    // Kills ntlm/mod.rs:159 — sign: replace += with *= on client_seq_num
512    // *= 1 keeps seq at 0 forever; += 1 increments. After two signs,
513    // the sequence numbers embedded in the signatures must differ.
514    #[test]
515    fn sign_increments_sequence_number() {
516        let key = [0xDD; 16];
517        let mut session = NtlmSession::from_auth(&key);
518        let sig1 = session.sign(b"msg1");
519        let sig2 = session.sign(b"msg1"); // same data, different seq
520        let seq1 = u32::from_le_bytes(sig1[12..16].try_into().unwrap());
521        let seq2 = u32::from_le_bytes(sig2[12..16].try_into().unwrap());
522        assert_eq!(seq1, 0);
523        assert_eq!(seq2, 1);
524    }
525
526    // Note: unseal's server_seq_num += 1 mutant (line 215) survives because
527    // unseal uses server-side keys that differ from seal's client-side keys.
528    // Killing it requires a real WinRM server or a hand-crafted server-keyed message.
529}