//! Managed NTLMv2 client context for DCE/RPC + packet integrity. //! //! Direct port of `src/MxNativeClient/ManagedNtlmClientContext.cs`. Produces //! Type1 (NEGOTIATE), Type3 (AUTHENTICATE) messages, the NTLMv2 //! `NTProofStr`/`NTLMv2Response` blobs, and the 16-byte per-message //! [`Ntlm1`-style] signature used for `auth_value` in the RPC `auth_verifier` //! trailer. //! //! # Wire-byte parity //! //! Every field offset, magic constant, and key-derivation magic string in this //! module cites the exact line in the .NET reference. The .NET reference is the //! executable spec per CLAUDE.md, so the Rust port mirrors it 1:1; cross-spec //! references to `[MS-NLMP]` are added only where the .NET code itself relies //! on them implicitly (e.g. signature/seal magic strings, AV-pair IDs). //! //! # Type1 (NEGOTIATE) layout //! //! Per `ManagedNtlmClientContext.cs:51-70`: //! //! ```text //! offset size field //! 0 8 "NTLMSSP\0" ASCII signature //! 8 4 message_type u32 LE = 1 //! 12 4 negotiate_flags u32 LE //! 16 16 zero (we do not advertise domain/workstation in Type1) //! ``` //! //! Total length: 32 bytes. //! //! # Type3 (AUTHENTICATE) layout //! //! Per `ManagedNtlmClientContext.cs:72-112`. Header is 64 bytes; payloads //! follow in this fixed order: `lm_response`, `nt_response`, `domain`, `user`, //! `workstation`, `encrypted_session_key`. Each gets an 8-byte security buffer //! (length, max length, payload offset). //! //! ```text //! offset size field //! 0 8 "NTLMSSP\0" //! 8 4 message_type u32 LE = 3 //! 12 8 LmResponseFields security buffer //! 20 8 NtResponseFields security buffer //! 28 8 DomainNameFields security buffer //! 36 8 UserNameFields security buffer //! 44 8 WorkstationFields security buffer //! 52 8 EncryptedRandomSessionKeyFields security buffer //! 60 4 negotiate_flags u32 LE //! 64+ payload bytes... //! ``` //! //! # NTLMv2 challenge-response (per `ManagedNtlmClientContext.cs:72-95`) //! //! ```text //! ResponseKeyNT = HMAC_MD5(NT_HASH(password), UNICODE(uppercase(user) || domain)) //! Temp = 0x01 0x01 0x00 0x00 0x00 0x00 0x00 0x00 //! || timestamp_filetime_le_64 //! || client_challenge[8] //! || 0x00 0x00 0x00 0x00 //! || target_info(server augmented with cifs/ + timestamp) //! NTProofStr = HMAC_MD5(ResponseKeyNT, server_challenge[8] || Temp) //! NtChallengeResponse = NTProofStr || Temp //! LmChallengeResponse = HMAC_MD5(ResponseKeyNT, server_challenge || client_challenge) //! || client_challenge //! SessionBaseKey = HMAC_MD5(ResponseKeyNT, NTProofStr) //! ExportedSessionKey = 16 random bytes (the session key the server will see //! after RC4-decrypting EncryptedRandomSessionKey) //! EncryptedRandomSessionKey = RC4(SessionBaseKey).Transform(ExportedSessionKey) //! ``` //! //! Signing/sealing keys are derived from `ExportedSessionKey` plus the magic //! strings at `ManagedNtlmClientContext.cs:179-191`. //! //! # Sign() — packet-integrity signature (`ManagedNtlmClientContext.cs:114-132`) //! //! ```text //! sequence_bytes_le32 = u32 LE of self.sequence //! digest = HMAC_MD5(client_signing_key, sequence_bytes_le32 || message) //! checksum = RC4(client_sealing_handle).Transform(digest[..8]) //! signature = u32 LE 1 // version //! || checksum // 8 bytes //! || u32 LE sequence //! self.sequence += 1 //! ``` //! //! Length: 16 bytes. Note the .NET code feeds **only the first 8 bytes** of //! the HMAC digest through RC4 (`ManagedNtlmClientContext.cs:124`), not the //! full 16; the Rust port matches. // Direct byte indexing is the right pattern for fixed-layout codec code: // every byte access is preceded by an explicit length check. See // `mxaccess-codec/src/reference_handle.rs` for the same allow + rationale. #![allow(clippy::indexing_slicing)] use hmac::{Hmac, Mac}; use md4::Md4; use md5::{Digest, Md5}; use rc4::{KeyInit, Rc4, StreamCipher}; use thiserror::Error; type HmacMd5 = Hmac; /// All RC4 keys in this module are 16 bytes (MD5 output). The /// `rc4 = "0.2"` API takes the key as a runtime slice (no generic /// key-size parameter — that was reworked in 0.2 vs the older 0.1 /// `Rc4` form). The alias is kept for documentation only. type Rc4_16 = Rc4; // --- NEGOTIATE flag constants — `ManagedNtlmClientContext.cs:10-21` --- /// `NTLMSSP_NEGOTIATE_UNICODE` — `cs:10`. pub const NEGOTIATE_UNICODE: u32 = 0x00000001; /// `NTLMSSP_REQUEST_TARGET` — `cs:11`. pub const REQUEST_TARGET: u32 = 0x00000004; /// `NTLMSSP_NEGOTIATE_SIGN` — `cs:12`. pub const NEGOTIATE_SIGN: u32 = 0x00000010; /// `NTLMSSP_NEGOTIATE_SEAL` — `cs:13`. pub const NEGOTIATE_SEAL: u32 = 0x00000020; /// `NTLMSSP_NEGOTIATE_NTLM` — `cs:14`. pub const NEGOTIATE_NTLM: u32 = 0x00000200; /// `NTLMSSP_NEGOTIATE_ALWAYS_SIGN` — `cs:15`. pub const NEGOTIATE_ALWAYS_SIGN: u32 = 0x00008000; /// `NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY` — `cs:16`. pub const NEGOTIATE_EXTENDED_SESSION_SECURITY: u32 = 0x00080000; /// `NTLMSSP_NEGOTIATE_TARGET_INFO` — `cs:17`. pub const NEGOTIATE_TARGET_INFO: u32 = 0x00800000; /// `NTLMSSP_NEGOTIATE_VERSION` — `cs:18`. pub const NEGOTIATE_VERSION: u32 = 0x02000000; /// `NTLMSSP_NEGOTIATE_128` — `cs:19`. pub const NEGOTIATE_128: u32 = 0x20000000; /// `NTLMSSP_NEGOTIATE_KEY_EXCH` — `cs:20`. pub const NEGOTIATE_KEY_EXCHANGE: u32 = 0x40000000; /// `NTLMSSP_NEGOTIATE_56` — `cs:21`. pub const NEGOTIATE_56: u32 = 0x80000000; /// Flags advertised by the .NET client in Type1. Mirrors /// `ManagedNtlmClientContext.cs:53-63`. **Note**: `NEGOTIATE_VERSION` is /// listed in the .cs constants (`cs:18`) but is *not* OR-ed into Type1; the /// Rust port matches that omission exactly. pub const TYPE1_FLAGS: u32 = NEGOTIATE_KEY_EXCHANGE | NEGOTIATE_SIGN | NEGOTIATE_ALWAYS_SIGN | NEGOTIATE_SEAL | NEGOTIATE_TARGET_INFO | NEGOTIATE_NTLM | NEGOTIATE_EXTENDED_SESSION_SECURITY | NEGOTIATE_UNICODE | REQUEST_TARGET | NEGOTIATE_128 | NEGOTIATE_56; /// 8-byte ASCII signature `"NTLMSSP\0"` shared by all NTLMSSP messages /// (`ManagedNtlmClientContext.cs:66`, `:100`, `:230`). pub const NTLMSSP_SIGNATURE: [u8; 8] = *b"NTLMSSP\0"; /// AV pair ID `MsvAvDnsComputerName` per `[MS-NLMP] §2.2.2.1` — /// .NET reads this at `ManagedNtlmClientContext.cs:148`. const AV_ID_DNS_HOST: u16 = 3; /// AV pair ID `MsvAvTimestamp` per `[MS-NLMP] §2.2.2.1` — .NET checks this /// at `cs:156`. const AV_ID_TIMESTAMP: u16 = 7; /// AV pair ID `MsvAvTargetName` per `[MS-NLMP] §2.2.2.1` — .NET writes this /// at `cs:152-153`. const AV_ID_TARGET_NAME: u16 = 9; /// AV pair terminator (id, length, both u16 = 0) — .NET writes 4 trailing /// zeros at `cs:173`. const AV_ID_EOL: u16 = 0; /// Type1 message header is exactly 32 bytes — `cs:65`. pub const TYPE1_LEN: usize = 32; /// Type3 message header (before security-buffer payloads) — `cs:97`. pub const TYPE3_HEADER_LEN: usize = 64; /// Type2 challenge minimum length — `cs:230`. pub const TYPE2_MIN_LEN: usize = 48; /// 16-byte signature length per `cs:126`. pub const SIGNATURE_LEN: usize = 16; /// Errors produced while building or consuming NTLM messages. Modelled after /// `mxaccess-codec`'s `CodecError` (see `crates/mxaccess-codec/src/error.rs`). #[derive(Debug, Error)] #[non_exhaustive] pub enum NtlmError { /// Buffer was shorter than required. #[error("short read: expected {expected} bytes, got {actual}")] ShortRead { expected: usize, actual: usize }, /// First eight bytes were not the NTLMSSP signature, or the message_type /// did not match the expected value. #[error("invalid NTLMSSP signature or message type")] InvalidSignature, /// Type2 declared a target_info offset/length that ran past the buffer. #[error("NTLM challenge target-info buffer is invalid")] InvalidTargetInfo, /// AV-pair declared a length that ran past the supplied buffer. #[error("NTLM AV-pair buffer is truncated")] TruncatedAvPair, /// `Sign` was called before `create_type3` produced signing/sealing keys. #[error("NTLM context has not completed Type3 negotiation")] NotAuthenticated, /// `verify_signature` was called with a signature that did not match the /// expected bytes. #[error("NTLM signature mismatch")] SignatureMismatch, /// [`NtlmClientContext::from_env`] could not find a required env var. /// Mirrors the .NET `ArgumentNullException` paths in /// `ManagedNtlmClientContext.FromEnvironment()` (`cs:43-48`). #[error("missing environment variable: {name}")] MissingEnvVar { name: &'static str }, } /// Look up the local machine name. Mirrors `Environment.MachineName` /// (`ManagedNtlmClientContext.cs:38`). /// /// Cross-platform: checks `COMPUTERNAME` (Windows) then `HOSTNAME` /// (POSIX). Returns the empty string when neither is set — same /// "unavailable" semantics as `Environment.MachineName` returning /// `null` on platforms where it can't read the host name. /// /// This deliberately doesn't call `gethostname(2)` to keep /// `mxaccess-rpc` free of native-libc dependencies and `unsafe`. For /// reliable hostname discovery on POSIX where `HOSTNAME` isn't /// exported (the common case in Bash subshells), callers can pass /// `Some(&hostname_string)` to [`NtlmClientContext::new`] explicitly. #[must_use] pub fn local_hostname() -> String { std::env::var("COMPUTERNAME") .or_else(|_| std::env::var("HOSTNAME")) .unwrap_or_default() } /// Trait for supplying the random/clock inputs the .NET reference reads from /// `RandomNumberGenerator.GetBytes` and `DateTimeOffset.UtcNow.ToFileTime`. /// /// The default implementation lives at [`OsInputs`] and uses `rand::OsRng` + /// `std::time::SystemTime`. Tests inject a deterministic implementation via /// [`FixedInputs`] so the resulting Type3 / Sign bytes are reproducible. pub trait NtlmInputs { /// 8 bytes of client challenge — `cs:77`. fn client_challenge(&mut self) -> [u8; 8]; /// 16 bytes of `ExportedSessionKey` — `cs:87`. fn exported_session_key(&mut self) -> [u8; 16]; /// 64-bit Windows FILETIME used in two places: /// /// - inside the NTLMv2 `temp` blob (`cs:139`) /// - synthesised `MsvAvTimestamp` when the server omitted one (`cs:159`) /// /// The .NET code calls `ToFileTime()` once per call site, so the trait /// returns a fresh value per invocation. fn filetime(&mut self) -> i64; } /// Deterministic test fixture inputs. #[derive(Debug, Clone)] pub struct FixedInputs { pub client_challenge: [u8; 8], pub exported_session_key: [u8; 16], pub filetime: i64, } impl NtlmInputs for FixedInputs { fn client_challenge(&mut self) -> [u8; 8] { self.client_challenge } fn exported_session_key(&mut self) -> [u8; 16] { self.exported_session_key } fn filetime(&mut self) -> i64 { self.filetime } } /// Production inputs — `OsRng` and `SystemTime`. /// /// The Windows FILETIME epoch is 1601-01-01 UTC; `cs:139` calls /// `DateTimeOffset.UtcNow.ToFileTime()` which returns 100-ns ticks since /// that epoch. #[derive(Debug, Default)] pub struct OsInputs; impl NtlmInputs for OsInputs { fn client_challenge(&mut self) -> [u8; 8] { let mut buf = [0u8; 8]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf); buf } fn exported_session_key(&mut self) -> [u8; 16] { let mut buf = [0u8; 16]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf); buf } fn filetime(&mut self) -> i64 { // Convert SystemTime → Windows FILETIME (100-ns ticks since 1601). // Difference between Unix epoch (1970) and FILETIME epoch (1601): // 11_644_473_600 seconds. let unix_ns = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let unix_100ns = (unix_ns / 100) as i64; unix_100ns + 11_644_473_600 * 10_000_000 } } /// Managed NTLMv2 client context. Mirrors the .NET `ManagedNtlmClientContext` /// class at `ManagedNtlmClientContext.cs:8-389`. /// /// Not `Clone` (the underlying `rc4 = "0.2"` `Rc4` cipher state holds an /// internally-mutated S-box and is not `Clone`). `Debug` is implemented /// manually below so credentials never reach trace output. pub struct NtlmClientContext { user: String, password: String, domain: String, workstation: String, flags: u32, exported_session_key: Vec, client_signing_key: Vec, /// RC4 cipher state for the client-to-server seal stream. The .NET /// reference holds an `Rc4` instance whose KSA-permuted S-box and i/j /// indices persist across calls (`cs:30`, `:124`); we store the key here /// and clone a fresh cipher per `sign` call only when the cipher state /// would otherwise be consumed — see `sign` for the actual stream. client_sealing_key: Vec, client_sealing_state: Option, sequence: u32, /// F2 — server-to-client signing/sealing keys. Derived from the same /// exported session key as the client-to-server pair but with the /// `S→C` magic constant variants per `[MS-NLMP]` §3.4.5.2/3. Used by /// `verify_signature` to validate inbound NTLM-signed PDUs (e.g. /// `INmxSvcCallback::StatusReceived` callbacks from `NmxSvc.exe`). server_signing_key: Vec, server_sealing_key: Vec, /// RC4 cipher state for the server-to-client seal stream — independent /// keystream from the client-to-server one (separate sub-key, separate /// sequence counter). server_sealing_state: Option, /// Counter for inbound (server→client) signatures. Each verify /// advances it; signatures with the wrong sequence number fail. server_sequence: u32, } impl core::fmt::Debug for NtlmClientContext { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("NtlmClientContext") .field("user", &self.user) .field("domain", &self.domain) .field("workstation", &self.workstation) .field("flags", &format_args!("{:#010x}", self.flags)) .field("authenticated", &!self.client_signing_key.is_empty()) .field("sequence", &self.sequence) .finish_non_exhaustive() } } impl NtlmClientContext { /// Create an unauthenticated context. Mirrors `cs:33-39`. /// /// `workstation` defaults to the empty string when `None`. The .NET /// reference defaults to `Environment.MachineName`; the Rust port keeps /// this caller-controlled because looking up the machine name from inside /// a codec is a side-effect that belongs in the transport layer (M2 wave 2). pub fn new(user: &str, password: &str, domain: &str, workstation: Option<&str>) -> Self { Self { user: user.to_string(), password: password.to_string(), domain: domain.to_string(), workstation: workstation.unwrap_or("").to_string(), flags: 0, exported_session_key: Vec::new(), client_signing_key: Vec::new(), client_sealing_key: Vec::new(), client_sealing_state: None, sequence: 0, server_signing_key: Vec::new(), server_sealing_key: Vec::new(), server_sealing_state: None, server_sequence: 0, } } /// Create an unauthenticated context from environment variables. /// Mirrors `ManagedNtlmClientContext.FromEnvironment()` /// (`ManagedNtlmClientContext.cs:41-49`). /// /// Required env vars (all three must be set; empty values are /// permitted for `MX_RPC_DOMAIN`): /// /// - `MX_RPC_USER` — user name /// - `MX_RPC_PASSWORD` — plaintext password /// - `MX_RPC_DOMAIN` — NT domain (or workgroup name; empty is fine) /// /// `workstation` defaults to [`local_hostname`] (the .NET reference /// uses `Environment.MachineName` per `cs:38`). When the env-var /// reads succeed but the local hostname is unavailable on this /// platform, `workstation` is the empty string — same as /// `Environment.MachineName` returning `null` on .NET. /// /// # Errors /// [`NtlmError::MissingEnvVar`] if any of the three env vars is unset. pub fn from_env() -> Result { let user = std::env::var("MX_RPC_USER").map_err(|_| NtlmError::MissingEnvVar { name: "MX_RPC_USER", })?; let password = std::env::var("MX_RPC_PASSWORD").map_err(|_| NtlmError::MissingEnvVar { name: "MX_RPC_PASSWORD", })?; let domain = std::env::var("MX_RPC_DOMAIN").map_err(|_| NtlmError::MissingEnvVar { name: "MX_RPC_DOMAIN", })?; let hostname = local_hostname(); Ok(Self::new(&user, &password, &domain, Some(&hostname))) } /// Build the 32-byte Type1 (NEGOTIATE) message. Mirrors `cs:51-70`. pub fn create_type1(&mut self) -> [u8; TYPE1_LEN] { self.flags = TYPE1_FLAGS; let mut msg = [0u8; TYPE1_LEN]; msg[..8].copy_from_slice(&NTLMSSP_SIGNATURE); // message_type = 1 — `cs:67` msg[8..12].copy_from_slice(&1u32.to_le_bytes()); // negotiate_flags — `cs:68` msg[12..16].copy_from_slice(&self.flags.to_le_bytes()); msg } /// Build the Type3 (AUTHENTICATE) message in response to a server-issued /// Type2 challenge. Mirrors `cs:72-112`. /// /// `inputs` supplies the client challenge, exported session key, and /// FILETIME value the .NET code obtains from `RandomNumberGenerator` and /// `DateTimeOffset.UtcNow`. Production callers pass [`OsInputs`]; tests /// pass [`FixedInputs`]. /// /// On success, the context retains the derived signing/sealing keys for /// later [`sign`](Self::sign) calls. /// /// # Errors /// /// Propagates [`NtlmError::ShortRead`] / [`NtlmError::InvalidSignature`] / /// [`NtlmError::InvalidTargetInfo`] / [`NtlmError::TruncatedAvPair`] from /// parsing the Type2 challenge. pub fn create_type3( &mut self, type2: &[u8], inputs: &mut I, ) -> Result, NtlmError> { let challenge = NtlmChallenge::parse(type2)?; // Mask self.flags by what the server agreed to — `cs:75`. self.flags &= challenge.flags; let client_challenge = inputs.client_challenge(); let target_info = build_target_info(&challenge.target_info, inputs.filetime())?; // ResponseKeyNT = HMAC_MD5(NT_HASH(password), Unicode(upper(user) || domain)) // — `cs:79`. let nt_hash = nt_hash(&self.password); let mut user_upper_domain = Vec::new(); push_utf16le(&mut user_upper_domain, &self.user.to_uppercase()); push_utf16le(&mut user_upper_domain, &self.domain); let response_key_nt = hmac_md5(&nt_hash, &user_upper_domain); // Temp = build_ntlmv2_temp(client_challenge, target_info) — `cs:81, 134-143` let temp = build_ntlmv2_temp(client_challenge, &target_info, inputs.filetime()); // NTProofStr = HMAC_MD5(ResponseKeyNT, server_challenge || temp) — `cs:82` let mut server_challenge_temp = Vec::with_capacity(8 + temp.len()); server_challenge_temp.extend_from_slice(&challenge.server_challenge); server_challenge_temp.extend_from_slice(&temp); let nt_proof = hmac_md5(&response_key_nt, &server_challenge_temp); // NtChallengeResponse = NTProofStr || temp — `cs:83` let mut nt_response = Vec::with_capacity(nt_proof.len() + temp.len()); nt_response.extend_from_slice(&nt_proof); nt_response.extend_from_slice(&temp); // LmChallengeResponse = HMAC_MD5(ResponseKeyNT, server_challenge || client_challenge) // || client_challenge — `cs:84` let mut sc_cc = [0u8; 16]; sc_cc[..8].copy_from_slice(&challenge.server_challenge); sc_cc[8..].copy_from_slice(&client_challenge); let mut lm_response = Vec::with_capacity(24); lm_response.extend_from_slice(&hmac_md5(&response_key_nt, &sc_cc)); lm_response.extend_from_slice(&client_challenge); // SessionBaseKey = HMAC_MD5(ResponseKeyNT, NTProofStr) — `cs:85` let session_base_key = hmac_md5(&response_key_nt, &nt_proof); // ExportedSessionKey = 16 random bytes — `cs:87` let exported_session_key = inputs.exported_session_key(); // EncryptedRandomSessionKey = RC4(SessionBaseKey).Transform(ExportedSessionKey) — `cs:88` let mut encrypted_session_key = exported_session_key.to_vec(); let mut sb_cipher = Rc4_16::new_from_slice(&session_base_key).map_err(|_| NtlmError::InvalidSignature)?; StreamCipher::apply_keystream(&mut sb_cipher, &mut encrypted_session_key); // Derive signing/sealing keys + reset sequence — `cs:89-91` self.client_signing_key = sign_key(&exported_session_key, true); self.client_sealing_key = seal_key(&exported_session_key, true); self.client_sealing_state = Rc4_16::new_from_slice(&self.client_sealing_key).ok(); // F2: derive server-to-client sub-keys at the same time. Same // exported session key, different magic constants per // [MS-NLMP] §3.4.5.2/3. self.server_signing_key = sign_key(&exported_session_key, false); self.server_sealing_key = seal_key(&exported_session_key, false); self.server_sealing_state = Rc4_16::new_from_slice(&self.server_sealing_key).ok(); self.server_sequence = 0; if self.client_sealing_state.is_none() { return Err(NtlmError::InvalidSignature); } self.exported_session_key = exported_session_key.to_vec(); self.sequence = 0; // Encode payload strings — `cs:93-95` let mut domain_bytes = Vec::new(); push_utf16le(&mut domain_bytes, &self.domain); let mut user_bytes = Vec::new(); push_utf16le(&mut user_bytes, &self.user); let mut workstation_bytes = Vec::new(); push_utf16le(&mut workstation_bytes, &self.workstation); // Header + payload assembly — `cs:97-110` let payload_len = lm_response.len() + nt_response.len() + domain_bytes.len() + user_bytes.len() + workstation_bytes.len() + encrypted_session_key.len(); let mut msg = vec![0u8; TYPE3_HEADER_LEN + payload_len]; msg[..8].copy_from_slice(&NTLMSSP_SIGNATURE); // message_type = 3 — `cs:101` msg[8..12].copy_from_slice(&3u32.to_le_bytes()); let mut payload_offset = TYPE3_HEADER_LEN; write_security_buffer(&mut msg, 12, &lm_response, &mut payload_offset); write_security_buffer(&mut msg, 20, &nt_response, &mut payload_offset); write_security_buffer(&mut msg, 28, &domain_bytes, &mut payload_offset); write_security_buffer(&mut msg, 36, &user_bytes, &mut payload_offset); write_security_buffer(&mut msg, 44, &workstation_bytes, &mut payload_offset); write_security_buffer(&mut msg, 52, &encrypted_session_key, &mut payload_offset); // negotiate_flags — `cs:110` msg[60..64].copy_from_slice(&self.flags.to_le_bytes()); Ok(msg) } /// Produce the 16-byte NTLM packet-integrity signature for `message`. /// Mirrors `cs:114-132`. /// /// Each call advances `self.sequence` by 1 and consumes 8 bytes of the /// client sealing RC4 keystream. Callers must call `sign` exactly once /// per outbound RPC PDU; a second call with the same sequence number /// would diverge from the server's keystream and verify as garbage. /// /// # Errors /// /// Returns [`NtlmError::NotAuthenticated`] if `create_type3` has not yet /// produced signing/sealing material. pub fn sign(&mut self, message: &[u8]) -> Result<[u8; SIGNATURE_LEN], NtlmError> { if self.client_signing_key.is_empty() || self.client_sealing_state.is_none() { return Err(NtlmError::NotAuthenticated); } let mut seq_bytes = [0u8; 4]; seq_bytes.copy_from_slice(&self.sequence.to_le_bytes()); // digest = HMAC_MD5(client_signing_key, seq_bytes || message) — `cs:123` let mut hmac = HmacMd5::new_from_slice(&self.client_signing_key) .map_err(|_| NtlmError::NotAuthenticated)?; hmac.update(&seq_bytes); hmac.update(message); let digest = hmac.finalize().into_bytes(); // checksum = RC4(client_sealing_handle).Transform(digest[..8]) — `cs:124` let mut checksum = [0u8; 8]; checksum.copy_from_slice(&digest[..8]); if let Some(rc4) = self.client_sealing_state.as_mut() { StreamCipher::apply_keystream(rc4, &mut checksum); } else { return Err(NtlmError::NotAuthenticated); } // signature = u32 LE 1 || checksum || u32 LE sequence — `cs:126-129` let mut signature = [0u8; SIGNATURE_LEN]; signature[0..4].copy_from_slice(&1u32.to_le_bytes()); signature[4..12].copy_from_slice(&checksum); signature[12..16].copy_from_slice(&self.sequence.to_le_bytes()); self.sequence = self.sequence.wrapping_add(1); Ok(signature) } /// F2 — verify an inbound NTLM signature on a server→client PDU. /// /// Mirrors [`Self::sign`] but uses the **server-to-client** /// sub-keys (derived from the exported session key with the /// `S→C` magic constants per `[MS-NLMP]` §3.4.5.2/3) and the /// independent `server_sequence` counter. Recomputes the /// expected 16-byte signature for `message` at the current /// sequence number and constant-time-compares against the /// supplied `signature` slice. /// /// On success advances `server_sequence` by 1. On failure the /// counter does NOT advance — the next call retries against the /// same expected sequence number, matching the .NET RPC stack's /// reject-and-retry semantics. /// /// MAC compare uses [`subtle::ConstantTimeEq`] so a timing oracle /// can't recover MAC bits byte-by-byte. /// /// # Errors /// /// - [`NtlmError::NotAuthenticated`] if `create_type3` hasn't run /// yet (no S→C key material). /// - [`NtlmError::InvalidSignature`] if `signature.len() != 16`, /// the leading version word isn't `0x00000001`, the trailing /// sequence number doesn't match `self.server_sequence`, or /// the 8-byte MAC fails the constant-time compare. pub fn verify_signature( &mut self, message: &[u8], signature: &[u8], ) -> Result<(), NtlmError> { use subtle::ConstantTimeEq; if self.server_signing_key.is_empty() || self.server_sealing_state.is_none() { return Err(NtlmError::NotAuthenticated); } if signature.len() != SIGNATURE_LEN { return Err(NtlmError::InvalidSignature); } // Validate the version + sequence fields BEFORE the MAC // compute; a mismatch here is cheap to detect and doesn't // need to advance any cipher state. let version = u32::from_le_bytes([signature[0], signature[1], signature[2], signature[3]]); if version != 1 { return Err(NtlmError::InvalidSignature); } let wire_seq = u32::from_le_bytes([ signature[12], signature[13], signature[14], signature[15], ]); if wire_seq != self.server_sequence { return Err(NtlmError::InvalidSignature); } // expected = HMAC_MD5(server_signing_key, seq || message)[0..8] // then RC4(server_sealing_state).Transform(expected). let mut seq_bytes = [0u8; 4]; seq_bytes.copy_from_slice(&self.server_sequence.to_le_bytes()); let mut hmac = HmacMd5::new_from_slice(&self.server_signing_key) .map_err(|_| NtlmError::NotAuthenticated)?; hmac.update(&seq_bytes); hmac.update(message); let digest = hmac.finalize().into_bytes(); let mut expected_mac = [0u8; 8]; expected_mac.copy_from_slice(&digest[..8]); // Take ownership of the existing RC4 state, advance it 8 // bytes for this verify, then put it back ONLY if the MAC // matched. If verify fails the original state is dropped — // the next attempt re-derives from `server_sealing_key` to // restore the same starting position. This mirrors the // .NET RPC stack's reject-and-retry semantics: a bad // signature doesn't permanently desync the keystream // because the receiver hasn't yet committed the advance. let mut rc4 = self .server_sealing_state .take() .ok_or(NtlmError::NotAuthenticated)?; StreamCipher::apply_keystream(&mut rc4, &mut expected_mac); // Constant-time compare against the wire MAC bytes (offsets 4..12). if expected_mac.ct_eq(&signature[4..12]).unwrap_u8() != 1 { // Verify failed — restore RC4 from the sealing key to // undo this verify's keystream advance. Caller can // retry with the same `server_sequence`. self.server_sealing_state = self.fresh_server_rc4(); return Err(NtlmError::InvalidSignature); } // MAC matched: commit the advanced cipher state + sequence. self.server_sealing_state = Some(rc4); self.server_sequence = self.server_sequence.wrapping_add(1); Ok(()) } /// Re-derive a fresh RC4 stream seeded with the server sealing /// key, advanced past the keystream bytes already consumed by /// successful verifies (`8 × server_sequence`). Used to recover /// the cipher state after a verify failure resets it. fn fresh_server_rc4(&self) -> Option { let mut rc4 = Rc4_16::new_from_slice(&self.server_sealing_key).ok()?; let skip = (self.server_sequence as usize) * 8; if skip > 0 { let mut sink = vec![0u8; skip]; StreamCipher::apply_keystream(&mut rc4, &mut sink); } Some(rc4) } /// Recompute the expected signature for `message` at sequence `seq` using /// a *fresh* RC4 stream seeded with `client_sealing_key`. Used in unit /// tests; the .NET reference does not expose a `Verify` because the /// transport never receives signed inbound frames it must validate /// (server callbacks come in over a different channel — see /// `MxNativeClient/ManagedNmxService2Client.cs`). /// /// # Errors /// /// Returns [`NtlmError::NotAuthenticated`] if no signing material exists. pub fn recompute_signature_at( &self, seq: u32, message: &[u8], rc4_skip_bytes: usize, ) -> Result<[u8; SIGNATURE_LEN], NtlmError> { if self.client_signing_key.is_empty() || self.client_sealing_key.is_empty() { return Err(NtlmError::NotAuthenticated); } let mut seq_bytes = [0u8; 4]; seq_bytes.copy_from_slice(&seq.to_le_bytes()); let mut hmac = HmacMd5::new_from_slice(&self.client_signing_key) .map_err(|_| NtlmError::NotAuthenticated)?; hmac.update(&seq_bytes); hmac.update(message); let digest = hmac.finalize().into_bytes(); let mut rc4 = Rc4_16::new_from_slice(&self.client_sealing_key) .map_err(|_| NtlmError::NotAuthenticated)?; // Skip past keystream bytes consumed by earlier signatures so callers // can verify a specific sequence number without replaying every prior // sign() call. if rc4_skip_bytes > 0 { let mut skip = vec![0u8; rc4_skip_bytes]; StreamCipher::apply_keystream(&mut rc4, &mut skip); } let mut checksum = [0u8; 8]; checksum.copy_from_slice(&digest[..8]); StreamCipher::apply_keystream(&mut rc4, &mut checksum); let mut signature = [0u8; SIGNATURE_LEN]; signature[0..4].copy_from_slice(&1u32.to_le_bytes()); signature[4..12].copy_from_slice(&checksum); signature[12..16].copy_from_slice(&seq.to_le_bytes()); Ok(signature) } /// Current sequence counter — exposed for diagnostics and integration tests. pub fn sequence(&self) -> u32 { self.sequence } /// Returns the negotiated flags after `create_type3` has merged them with /// the server's challenge flags (`cs:75`). pub fn flags(&self) -> u32 { self.flags } /// Borrow the 16-byte exported session key. Empty before `create_type3`. pub fn exported_session_key(&self) -> &[u8] { &self.exported_session_key } } // --- Type2 challenge parser — `cs:226-247` --- #[derive(Debug, Clone)] struct NtlmChallenge { flags: u32, server_challenge: [u8; 8], target_info: Vec, } impl NtlmChallenge { fn parse(message: &[u8]) -> Result { if message.len() < TYPE2_MIN_LEN { return Err(NtlmError::ShortRead { expected: TYPE2_MIN_LEN, actual: message.len(), }); } if message[..8] != NTLMSSP_SIGNATURE { return Err(NtlmError::InvalidSignature); } // target_info security buffer at offset 40 — `cs:235-236` let target_info_len = u16::from_le_bytes([message[40], message[41]]) as usize; let target_info_offset = u32::from_le_bytes([message[44], message[45], message[46], message[47]]) as usize; // Bounds check — `cs:237` let end = target_info_offset .checked_add(target_info_len) .ok_or(NtlmError::InvalidTargetInfo)?; if end > message.len() { return Err(NtlmError::InvalidTargetInfo); } // flags at offset 20, server_challenge at 24 — `cs:243-244` let flags = u32::from_le_bytes([message[20], message[21], message[22], message[23]]); let mut server_challenge = [0u8; 8]; server_challenge.copy_from_slice(&message[24..32]); let target_info = message[target_info_offset..end].to_vec(); Ok(Self { flags, server_challenge, target_info, }) } } // --- AV-pair parser/builder — `cs:145-175, 249-276` --- #[derive(Debug, Clone)] struct AvPair { id: u16, value: Vec, } fn parse_av_pairs(buffer: &[u8]) -> Result, NtlmError> { let mut pairs = Vec::new(); let mut offset = 0usize; while offset + 4 <= buffer.len() { let id = u16::from_le_bytes([buffer[offset], buffer[offset + 1]]); let length = u16::from_le_bytes([buffer[offset + 2], buffer[offset + 3]]) as usize; offset += 4; if id == AV_ID_EOL { break; } let end = offset .checked_add(length) .ok_or(NtlmError::TruncatedAvPair)?; if end > buffer.len() { return Err(NtlmError::TruncatedAvPair); } pairs.push(AvPair { id, value: buffer[offset..end].to_vec(), }); offset = end; } Ok(pairs) } /// Mirrors `BuildTargetInfo` (`cs:145-175`). /// /// The .NET reference: /// - Replaces the `MsvAvTargetName` (id=9) AV pair with `cifs/` /// if the server emitted a `MsvAvDnsComputerName` (id=3) — `cs:148-154`. /// - Synthesises a `MsvAvTimestamp` (id=7) if the server omitted one — /// `cs:156-161`. /// - Drops any `MsvAvEOL` (id=0) entries from the middle of the list — /// `cs:165`. /// - Re-emits each pair as `(id u16 LE, length u16 LE, value bytes)` followed /// by a 4-byte zero terminator — `cs:166-173`. fn build_target_info(original: &[u8], filetime: i64) -> Result, NtlmError> { let mut pairs = parse_av_pairs(original)?; // dnsHost from id=3, if present let dns_host = pairs .iter() .find(|p| p.id == AV_ID_DNS_HOST) .map(|p| p.value.clone()); if let Some(host) = dns_host { pairs.retain(|p| p.id != AV_ID_TARGET_NAME); let mut prefix = Vec::new(); push_utf16le(&mut prefix, "cifs/"); prefix.extend_from_slice(&host); pairs.push(AvPair { id: AV_ID_TARGET_NAME, value: prefix, }); } if !pairs.iter().any(|p| p.id == AV_ID_TIMESTAMP) { let mut ts = [0u8; 8]; ts.copy_from_slice(&filetime.to_le_bytes()); pairs.push(AvPair { id: AV_ID_TIMESTAMP, value: ts.to_vec(), }); } let mut out = Vec::new(); for pair in pairs.iter().filter(|p| p.id != AV_ID_EOL) { out.extend_from_slice(&pair.id.to_le_bytes()); out.extend_from_slice(&(pair.value.len() as u16).to_le_bytes()); out.extend_from_slice(&pair.value); } // 4-byte terminator (id=0, length=0) — `cs:173` out.extend_from_slice(&[0u8; 4]); Ok(out) } /// `Temp` blob from `cs:134-143`. /// /// ```text /// offset size value /// 0 1 0x01 Resp version /// 1 1 0x01 HiRespType /// 2 6 0 reserved /// 8 8 filetime (i64 LE) /// 16 8 client_challenge /// 24 4 0 reserved /// 28 ... target_info (already terminator-suffixed) /// ``` fn build_ntlmv2_temp(client_challenge: [u8; 8], target_info: &[u8], filetime: i64) -> Vec { let mut temp = vec![0u8; 28 + target_info.len()]; temp[0] = 1; temp[1] = 1; // bytes 2..8 already zero from vec! initialiser temp[8..16].copy_from_slice(&filetime.to_le_bytes()); temp[16..24].copy_from_slice(&client_challenge); // bytes 24..28 already zero temp[28..].copy_from_slice(target_info); temp } // --- Key derivation magic strings — `cs:179-191` --- const SIGN_MAGIC_C2S: &[u8] = b"session key to client-to-server signing key magic constant\0"; const SIGN_MAGIC_S2C: &[u8] = b"session key to server-to-client signing key magic constant\0"; const SEAL_MAGIC_C2S: &[u8] = b"session key to client-to-server sealing key magic constant\0"; const SEAL_MAGIC_S2C: &[u8] = b"session key to server-to-client sealing key magic constant\0"; fn sign_key(session_key: &[u8], client_mode: bool) -> Vec { let magic = if client_mode { SIGN_MAGIC_C2S } else { SIGN_MAGIC_S2C }; let mut hasher = Md5::new(); hasher.update(session_key); hasher.update(magic); hasher.finalize().to_vec() } fn seal_key(session_key: &[u8], client_mode: bool) -> Vec { let magic = if client_mode { SEAL_MAGIC_C2S } else { SEAL_MAGIC_S2C }; let mut hasher = Md5::new(); hasher.update(session_key); hasher.update(magic); hasher.finalize().to_vec() } /// NT hash = MD4(UTF-16LE(password)) — `cs:193-196`. fn nt_hash(password: &str) -> [u8; 16] { let mut bytes = Vec::with_capacity(password.len() * 2); push_utf16le(&mut bytes, password); let mut hasher = Md4::new(); hasher.update(&bytes); let out = hasher.finalize(); let mut hash = [0u8; 16]; hash.copy_from_slice(&out); hash } /// HMAC-MD5 — `cs:198-202`. fn hmac_md5(key: &[u8], data: &[u8]) -> [u8; 16] { // HmacMd5 only fails on key-size errors; MD5 has no key size limit so this // path is unreachable in practice. We map any error to a zeroed hash so // the function remains panic-free. let mut out = [0u8; 16]; if let Ok(mut mac) = HmacMd5::new_from_slice(key) { mac.update(data); let bytes = mac.finalize().into_bytes(); out.copy_from_slice(&bytes); } out } /// `WriteSecurityBuffer` — `cs:217-224`. fn write_security_buffer( message: &mut [u8], descriptor_offset: usize, value: &[u8], payload_offset: &mut usize, ) { let len = value.len() as u16; message[descriptor_offset..descriptor_offset + 2].copy_from_slice(&len.to_le_bytes()); // .NET writes the same length twice (length and max length) — `cs:220`. message[descriptor_offset + 2..descriptor_offset + 4].copy_from_slice(&len.to_le_bytes()); let off = *payload_offset as u32; message[descriptor_offset + 4..descriptor_offset + 8].copy_from_slice(&off.to_le_bytes()); message[*payload_offset..*payload_offset + value.len()].copy_from_slice(value); *payload_offset += value.len(); } /// Append `s` to `dst` in UTF-16LE (no BOM, no terminator). Mirrors /// `Encoding.Unicode.GetBytes(s)` used throughout the .NET reference. fn push_utf16le(dst: &mut Vec, s: &str) { for unit in s.encode_utf16() { dst.extend_from_slice(&unit.to_le_bytes()); } } // --- Compile-time invariants --- const _: () = assert!(TYPE1_LEN == 32); const _: () = assert!(TYPE3_HEADER_LEN == 64); const _: () = assert!(SIGNATURE_LEN == 16); const _: () = assert!(NTLMSSP_SIGNATURE[7] == 0); #[cfg(test)] #[allow( clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::panic )] mod tests { use super::*; fn fixed_inputs() -> FixedInputs { FixedInputs { client_challenge: [0xaa; 8], // Same exported session key used by [MS-NLMP] §4.2.4.3 example // ("RandomSessionKey") so external comparison is possible. exported_session_key: [ 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, ], // [MS-NLMP] §4.2.4 examples use timestamp = 0. filetime: 0, } } /// [MS-NLMP] §4.2.4.1.1 — `NTOWFv1("Password")`: /// `a4 f4 9c 40 65 10 bd ca b6 82 4e e7 c3 0f d8 52`. /// MD4 of UTF-16LE("Password") — confirms our MD4 + UTF-16 encoding. #[test] fn nt_hash_matches_msnlmp_v1_example() { let h = nt_hash("Password"); let expected: [u8; 16] = [ 0xa4, 0xf4, 0x9c, 0x40, 0x65, 0x10, 0xbd, 0xca, 0xb6, 0x82, 0x4e, 0xe7, 0xc3, 0x0f, 0xd8, 0x52, ]; assert_eq!(h, expected); } /// [MS-NLMP] §4.2.4.1.1 — `NTOWFv2("Password","User","Domain")` = /// HMAC_MD5(NTOWFv1, UPPER("User") || "Domain") = /// `0c 86 8a 40 3b fd 7a 93 a3 00 1e f2 2e f0 2e 3f`. #[test] fn ntowfv2_matches_msnlmp_example() { let nt = nt_hash("Password"); let mut data = Vec::new(); push_utf16le(&mut data, "USER"); push_utf16le(&mut data, "Domain"); let key = hmac_md5(&nt, &data); let expected: [u8; 16] = [ 0x0c, 0x86, 0x8a, 0x40, 0x3b, 0xfd, 0x7a, 0x93, 0xa3, 0x00, 0x1e, 0xf2, 0x2e, 0xf0, 0x2e, 0x3f, ]; assert_eq!(key, expected); } #[test] fn type1_layout_is_thirty_two_bytes_with_signature_and_flags() { let mut ctx = NtlmClientContext::new("user", "pass", "domain", Some("ws")); let msg = ctx.create_type1(); assert_eq!(msg.len(), 32); assert_eq!(&msg[..8], b"NTLMSSP\0"); // message_type LE assert_eq!(&msg[8..12], &1u32.to_le_bytes()); // flags LE let flags = u32::from_le_bytes([msg[12], msg[13], msg[14], msg[15]]); assert_eq!(flags, TYPE1_FLAGS); // VERSION flag (`cs:18`) is NOT set by the .NET client (`cs:53-63`). assert_eq!(flags & NEGOTIATE_VERSION, 0); // Bytes 16..32 are zero (no domain/workstation in Type1). assert_eq!(&msg[16..32], &[0u8; 16]); } /// Build a minimal Type2 challenge with an empty target_info so we can /// exercise create_type3 without a live server. fn make_type2(server_challenge: [u8; 8], flags: u32, target_info: &[u8]) -> Vec { let mut msg = vec![0u8; 48 + target_info.len()]; msg[..8].copy_from_slice(b"NTLMSSP\0"); msg[8..12].copy_from_slice(&2u32.to_le_bytes()); // target_name security buffer (empty) at offset 12 left zeroed. msg[20..24].copy_from_slice(&flags.to_le_bytes()); msg[24..32].copy_from_slice(&server_challenge); // Reserved 32..40 zero. let ti_len = target_info.len() as u16; msg[40..42].copy_from_slice(&ti_len.to_le_bytes()); msg[42..44].copy_from_slice(&ti_len.to_le_bytes()); msg[44..48].copy_from_slice(&48u32.to_le_bytes()); msg[48..].copy_from_slice(target_info); msg } #[test] fn type3_round_trip_with_fixed_inputs_is_deterministic() { let mut a = NtlmClientContext::new("User", "Password", "Domain", Some("")); let mut b = NtlmClientContext::new("User", "Password", "Domain", Some("")); a.create_type1(); b.create_type1(); let server_challenge = [0x01u8, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]; let challenge = make_type2(server_challenge, TYPE1_FLAGS, &[0u8; 4]); let mut inputs_a = fixed_inputs(); let mut inputs_b = fixed_inputs(); let m_a = a.create_type3(&challenge, &mut inputs_a).unwrap(); let m_b = b.create_type3(&challenge, &mut inputs_b).unwrap(); assert_eq!(m_a, m_b, "fixed inputs must yield identical Type3"); // header sanity assert_eq!(&m_a[..8], b"NTLMSSP\0"); assert_eq!(&m_a[8..12], &3u32.to_le_bytes()); assert!(m_a.len() >= TYPE3_HEADER_LEN); // negotiated flags merged (server returned same TYPE1_FLAGS so identical) assert_eq!(a.flags(), TYPE1_FLAGS); } #[test] fn type3_security_buffer_offsets_are_in_order() { let mut ctx = NtlmClientContext::new("U", "P", "D", Some("W")); ctx.create_type1(); let challenge = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); let msg = ctx.create_type3(&challenge, &mut fixed_inputs()).unwrap(); // Read each security buffer and verify offsets are monotonically // increasing and each (offset, length) is in-bounds. let read_sb = |off: usize| { let len = u16::from_le_bytes([msg[off], msg[off + 1]]) as usize; let pos = u32::from_le_bytes([msg[off + 4], msg[off + 5], msg[off + 6], msg[off + 7]]) as usize; (len, pos) }; let lm = read_sb(12); let nt = read_sb(20); let dom = read_sb(28); let user = read_sb(36); let ws = read_sb(44); let key = read_sb(52); let mut expected_off = TYPE3_HEADER_LEN; for (len, off) in [lm, nt, dom, user, ws, key] { assert_eq!(off, expected_off); assert!(off + len <= msg.len()); expected_off += len; } // EncryptedRandomSessionKey is exactly 16 bytes. assert_eq!(key.0, 16); // LM response is HMAC(16) || client_challenge(8) = 24 bytes. assert_eq!(lm.0, 24); } #[test] fn type3_encrypted_session_key_decrypts_to_exported_key() { // The encrypted session key is RC4(SessionBaseKey).Transform(ExportedKey). // Reversing it requires SessionBaseKey, which requires the user/pass // path. Here we just confirm the bytes change (RC4 isn't identity). let mut ctx = NtlmClientContext::new("User", "Password", "Domain", Some("")); ctx.create_type1(); let challenge = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); let msg = ctx.create_type3(&challenge, &mut fixed_inputs()).unwrap(); let key_off = u32::from_le_bytes([msg[56], msg[57], msg[58], msg[59]]) as usize; let key_len = u16::from_le_bytes([msg[52], msg[53]]) as usize; let encrypted = &msg[key_off..key_off + key_len]; // Exported key in fixed_inputs is all-0x55. After RC4 it should differ. assert_ne!(encrypted, &[0x55u8; 16]); assert_eq!(ctx.exported_session_key(), &[0x55u8; 16]); } #[test] fn sign_layout_matches_msnlmp_3_4_4_2() { // Walk through one Sign() call and verify the 16-byte layout: // [0..4] = 1 (LE), [4..12] = RC4-checksum, [12..16] = sequence (LE). let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); ctx.create_type1(); let challenge = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); ctx.create_type3(&challenge, &mut fixed_inputs()).unwrap(); let msg = b"hello world"; let sig = ctx.sign(msg).unwrap(); assert_eq!(sig.len(), 16); assert_eq!(&sig[0..4], &1u32.to_le_bytes()); assert_eq!(&sig[12..16], &0u32.to_le_bytes(), "first call uses seq=0"); assert_eq!(ctx.sequence(), 1); // Second call advances seq. let sig2 = ctx.sign(msg).unwrap(); assert_eq!(&sig2[12..16], &1u32.to_le_bytes()); // Different seq + advancing RC4 keystream → different checksum bytes. assert_ne!(&sig[4..12], &sig2[4..12]); assert_eq!(ctx.sequence(), 2); } #[test] fn sign_byte_for_byte_recompute_matches() { // Build a context, sign a few messages, then recompute each signature // independently against the same sealing key (the `recompute_*` // helper). Both paths must agree byte-for-byte. let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); ctx.create_type1(); let challenge = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); ctx.create_type3(&challenge, &mut fixed_inputs()).unwrap(); let messages: &[&[u8]] = &[b"first", b"second message", b"third!!!"]; let mut sigs = Vec::new(); for m in messages { sigs.push(ctx.sign(m).unwrap()); } // Recompute from an independent context derived from the same inputs // (the rc4 0.2 cipher state is not Clone, so a fresh derivation is // semantically equivalent — both contexts land on the same keys). // `rc4_skip_bytes` advances by 8 per signed message because each // Sign consumes exactly 8 bytes of keystream (`cs:124`). let mut snapshot = NtlmClientContext::new("U", "P", "D", Some("")); snapshot.create_type1(); snapshot .create_type3(&challenge, &mut fixed_inputs()) .unwrap(); for (i, m) in messages.iter().enumerate() { let recomputed = snapshot.recompute_signature_at(i as u32, m, i * 8).unwrap(); assert_eq!( recomputed, sigs[i], "recomputed signature for seq={i} diverged" ); } } #[test] fn sign_before_type3_fails() { let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); let err = ctx.sign(b"x").unwrap_err(); assert!(matches!(err, NtlmError::NotAuthenticated)); } #[test] fn type2_short_buffer_rejected() { let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); ctx.create_type1(); let err = ctx .create_type3(&[0u8; 32], &mut fixed_inputs()) .unwrap_err(); assert!(matches!(err, NtlmError::ShortRead { .. })); } #[test] fn type2_bad_signature_rejected() { let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); ctx.create_type1(); let mut bad = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); bad[0] = b'X'; // corrupt the leading "NTLMSSP\0" let err = ctx.create_type3(&bad, &mut fixed_inputs()).unwrap_err(); assert!(matches!(err, NtlmError::InvalidSignature)); } #[test] fn type2_target_info_oob_rejected() { let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); ctx.create_type1(); let mut bad = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); // Claim 200 target_info bytes when we only have 4. bad[40..42].copy_from_slice(&200u16.to_le_bytes()); let err = ctx.create_type3(&bad, &mut fixed_inputs()).unwrap_err(); assert!(matches!(err, NtlmError::InvalidTargetInfo)); } #[test] fn av_pair_truncated_rejected() { // id=3 (DnsHost), length=200, but only 4 bytes of value supplied. let mut buf = Vec::new(); buf.extend_from_slice(&3u16.to_le_bytes()); buf.extend_from_slice(&200u16.to_le_bytes()); buf.extend_from_slice(&[0u8; 4]); let err = parse_av_pairs(&buf).unwrap_err(); assert!(matches!(err, NtlmError::TruncatedAvPair)); } #[test] fn build_target_info_synthesises_timestamp_when_missing() { // Empty input AV table → just the 4-byte EOL terminator. let out = build_target_info(&[0u8; 4], 0x1122334455667788).unwrap(); // Should now contain id=7 (timestamp) followed by 8 bytes of FILETIME. // Format: id(2) || len(2) || value(len) ... then 4 zero bytes. assert_eq!(&out[0..2], &7u16.to_le_bytes()); assert_eq!(&out[2..4], &8u16.to_le_bytes()); assert_eq!(&out[4..12], &0x1122334455667788i64.to_le_bytes()); assert_eq!(&out[12..16], &[0u8; 4]); } #[test] fn build_target_info_does_not_overwrite_server_timestamp() { // Server already supplied a timestamp (id=7). let mut input = Vec::new(); input.extend_from_slice(&7u16.to_le_bytes()); input.extend_from_slice(&8u16.to_le_bytes()); input.extend_from_slice(&[0xaa; 8]); input.extend_from_slice(&[0u8; 4]); // EOL let out = build_target_info(&input, 0).unwrap(); // Only one timestamp pair. let pairs = parse_av_pairs(&out).unwrap(); let ts: Vec<_> = pairs.iter().filter(|p| p.id == AV_ID_TIMESTAMP).collect(); assert_eq!(ts.len(), 1); assert_eq!(ts[0].value, vec![0xaa; 8]); } #[test] fn build_target_info_replaces_target_name_with_cifs_dnshost() { // Server supplies id=3 (DnsComputerName) = UTF-16LE("HOST"). let mut input = Vec::new(); let mut host = Vec::new(); push_utf16le(&mut host, "HOST"); input.extend_from_slice(&3u16.to_le_bytes()); input.extend_from_slice(&(host.len() as u16).to_le_bytes()); input.extend_from_slice(&host); // Pre-existing id=9 that should be replaced. input.extend_from_slice(&9u16.to_le_bytes()); input.extend_from_slice(&4u16.to_le_bytes()); input.extend_from_slice(&[0xff; 4]); input.extend_from_slice(&[0u8; 4]); // EOL let out = build_target_info(&input, 0).unwrap(); let pairs = parse_av_pairs(&out).unwrap(); let target: Vec<_> = pairs.iter().filter(|p| p.id == AV_ID_TARGET_NAME).collect(); assert_eq!(target.len(), 1); let mut expected = Vec::new(); push_utf16le(&mut expected, "cifs/"); expected.extend_from_slice(&host); assert_eq!(target[0].value, expected); } #[test] fn ntlmv2_temp_layout() { let cc = [0xaa; 8]; let temp = build_ntlmv2_temp(cc, &[0xee; 4], 0x1122334455667788); assert_eq!(temp[0], 0x01); assert_eq!(temp[1], 0x01); assert_eq!(&temp[2..8], &[0u8; 6]); assert_eq!(&temp[8..16], &0x1122334455667788i64.to_le_bytes()); assert_eq!(&temp[16..24], &cc); assert_eq!(&temp[24..28], &[0u8; 4]); assert_eq!(&temp[28..], &[0xee; 4]); } #[test] fn sign_and_seal_keys_are_md5_of_session_key_plus_magic() { // Cross-check our derivation against an independent MD5 of // session_key || magic — same recipe the .NET reference uses. let session_key = [0x11u8; 16]; let derived = sign_key(&session_key, true); let mut h = Md5::new(); h.update(session_key); h.update(SIGN_MAGIC_C2S); let manual = h.finalize().to_vec(); assert_eq!(derived, manual); let derived_seal = seal_key(&session_key, true); let mut h = Md5::new(); h.update(session_key); h.update(SEAL_MAGIC_C2S); let manual_seal = h.finalize().to_vec(); assert_eq!(derived_seal, manual_seal); } #[test] fn write_security_buffer_emits_len_maxlen_offset() { let mut msg = vec![0u8; 32]; let mut off = 16; write_security_buffer(&mut msg, 0, &[0xaa, 0xbb, 0xcc], &mut off); assert_eq!(&msg[0..2], &3u16.to_le_bytes()); assert_eq!(&msg[2..4], &3u16.to_le_bytes()); assert_eq!(&msg[4..8], &16u32.to_le_bytes()); assert_eq!(&msg[16..19], &[0xaa, 0xbb, 0xcc]); assert_eq!(off, 19); } // ---- F1: from_env + local_hostname ------------------------------ /// All env-mutating tests in this mod serialize on this mutex. /// `#[test]`s run in parallel by default, but `std::env::set_var` /// touches process-global state, so concurrent env-var tests race. /// Holding `ENV_LOCK` for the duration of each `EnvScope` makes /// snapshot-and-restore safe. static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// Snapshot the listed env vars on creation, restore them on Drop. /// Holds [`ENV_LOCK`] for the scope's lifetime so tests don't race /// on process-global env state. struct EnvScope { snapshots: Vec<(&'static str, Option)>, _guard: std::sync::MutexGuard<'static, ()>, } impl EnvScope { fn capture(names: &[&'static str]) -> Self { // Acquire before snapshotting so a concurrent test can't // mutate vars between our snapshot and restore. let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let snapshots = names.iter().map(|n| (*n, std::env::var(n).ok())).collect(); Self { snapshots, _guard: guard, } } } impl Drop for EnvScope { fn drop(&mut self) { for (name, prev) in &self.snapshots { // SAFETY: ENV_LOCK serializes all env-mutating tests in // this mod, so concurrent set_var/remove_var calls from // tests-in-this-file are not possible. Other Cargo test // binaries run in separate processes. unsafe { match prev { Some(v) => std::env::set_var(name, v), None => std::env::remove_var(name), } } } } } fn set_env(name: &str, value: &str) { // SAFETY: see EnvScope::drop note. Caller must hold ENV_LOCK // (acquired via EnvScope::capture). unsafe { std::env::set_var(name, value); } } fn unset_env(name: &str) { // SAFETY: see EnvScope::drop note. unsafe { std::env::remove_var(name); } } #[test] fn from_env_reads_three_mx_rpc_vars() { let _scope = EnvScope::capture(&[ "MX_RPC_USER", "MX_RPC_PASSWORD", "MX_RPC_DOMAIN", "COMPUTERNAME", "HOSTNAME", ]); set_env("MX_RPC_USER", "alice"); set_env("MX_RPC_PASSWORD", "secret"); set_env("MX_RPC_DOMAIN", "TESTDOMAIN"); set_env("COMPUTERNAME", "TESTHOST"); unset_env("HOSTNAME"); let ctx = NtlmClientContext::from_env().unwrap(); // Use the Debug projection (which exposes user/domain/workstation // but redacts the password) to assert the fields landed. let dbg = format!("{ctx:?}"); assert!(dbg.contains("user: \"alice\"")); assert!(dbg.contains("domain: \"TESTDOMAIN\"")); assert!(dbg.contains("workstation: \"TESTHOST\"")); } #[test] fn from_env_missing_user_errors() { let _scope = EnvScope::capture(&["MX_RPC_USER", "MX_RPC_PASSWORD", "MX_RPC_DOMAIN"]); unset_env("MX_RPC_USER"); set_env("MX_RPC_PASSWORD", "p"); set_env("MX_RPC_DOMAIN", "d"); let err = NtlmClientContext::from_env().unwrap_err(); match err { NtlmError::MissingEnvVar { name } => assert_eq!(name, "MX_RPC_USER"), other => panic!("expected MissingEnvVar(MX_RPC_USER), got {other:?}"), } } #[test] fn from_env_missing_password_errors() { let _scope = EnvScope::capture(&["MX_RPC_USER", "MX_RPC_PASSWORD", "MX_RPC_DOMAIN"]); set_env("MX_RPC_USER", "u"); unset_env("MX_RPC_PASSWORD"); set_env("MX_RPC_DOMAIN", "d"); let err = NtlmClientContext::from_env().unwrap_err(); assert!(matches!( err, NtlmError::MissingEnvVar { name: "MX_RPC_PASSWORD" } )); } #[test] fn from_env_missing_domain_errors() { let _scope = EnvScope::capture(&["MX_RPC_USER", "MX_RPC_PASSWORD", "MX_RPC_DOMAIN"]); set_env("MX_RPC_USER", "u"); set_env("MX_RPC_PASSWORD", "p"); unset_env("MX_RPC_DOMAIN"); let err = NtlmClientContext::from_env().unwrap_err(); assert!(matches!( err, NtlmError::MissingEnvVar { name: "MX_RPC_DOMAIN" } )); } #[test] fn from_env_accepts_empty_domain() { let _scope = EnvScope::capture(&[ "MX_RPC_USER", "MX_RPC_PASSWORD", "MX_RPC_DOMAIN", "COMPUTERNAME", "HOSTNAME", ]); set_env("MX_RPC_USER", "u"); set_env("MX_RPC_PASSWORD", "p"); set_env("MX_RPC_DOMAIN", ""); set_env("COMPUTERNAME", "X"); let ctx = NtlmClientContext::from_env().unwrap(); let dbg = format!("{ctx:?}"); assert!(dbg.contains("domain: \"\"")); } #[test] fn local_hostname_prefers_computername_over_hostname() { let _scope = EnvScope::capture(&["COMPUTERNAME", "HOSTNAME"]); set_env("COMPUTERNAME", "WIN_HOST"); set_env("HOSTNAME", "POSIX_HOST"); assert_eq!(local_hostname(), "WIN_HOST"); } #[test] fn local_hostname_falls_back_to_hostname_when_computername_unset() { let _scope = EnvScope::capture(&["COMPUTERNAME", "HOSTNAME"]); unset_env("COMPUTERNAME"); set_env("HOSTNAME", "POSIX_HOST"); assert_eq!(local_hostname(), "POSIX_HOST"); } #[test] fn local_hostname_returns_empty_when_neither_set() { let _scope = EnvScope::capture(&["COMPUTERNAME", "HOSTNAME"]); unset_env("COMPUTERNAME"); unset_env("HOSTNAME"); assert_eq!(local_hostname(), ""); } // ---- F2 — verify_signature server-to-client tests -------------- /// Drive a fully-authenticated context, then alias the server /// sub-keys onto the client ones so `sign` and /// `verify_signature` use the same key material. This lets a /// single context act as its own peer for round-trip testing — /// the actual C↔S key separation is verified by /// `sign_and_seal_keys_are_md5_of_session_key_plus_magic` etc. /// elsewhere; this helper is purely about exercising the /// verify control flow. fn paired_authed_context() -> NtlmClientContext { let mut ctx = NtlmClientContext::new("User", "Password", "Domain", Some("")); ctx.create_type1(); let challenge = make_type2([0x12u8; 8], TYPE1_FLAGS, &[0u8; 4]); let _ = ctx.create_type3(&challenge, &mut fixed_inputs()).unwrap(); // Force server-side keys to alias client-side so a single // context's sign() output validates via its own // verify_signature(). The cipher state needs a fresh init // from the (now-aliased) sealing key so verify reads from // the start of the keystream that sign also started from. ctx.server_signing_key = ctx.client_signing_key.clone(); ctx.server_sealing_key = ctx.client_sealing_key.clone(); ctx.server_sealing_state = Rc4_16::new_from_slice(&ctx.server_sealing_key).ok(); ctx.server_sequence = 0; ctx } #[test] fn verify_signature_round_trip_against_sign() { let mut ctx = paired_authed_context(); let messages: &[&[u8]] = &[b"hello", b"world", b"third"]; let mut signatures = Vec::new(); for m in messages { signatures.push(ctx.sign(m).unwrap()); } for (m, sig) in messages.iter().zip(signatures.iter()) { ctx.verify_signature(m, sig).expect("verify round-trip"); } } #[test] fn verify_signature_rejects_corrupted_mac() { let mut ctx = paired_authed_context(); let signature = ctx.sign(b"payload").unwrap(); // Flip a bit in the MAC field (offsets 4..12). let mut bad = signature; bad[6] ^= 0x80; let err = ctx.verify_signature(b"payload", &bad).unwrap_err(); assert!(matches!(err, NtlmError::InvalidSignature)); // After failure, server_sequence must NOT advance — caller // can retry with the corrected signature. assert_eq!(ctx.server_sequence, 0); } #[test] fn verify_signature_rejects_wrong_sequence_number() { let mut ctx = paired_authed_context(); let signature = ctx.sign(b"x").unwrap(); ctx.server_sequence = 1; // Expected: 0; signature carries 0; mismatch. let err = ctx.verify_signature(b"x", &signature).unwrap_err(); assert!(matches!(err, NtlmError::InvalidSignature)); } #[test] fn verify_signature_rejects_wrong_version_field() { let mut ctx = paired_authed_context(); let mut signature = ctx.sign(b"y").unwrap(); ctx.server_sequence = 0; // Tamper with the leading version word (must be 0x00000001). signature[0] ^= 0xFF; let err = ctx.verify_signature(b"y", &signature).unwrap_err(); assert!(matches!(err, NtlmError::InvalidSignature)); } #[test] fn verify_signature_rejects_wrong_length() { let mut ctx = paired_authed_context(); let too_short = [0u8; 8]; let err = ctx.verify_signature(b"z", &too_short).unwrap_err(); assert!(matches!(err, NtlmError::InvalidSignature)); } #[test] fn verify_signature_before_authenticate_errors() { let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); let dummy = [0u8; SIGNATURE_LEN]; let err = ctx.verify_signature(b"any", &dummy).unwrap_err(); assert!(matches!(err, NtlmError::NotAuthenticated)); } }