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>
Pure whitespace cleanup from running `cargo fmt --all` between
iterations; no semantic change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reduces open followups from 11 → 10 (back at the soft threshold).
Step 0 triage flagged F1 as resolvable now: M4's connect-path
example will need a from_env constructor anyway, and the hostname
lookup is portable enough not to need a native-libc dep.
New
- NtlmClientContext::from_env() -> Result<Self, NtlmError>: reads
MX_RPC_USER / MX_RPC_PASSWORD / MX_RPC_DOMAIN env vars. Empty
MX_RPC_DOMAIN is permitted (workgroup auth). Mirrors the .NET
ManagedNtlmClientContext.FromEnvironment() at cs:41-49.
- local_hostname() -> String public helper: checks COMPUTERNAME
(Windows) then HOSTNAME (POSIX) and returns the empty string when
neither is set — same "unavailable" semantics as
Environment.MachineName returning null. No gethostname(2) call,
no unsafe, no native-libc dep. Callers needing reliable POSIX
hostnames can pass workstation explicitly.
- NtlmError::MissingEnvVar { name: &'static str } variant.
Tests (8 new in ntlm; total 27)
- from_env three-var happy path
- from_env missing each of the three vars (3 tests)
- from_env accepts empty MX_RPC_DOMAIN
- local_hostname prefers COMPUTERNAME over HOSTNAME
- local_hostname falls back to HOSTNAME
- local_hostname returns empty when neither set
- All env-mutating tests serialize via a static ENV_LOCK Mutex inside
EnvScope, since std::env::set_var touches process-global state and
cargo runs #[test]s in parallel by default.
design/followups.md: F1 moved to Resolved.
Open followups: 11 → 10 (back at soft threshold).
Test count delta: 498 -> 506 (+8). All four DoD gates green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands M2 wave 1 — three pure-Rust modules under crates/mxaccess-rpc with
60 unit tests. Each is a 1:1 port of one .NET reference file:
- ntlm.rs (1137 LoC, 19 tests) — `ManagedNtlmClientContext.cs`. NTLMv2
challenge/response, Type1/Type3 builders, sign() with RC4-sealed checksum
and per-call sequence advance. Manual `Debug` impl that hides credentials;
not Clone (rc4 0.2 cipher state is non-Clone). Pure-Rust crypto via
hmac/md-5/md4/rc4 v0.2/rand v0.8 (rc4 0.2 chosen per design/review.md:78).
- pdu.rs (1573 LoC, 33 tests) — `DceRpcPdu.cs` + auth-trailer types from
`DceRpcAuthentication.cs`. Bind/AlterContext/Auth3/Request/Response/Fault
PDUs, NDR20 transfer syntax, auth_value with 4-byte alignment padding,
preserved-byte fields per CLAUDE.md unknown-bytes rule.
- objref.rs (~470 LoC, 11 tests including a 366-byte captured OBJREF
round-trip) — `ComObjRef.cs`. MEOW signature, OXID/OID/IPID, dual-string
array with printable-ASCII escaping and security-binding boundary.
ComObjRefProvider.cs deferred (windows-rs Win32 wrapper — see F6).
Every wire-byte claim cites src/MxNativeClient/<file>.cs:LINE per
CLAUDE.md "no fabricated protocol behaviour" rule.
Test count delta: 217 → 277 (+60)
Open followups touched: F1–F8 (new — see design/followups.md)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>