1de049e114
rust / build / test / clippy / fmt (push) Has been cancelled
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>
1634 lines
65 KiB
Rust
1634 lines
65 KiB
Rust
//! 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));
|
||
}
|
||
}
|