Files
mxaccess/rust/crates/mxaccess-rpc/src/ntlm.rs
T
Joseph Doherty 1de049e114
rust / build / test / clippy / fmt (push) Has been cancelled
[F2] mxaccess-rpc: NTLM verify_signature (server-to-client) with constant-time MAC compare
Closes F2. Structural port from [MS-NLMP] §3.4.4 — same shape as
the existing sign path but uses the server-to-client sub-keys
(`SealKey_S→C` / `SignKey_S→C`) derived alongside the client-to-
server pair at the end of create_type3.

NtlmClientContext gained four new fields populated during
create_type3:
  - server_signing_key
  - server_sealing_key
  - server_sealing_state (independent RC4 stream)
  - server_sequence (independent counter)

The S→C key derivation already existed in auth.rs (the seal_key /
sign_key helpers take a client_mode flag); F2 plumbs them into a
new verify_signature(message, signature) method.

The verify path:
  1. Validates signature.len() == 16 + leading version word 0x01.
  2. Reads trailing seq num, compares against self.server_sequence
     (mismatch ⇒ InvalidSignature, no state change).
  3. Computes expected_mac = HMAC_MD5(server_signing_key,
     seq || message)[0..8] then RC4 transform.
  4. Constant-time compares expected_mac against wire bytes 4..12
     via subtle::ConstantTimeEq.
  5. On success: commits cipher-state advance + ++server_sequence.
     On failure: re-derives RC4 from server_sealing_key and skips
     past server_sequence × 8 keystream bytes to restore the
     pre-verify position — caller can retry.

New dep `subtle = "2"` (workspace-internal to mxaccess-rpc) for
the timing-oracle-safe MAC compare.

6 new tests:
  - verify_signature_round_trip_against_sign (3-message sequence
    via paired_authed_context helper that aliases server-side keys
    onto client-side for self-validating round-trip)
  - verify_signature_rejects_corrupted_mac (with
    server_sequence-non-advance assertion)
  - verify_signature_rejects_wrong_sequence_number
  - verify_signature_rejects_wrong_version_field
  - verify_signature_rejects_wrong_length
  - verify_signature_before_authenticate_errors

mxaccess-rpc 188 → 194 tests; default-feature clippy clean.

The "awaiting wire-fixture capture" step listed in F2's prior
status note is no longer a hard prerequisite — [MS-NLMP] §3.4.4
fully defines the algorithm and the round-trip tests prove the
encoder/decoder pair is internally consistent. A captured
StatusReceived frame would still validate byte-parity vs a real
NmxSvc.exe signer, but that's future verification work; the
structural port ships unblocked.

design/followups.md F2 moved to Resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 03:30:48 -04:00

1634 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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/<dnsHost> + 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<Md5>;
/// 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<KeySize>` 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<u8>,
client_signing_key: Vec<u8>,
/// 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<u8>,
client_sealing_state: Option<Rc4_16>,
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<u8>,
server_sealing_key: Vec<u8>,
/// 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<Rc4_16>,
/// 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<Self, NtlmError> {
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<I: NtlmInputs>(
&mut self,
type2: &[u8],
inputs: &mut I,
) -> Result<Vec<u8>, 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<Rc4_16> {
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<u8>,
}
impl NtlmChallenge {
fn parse(message: &[u8]) -> Result<Self, NtlmError> {
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<u8>,
}
fn parse_av_pairs(buffer: &[u8]) -> Result<Vec<AvPair>, 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/<DnsHost>`
/// 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<Vec<u8>, 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<u8> {
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<u8> {
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<u8> {
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<u8>, 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<u8> {
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<String>)>,
_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));
}
}