From 95bd2181838db73c99e093e272b3b31e62cf408b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 06:54:39 -0400 Subject: [PATCH] [M2] mxaccess-rpc: NTLMv2 + DCE/RPC PDU + OBJREF parser (wave 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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) --- design/followups.md | 59 + rust/Cargo.lock | 238 ++++ rust/crates/mxaccess-rpc/Cargo.toml | 6 + rust/crates/mxaccess-rpc/src/lib.rs | 11 +- rust/crates/mxaccess-rpc/src/ntlm.rs | 1170 +++++++++++++++++ rust/crates/mxaccess-rpc/src/objref.rs | 672 ++++++++++ rust/crates/mxaccess-rpc/src/pdu.rs | 1609 ++++++++++++++++++++++++ 7 files changed, 3763 insertions(+), 2 deletions(-) create mode 100644 design/followups.md create mode 100644 rust/crates/mxaccess-rpc/src/ntlm.rs create mode 100644 rust/crates/mxaccess-rpc/src/objref.rs create mode 100644 rust/crates/mxaccess-rpc/src/pdu.rs diff --git a/design/followups.md b/design/followups.md new file mode 100644 index 0000000..4412eea --- /dev/null +++ b/design/followups.md @@ -0,0 +1,59 @@ +# Followups + +Open work items deferred during /loop iterations. Triaged at the top of +every iteration. New items are appended under `## Open`; resolved items +move to `## Resolved` with a date + commit hash. + +## Open + +### F1 — NTLM consumer-layer helpers (workstation default + from_env constructor) +**Severity:** P3 +**Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs` +**Why deferred:** The .NET reference's `Environment.MachineName` default for `workstation` and its `FromEnvironment()` constructor (`ManagedNtlmClientContext.cs:38`, `:41-49`) read host state and env vars — both side effects that don't belong in a pure codec module. The constructor takes `workstation: Option<&str>` so callers can wire either later. +**Resolves when:** M2 wave 2 transport (or the M2 example `connect-nmx.rs`) wires `NtlmClientContext::new(.., Some(hostname()?))` and provides a small `from_env` helper at the consumer layer. + +### F2 — NTLM verify_signature path + constant-time MAC compare (server-to-client direction) +**Severity:** P2 +**Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs` +**Why deferred:** The .NET `ManagedNtlmClientContext` only implements client-to-server signing (`cs:30,124`); there is no implementation of server-to-client sign/seal keys or `verify_signature`. Both are needed when the callback exporter receives a signed inbound frame from `NmxSvc.exe`, but no such fixture exists yet. +**Resolves when:** M2 wave 3 (callback exporter) captures an `INmxSvcCallback::StatusReceived` frame with an `auth_value` trailer per `design/60-roadmap.md:56` (DoD #3) and a fixture lands under `tests/fixtures/m2-status-frame/`. Add `subtle = "2"` and gate the byte compare behind `ConstantTimeEq` at the same time. + +### F3 — Cross-domain NTLM Type1/2/3 fixture +**Severity:** P2 +**Source:** M2 wave 1, `crates/mxaccess-rpc/src/ntlm.rs` +**Why deferred:** All current NTLM fixtures are single-domain (the local AVEVA install). Tracked separately in `design/70-risks-and-open-questions.md` R8 (P1 risk) and the open-evidence-gaps table. +**Resolves when:** A multi-domain AVEVA test harness lands and a successful cross-domain authenticate round-trip captures Type1/2/3 bytes. Notes: this clears R8. + +### F4 — BindAck / AlterContextResponse body parser +**Severity:** P2 +**Source:** M2 wave 1, `crates/mxaccess-rpc/src/pdu.rs` +**Why deferred:** The .NET reference (`DceRpcPdu.cs:217-262`) parses Bind and AlterContext into the same struct but does not decode the corresponding *response* body (result list + secondary address). The Rust port's `BindPdu::decode` accepts `BindAck` packet type but does not interpret the body. The negotiated transfer syntax — needed before opnum dispatch — is currently inferred from request-side context. +**Resolves when:** A captured BindAck frame from `captures/013-loopback-subscribe-scalars/nmx-stream-*.bin` is decoded and the body shape is documented in `docs/Loopback-Protocol-Findings.md`. + +### F5 — Captured DCE/RPC bind-frame fixture round-trip +**Severity:** P2 +**Source:** M2 wave 1, `crates/mxaccess-rpc/src/pdu.rs` +**Why deferred:** Existing PDU tests build hand-constructed `[C706]`-conformant frames. A capture-driven round-trip (extract bind/alter PDUs from `captures/013-loopback-subscribe-scalars/nmx-stream-*.bin`, decode → encode → assert byte-identical) would be stronger evidence of parity with the live wire. +**Resolves when:** Bytes from that capture are extracted into `tests/fixtures/m2-pdu/` and the round-trip test lands. + +### F6 — Port `ComObjRefProvider.cs` (OBJREF emitter via Win32 CoMarshalInterface) +**Severity:** P2 +**Source:** M2 wave 1, `crates/mxaccess-rpc/src/objref.rs` +**Why deferred:** The provider is a wrapper around `ole32::CoMarshalInterface` / `IStream` / `GlobalLock` / `GlobalSize`. It needs `windows-rs`, which is currently behind the `windows-com` feature in `mxaccess-rpc/Cargo.toml`. The pure-Rust parser stands alone for the inbound activation-response path that M2 wave 1 needs. +**Resolves when:** `windows-rs` is wired into `mxaccess-rpc` (M2 wave 3 callback exporter needs to publish its own OBJREF for `IRemUnknown` / `INmxSvcCallback` registration) and an emitter port lands behind the `windows-com` feature. + +### F7 — Consolidate `Guid` type across `mxaccess-rpc` +**Severity:** P3 +**Source:** M2 wave 1, `crates/mxaccess-rpc/src/{objref.rs,pdu.rs}` +**Why deferred:** `objref::Guid` is a self-contained `[u8; 16]` newtype with `Display` matching `.NET Guid.ToString("D")`. `pdu::SyntaxId` uses raw `[u8; 16]` for IIDs. Both work but a single shared type would be cleaner. +**Resolves when:** A small consolidation lands — either `pub use objref::Guid as Guid;` from `pdu`, or both move to a shared `crate::guid` module. Trivial; pick during M2 wave 2 when the next agent touches the crate. + +### F8 — `RpcError` is duplicated across `objref` and `pdu` modules +**Severity:** P3 +**Source:** M2 wave 1, `crates/mxaccess-rpc/src/{objref.rs,pdu.rs}` +**Why deferred:** Each module defined its own `RpcError` enum with a partial set of variants. Both are sound in isolation but the crate-public `RpcError` should be a single union. Not blocking — they don't collide because each module re-exports its own. +**Resolves when:** M2 wave 2 (OXID + `IRemUnknown::RemQueryInterface`) needs a third error surface. At that point, hoist `RpcError` to `crates/mxaccess-rpc/src/error.rs` mirroring `mxaccess-codec/src/error.rs`, and have each module use the shared enum. + +## Resolved + +(none yet) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index abad83c..0760918 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,144 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", + "inout", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "md4" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda" +dependencies = [ + "digest", +] + [[package]] name = "mxaccess" version = "0.0.0" @@ -60,6 +198,23 @@ dependencies = [ [[package]] name = "mxaccess-rpc" version = "0.0.0" +dependencies = [ + "hmac", + "md-5", + "md4", + "rand", + "rc4", + "thiserror", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] [[package]] name = "proc-macro2" @@ -79,6 +234,51 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rc4" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840038b674daa9f7a7957440d937951d15c0143c056e631e529141fd780e0c92" +dependencies = [ + "cipher", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -110,8 +310,46 @@ dependencies = [ "syn", ] +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/rust/crates/mxaccess-rpc/Cargo.toml b/rust/crates/mxaccess-rpc/Cargo.toml index 2a5853a..9dd2756 100644 --- a/rust/crates/mxaccess-rpc/Cargo.toml +++ b/rust/crates/mxaccess-rpc/Cargo.toml @@ -9,6 +9,12 @@ rust-version.workspace = true authors.workspace = true [dependencies] +thiserror = { workspace = true } +hmac = "0.12" +md-5 = "0.10" +md4 = "0.10" +rc4 = "0.2" +rand = "0.8" [features] default = [] diff --git a/rust/crates/mxaccess-rpc/src/lib.rs b/rust/crates/mxaccess-rpc/src/lib.rs index 7f1eb0d..9b85927 100644 --- a/rust/crates/mxaccess-rpc/src/lib.rs +++ b/rust/crates/mxaccess-rpc/src/lib.rs @@ -1,10 +1,17 @@ //! `mxaccess-rpc` — DCE/RPC + NTLMv2 + OBJREF + OXID + IRemUnknown::RemQueryInterface. //! -//! M0 stub. Real implementation lands in M2 — see `design/60-roadmap.md`. +//! M2 wave 1 landed: `ntlm`, `pdu`, `objref`. OXID resolution and +//! `IRemUnknown::RemQueryInterface` follow in wave 2; the callback exporter +//! in wave 3 — see `design/60-roadmap.md` and `design/dependencies.md`. //! //! Internal `unsafe` is permitted only for `windows-rs` COM activation paths //! (per `design/00-overview.md` principle 3); all such calls must be wrapped -//! in safe abstractions at the crate boundary. +//! in safe abstractions at the crate boundary. Wave 1 modules are pure-Rust +//! and contain no `unsafe`. // `mxaccess-rpc` is the only crate where internal unsafe is permitted (for // windows-rs COM calls). Public API stays safe. + +pub mod ntlm; +pub mod objref; +pub mod pdu; diff --git a/rust/crates/mxaccess-rpc/src/ntlm.rs b/rust/crates/mxaccess-rpc/src/ntlm.rs new file mode 100644 index 0000000..1a8c3fe --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/ntlm.rs @@ -0,0 +1,1170 @@ +//! Managed NTLMv2 client context for DCE/RPC + packet integrity. +//! +//! Direct port of `src/MxNativeClient/ManagedNtlmClientContext.cs`. Produces +//! Type1 (NEGOTIATE), Type3 (AUTHENTICATE) messages, the NTLMv2 +//! `NTProofStr`/`NTLMv2Response` blobs, and the 16-byte per-message +//! [`Ntlm1`-style] signature used for `auth_value` in the RPC `auth_verifier` +//! trailer. +//! +//! # Wire-byte parity +//! +//! Every field offset, magic constant, and key-derivation magic string in this +//! module cites the exact line in the .NET reference. The .NET reference is the +//! executable spec per CLAUDE.md, so the Rust port mirrors it 1:1; cross-spec +//! references to `[MS-NLMP]` are added only where the .NET code itself relies +//! on them implicitly (e.g. signature/seal magic strings, AV-pair IDs). +//! +//! # Type1 (NEGOTIATE) layout +//! +//! Per `ManagedNtlmClientContext.cs:51-70`: +//! +//! ```text +//! offset size field +//! 0 8 "NTLMSSP\0" ASCII signature +//! 8 4 message_type u32 LE = 1 +//! 12 4 negotiate_flags u32 LE +//! 16 16 zero (we do not advertise domain/workstation in Type1) +//! ``` +//! +//! Total length: 32 bytes. +//! +//! # Type3 (AUTHENTICATE) layout +//! +//! Per `ManagedNtlmClientContext.cs:72-112`. Header is 64 bytes; payloads +//! follow in this fixed order: `lm_response`, `nt_response`, `domain`, `user`, +//! `workstation`, `encrypted_session_key`. Each gets an 8-byte security buffer +//! (length, max length, payload offset). +//! +//! ```text +//! offset size field +//! 0 8 "NTLMSSP\0" +//! 8 4 message_type u32 LE = 3 +//! 12 8 LmResponseFields security buffer +//! 20 8 NtResponseFields security buffer +//! 28 8 DomainNameFields security buffer +//! 36 8 UserNameFields security buffer +//! 44 8 WorkstationFields security buffer +//! 52 8 EncryptedRandomSessionKeyFields security buffer +//! 60 4 negotiate_flags u32 LE +//! 64+ payload bytes... +//! ``` +//! +//! # NTLMv2 challenge-response (per `ManagedNtlmClientContext.cs:72-95`) +//! +//! ```text +//! ResponseKeyNT = HMAC_MD5(NT_HASH(password), UNICODE(uppercase(user) || domain)) +//! Temp = 0x01 0x01 0x00 0x00 0x00 0x00 0x00 0x00 +//! || timestamp_filetime_le_64 +//! || client_challenge[8] +//! || 0x00 0x00 0x00 0x00 +//! || target_info(server augmented with cifs/ + timestamp) +//! NTProofStr = HMAC_MD5(ResponseKeyNT, server_challenge[8] || Temp) +//! NtChallengeResponse = NTProofStr || Temp +//! LmChallengeResponse = HMAC_MD5(ResponseKeyNT, server_challenge || client_challenge) +//! || client_challenge +//! SessionBaseKey = HMAC_MD5(ResponseKeyNT, NTProofStr) +//! ExportedSessionKey = 16 random bytes (the session key the server will see +//! after RC4-decrypting EncryptedRandomSessionKey) +//! EncryptedRandomSessionKey = RC4(SessionBaseKey).Transform(ExportedSessionKey) +//! ``` +//! +//! Signing/sealing keys are derived from `ExportedSessionKey` plus the magic +//! strings at `ManagedNtlmClientContext.cs:179-191`. +//! +//! # Sign() — packet-integrity signature (`ManagedNtlmClientContext.cs:114-132`) +//! +//! ```text +//! sequence_bytes_le32 = u32 LE of self.sequence +//! digest = HMAC_MD5(client_signing_key, sequence_bytes_le32 || message) +//! checksum = RC4(client_sealing_handle).Transform(digest[..8]) +//! signature = u32 LE 1 // version +//! || checksum // 8 bytes +//! || u32 LE sequence +//! self.sequence += 1 +//! ``` +//! +//! Length: 16 bytes. Note the .NET code feeds **only the first 8 bytes** of +//! the HMAC digest through RC4 (`ManagedNtlmClientContext.cs:124`), not the +//! full 16; the Rust port matches. + +// Direct byte indexing is the right pattern for fixed-layout codec code: +// every byte access is preceded by an explicit length check. See +// `mxaccess-codec/src/reference_handle.rs` for the same allow + rationale. +#![allow(clippy::indexing_slicing)] + +use hmac::{Hmac, Mac}; +use md4::Md4; +use md5::{Digest, Md5}; +use rc4::{KeyInit, Rc4, StreamCipher}; +use thiserror::Error; + +type HmacMd5 = Hmac; +/// All RC4 keys in this module are 16 bytes (MD5 output). The +/// `rc4 = "0.2"` API takes the key as a runtime slice (no generic +/// key-size parameter — that was reworked in 0.2 vs the older 0.1 +/// `Rc4` form). The alias is kept for documentation only. +type Rc4_16 = Rc4; + +// --- NEGOTIATE flag constants — `ManagedNtlmClientContext.cs:10-21` --- + +/// `NTLMSSP_NEGOTIATE_UNICODE` — `cs:10`. +pub const NEGOTIATE_UNICODE: u32 = 0x00000001; +/// `NTLMSSP_REQUEST_TARGET` — `cs:11`. +pub const REQUEST_TARGET: u32 = 0x00000004; +/// `NTLMSSP_NEGOTIATE_SIGN` — `cs:12`. +pub const NEGOTIATE_SIGN: u32 = 0x00000010; +/// `NTLMSSP_NEGOTIATE_SEAL` — `cs:13`. +pub const NEGOTIATE_SEAL: u32 = 0x00000020; +/// `NTLMSSP_NEGOTIATE_NTLM` — `cs:14`. +pub const NEGOTIATE_NTLM: u32 = 0x00000200; +/// `NTLMSSP_NEGOTIATE_ALWAYS_SIGN` — `cs:15`. +pub const NEGOTIATE_ALWAYS_SIGN: u32 = 0x00008000; +/// `NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY` — `cs:16`. +pub const NEGOTIATE_EXTENDED_SESSION_SECURITY: u32 = 0x00080000; +/// `NTLMSSP_NEGOTIATE_TARGET_INFO` — `cs:17`. +pub const NEGOTIATE_TARGET_INFO: u32 = 0x00800000; +/// `NTLMSSP_NEGOTIATE_VERSION` — `cs:18`. +pub const NEGOTIATE_VERSION: u32 = 0x02000000; +/// `NTLMSSP_NEGOTIATE_128` — `cs:19`. +pub const NEGOTIATE_128: u32 = 0x20000000; +/// `NTLMSSP_NEGOTIATE_KEY_EXCH` — `cs:20`. +pub const NEGOTIATE_KEY_EXCHANGE: u32 = 0x40000000; +/// `NTLMSSP_NEGOTIATE_56` — `cs:21`. +pub const NEGOTIATE_56: u32 = 0x80000000; + +/// Flags advertised by the .NET client in Type1. Mirrors +/// `ManagedNtlmClientContext.cs:53-63`. **Note**: `NEGOTIATE_VERSION` is +/// listed in the .cs constants (`cs:18`) but is *not* OR-ed into Type1; the +/// Rust port matches that omission exactly. +pub const TYPE1_FLAGS: u32 = NEGOTIATE_KEY_EXCHANGE + | NEGOTIATE_SIGN + | NEGOTIATE_ALWAYS_SIGN + | NEGOTIATE_SEAL + | NEGOTIATE_TARGET_INFO + | NEGOTIATE_NTLM + | NEGOTIATE_EXTENDED_SESSION_SECURITY + | NEGOTIATE_UNICODE + | REQUEST_TARGET + | NEGOTIATE_128 + | NEGOTIATE_56; + +/// 8-byte ASCII signature `"NTLMSSP\0"` shared by all NTLMSSP messages +/// (`ManagedNtlmClientContext.cs:66`, `:100`, `:230`). +pub const NTLMSSP_SIGNATURE: [u8; 8] = *b"NTLMSSP\0"; + +/// AV pair ID `MsvAvDnsComputerName` per `[MS-NLMP] §2.2.2.1` — +/// .NET reads this at `ManagedNtlmClientContext.cs:148`. +const AV_ID_DNS_HOST: u16 = 3; +/// AV pair ID `MsvAvTimestamp` per `[MS-NLMP] §2.2.2.1` — .NET checks this +/// at `cs:156`. +const AV_ID_TIMESTAMP: u16 = 7; +/// AV pair ID `MsvAvTargetName` per `[MS-NLMP] §2.2.2.1` — .NET writes this +/// at `cs:152-153`. +const AV_ID_TARGET_NAME: u16 = 9; +/// AV pair terminator (id, length, both u16 = 0) — .NET writes 4 trailing +/// zeros at `cs:173`. +const AV_ID_EOL: u16 = 0; + +/// Type1 message header is exactly 32 bytes — `cs:65`. +pub const TYPE1_LEN: usize = 32; +/// Type3 message header (before security-buffer payloads) — `cs:97`. +pub const TYPE3_HEADER_LEN: usize = 64; +/// Type2 challenge minimum length — `cs:230`. +pub const TYPE2_MIN_LEN: usize = 48; + +/// 16-byte signature length per `cs:126`. +pub const SIGNATURE_LEN: usize = 16; + +/// Errors produced while building or consuming NTLM messages. Modelled after +/// `mxaccess-codec`'s `CodecError` (see `crates/mxaccess-codec/src/error.rs`). +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum NtlmError { + /// Buffer was shorter than required. + #[error("short read: expected {expected} bytes, got {actual}")] + ShortRead { expected: usize, actual: usize }, + + /// First eight bytes were not the NTLMSSP signature, or the message_type + /// did not match the expected value. + #[error("invalid NTLMSSP signature or message type")] + InvalidSignature, + + /// Type2 declared a target_info offset/length that ran past the buffer. + #[error("NTLM challenge target-info buffer is invalid")] + InvalidTargetInfo, + + /// AV-pair declared a length that ran past the supplied buffer. + #[error("NTLM AV-pair buffer is truncated")] + TruncatedAvPair, + + /// `Sign` was called before `create_type3` produced signing/sealing keys. + #[error("NTLM context has not completed Type3 negotiation")] + NotAuthenticated, + + /// `verify_signature` was called with a signature that did not match the + /// expected bytes. + #[error("NTLM signature mismatch")] + SignatureMismatch, +} + +/// Trait for supplying the random/clock inputs the .NET reference reads from +/// `RandomNumberGenerator.GetBytes` and `DateTimeOffset.UtcNow.ToFileTime`. +/// +/// The default implementation lives at [`OsInputs`] and uses `rand::OsRng` + +/// `std::time::SystemTime`. Tests inject a deterministic implementation via +/// [`FixedInputs`] so the resulting Type3 / Sign bytes are reproducible. +pub trait NtlmInputs { + /// 8 bytes of client challenge — `cs:77`. + fn client_challenge(&mut self) -> [u8; 8]; + /// 16 bytes of `ExportedSessionKey` — `cs:87`. + fn exported_session_key(&mut self) -> [u8; 16]; + /// 64-bit Windows FILETIME used in two places: + /// + /// - inside the NTLMv2 `temp` blob (`cs:139`) + /// - synthesised `MsvAvTimestamp` when the server omitted one (`cs:159`) + /// + /// The .NET code calls `ToFileTime()` once per call site, so the trait + /// returns a fresh value per invocation. + fn filetime(&mut self) -> i64; +} + +/// Deterministic test fixture inputs. +#[derive(Debug, Clone)] +pub struct FixedInputs { + pub client_challenge: [u8; 8], + pub exported_session_key: [u8; 16], + pub filetime: i64, +} + +impl NtlmInputs for FixedInputs { + fn client_challenge(&mut self) -> [u8; 8] { + self.client_challenge + } + fn exported_session_key(&mut self) -> [u8; 16] { + self.exported_session_key + } + fn filetime(&mut self) -> i64 { + self.filetime + } +} + +/// Production inputs — `OsRng` and `SystemTime`. +/// +/// The Windows FILETIME epoch is 1601-01-01 UTC; `cs:139` calls +/// `DateTimeOffset.UtcNow.ToFileTime()` which returns 100-ns ticks since +/// that epoch. +#[derive(Debug, Default)] +pub struct OsInputs; + +impl NtlmInputs for OsInputs { + fn client_challenge(&mut self) -> [u8; 8] { + let mut buf = [0u8; 8]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf); + buf + } + fn exported_session_key(&mut self) -> [u8; 16] { + let mut buf = [0u8; 16]; + rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf); + buf + } + fn filetime(&mut self) -> i64 { + // Convert SystemTime → Windows FILETIME (100-ns ticks since 1601). + // Difference between Unix epoch (1970) and FILETIME epoch (1601): + // 11_644_473_600 seconds. + let unix_ns = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let unix_100ns = (unix_ns / 100) as i64; + unix_100ns + 11_644_473_600 * 10_000_000 + } +} + +/// Managed NTLMv2 client context. Mirrors the .NET `ManagedNtlmClientContext` +/// class at `ManagedNtlmClientContext.cs:8-389`. +/// +/// Not `Clone` (the underlying `rc4 = "0.2"` `Rc4` cipher state holds an +/// internally-mutated S-box and is not `Clone`). `Debug` is implemented +/// manually below so credentials never reach trace output. +pub struct NtlmClientContext { + user: String, + password: String, + domain: String, + workstation: String, + flags: u32, + exported_session_key: Vec, + client_signing_key: Vec, + /// RC4 cipher state for the client-to-server seal stream. The .NET + /// reference holds an `Rc4` instance whose KSA-permuted S-box and i/j + /// indices persist across calls (`cs:30`, `:124`); we store the key here + /// and clone a fresh cipher per `sign` call only when the cipher state + /// would otherwise be consumed — see `sign` for the actual stream. + client_sealing_key: Vec, + client_sealing_state: Option, + sequence: u32, +} + +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, + } + } + + /// Build the 32-byte Type1 (NEGOTIATE) message. Mirrors `cs:51-70`. + pub fn create_type1(&mut self) -> [u8; TYPE1_LEN] { + self.flags = TYPE1_FLAGS; + let mut msg = [0u8; TYPE1_LEN]; + msg[..8].copy_from_slice(&NTLMSSP_SIGNATURE); + // message_type = 1 — `cs:67` + msg[8..12].copy_from_slice(&1u32.to_le_bytes()); + // negotiate_flags — `cs:68` + msg[12..16].copy_from_slice(&self.flags.to_le_bytes()); + msg + } + + /// Build the Type3 (AUTHENTICATE) message in response to a server-issued + /// Type2 challenge. Mirrors `cs:72-112`. + /// + /// `inputs` supplies the client challenge, exported session key, and + /// FILETIME value the .NET code obtains from `RandomNumberGenerator` and + /// `DateTimeOffset.UtcNow`. Production callers pass [`OsInputs`]; tests + /// pass [`FixedInputs`]. + /// + /// On success, the context retains the derived signing/sealing keys for + /// later [`sign`](Self::sign) calls. + /// + /// # Errors + /// + /// Propagates [`NtlmError::ShortRead`] / [`NtlmError::InvalidSignature`] / + /// [`NtlmError::InvalidTargetInfo`] / [`NtlmError::TruncatedAvPair`] from + /// parsing the Type2 challenge. + pub fn create_type3( + &mut self, + type2: &[u8], + inputs: &mut I, + ) -> Result, NtlmError> { + let challenge = NtlmChallenge::parse(type2)?; + // Mask self.flags by what the server agreed to — `cs:75`. + self.flags &= challenge.flags; + + let client_challenge = inputs.client_challenge(); + let target_info = build_target_info(&challenge.target_info, inputs.filetime())?; + + // ResponseKeyNT = HMAC_MD5(NT_HASH(password), Unicode(upper(user) || domain)) + // — `cs:79`. + let nt_hash = nt_hash(&self.password); + let mut user_upper_domain = Vec::new(); + push_utf16le(&mut user_upper_domain, &self.user.to_uppercase()); + push_utf16le(&mut user_upper_domain, &self.domain); + let response_key_nt = hmac_md5(&nt_hash, &user_upper_domain); + + // Temp = build_ntlmv2_temp(client_challenge, target_info) — `cs:81, 134-143` + let temp = build_ntlmv2_temp(client_challenge, &target_info, inputs.filetime()); + + // NTProofStr = HMAC_MD5(ResponseKeyNT, server_challenge || temp) — `cs:82` + let mut server_challenge_temp = Vec::with_capacity(8 + temp.len()); + server_challenge_temp.extend_from_slice(&challenge.server_challenge); + server_challenge_temp.extend_from_slice(&temp); + let nt_proof = hmac_md5(&response_key_nt, &server_challenge_temp); + + // NtChallengeResponse = NTProofStr || temp — `cs:83` + let mut nt_response = Vec::with_capacity(nt_proof.len() + temp.len()); + nt_response.extend_from_slice(&nt_proof); + nt_response.extend_from_slice(&temp); + + // LmChallengeResponse = HMAC_MD5(ResponseKeyNT, server_challenge || client_challenge) + // || client_challenge — `cs:84` + let mut sc_cc = [0u8; 16]; + sc_cc[..8].copy_from_slice(&challenge.server_challenge); + sc_cc[8..].copy_from_slice(&client_challenge); + let mut lm_response = Vec::with_capacity(24); + lm_response.extend_from_slice(&hmac_md5(&response_key_nt, &sc_cc)); + lm_response.extend_from_slice(&client_challenge); + + // SessionBaseKey = HMAC_MD5(ResponseKeyNT, NTProofStr) — `cs:85` + let session_base_key = hmac_md5(&response_key_nt, &nt_proof); + + // ExportedSessionKey = 16 random bytes — `cs:87` + let exported_session_key = inputs.exported_session_key(); + + // EncryptedRandomSessionKey = RC4(SessionBaseKey).Transform(ExportedSessionKey) — `cs:88` + let mut encrypted_session_key = exported_session_key.to_vec(); + let mut sb_cipher = + Rc4_16::new_from_slice(&session_base_key).map_err(|_| NtlmError::InvalidSignature)?; + StreamCipher::apply_keystream(&mut sb_cipher, &mut encrypted_session_key); + + // Derive signing/sealing keys + reset sequence — `cs:89-91` + self.client_signing_key = sign_key(&exported_session_key, true); + self.client_sealing_key = seal_key(&exported_session_key, true); + self.client_sealing_state = Rc4_16::new_from_slice(&self.client_sealing_key).ok(); + 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) + } + + /// Recompute the expected signature for `message` at sequence `seq` using + /// a *fresh* RC4 stream seeded with `client_sealing_key`. Used in unit + /// tests; the .NET reference does not expose a `Verify` because the + /// transport never receives signed inbound frames it must validate + /// (server callbacks come in over a different channel — see + /// `MxNativeClient/ManagedNmxService2Client.cs`). + /// + /// # Errors + /// + /// Returns [`NtlmError::NotAuthenticated`] if no signing material exists. + pub fn recompute_signature_at( + &self, + seq: u32, + message: &[u8], + rc4_skip_bytes: usize, + ) -> Result<[u8; SIGNATURE_LEN], NtlmError> { + if self.client_signing_key.is_empty() || self.client_sealing_key.is_empty() { + return Err(NtlmError::NotAuthenticated); + } + let mut seq_bytes = [0u8; 4]; + seq_bytes.copy_from_slice(&seq.to_le_bytes()); + let mut hmac = HmacMd5::new_from_slice(&self.client_signing_key) + .map_err(|_| NtlmError::NotAuthenticated)?; + hmac.update(&seq_bytes); + hmac.update(message); + let digest = hmac.finalize().into_bytes(); + + let mut rc4 = Rc4_16::new_from_slice(&self.client_sealing_key) + .map_err(|_| NtlmError::NotAuthenticated)?; + // Skip past keystream bytes consumed by earlier signatures so callers + // can verify a specific sequence number without replaying every prior + // sign() call. + if rc4_skip_bytes > 0 { + let mut skip = vec![0u8; rc4_skip_bytes]; + StreamCipher::apply_keystream(&mut rc4, &mut skip); + } + let mut checksum = [0u8; 8]; + checksum.copy_from_slice(&digest[..8]); + StreamCipher::apply_keystream(&mut rc4, &mut checksum); + + let mut signature = [0u8; SIGNATURE_LEN]; + signature[0..4].copy_from_slice(&1u32.to_le_bytes()); + signature[4..12].copy_from_slice(&checksum); + signature[12..16].copy_from_slice(&seq.to_le_bytes()); + Ok(signature) + } + + /// Current sequence counter — exposed for diagnostics and integration tests. + pub fn sequence(&self) -> u32 { + self.sequence + } + + /// Returns the negotiated flags after `create_type3` has merged them with + /// the server's challenge flags (`cs:75`). + pub fn flags(&self) -> u32 { + self.flags + } + + /// Borrow the 16-byte exported session key. Empty before `create_type3`. + pub fn exported_session_key(&self) -> &[u8] { + &self.exported_session_key + } +} + +// --- Type2 challenge parser — `cs:226-247` --- + +#[derive(Debug, Clone)] +struct NtlmChallenge { + flags: u32, + server_challenge: [u8; 8], + target_info: Vec, +} + +impl NtlmChallenge { + fn parse(message: &[u8]) -> Result { + if message.len() < TYPE2_MIN_LEN { + return Err(NtlmError::ShortRead { + expected: TYPE2_MIN_LEN, + actual: message.len(), + }); + } + if message[..8] != NTLMSSP_SIGNATURE { + return Err(NtlmError::InvalidSignature); + } + // target_info security buffer at offset 40 — `cs:235-236` + let target_info_len = u16::from_le_bytes([message[40], message[41]]) as usize; + let target_info_offset = + u32::from_le_bytes([message[44], message[45], message[46], message[47]]) as usize; + // Bounds check — `cs:237` + let end = target_info_offset + .checked_add(target_info_len) + .ok_or(NtlmError::InvalidTargetInfo)?; + if end > message.len() { + return Err(NtlmError::InvalidTargetInfo); + } + // flags at offset 20, server_challenge at 24 — `cs:243-244` + let flags = u32::from_le_bytes([message[20], message[21], message[22], message[23]]); + let mut server_challenge = [0u8; 8]; + server_challenge.copy_from_slice(&message[24..32]); + let target_info = message[target_info_offset..end].to_vec(); + Ok(Self { + flags, + server_challenge, + target_info, + }) + } +} + +// --- AV-pair parser/builder — `cs:145-175, 249-276` --- + +#[derive(Debug, Clone)] +struct AvPair { + id: u16, + value: Vec, +} + +fn parse_av_pairs(buffer: &[u8]) -> Result, NtlmError> { + let mut pairs = Vec::new(); + let mut offset = 0usize; + while offset + 4 <= buffer.len() { + let id = u16::from_le_bytes([buffer[offset], buffer[offset + 1]]); + let length = u16::from_le_bytes([buffer[offset + 2], buffer[offset + 3]]) as usize; + offset += 4; + if id == AV_ID_EOL { + break; + } + let end = offset + .checked_add(length) + .ok_or(NtlmError::TruncatedAvPair)?; + if end > buffer.len() { + return Err(NtlmError::TruncatedAvPair); + } + pairs.push(AvPair { + id, + value: buffer[offset..end].to_vec(), + }); + offset = end; + } + Ok(pairs) +} + +/// Mirrors `BuildTargetInfo` (`cs:145-175`). +/// +/// The .NET reference: +/// - Replaces the `MsvAvTargetName` (id=9) AV pair with `cifs/` +/// if the server emitted a `MsvAvDnsComputerName` (id=3) — `cs:148-154`. +/// - Synthesises a `MsvAvTimestamp` (id=7) if the server omitted one — +/// `cs:156-161`. +/// - Drops any `MsvAvEOL` (id=0) entries from the middle of the list — +/// `cs:165`. +/// - Re-emits each pair as `(id u16 LE, length u16 LE, value bytes)` followed +/// by a 4-byte zero terminator — `cs:166-173`. +fn build_target_info(original: &[u8], filetime: i64) -> Result, NtlmError> { + let mut pairs = parse_av_pairs(original)?; + // dnsHost from id=3, if present + let dns_host = pairs + .iter() + .find(|p| p.id == AV_ID_DNS_HOST) + .map(|p| p.value.clone()); + if let Some(host) = dns_host { + pairs.retain(|p| p.id != AV_ID_TARGET_NAME); + let mut prefix = Vec::new(); + push_utf16le(&mut prefix, "cifs/"); + prefix.extend_from_slice(&host); + pairs.push(AvPair { + id: AV_ID_TARGET_NAME, + value: prefix, + }); + } + if !pairs.iter().any(|p| p.id == AV_ID_TIMESTAMP) { + let mut ts = [0u8; 8]; + ts.copy_from_slice(&filetime.to_le_bytes()); + pairs.push(AvPair { + id: AV_ID_TIMESTAMP, + value: ts.to_vec(), + }); + } + + let mut out = Vec::new(); + for pair in pairs.iter().filter(|p| p.id != AV_ID_EOL) { + out.extend_from_slice(&pair.id.to_le_bytes()); + out.extend_from_slice(&(pair.value.len() as u16).to_le_bytes()); + out.extend_from_slice(&pair.value); + } + // 4-byte terminator (id=0, length=0) — `cs:173` + out.extend_from_slice(&[0u8; 4]); + Ok(out) +} + +/// `Temp` blob from `cs:134-143`. +/// +/// ```text +/// offset size value +/// 0 1 0x01 Resp version +/// 1 1 0x01 HiRespType +/// 2 6 0 reserved +/// 8 8 filetime (i64 LE) +/// 16 8 client_challenge +/// 24 4 0 reserved +/// 28 ... target_info (already terminator-suffixed) +/// ``` +fn build_ntlmv2_temp(client_challenge: [u8; 8], target_info: &[u8], filetime: i64) -> Vec { + let mut temp = vec![0u8; 28 + target_info.len()]; + temp[0] = 1; + temp[1] = 1; + // bytes 2..8 already zero from vec! initialiser + temp[8..16].copy_from_slice(&filetime.to_le_bytes()); + temp[16..24].copy_from_slice(&client_challenge); + // bytes 24..28 already zero + temp[28..].copy_from_slice(target_info); + temp +} + +// --- Key derivation magic strings — `cs:179-191` --- + +const SIGN_MAGIC_C2S: &[u8] = b"session key to client-to-server signing key magic constant\0"; +const SIGN_MAGIC_S2C: &[u8] = b"session key to server-to-client signing key magic constant\0"; +const SEAL_MAGIC_C2S: &[u8] = b"session key to client-to-server sealing key magic constant\0"; +const SEAL_MAGIC_S2C: &[u8] = b"session key to server-to-client sealing key magic constant\0"; + +fn sign_key(session_key: &[u8], client_mode: bool) -> Vec { + let magic = if client_mode { + SIGN_MAGIC_C2S + } else { + SIGN_MAGIC_S2C + }; + let mut hasher = Md5::new(); + hasher.update(session_key); + hasher.update(magic); + hasher.finalize().to_vec() +} + +fn seal_key(session_key: &[u8], client_mode: bool) -> Vec { + let magic = if client_mode { + SEAL_MAGIC_C2S + } else { + SEAL_MAGIC_S2C + }; + let mut hasher = Md5::new(); + hasher.update(session_key); + hasher.update(magic); + hasher.finalize().to_vec() +} + +/// NT hash = MD4(UTF-16LE(password)) — `cs:193-196`. +fn nt_hash(password: &str) -> [u8; 16] { + let mut bytes = Vec::with_capacity(password.len() * 2); + push_utf16le(&mut bytes, password); + let mut hasher = Md4::new(); + hasher.update(&bytes); + let out = hasher.finalize(); + let mut hash = [0u8; 16]; + hash.copy_from_slice(&out); + hash +} + +/// HMAC-MD5 — `cs:198-202`. +fn hmac_md5(key: &[u8], data: &[u8]) -> [u8; 16] { + // HmacMd5 only fails on key-size errors; MD5 has no key size limit so this + // path is unreachable in practice. We map any error to a zeroed hash so + // the function remains panic-free. + let mut out = [0u8; 16]; + if let Ok(mut mac) = HmacMd5::new_from_slice(key) { + mac.update(data); + let bytes = mac.finalize().into_bytes(); + out.copy_from_slice(&bytes); + } + out +} + +/// `WriteSecurityBuffer` — `cs:217-224`. +fn write_security_buffer( + message: &mut [u8], + descriptor_offset: usize, + value: &[u8], + payload_offset: &mut usize, +) { + let len = value.len() as u16; + message[descriptor_offset..descriptor_offset + 2].copy_from_slice(&len.to_le_bytes()); + // .NET writes the same length twice (length and max length) — `cs:220`. + message[descriptor_offset + 2..descriptor_offset + 4].copy_from_slice(&len.to_le_bytes()); + let off = *payload_offset as u32; + message[descriptor_offset + 4..descriptor_offset + 8].copy_from_slice(&off.to_le_bytes()); + message[*payload_offset..*payload_offset + value.len()].copy_from_slice(value); + *payload_offset += value.len(); +} + +/// Append `s` to `dst` in UTF-16LE (no BOM, no terminator). Mirrors +/// `Encoding.Unicode.GetBytes(s)` used throughout the .NET reference. +fn push_utf16le(dst: &mut Vec, s: &str) { + for unit in s.encode_utf16() { + dst.extend_from_slice(&unit.to_le_bytes()); + } +} + +// --- Compile-time invariants --- + +const _: () = assert!(TYPE1_LEN == 32); +const _: () = assert!(TYPE3_HEADER_LEN == 64); +const _: () = assert!(SIGNATURE_LEN == 16); +const _: () = assert!(NTLMSSP_SIGNATURE[7] == 0); + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + fn fixed_inputs() -> FixedInputs { + FixedInputs { + client_challenge: [0xaa; 8], + // Same exported session key used by [MS-NLMP] §4.2.4.3 example + // ("RandomSessionKey") so external comparison is possible. + exported_session_key: [ + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, + ], + // [MS-NLMP] §4.2.4 examples use timestamp = 0. + filetime: 0, + } + } + + /// [MS-NLMP] §4.2.4.1.1 — `NTOWFv1("Password")`: + /// `a4 f4 9c 40 65 10 bd ca b6 82 4e e7 c3 0f d8 52`. + /// MD4 of UTF-16LE("Password") — confirms our MD4 + UTF-16 encoding. + #[test] + fn nt_hash_matches_msnlmp_v1_example() { + let h = nt_hash("Password"); + let expected: [u8; 16] = [ + 0xa4, 0xf4, 0x9c, 0x40, 0x65, 0x10, 0xbd, 0xca, 0xb6, 0x82, 0x4e, 0xe7, 0xc3, 0x0f, + 0xd8, 0x52, + ]; + assert_eq!(h, expected); + } + + /// [MS-NLMP] §4.2.4.1.1 — `NTOWFv2("Password","User","Domain")` = + /// HMAC_MD5(NTOWFv1, UPPER("User") || "Domain") = + /// `0c 86 8a 40 3b fd 7a 93 a3 00 1e f2 2e f0 2e 3f`. + #[test] + fn ntowfv2_matches_msnlmp_example() { + let nt = nt_hash("Password"); + let mut data = Vec::new(); + push_utf16le(&mut data, "USER"); + push_utf16le(&mut data, "Domain"); + let key = hmac_md5(&nt, &data); + let expected: [u8; 16] = [ + 0x0c, 0x86, 0x8a, 0x40, 0x3b, 0xfd, 0x7a, 0x93, 0xa3, 0x00, 0x1e, 0xf2, 0x2e, 0xf0, + 0x2e, 0x3f, + ]; + assert_eq!(key, expected); + } + + #[test] + fn type1_layout_is_thirty_two_bytes_with_signature_and_flags() { + let mut ctx = NtlmClientContext::new("user", "pass", "domain", Some("ws")); + let msg = ctx.create_type1(); + assert_eq!(msg.len(), 32); + assert_eq!(&msg[..8], b"NTLMSSP\0"); + // message_type LE + assert_eq!(&msg[8..12], &1u32.to_le_bytes()); + // flags LE + let flags = u32::from_le_bytes([msg[12], msg[13], msg[14], msg[15]]); + assert_eq!(flags, TYPE1_FLAGS); + // VERSION flag (`cs:18`) is NOT set by the .NET client (`cs:53-63`). + assert_eq!(flags & NEGOTIATE_VERSION, 0); + // Bytes 16..32 are zero (no domain/workstation in Type1). + assert_eq!(&msg[16..32], &[0u8; 16]); + } + + /// Build a minimal Type2 challenge with an empty target_info so we can + /// exercise create_type3 without a live server. + fn make_type2(server_challenge: [u8; 8], flags: u32, target_info: &[u8]) -> Vec { + let mut msg = vec![0u8; 48 + target_info.len()]; + msg[..8].copy_from_slice(b"NTLMSSP\0"); + msg[8..12].copy_from_slice(&2u32.to_le_bytes()); + // target_name security buffer (empty) at offset 12 left zeroed. + msg[20..24].copy_from_slice(&flags.to_le_bytes()); + msg[24..32].copy_from_slice(&server_challenge); + // Reserved 32..40 zero. + let ti_len = target_info.len() as u16; + msg[40..42].copy_from_slice(&ti_len.to_le_bytes()); + msg[42..44].copy_from_slice(&ti_len.to_le_bytes()); + msg[44..48].copy_from_slice(&48u32.to_le_bytes()); + msg[48..].copy_from_slice(target_info); + msg + } + + #[test] + fn type3_round_trip_with_fixed_inputs_is_deterministic() { + let mut a = NtlmClientContext::new("User", "Password", "Domain", Some("")); + let mut b = NtlmClientContext::new("User", "Password", "Domain", Some("")); + a.create_type1(); + b.create_type1(); + let server_challenge = [0x01u8, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]; + let challenge = make_type2(server_challenge, TYPE1_FLAGS, &[0u8; 4]); + let mut inputs_a = fixed_inputs(); + let mut inputs_b = fixed_inputs(); + let m_a = a.create_type3(&challenge, &mut inputs_a).unwrap(); + let m_b = b.create_type3(&challenge, &mut inputs_b).unwrap(); + assert_eq!(m_a, m_b, "fixed inputs must yield identical Type3"); + // header sanity + assert_eq!(&m_a[..8], b"NTLMSSP\0"); + assert_eq!(&m_a[8..12], &3u32.to_le_bytes()); + assert!(m_a.len() >= TYPE3_HEADER_LEN); + // negotiated flags merged (server returned same TYPE1_FLAGS so identical) + assert_eq!(a.flags(), TYPE1_FLAGS); + } + + #[test] + fn type3_security_buffer_offsets_are_in_order() { + let mut ctx = NtlmClientContext::new("U", "P", "D", Some("W")); + ctx.create_type1(); + let challenge = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); + let msg = ctx.create_type3(&challenge, &mut fixed_inputs()).unwrap(); + // Read each security buffer and verify offsets are monotonically + // increasing and each (offset, length) is in-bounds. + let read_sb = |off: usize| { + let len = u16::from_le_bytes([msg[off], msg[off + 1]]) as usize; + let pos = u32::from_le_bytes([msg[off + 4], msg[off + 5], msg[off + 6], msg[off + 7]]) + as usize; + (len, pos) + }; + let lm = read_sb(12); + let nt = read_sb(20); + let dom = read_sb(28); + let user = read_sb(36); + let ws = read_sb(44); + let key = read_sb(52); + let mut expected_off = TYPE3_HEADER_LEN; + for (len, off) in [lm, nt, dom, user, ws, key] { + assert_eq!(off, expected_off); + assert!(off + len <= msg.len()); + expected_off += len; + } + // EncryptedRandomSessionKey is exactly 16 bytes. + assert_eq!(key.0, 16); + // LM response is HMAC(16) || client_challenge(8) = 24 bytes. + assert_eq!(lm.0, 24); + } + + #[test] + fn type3_encrypted_session_key_decrypts_to_exported_key() { + // The encrypted session key is RC4(SessionBaseKey).Transform(ExportedKey). + // Reversing it requires SessionBaseKey, which requires the user/pass + // path. Here we just confirm the bytes change (RC4 isn't identity). + let mut ctx = NtlmClientContext::new("User", "Password", "Domain", Some("")); + ctx.create_type1(); + let challenge = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); + let msg = ctx.create_type3(&challenge, &mut fixed_inputs()).unwrap(); + let key_off = u32::from_le_bytes([msg[56], msg[57], msg[58], msg[59]]) as usize; + let key_len = u16::from_le_bytes([msg[52], msg[53]]) as usize; + let encrypted = &msg[key_off..key_off + key_len]; + // Exported key in fixed_inputs is all-0x55. After RC4 it should differ. + assert_ne!(encrypted, &[0x55u8; 16]); + assert_eq!(ctx.exported_session_key(), &[0x55u8; 16]); + } + + #[test] + fn sign_layout_matches_msnlmp_3_4_4_2() { + // Walk through one Sign() call and verify the 16-byte layout: + // [0..4] = 1 (LE), [4..12] = RC4-checksum, [12..16] = sequence (LE). + let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); + ctx.create_type1(); + let challenge = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); + ctx.create_type3(&challenge, &mut fixed_inputs()).unwrap(); + + let msg = b"hello world"; + let sig = ctx.sign(msg).unwrap(); + assert_eq!(sig.len(), 16); + assert_eq!(&sig[0..4], &1u32.to_le_bytes()); + assert_eq!(&sig[12..16], &0u32.to_le_bytes(), "first call uses seq=0"); + assert_eq!(ctx.sequence(), 1); + + // Second call advances seq. + let sig2 = ctx.sign(msg).unwrap(); + assert_eq!(&sig2[12..16], &1u32.to_le_bytes()); + // Different seq + advancing RC4 keystream → different checksum bytes. + assert_ne!(&sig[4..12], &sig2[4..12]); + assert_eq!(ctx.sequence(), 2); + } + + #[test] + fn sign_byte_for_byte_recompute_matches() { + // Build a context, sign a few messages, then recompute each signature + // independently against the same sealing key (the `recompute_*` + // helper). Both paths must agree byte-for-byte. + let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); + ctx.create_type1(); + let challenge = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); + ctx.create_type3(&challenge, &mut fixed_inputs()).unwrap(); + + let messages: &[&[u8]] = &[b"first", b"second message", b"third!!!"]; + let mut sigs = Vec::new(); + for m in messages { + sigs.push(ctx.sign(m).unwrap()); + } + // Recompute from an independent context derived from the same inputs + // (the rc4 0.2 cipher state is not Clone, so a fresh derivation is + // semantically equivalent — both contexts land on the same keys). + // `rc4_skip_bytes` advances by 8 per signed message because each + // Sign consumes exactly 8 bytes of keystream (`cs:124`). + let mut snapshot = NtlmClientContext::new("U", "P", "D", Some("")); + snapshot.create_type1(); + snapshot + .create_type3(&challenge, &mut fixed_inputs()) + .unwrap(); + for (i, m) in messages.iter().enumerate() { + let recomputed = snapshot.recompute_signature_at(i as u32, m, i * 8).unwrap(); + assert_eq!( + recomputed, sigs[i], + "recomputed signature for seq={i} diverged" + ); + } + } + + #[test] + fn sign_before_type3_fails() { + let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); + let err = ctx.sign(b"x").unwrap_err(); + assert!(matches!(err, NtlmError::NotAuthenticated)); + } + + #[test] + fn type2_short_buffer_rejected() { + let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); + ctx.create_type1(); + let err = ctx + .create_type3(&[0u8; 32], &mut fixed_inputs()) + .unwrap_err(); + assert!(matches!(err, NtlmError::ShortRead { .. })); + } + + #[test] + fn type2_bad_signature_rejected() { + let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); + ctx.create_type1(); + let mut bad = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); + bad[0] = b'X'; // corrupt the leading "NTLMSSP\0" + let err = ctx.create_type3(&bad, &mut fixed_inputs()).unwrap_err(); + assert!(matches!(err, NtlmError::InvalidSignature)); + } + + #[test] + fn type2_target_info_oob_rejected() { + let mut ctx = NtlmClientContext::new("U", "P", "D", Some("")); + ctx.create_type1(); + let mut bad = make_type2([0u8; 8], TYPE1_FLAGS, &[0u8; 4]); + // Claim 200 target_info bytes when we only have 4. + bad[40..42].copy_from_slice(&200u16.to_le_bytes()); + let err = ctx.create_type3(&bad, &mut fixed_inputs()).unwrap_err(); + assert!(matches!(err, NtlmError::InvalidTargetInfo)); + } + + #[test] + fn av_pair_truncated_rejected() { + // id=3 (DnsHost), length=200, but only 4 bytes of value supplied. + let mut buf = Vec::new(); + buf.extend_from_slice(&3u16.to_le_bytes()); + buf.extend_from_slice(&200u16.to_le_bytes()); + buf.extend_from_slice(&[0u8; 4]); + let err = parse_av_pairs(&buf).unwrap_err(); + assert!(matches!(err, NtlmError::TruncatedAvPair)); + } + + #[test] + fn build_target_info_synthesises_timestamp_when_missing() { + // Empty input AV table → just the 4-byte EOL terminator. + let out = build_target_info(&[0u8; 4], 0x1122334455667788).unwrap(); + // Should now contain id=7 (timestamp) followed by 8 bytes of FILETIME. + // Format: id(2) || len(2) || value(len) ... then 4 zero bytes. + assert_eq!(&out[0..2], &7u16.to_le_bytes()); + assert_eq!(&out[2..4], &8u16.to_le_bytes()); + assert_eq!(&out[4..12], &0x1122334455667788i64.to_le_bytes()); + assert_eq!(&out[12..16], &[0u8; 4]); + } + + #[test] + fn build_target_info_does_not_overwrite_server_timestamp() { + // Server already supplied a timestamp (id=7). + let mut input = Vec::new(); + input.extend_from_slice(&7u16.to_le_bytes()); + input.extend_from_slice(&8u16.to_le_bytes()); + input.extend_from_slice(&[0xaa; 8]); + input.extend_from_slice(&[0u8; 4]); // EOL + let out = build_target_info(&input, 0).unwrap(); + // Only one timestamp pair. + let pairs = parse_av_pairs(&out).unwrap(); + let ts: Vec<_> = pairs.iter().filter(|p| p.id == AV_ID_TIMESTAMP).collect(); + assert_eq!(ts.len(), 1); + assert_eq!(ts[0].value, vec![0xaa; 8]); + } + + #[test] + fn build_target_info_replaces_target_name_with_cifs_dnshost() { + // Server supplies id=3 (DnsComputerName) = UTF-16LE("HOST"). + let mut input = Vec::new(); + let mut host = Vec::new(); + push_utf16le(&mut host, "HOST"); + input.extend_from_slice(&3u16.to_le_bytes()); + input.extend_from_slice(&(host.len() as u16).to_le_bytes()); + input.extend_from_slice(&host); + // Pre-existing id=9 that should be replaced. + input.extend_from_slice(&9u16.to_le_bytes()); + input.extend_from_slice(&4u16.to_le_bytes()); + input.extend_from_slice(&[0xff; 4]); + input.extend_from_slice(&[0u8; 4]); // EOL + let out = build_target_info(&input, 0).unwrap(); + let pairs = parse_av_pairs(&out).unwrap(); + let target: Vec<_> = pairs.iter().filter(|p| p.id == AV_ID_TARGET_NAME).collect(); + assert_eq!(target.len(), 1); + let mut expected = Vec::new(); + push_utf16le(&mut expected, "cifs/"); + expected.extend_from_slice(&host); + assert_eq!(target[0].value, expected); + } + + #[test] + fn ntlmv2_temp_layout() { + let cc = [0xaa; 8]; + let temp = build_ntlmv2_temp(cc, &[0xee; 4], 0x1122334455667788); + assert_eq!(temp[0], 0x01); + assert_eq!(temp[1], 0x01); + assert_eq!(&temp[2..8], &[0u8; 6]); + assert_eq!(&temp[8..16], &0x1122334455667788i64.to_le_bytes()); + assert_eq!(&temp[16..24], &cc); + assert_eq!(&temp[24..28], &[0u8; 4]); + assert_eq!(&temp[28..], &[0xee; 4]); + } + + #[test] + fn sign_and_seal_keys_are_md5_of_session_key_plus_magic() { + // Cross-check our derivation against an independent MD5 of + // session_key || magic — same recipe the .NET reference uses. + let session_key = [0x11u8; 16]; + let derived = sign_key(&session_key, true); + let mut h = Md5::new(); + h.update(session_key); + h.update(SIGN_MAGIC_C2S); + let manual = h.finalize().to_vec(); + assert_eq!(derived, manual); + + let derived_seal = seal_key(&session_key, true); + let mut h = Md5::new(); + h.update(session_key); + h.update(SEAL_MAGIC_C2S); + let manual_seal = h.finalize().to_vec(); + assert_eq!(derived_seal, manual_seal); + } + + #[test] + fn write_security_buffer_emits_len_maxlen_offset() { + let mut msg = vec![0u8; 32]; + let mut off = 16; + write_security_buffer(&mut msg, 0, &[0xaa, 0xbb, 0xcc], &mut off); + assert_eq!(&msg[0..2], &3u16.to_le_bytes()); + assert_eq!(&msg[2..4], &3u16.to_le_bytes()); + assert_eq!(&msg[4..8], &16u32.to_le_bytes()); + assert_eq!(&msg[16..19], &[0xaa, 0xbb, 0xcc]); + assert_eq!(off, 19); + } +} diff --git a/rust/crates/mxaccess-rpc/src/objref.rs b/rust/crates/mxaccess-rpc/src/objref.rs new file mode 100644 index 0000000..9f73ec7 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/objref.rs @@ -0,0 +1,672 @@ +//! `ComObjRef` — DCOM OBJREF parser. +//! +//! Direct port of `src/MxNativeClient/ComObjRef.cs`. Parses the marshalled +//! interface byte stream produced by `CoMarshalInterface` (per `[MS-DCOM]` +//! §2.2.18) into a structured form the RPC layer can inspect for OXID/OID/IPID +//! and the dual-string-array bindings (string + security towers). +//! +//! The .NET reference parses 68 fixed bytes followed by the dual-string array, +//! which is decoded character-by-character and used purely for diagnostics. +//! The Rust port mirrors that parser shape exactly — every field offset, +//! the `Math.Min(entries, data.Length / 2)` bound, and the printable-ASCII +//! escaping of each UTF-16 code unit are 1:1 with `ComObjRef.cs:18-117`. +//! +//! `ComObjRefProvider.cs` is **not** ported here — it is a thin wrapper around +//! Win32 `CoMarshalInterface` / `IStream` / `GlobalLock` and produces OBJREF +//! bytes by calling into ole32. That belongs behind `windows-rs` in a later +//! M2/M3 wave; the pure-Rust parser stands alone and is what M2 wave 1 needs +//! for OBJREF inspection on inbound activation responses. See followup F1 +//! in this module's report. + +// Direct byte indexing — every access is guarded by an explicit length check +// and the result reads as a 1:1 mirror of the .NET `BinaryPrimitives` calls. +// `.get(n)?` would obscure the byte map. Mirrors the rationale documented in +// `crates/mxaccess-codec/src/reference_handle.rs:7-11`. +#![allow(clippy::indexing_slicing)] + +use std::fmt::Write as _; + +use thiserror::Error; + +/// Encoded layout per `ComObjRef.cs:25-39`: +/// +/// ```text +/// offset size field +/// 0 4 signature u32 LE = 0x574F454D ("MEOW") +/// 4 4 flags u32 LE (1 = OBJREF_STANDARD; only standard parsed) +/// 8 16 iid GUID +/// 24 4 std_flags u32 LE (STDOBJREF flags) +/// 28 4 public_refs u32 LE +/// 32 8 oxid u64 LE +/// 40 8 oid u64 LE +/// 48 16 ipid GUID +/// 64 2 dual_string_entries u16 LE (count of u16 code units in the array) +/// 66 2 dual_string_security_offset u16 LE (boundary index between string and security bindings) +/// 68 .. dual-string array (variable; UTF-16LE code units terminated by 0x0000 per entry) +/// ``` +/// +/// **Header length** is 68 bytes; the dual-string array follows starting at +/// offset 68. The `dual_string_entries` count is bounded by the actual byte +/// length of the trailing bytes via `min(entries, data.len() / 2)` +/// (`ComObjRef.cs:59`). +pub const OBJREF_HEADER_LEN: usize = 68; + +/// "MEOW" — the OBJREF signature (`ComObjRef.cs:29`, also `[MS-DCOM]` §2.2.18.1). +pub const OBJREF_SIGNATURE: u32 = 0x574F_454D; + +const FLAGS_OFFSET: usize = 4; +const IID_OFFSET: usize = 8; +const STD_FLAGS_OFFSET: usize = 24; +const PUBLIC_REFS_OFFSET: usize = 28; +const OXID_OFFSET: usize = 32; +const OID_OFFSET: usize = 40; +const IPID_OFFSET: usize = 48; +const DUAL_STRING_ENTRIES_OFFSET: usize = 64; +const DUAL_STRING_SECURITY_OFFSET_OFFSET: usize = 66; + +/// 16-byte GUID. Stored as little-endian wire bytes for the first three groups +/// (Data1 u32 LE, Data2 u16 LE, Data3 u16 LE) followed by 8 big-endian +/// `Data4` bytes — matches the byte layout produced by .NET +/// `new Guid(ReadOnlySpan)` (`ComObjRef.cs:31,36`). +/// +/// Kept as a self-contained type to avoid pulling `uuid` into `mxaccess-rpc`; +/// the sibling DCE/RPC PDU codec may consolidate to a shared type at the +/// loop-driver level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct Guid(pub [u8; 16]); + +impl Guid { + pub const fn new(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + pub const fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } +} + +impl std::fmt::Display for Guid { + /// Mirrors .NET `Guid.ToString("D")`: dashed hex, lowercase, e.g. + /// `b49f92f7-c748-4169-8eca-a0670b012746`. The first three groups are + /// little-endian on the wire so are byte-swapped on display. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let b = &self.0; + write!( + f, + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + b[3], + b[2], + b[1], + b[0], + b[5], + b[4], + b[7], + b[6], + b[8], + b[9], + b[10], + b[11], + b[12], + b[13], + b[14], + b[15], + ) + } +} + +/// Errors produced by the OBJREF parser. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum RpcError { + /// Buffer too short to satisfy a fixed-layout read. + #[error("short read: expected {expected} bytes, got {actual}")] + ShortRead { expected: usize, actual: usize }, +} + +/// One decoded entry of the OBJREF dual-string array. `value` is the +/// printable-ASCII escaping of the UTF-16 string per `ComObjRef.cs:82-91` — +/// non-printable code units appear as `` lowercase hex. `is_security_binding` +/// is set when the entry's start offset (in u16 units) is at or past +/// `DualStringSecurityOffset`. +/// +/// Mirrors `ComDualStringEntry` (`ComObjRef.cs:138-145`). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ComDualStringEntry { + pub tower_id: u16, + pub protocol: &'static str, + pub value: String, + pub is_security_binding: bool, +} + +impl ComDualStringEntry { + /// Mirrors `ComDualStringEntry.ToDiagnosticString` (`ComObjRef.cs:140-144`): + /// `":0x::"`. + pub fn to_diagnostic_string(&self) -> String { + let kind = if self.is_security_binding { + "security" + } else { + "string" + }; + format!( + "{}:0x{:04x}:{}:{}", + kind, self.tower_id, self.protocol, self.value + ) + } +} + +/// Parsed DCOM OBJREF (standard form). +/// +/// Mirrors `ComObjRef` record (`ComObjRef.cs:5-16`). All eleven fields of the +/// .NET record are preserved including `signature`, `flags`, `std_flags`, +/// `dual_string_entries`, and `dual_string_security_offset` — even though the +/// signature is a known constant, the parser does not validate it (the .NET +/// reference doesn't either; bytes are surfaced verbatim per CLAUDE.md +/// preserve-unknown-bytes rule). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ComObjRef { + pub signature: u32, + pub flags: u32, + pub iid: Guid, + pub standard_flags: u32, + pub public_refs: u32, + pub oxid: u64, + pub oid: u64, + pub ipid: Guid, + /// Raw entry-count field — measured in **u16 code units**, not entries. + /// Preserved verbatim from the wire even when it overruns the buffer; the + /// parse loop bounds itself by `min(entries, data.len() / 2)` + /// (`ComObjRef.cs:59`). + pub dual_string_entries: u16, + /// Boundary (in u16 code-unit indices) between string bindings and + /// security bindings within the dual-string array (`ComObjRef.cs:98`). + pub dual_string_security_offset: u16, + pub dual_string_entries_decoded: Vec, +} + +impl ComObjRef { + /// Header length (68 bytes) before the dual-string array. + pub const HEADER_LEN: usize = OBJREF_HEADER_LEN; + + /// Parse an OBJREF buffer. Mirrors `ComObjRef.Parse` (`ComObjRef.cs:18-40`) + /// byte-for-byte: 68-byte fixed header followed by a UTF-16LE + /// dual-string array bounded by `min(entries, tail.len() / 2)`. + /// + /// The signature field is read but not validated — the .NET reference + /// surfaces it verbatim so callers can diff against captures. + /// + /// # Errors + /// + /// - [`RpcError::ShortRead`] if `buffer.len() < 68` + /// (matches `ComObjRef.cs:20-23`). + pub fn parse(buffer: &[u8]) -> Result { + if buffer.len() < Self::HEADER_LEN { + return Err(RpcError::ShortRead { + expected: Self::HEADER_LEN, + actual: buffer.len(), + }); + } + + let dual_string_entries = read_u16_le(buffer, DUAL_STRING_ENTRIES_OFFSET); + let security_offset = read_u16_le(buffer, DUAL_STRING_SECURITY_OFFSET_OFFSET); + + let mut iid_bytes = [0u8; 16]; + iid_bytes.copy_from_slice(&buffer[IID_OFFSET..IID_OFFSET + 16]); + let mut ipid_bytes = [0u8; 16]; + ipid_bytes.copy_from_slice(&buffer[IPID_OFFSET..IPID_OFFSET + 16]); + + let tail = &buffer[Self::HEADER_LEN..]; + let decoded = decode_dual_string_array(tail, dual_string_entries, security_offset); + + Ok(Self { + signature: read_u32_le(buffer, 0), + flags: read_u32_le(buffer, FLAGS_OFFSET), + iid: Guid(iid_bytes), + standard_flags: read_u32_le(buffer, STD_FLAGS_OFFSET), + public_refs: read_u32_le(buffer, PUBLIC_REFS_OFFSET), + oxid: read_u64_le(buffer, OXID_OFFSET), + oid: read_u64_le(buffer, OID_OFFSET), + ipid: Guid(ipid_bytes), + dual_string_entries, + dual_string_security_offset: security_offset, + dual_string_entries_decoded: decoded, + }) + } + + /// Diagnostic line emitter — byte-identical to `ToDiagnosticLines` + /// (`ComObjRef.cs:42-55`). The output is intended for matching against + /// Frida-captured probe output (`captures/057-managed-callback-route-service-trace-saved/probe.stdout.txt`). + pub fn to_diagnostic_lines(&self) -> Vec { + let dual_strings = self + .dual_string_entries_decoded + .iter() + .map(ComDualStringEntry::to_diagnostic_string) + .collect::>() + .join("|"); + vec![ + format!("objref_signature=0x{:08X}", self.signature), + format!("objref_flags=0x{:08X}", self.flags), + format!("objref_iid={}", self.iid), + format!("std_flags=0x{:08X}", self.standard_flags), + format!("std_public_refs={}", self.public_refs), + format!("std_oxid=0x{:016X}", self.oxid), + format!("std_oid=0x{:016X}", self.oid), + format!("std_ipid={}", self.ipid), + format!("dual_string_entries={}", self.dual_string_entries), + format!( + "dual_string_security_offset={}", + self.dual_string_security_offset + ), + format!("dual_strings={}", dual_strings), + ] + } +} + +/// Decode the trailing dual-string array. Mirrors +/// `DecodeDualStringArray` (`ComObjRef.cs:57-102`). +/// +/// The loop walks `i` in u16 code-unit indices, capped by +/// `min(entries, data.len() / 2)`. Each entry begins with a 16-bit +/// `tower_id`; if zero, it terminates the string-binding region (the +/// `continue` skips to the next index without producing an entry — same as +/// the .NET source). Otherwise the following u16 code units up to (but not +/// including) the next 0x0000 terminator form the entry's value, escaped +/// printable-ASCII per the `0x20..=0x7e` rule. +fn decode_dual_string_array( + data: &[u8], + entries: u16, + security_offset: u16, +) -> Vec { + let entries = entries as usize; + let count = entries.min(data.len() / 2); + let mut strings = Vec::new(); + + let mut i: usize = 0; + while i < count { + let entry_start = i; + let tower_id = read_u16_le(data, i * 2); + i += 1; + if tower_id == 0 { + continue; + } + + let mut text = String::new(); + while i < count { + let value = read_u16_le(data, i * 2); + i += 1; + if value == 0 { + break; + } + + if (0x20..=0x7e).contains(&value) { + // Safe: 0x20..=0x7e is printable ASCII, valid UTF-8. + text.push(value as u8 as char); + } else { + // Non-printable: emit "" lowercase hex (mirrors .NET + // `value.ToString("x4", InvariantCulture)`). + // write! to a String never fails; ignore the Result. + let _ = write!(&mut text, "<{:04x}>", value); + } + } + + strings.push(ComDualStringEntry { + tower_id, + protocol: protocol_tower_name(tower_id), + value: text, + is_security_binding: entry_start >= security_offset as usize, + }); + } + + strings +} + +/// Protocol-tower name table per `ComObjRef.cs:104-117`. Returns `"unknown"` +/// for unrecognised tower ids — mirrors the `_ =>` fall-through. +pub const fn protocol_tower_name(tower_id: u16) -> &'static str { + match tower_id { + 0x0007 => "ncacn_ip_tcp", + 0x0008 => "ncadg_ip_udp", + 0x0009 => "ncacn_np", + 0x000f => "ncacn_spx", + 0x0010 => "ncacn_nb_nb", + 0x0016 => "ncadg_ip_udp_or_netbios", + 0x001f => "ncalrpc", + _ => "unknown", + } +} + +#[inline] +fn read_u16_le(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes([bytes[offset], bytes[offset + 1]]) +} + +#[inline] +fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +#[inline] +fn read_u64_le(bytes: &[u8], offset: usize) -> u64 { + u64::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + bytes[offset + 4], + bytes[offset + 5], + bytes[offset + 6], + bytes[offset + 7], + ]) +} + +// Compile-time invariant: header length matches the documented byte layout. +const _: () = assert!(OBJREF_HEADER_LEN == 68); +const _: () = assert!(OBJREF_SIGNATURE == 0x574F_454D); + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + /// Hand-built OBJREF: signature + flags=1 + sample IID + std_flags + + /// public_refs=5 + fake OXID/OID/IPID + dual_string array containing one + /// `ncacn_ip_tcp` entry then a `0x0000` terminator. Returns the bytes. + fn build_minimal_objref() -> Vec { + let mut buf = Vec::new(); + + // signature "MEOW" 0x574F454D LE + buf.extend_from_slice(&0x574F_454Du32.to_le_bytes()); + // flags = 1 (OBJREF_STANDARD) + buf.extend_from_slice(&1u32.to_le_bytes()); + // iid (16 bytes; arbitrary) + buf.extend_from_slice(&[ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, + ]); + // std_flags + buf.extend_from_slice(&0u32.to_le_bytes()); + // public_refs = 5 + buf.extend_from_slice(&5u32.to_le_bytes()); + // oxid + buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes()); + // oid + buf.extend_from_slice(&0xAABB_CCDD_EEFF_0011u64.to_le_bytes()); + // ipid + buf.extend_from_slice(&[ + 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, + 0xae, 0xaf, + ]); + + // Build the dual-string array: + // tower_id 0x0007 (ncacn_ip_tcp) + // "AB" as UTF-16LE + // 0x0000 terminator + // That's 4 u16 code units = 8 bytes. + let array_units: [u16; 4] = [0x0007, b'A' as u16, b'B' as u16, 0x0000]; + let dual_entries: u16 = array_units.len() as u16; + let security_offset: u16 = dual_entries; // no security bindings + + // dual_string_entries (count of u16 code units) + buf.extend_from_slice(&dual_entries.to_le_bytes()); + // dual_string_security_offset + buf.extend_from_slice(&security_offset.to_le_bytes()); + + // header now exactly 68 bytes + assert_eq!(buf.len(), 68); + + for unit in array_units { + buf.extend_from_slice(&unit.to_le_bytes()); + } + buf + } + + #[test] + fn parse_minimal_objref() { + let bytes = build_minimal_objref(); + let parsed = ComObjRef::parse(&bytes).unwrap(); + + assert_eq!(parsed.signature, 0x574F_454D); + assert_eq!(parsed.flags, 1); + assert_eq!(parsed.standard_flags, 0); + assert_eq!(parsed.public_refs, 5); + assert_eq!(parsed.oxid, 0x1122_3344_5566_7788); + assert_eq!(parsed.oid, 0xAABB_CCDD_EEFF_0011); + assert_eq!(parsed.dual_string_entries, 4); + assert_eq!(parsed.dual_string_security_offset, 4); + assert_eq!(parsed.dual_string_entries_decoded.len(), 1); + + let entry = &parsed.dual_string_entries_decoded[0]; + assert_eq!(entry.tower_id, 0x0007); + assert_eq!(entry.protocol, "ncacn_ip_tcp"); + assert_eq!(entry.value, "AB"); + // entry_start (0) < security_offset (4) → string binding. + assert!(!entry.is_security_binding); + } + + #[test] + fn diagnostic_lines_format_minimal() { + let bytes = build_minimal_objref(); + let parsed = ComObjRef::parse(&bytes).unwrap(); + let lines = parsed.to_diagnostic_lines(); + // Per ComObjRef.cs:42-55 there are exactly 11 lines. + assert_eq!(lines.len(), 11); + assert_eq!(lines[0], "objref_signature=0x574F454D"); + assert_eq!(lines[1], "objref_flags=0x00000001"); + assert_eq!(lines[3], "std_flags=0x00000000"); + assert_eq!(lines[4], "std_public_refs=5"); + assert_eq!(lines[5], "std_oxid=0x1122334455667788"); + assert_eq!(lines[6], "std_oid=0xAABBCCDDEEFF0011"); + assert_eq!(lines[8], "dual_string_entries=4"); + assert_eq!(lines[9], "dual_string_security_offset=4"); + assert_eq!(lines[10], "dual_strings=string:0x0007:ncacn_ip_tcp:AB"); + } + + #[test] + fn parse_rejects_short_buffer() { + // 67-byte buffer (one shy of header) must error, not panic. + let err = ComObjRef::parse(&[0u8; 67]).unwrap_err(); + match err { + RpcError::ShortRead { expected, actual } => { + assert_eq!(expected, 68); + assert_eq!(actual, 67); + } + } + } + + #[test] + fn parse_accepts_exact_header_no_array() { + // 68 bytes with dual_string_entries=0 → no decoded entries. + let mut buf = vec![0u8; 68]; + // signature + buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes()); + let parsed = ComObjRef::parse(&buf).unwrap(); + assert_eq!(parsed.dual_string_entries, 0); + assert_eq!(parsed.dual_string_security_offset, 0); + assert!(parsed.dual_string_entries_decoded.is_empty()); + } + + #[test] + fn protocol_tower_name_table() { + // All 7 documented tower ids per ComObjRef.cs:106-117. + assert_eq!(protocol_tower_name(0x0007), "ncacn_ip_tcp"); + assert_eq!(protocol_tower_name(0x0008), "ncadg_ip_udp"); + assert_eq!(protocol_tower_name(0x0009), "ncacn_np"); + assert_eq!(protocol_tower_name(0x000f), "ncacn_spx"); + assert_eq!(protocol_tower_name(0x0010), "ncacn_nb_nb"); + assert_eq!(protocol_tower_name(0x0016), "ncadg_ip_udp_or_netbios"); + assert_eq!(protocol_tower_name(0x001f), "ncalrpc"); + // Fall-through. + assert_eq!(protocol_tower_name(0x0000), "unknown"); + assert_eq!(protocol_tower_name(0xFFFF), "unknown"); + } + + #[test] + fn dual_string_array_overrun_bounded() { + // Build a header that claims 1000 dual-string code units but only + // includes 4 bytes (= 2 code units) of trailing data. The parser + // must bound itself via min(entries, data.len()/2) per + // ComObjRef.cs:59 and not read past the end. + let mut buf = build_minimal_objref(); + // Truncate the trailing dual-string bytes back to 0 and lie about + // entries=1000. + buf.truncate(68); + buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2] + .copy_from_slice(&1000u16.to_le_bytes()); + let parsed = ComObjRef::parse(&buf).unwrap(); + // The wire-declared entry count is preserved verbatim per + // CLAUDE.md unknown-bytes rule. + assert_eq!(parsed.dual_string_entries, 1000); + // But the loop bound prevents any decoding. + assert!(parsed.dual_string_entries_decoded.is_empty()); + } + + #[test] + fn security_binding_flag_split() { + // Build a dual-string array with one string binding then one security + // binding. Layout (u16 code units): + // [0] tower=0x0007 (string binding starts at index 0) + // [1] 'A' + // [2] 0x0000 terminator + // [3] tower=0x0007 (security binding starts at index 3) + // [4] 'B' + // [5] 0x0000 terminator + // dual_string_entries = 6, security_offset = 3 (entries with start + // index >= 3 are security bindings). + let mut buf = vec![0u8; 68]; + buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes()); + let entries: u16 = 6; + let sec_off: u16 = 3; + buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2] + .copy_from_slice(&entries.to_le_bytes()); + buf[DUAL_STRING_SECURITY_OFFSET_OFFSET..DUAL_STRING_SECURITY_OFFSET_OFFSET + 2] + .copy_from_slice(&sec_off.to_le_bytes()); + for unit in [0x0007u16, b'A' as u16, 0x0000, 0x0007, b'B' as u16, 0x0000] { + buf.extend_from_slice(&unit.to_le_bytes()); + } + let parsed = ComObjRef::parse(&buf).unwrap(); + assert_eq!(parsed.dual_string_entries_decoded.len(), 2); + assert_eq!(parsed.dual_string_entries_decoded[0].value, "A"); + assert!(!parsed.dual_string_entries_decoded[0].is_security_binding); + assert_eq!(parsed.dual_string_entries_decoded[1].value, "B"); + assert!(parsed.dual_string_entries_decoded[1].is_security_binding); + } + + #[test] + fn non_printable_codeunit_escaped_as_hex() { + // tower=0x0007, then a non-printable u16 (0x0100), then 'a', then 0x0000. + let mut buf = vec![0u8; 68]; + buf[0..4].copy_from_slice(&0x574F_454Du32.to_le_bytes()); + let entries: u16 = 4; + buf[DUAL_STRING_ENTRIES_OFFSET..DUAL_STRING_ENTRIES_OFFSET + 2] + .copy_from_slice(&entries.to_le_bytes()); + buf[DUAL_STRING_SECURITY_OFFSET_OFFSET..DUAL_STRING_SECURITY_OFFSET_OFFSET + 2] + .copy_from_slice(&entries.to_le_bytes()); + for unit in [0x0007u16, 0x0100, b'a' as u16, 0x0000] { + buf.extend_from_slice(&unit.to_le_bytes()); + } + let parsed = ComObjRef::parse(&buf).unwrap(); + assert_eq!(parsed.dual_string_entries_decoded.len(), 1); + // Expect "<0100>a" per the printable-ASCII escape rule + // (ComObjRef.cs:82-91). + assert_eq!(parsed.dual_string_entries_decoded[0].value, "<0100>a"); + } + + /// Captured OBJREF from `captures/057-managed-callback-route-service-trace-saved/probe.stdout.txt:6` + /// (`managed_callback_objref_hex`). 366 bytes; produced by the .NET + /// managed-callback exporter via `MarshalIUnknownObjRef` in the live + /// probe. Used to verify that the Rust parser interprets a real-world + /// OBJREF identically to the .NET reference. + const CAPTURED_OBJREF_HEX: &str = "4D454F5701000000F7929FB448C769418ECAA0670B012746800A000005000000750CC6C8BA9B1EFD3BF71E12FEE5B5C1022C0000DC32FFFF8AC67645E6ED23FF95007F0007004400450053004B0054004F0050002D0036004A004C0033004B004B004F0000000700310030002E003100300030002E0030002E0034003800000007003100370032002E00320039002E003200320034002E0031000000070066006400650031003A0061006500340031003A0038006100300030003A0034003500320061003A0062006200340031003A0065006500370065003A0035006600640034003A0064006300310038000000070066006400650031003A0061006500340031003A0038006100300030003A0034003500320061003A0035003000620031003A0038003400360066003A0037006200350031003A006500610034003000000000000900FFFF00001E00FFFF00001000FFFF00000A00FFFF00001600FFFF00001F00FFFF00000E00FFFF00000000"; + + fn hex_decode(hex: &str) -> Vec { + let bytes = hex.as_bytes(); + assert!(bytes.len() % 2 == 0); + let mut out = Vec::with_capacity(bytes.len() / 2); + for chunk in bytes.chunks(2) { + let hi = (chunk[0] as char).to_digit(16).unwrap() as u8; + let lo = (chunk[1] as char).to_digit(16).unwrap() as u8; + out.push((hi << 4) | lo); + } + out + } + + #[test] + fn captured_objref_parses() { + let bytes = hex_decode(CAPTURED_OBJREF_HEX); + // probe.stdout.txt:5 reports managed_callback_objref_size=366. + assert_eq!(bytes.len(), 366); + + let parsed = ComObjRef::parse(&bytes).unwrap(); + + // Signature is the canonical "MEOW". + assert_eq!(parsed.signature, OBJREF_SIGNATURE); + // OBJREF_STANDARD. + assert_eq!(parsed.flags, 1); + // public_refs = 5 (per the captured bytes 28..32 = 05 00 00 00). + assert_eq!(parsed.public_refs, 5); + // Captured bytes at offset 64..68 are `95 00 7F 00`: + // dual_string_entries (u16 LE) = 0x0095 = 149 + // dual_string_security_offset (u16 LE) = 0x007F = 127 + // 149 u16 units from offset 68 onwards exactly fills the remaining + // 366 - 68 = 298 bytes (149 * 2), so the entries count saturates the + // buffer — confirming the parser's `min(entries, data.len()/2)` + // bound at `ComObjRef.cs:59` produces the same effective walk length. + assert_eq!(parsed.dual_string_entries, 0x0095); + assert_eq!(parsed.dual_string_security_offset, 0x007F); + + // First decoded string-binding is the hostname over ncacn_ip_tcp. + // The probe was run on host DESKTOP-6JL3KKO per the captured UTF-16 + // bytes immediately following the dual-string header. + let first = &parsed.dual_string_entries_decoded[0]; + assert_eq!(first.tower_id, 0x0007); + assert_eq!(first.protocol, "ncacn_ip_tcp"); + assert_eq!(first.value, "DESKTOP-6JL3KKO"); + assert!(!first.is_security_binding); + + // Subsequent string-bindings are the IPv4 + IPv6 endpoint addresses. + // Confirm we got at least 4 string-bindings (host + v4 + 2x v6) plus + // the security-binding entries. + assert!(parsed.dual_string_entries_decoded.len() >= 5); + + // At least one entry must be a security binding (entries past + // security_offset). The "0900FFFF" sequence in the captured bytes + // decodes as tower=0x0009 (ncacn_np) with a single non-printable + // u16 0xFFFF — appears in the security-binding tail. + assert!( + parsed + .dual_string_entries_decoded + .iter() + .any(|e| e.is_security_binding), + "expected at least one security binding in captured OBJREF" + ); + + // The diagnostic emitter must produce exactly 11 lines. + let lines = parsed.to_diagnostic_lines(); + assert_eq!(lines.len(), 11); + assert_eq!(lines[0], "objref_signature=0x574F454D"); + } + + #[test] + fn guid_display_matches_dotnet_d_format() { + // .NET Guid("F7929FB4-48C7-6941-8ECA-A0670B012746".replace order): + // The 16-byte sequence F7 92 9F B4 48 C7 69 41 8E CA A0 67 0B 01 27 46 + // displays as "b49f92f7-c748-4169-8eca-a0670b012746" — first three + // groups are byte-swapped (LE on wire, BE in display). + let g = Guid([ + 0xF7, 0x92, 0x9F, 0xB4, 0x48, 0xC7, 0x69, 0x41, 0x8E, 0xCA, 0xA0, 0x67, 0x0B, 0x01, + 0x27, 0x46, + ]); + assert_eq!(format!("{}", g), "b49f92f7-c748-4169-8eca-a0670b012746"); + } + + #[test] + fn header_length_constant() { + assert_eq!(ComObjRef::HEADER_LEN, 68); + assert_eq!(OBJREF_HEADER_LEN, 68); + assert_eq!(OBJREF_SIGNATURE, 0x574F_454D); + } +} diff --git a/rust/crates/mxaccess-rpc/src/pdu.rs b/rust/crates/mxaccess-rpc/src/pdu.rs new file mode 100644 index 0000000..cefb003 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/pdu.rs @@ -0,0 +1,1609 @@ +//! DCE/RPC 1.1 connection-oriented PDU codec. +//! +//! Direct port of `src/MxNativeClient/DceRpcPdu.cs` and the +//! protocol-only types from `src/MxNativeClient/DceRpcAuthentication.cs` +//! (`DceRpcAuthType`, `DceRpcAuthLevel`, `DceRpcAuthTrailer`, +//! `DceRpcAuthValue`). The SSPI/`NegotiateAuthentication` glue from that +//! `.cs` file is **out of scope** for this codec module — see the M2 NTLMv2 +//! task and `mxaccess-rpc::ntlm` (sibling agent's deliverable). +//! +//! All multi-byte fields are little-endian per `[C706]` §14.2 NDR +//! "data representation" of `0x10000000` (LE / ASCII / IEEE 754) which the +//! .NET reference asserts unconditionally via `BinaryPrimitives.*LittleEndian` +//! reads (`DceRpcPdu.cs:54-57`). +//! +//! Per the CLAUDE.md "preserve unknown bytes" rule, padding fields that the +//! .NET reference reads/writes verbatim are carried through as `[u8; N]` — +//! see [`AuthTrailer::auth_reserved`] (`DceRpcAuthentication.cs:27,42-43,57`). + +// Direct byte indexing matches the .NET reference's `BinaryPrimitives` reads +// at fixed offsets — see `mxaccess-codec/src/reference_handle.rs:7-11` for +// the same rationale. +#![allow(clippy::indexing_slicing)] + +use thiserror::Error; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Errors raised by the DCE/RPC PDU codec. +#[derive(Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum RpcError { + /// Buffer was shorter than required to decode the type. + /// Mirrors `ArgumentException("DCE/RPC PDU header is too short.")` + /// at `DceRpcPdu.cs:46` and the per-PDU truncation checks at + /// `DceRpcPdu.cs:94,150,188,226`. + #[error("short read: expected {expected} bytes, got {actual}")] + ShortRead { expected: usize, actual: usize }, + + /// Packet type byte at offset 2 (`DceRpcPdu.cs:52`) did not match the + /// expected `DceRpcPacketType` for the parser invoked. Mirrors + /// `ArgumentException("PDU is not a request.", ...)` at + /// `DceRpcPdu.cs:91,147,185,223`. + #[error("unexpected packet type {actual}, expected {expected}")] + UnexpectedPacketType { expected: u8, actual: u8 }, + + /// Packet type byte was not a known [`PacketType`] value + /// (`DceRpcPdu.cs:5-15`). + #[error("unknown packet type byte {0}")] + UnknownPacketType(u8), + + /// `header.frag_length` exceeds the supplied buffer length (per + /// `DceRpcPdu.cs:94,150,188,226`) or the body declared by it would + /// produce a negative stub length (`DceRpcPdu.cs:101-104,156-159, + /// 195-198`). + #[error( + "fragment length {frag_length} inconsistent with buffer length {buffer_len} \ + (auth_length={auth_length})" + )] + InvalidFragmentLength { + frag_length: usize, + buffer_len: usize, + auth_length: usize, + }, + + /// A bind PDU's per-context list ran past `frag_length` + /// (`DceRpcPdu.cs:237`) or a syntax identifier was truncated + /// (`DceRpcPdu.cs:354`). + #[error("truncated bind body at offset {offset}; need {need} bytes, frag_length={frag_length}")] + TruncatedBindBody { + offset: usize, + need: usize, + frag_length: usize, + }, + + /// Auth-trailer offset is below the 16-byte header + /// (`DceRpcPdu.cs:341-345`). + #[error("invalid auth trailer offset {offset}")] + InvalidAuthTrailer { offset: usize }, + + /// Tried to extract an auth value from a PDU whose `auth_length` is 0 + /// (`DceRpcPdu.cs:336-339`). + #[error("PDU has no auth value")] + MissingAuthValue, +} + +// --------------------------------------------------------------------------- +// Packet type — `DceRpcPdu.cs:5-15` +// --------------------------------------------------------------------------- + +/// Connection-oriented PDU packet type byte (`[C706]` §12.6.3.1 +/// `ptype` field, byte offset 2 of every PDU). +/// +/// Only the variants used by the .NET reference at +/// `src/MxNativeClient/DceRpcPdu.cs:5-15` are enumerated. `BindNak` (=13) +/// per `[C706]` §12.6.4.5 is not consumed by the .NET reference and is +/// therefore not represented; if/when needed, see followup F1. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum PacketType { + /// `DceRpcPdu.cs:7`. + Request = 0, + /// `DceRpcPdu.cs:8`. + Response = 2, + /// `DceRpcPdu.cs:9`. + Fault = 3, + /// `DceRpcPdu.cs:10`. + Bind = 11, + /// `DceRpcPdu.cs:11`. + BindAck = 12, + /// `DceRpcPdu.cs:12`. + AlterContext = 14, + /// `DceRpcPdu.cs:13`. + AlterContextResponse = 15, + /// `DceRpcPdu.cs:14`. + Auth3 = 16, +} + +impl PacketType { + /// Decode the byte at PDU offset 2. + pub fn from_byte(byte: u8) -> Result { + match byte { + 0 => Ok(Self::Request), + 2 => Ok(Self::Response), + 3 => Ok(Self::Fault), + 11 => Ok(Self::Bind), + 12 => Ok(Self::BindAck), + 14 => Ok(Self::AlterContext), + 15 => Ok(Self::AlterContextResponse), + 16 => Ok(Self::Auth3), + other => Err(RpcError::UnknownPacketType(other)), + } + } + + /// Raw byte value as written at PDU offset 2 (`DceRpcPdu.cs:69`). + pub fn as_byte(self) -> u8 { + self as u8 + } +} + +// --------------------------------------------------------------------------- +// Auth type / level — `DceRpcAuthentication.cs:8-21` +// --------------------------------------------------------------------------- + +/// Auth-trailer `auth_type` byte (`[C706]` §13.2.6.1; values per +/// `DceRpcAuthentication.cs:8-13`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum AuthType { + /// `DceRpcAuthentication.cs:10`. + None = 0, + /// `DceRpcAuthentication.cs:11`. RPC_C_AUTHN_GSS_NEGOTIATE. + GssNegotiate = 9, + /// `DceRpcAuthentication.cs:12`. RPC_C_AUTHN_WINNT (NTLM). + WinNt = 10, +} + +impl AuthType { + /// Convert the trailer byte to the typed enum. Unknown values surface as + /// [`AuthType::None`] mirroring the .NET reference's `(DceRpcAuthType)` + /// cast at `DceRpcAuthentication.cs:40`, except that the .NET cast is + /// lossless and the Rust port deliberately funnels unknown values to + /// [`AuthType::None`] to avoid `unsafe` transmutes; see followup F2. + pub fn from_byte(byte: u8) -> Self { + match byte { + 9 => Self::GssNegotiate, + 10 => Self::WinNt, + _ => Self::None, + } + } + + /// Raw byte value as written at trailer offset 0 (`DceRpcAuthentication.cs:54`). + pub fn as_byte(self) -> u8 { + self as u8 + } +} + +/// Auth-trailer `auth_level` byte (`[C706]` §13.2.6.1; values per +/// `DceRpcAuthentication.cs:15-21`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum AuthLevel { + /// `DceRpcAuthentication.cs:17`. RPC_C_AUTHN_LEVEL_NONE — note the .NET + /// reference uses `1` here, matching `[MS-RPCE]` §2.2.1.1.8 where the + /// minimum legal `auth_level` for an attached trailer is 1 (`Connect` + /// is sometimes also treated as the floor). + None = 1, + /// `DceRpcAuthentication.cs:18`. RPC_C_AUTHN_LEVEL_CONNECT. + Connect = 2, + /// `DceRpcAuthentication.cs:19`. RPC_C_AUTHN_LEVEL_PKT_INTEGRITY. + PacketIntegrity = 5, + /// `DceRpcAuthentication.cs:20`. RPC_C_AUTHN_LEVEL_PKT_PRIVACY. + PacketPrivacy = 6, +} + +impl AuthLevel { + /// Convert the trailer byte to the typed enum. Unknown values map to + /// [`AuthLevel::None`] — same rationale as [`AuthType::from_byte`]. + pub fn from_byte(byte: u8) -> Self { + match byte { + 2 => Self::Connect, + 5 => Self::PacketIntegrity, + 6 => Self::PacketPrivacy, + _ => Self::None, + } + } + + /// Raw byte value as written at trailer offset 1 + /// (`DceRpcAuthentication.cs:55`). + pub fn as_byte(self) -> u8 { + self as u8 + } +} + +// --------------------------------------------------------------------------- +// Auth trailer — `DceRpcAuthentication.cs:23-60` +// --------------------------------------------------------------------------- + +/// 8-byte auth verifier trailer that prefixes the auth token at the tail of +/// every authenticated PDU (`[C706]` §13.2.6.1, "auth_verifier_co_t"). +/// +/// Encoded layout per `DceRpcAuthentication.cs:32-58`: +/// +/// ```text +/// offset size field +/// 0 1 auth_type u8 e.g. 10 = WinNt (NTLM) +/// 1 1 auth_level u8 e.g. 5 = PacketIntegrity +/// 2 1 auth_pad_length u8 pre-trailer padding count for 4B align +/// 3 1 auth_reserved u8 must be preserved verbatim +/// 4 4 auth_context_id u32 LE +/// ``` +/// +/// `auth_reserved` is preserved per CLAUDE.md "preserve unknown bytes": +/// the .NET reference reads it at `DceRpcAuthentication.cs:42-43` and writes +/// it back at `DceRpcAuthentication.cs:57`; the Rust port carries it through +/// rather than zeroing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AuthTrailer { + pub auth_type: AuthType, + pub auth_level: AuthLevel, + /// Number of `0x00` pad bytes the encoder inserted between the PDU body + /// and this trailer to 4-byte align the trailer. Per `[C706]` §13.2.6.1 + /// the receiver MUST trust this length. + pub auth_pad_length: u8, + /// Reserved byte. Preserved verbatim per CLAUDE.md unknown-bytes rule + /// (`DceRpcAuthentication.cs:27,42-43,57`). + pub auth_reserved: u8, + pub auth_context_id: u32, +} + +impl AuthTrailer { + /// Encoded length excluding the trailing auth token + /// (`DceRpcAuthentication.cs:30`). + pub const LENGTH: usize = 8; + + /// Decode an 8-byte trailer from `buf[..8]`. + /// + /// # Errors + /// + /// [`RpcError::ShortRead`] when `buf.len() < 8` + /// (`DceRpcAuthentication.cs:34-37`). + pub fn decode(buf: &[u8]) -> Result { + if buf.len() < Self::LENGTH { + return Err(RpcError::ShortRead { + expected: Self::LENGTH, + actual: buf.len(), + }); + } + + Ok(Self { + auth_type: AuthType::from_byte(buf[0]), + auth_level: AuthLevel::from_byte(buf[1]), + auth_pad_length: buf[2], + auth_reserved: buf[3], + auth_context_id: read_u32_le(buf, 4), + }) + } + + /// Encode the trailer into the first 8 bytes of `buf`. + /// + /// # Errors + /// + /// [`RpcError::ShortRead`] when `buf.len() < 8` + /// (`DceRpcAuthentication.cs:49-52`). + pub fn encode(&self, buf: &mut [u8]) -> Result<(), RpcError> { + if buf.len() < Self::LENGTH { + return Err(RpcError::ShortRead { + expected: Self::LENGTH, + actual: buf.len(), + }); + } + buf[0] = self.auth_type.as_byte(); + buf[1] = self.auth_level.as_byte(); + buf[2] = self.auth_pad_length; + buf[3] = self.auth_reserved; + write_u32_le(buf, 4, self.auth_context_id); + Ok(()) + } +} + +/// Auth trailer + token pair extracted from a PDU +/// (`DceRpcAuthentication.cs:62`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthValue { + pub trailer: AuthTrailer, + pub token: Vec, +} + +// --------------------------------------------------------------------------- +// Common PDU header — `DceRpcPdu.cs:30-76` +// --------------------------------------------------------------------------- + +/// 16-byte common PDU header (`[C706]` §12.6.3.1, "common header"). +/// +/// Encoded layout per `DceRpcPdu.cs:60-75`: +/// +/// ```text +/// offset size field +/// 0 1 version u8 = 5 (RPC v5) +/// 1 1 version_minor u8 = 0 or 1 +/// 2 1 packet_type u8 see PacketType +/// 3 1 packet_flags u8 PFC_* bit flags +/// 4 4 data_repr u32 LE byte order/char/float; 0x10000000 = LE/ASCII/IEEE +/// 8 2 frag_length u16 LE total PDU length including trailer +/// 10 2 auth_length u16 LE length of auth token (NOT including the 8-byte trailer) +/// 12 4 call_id u32 LE +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PduHeader { + pub version: u8, + pub version_minor: u8, + pub packet_type: PacketType, + pub packet_flags: u8, + pub data_representation: u32, + pub fragment_length: u16, + pub auth_length: u16, + pub call_id: u32, +} + +impl PduHeader { + /// Encoded length (`DceRpcPdu.cs:40`). + pub const LENGTH: usize = 16; + + /// Decode the 16-byte header at the front of `buf`. + /// + /// # Errors + /// + /// [`RpcError::ShortRead`] when `buf.len() < 16` (`DceRpcPdu.cs:44-47`). + /// [`RpcError::UnknownPacketType`] when byte 2 is unrecognised. + pub fn decode(buf: &[u8]) -> Result { + if buf.len() < Self::LENGTH { + return Err(RpcError::ShortRead { + expected: Self::LENGTH, + actual: buf.len(), + }); + } + + Ok(Self { + version: buf[0], + version_minor: buf[1], + packet_type: PacketType::from_byte(buf[2])?, + packet_flags: buf[3], + data_representation: read_u32_le(buf, 4), + fragment_length: read_u16_le(buf, 8), + auth_length: read_u16_le(buf, 10), + call_id: read_u32_le(buf, 12), + }) + } + + /// Encode the header into the first 16 bytes of `buf` + /// (`DceRpcPdu.cs:60-75`). + /// + /// # Errors + /// + /// [`RpcError::ShortRead`] when `buf.len() < 16`. + pub fn encode(&self, buf: &mut [u8]) -> Result<(), RpcError> { + if buf.len() < Self::LENGTH { + return Err(RpcError::ShortRead { + expected: Self::LENGTH, + actual: buf.len(), + }); + } + buf[0] = self.version; + buf[1] = self.version_minor; + buf[2] = self.packet_type.as_byte(); + buf[3] = self.packet_flags; + write_u32_le(buf, 4, self.data_representation); + write_u16_le(buf, 8, self.fragment_length); + write_u16_le(buf, 10, self.auth_length); + write_u32_le(buf, 12, self.call_id); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Presentation context / syntax id — `DceRpcPdu.cs:17-28` +// --------------------------------------------------------------------------- + +/// 20-byte syntax identifier (`[C706]` §12.6.3.1 "p_syntax_id_t"). +/// +/// Encoded layout per `DceRpcPdu.cs:359-372`: +/// +/// ```text +/// offset size field +/// 0 16 uuid GUID, in MS GUID byte order (Data1/Data2/Data3 LE, Data4 BE) +/// 16 2 version_major u16 LE +/// 18 2 version_minor u16 LE +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SyntaxId { + /// 16-byte GUID, in the wire byte order produced by .NET `Guid.TryWriteBytes` + /// (`DceRpcPdu.cs:369`) — `Data1` / `Data2` / `Data3` little-endian, `Data4` + /// (8 bytes) big-endian. The Rust port stores the wire bytes verbatim so + /// callers don't have to translate between MS GUID byte order and RFC4122. + pub uuid_bytes: [u8; 16], + pub version_major: u16, + pub version_minor: u16, +} + +impl SyntaxId { + /// Encoded size on the wire (`DceRpcPdu.cs:354,372`). + pub const LENGTH: usize = 20; + + /// NDR transfer syntax v2.0 (`8a885d04-1ceb-11c9-9fe8-08002b104860`, + /// version 2.0). Source: `DceRpcPdu.cs:19-22`. The byte sequence below + /// is the MS-GUID encoding produced by `new Guid(string).ToByteArray()`. + pub const NDR20: Self = Self { + // 8a885d04-1ceb-11c9-9fe8-08002b104860 in MS GUID order: + // Data1 (LE) = 04 5d 88 8a + // Data2 (LE) = eb 1c + // Data3 (LE) = c9 11 + // Data4 = 9f e8 08 00 2b 10 48 60 + uuid_bytes: [ + 0x04, 0x5d, 0x88, 0x8a, 0xeb, 0x1c, 0xc9, 0x11, 0x9f, 0xe8, 0x08, 0x00, 0x2b, 0x10, + 0x48, 0x60, + ], + version_major: 2, + version_minor: 0, + }; +} + +/// A single presentation context within a Bind / AlterContext PDU +/// (`DceRpcPdu.cs:25-28`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PresentationContext { + pub context_id: u16, + pub abstract_syntax: SyntaxId, + pub transfer_syntaxes: Vec, +} + +impl PresentationContext { + /// Encoded length of this context: 4 fixed bytes + abstract syntax + + /// N transfer syntaxes (`DceRpcPdu.cs:266`). + pub fn encoded_len(&self) -> usize { + 4 + SyntaxId::LENGTH + self.transfer_syntaxes.len() * SyntaxId::LENGTH + } +} + +fn read_syntax(buf: &[u8], offset: &mut usize) -> Result { + if *offset + SyntaxId::LENGTH > buf.len() { + return Err(RpcError::ShortRead { + expected: *offset + SyntaxId::LENGTH, + actual: buf.len(), + }); + } + let mut uuid_bytes = [0u8; 16]; + uuid_bytes.copy_from_slice(&buf[*offset..*offset + 16]); + let syn = SyntaxId { + uuid_bytes, + version_major: read_u16_le(buf, *offset + 16), + version_minor: read_u16_le(buf, *offset + 18), + }; + *offset += SyntaxId::LENGTH; + Ok(syn) +} + +fn write_syntax(buf: &mut [u8], offset: &mut usize, syntax: &SyntaxId) { + buf[*offset..*offset + 16].copy_from_slice(&syntax.uuid_bytes); + write_u16_le(buf, *offset + 16, syntax.version_major); + write_u16_le(buf, *offset + 18, syntax.version_minor); + *offset += SyntaxId::LENGTH; +} + +// --------------------------------------------------------------------------- +// Bind / AlterContext PDU — `DceRpcPdu.cs:210-380` +// --------------------------------------------------------------------------- + +/// Bind / AlterContext PDU (`[C706]` §12.6.4.3-4). Both packet types share +/// the same body layout — `DceRpcPdu.cs:221-224` parses both into the same +/// struct. +/// +/// Encoded layout per `DceRpcPdu.cs:264-290`: +/// +/// ```text +/// offset size field +/// 0 16 PduHeader +/// 16 2 max_transmit_fragment u16 LE +/// 18 2 max_receive_fragment u16 LE +/// 20 4 association_group_id u32 LE +/// 24 1 n_context_elem u8 count of presentation contexts +/// 25 3 reserved [u8; 3] preserved verbatim +/// 28+ var presentation_context_list +/// ``` +/// +/// `reserved25_28` is carried through per CLAUDE.md "preserve unknown bytes": +/// `DceRpcPdu.cs:264-290` writes them as default 0 but the .NET parse skips +/// them (`DceRpcPdu.cs:231-232`), so non-zero values from a captured frame +/// would otherwise be lost on round-trip. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindPdu { + pub header: PduHeader, + pub max_transmit_fragment: u16, + pub max_receive_fragment: u16, + pub association_group_id: u32, + pub presentation_contexts: Vec, + /// 3 reserved bytes between `n_context_elem` and the first context. + /// Preserved verbatim per CLAUDE.md unknown-bytes rule. The .NET writer + /// always emits 0 here (`DceRpcPdu.cs:273`); set this field to round-trip + /// captured frames byte-identically. + pub reserved25_28: [u8; 3], +} + +const BIND_BODY_OFFSET: usize = 28; + +impl BindPdu { + /// Decode a Bind or AlterContext PDU (`DceRpcPdu.cs:217-262`). + /// + /// # Errors + /// + /// - [`RpcError::UnexpectedPacketType`] when not Bind / AlterContext. + /// - [`RpcError::ShortRead`] when `buf.len() < 28` or the declared + /// `frag_length` exceeds the buffer. + /// - [`RpcError::TruncatedBindBody`] when a context or syntax overflows. + pub fn decode(buf: &[u8]) -> Result { + let header = PduHeader::decode(buf)?; + if !matches!( + header.packet_type, + PacketType::Bind | PacketType::AlterContext + ) { + return Err(RpcError::UnexpectedPacketType { + expected: PacketType::Bind.as_byte(), + actual: header.packet_type.as_byte(), + }); + } + + let frag_length = header.fragment_length as usize; + if buf.len() < BIND_BODY_OFFSET || frag_length > buf.len() { + return Err(RpcError::InvalidFragmentLength { + frag_length, + buffer_len: buf.len(), + auth_length: header.auth_length as usize, + }); + } + + let context_count = buf[24] as usize; + let mut reserved25_28 = [0u8; 3]; + reserved25_28.copy_from_slice(&buf[25..28]); + + let mut offset = BIND_BODY_OFFSET; + let mut contexts = Vec::with_capacity(context_count); + for _ in 0..context_count { + // Per `DceRpcPdu.cs:237` the per-context check uses frag_length, + // not buf.len(), as the bound. + if offset + 4 > frag_length { + return Err(RpcError::TruncatedBindBody { + offset, + need: 4, + frag_length, + }); + } + let context_id = read_u16_le(buf, offset); + let transfer_count = buf[offset + 2] as usize; + // buf[offset + 3] is padding/reserved; .NET ignores it (cs:243-244). + offset += 4; + + let abstract_syntax = read_syntax(buf, &mut offset)?; + let mut transfer_syntaxes = Vec::with_capacity(transfer_count); + for _ in 0..transfer_count { + transfer_syntaxes.push(read_syntax(buf, &mut offset)?); + } + + contexts.push(PresentationContext { + context_id, + abstract_syntax, + transfer_syntaxes, + }); + } + + Ok(Self { + header, + max_transmit_fragment: read_u16_le(buf, 16), + max_receive_fragment: read_u16_le(buf, 18), + association_group_id: read_u32_le(buf, 20), + presentation_contexts: contexts, + reserved25_28, + }) + } + + /// Encode the Bind / AlterContext PDU. Returns the wire bytes + /// (`DceRpcPdu.cs:264-290`). Sets `frag_length` and `auth_length=0` on + /// the encoded header — to attach an auth verifier use [`encode_with_auth`]. + pub fn encode(&self) -> Vec { + let length: usize = BIND_BODY_OFFSET + + self + .presentation_contexts + .iter() + .map(PresentationContext::encoded_len) + .sum::(); + let mut out = vec![0u8; length]; + + let frag_length = u16::try_from(length).unwrap_or(u16::MAX); + let header = PduHeader { + fragment_length: frag_length, + auth_length: 0, + ..self.header + }; + // 16-byte buffer is guaranteed by `length >= 28`; encode ignores the + // ShortRead branch here. + let _ = header.encode(&mut out); + write_u16_le(&mut out, 16, self.max_transmit_fragment); + write_u16_le(&mut out, 18, self.max_receive_fragment); + write_u32_le(&mut out, 20, self.association_group_id); + // Mirror `DceRpcPdu.cs:273` — caller-controlled context count; cast safely. + out[24] = u8::try_from(self.presentation_contexts.len()).unwrap_or(u8::MAX); + out[25..28].copy_from_slice(&self.reserved25_28); + + let mut offset = BIND_BODY_OFFSET; + for ctx in &self.presentation_contexts { + write_u16_le(&mut out, offset, ctx.context_id); + out[offset + 2] = u8::try_from(ctx.transfer_syntaxes.len()).unwrap_or(u8::MAX); + // out[offset + 3] reserved — left as zero (matches .NET writer; see cs:279). + offset += 4; + write_syntax(&mut out, &mut offset, &ctx.abstract_syntax); + for ts in &ctx.transfer_syntaxes { + write_syntax(&mut out, &mut offset, ts); + } + } + + // Defensive: the loop above must consume exactly `length` bytes. + debug_assert_eq!(offset, length); + let _ = offset; // silence unused-assignment lint if debug_assert is stripped. + + out + } + + /// Encode the bind PDU with an auth verifier appended at the tail + /// (`DceRpcPdu.cs:292-311`). + /// + /// The auth trailer is preceded by 0..3 bytes of `0x00` padding so that + /// the trailer itself starts on a 4-byte boundary; `auth_pad_length` in + /// the returned trailer reflects the inserted pad. Returns the encoded + /// PDU bytes. + pub fn encode_with_auth(&self, trailer: AuthTrailer, auth_token: &[u8]) -> Vec { + let body = self.encode(); + let pad_length = align(body.len(), 4) - body.len(); + let length = body.len() + pad_length + AuthTrailer::LENGTH + auth_token.len(); + let mut out = vec![0u8; length]; + out[..body.len()].copy_from_slice(&body); + + let frag_length = u16::try_from(length).unwrap_or(u16::MAX); + let auth_length = u16::try_from(auth_token.len()).unwrap_or(u16::MAX); + let header = PduHeader { + fragment_length: frag_length, + auth_length, + ..self.header + }; + let _ = header.encode(&mut out); + + let aligned_trailer = AuthTrailer { + auth_pad_length: u8::try_from(pad_length).unwrap_or(u8::MAX), + ..trailer + }; + let trailer_offset = body.len() + pad_length; + let _ = + aligned_trailer.encode(&mut out[trailer_offset..trailer_offset + AuthTrailer::LENGTH]); + out[length - auth_token.len()..].copy_from_slice(auth_token); + out + } + + /// Encode an `Auth3` PDU per `DceRpcPdu.cs:313-331`. + /// + /// The `Auth3` body is 4 ASCII space bytes (" ") per the .NET + /// reference (`DceRpcPdu.cs:315`); `[C706]` §12.6.4.1 calls these the + /// "padding for verifier alignment" quad. The trailer is then padded to + /// a 4-byte boundary and the auth token appended. + pub fn encode_auth3(header: PduHeader, trailer: AuthTrailer, auth_token: &[u8]) -> Vec { + // Body = 4 ASCII space bytes (cs:315). + const BODY: [u8; 4] = [b' ', b' ', b' ', b' ']; + let pre_pad = PduHeader::LENGTH + BODY.len(); + let pad_length = align(pre_pad, 4) - pre_pad; + let length = pre_pad + pad_length + AuthTrailer::LENGTH + auth_token.len(); + let mut out = vec![0u8; length]; + + let frag_length = u16::try_from(length).unwrap_or(u16::MAX); + let auth_length = u16::try_from(auth_token.len()).unwrap_or(u16::MAX); + let auth_header = PduHeader { + packet_type: PacketType::Auth3, + fragment_length: frag_length, + auth_length, + ..header + }; + let _ = auth_header.encode(&mut out); + out[PduHeader::LENGTH..PduHeader::LENGTH + BODY.len()].copy_from_slice(&BODY); + + let aligned_trailer = AuthTrailer { + auth_pad_length: u8::try_from(pad_length).unwrap_or(u8::MAX), + ..trailer + }; + let trailer_offset = pre_pad + pad_length; + let _ = + aligned_trailer.encode(&mut out[trailer_offset..trailer_offset + AuthTrailer::LENGTH]); + out[length - auth_token.len()..].copy_from_slice(auth_token); + out + } + + /// Extract the auth trailer + token from any PDU that carries one + /// (`DceRpcPdu.cs:333-350`). Works on Bind/BindAck/AlterContext/ + /// AlterContextResponse / Request / Response / Fault PDUs. + /// + /// # Errors + /// + /// - [`RpcError::MissingAuthValue`] when `header.auth_length == 0`. + /// - [`RpcError::InvalidAuthTrailer`] when the trailer offset would land + /// inside the 16-byte header. + /// - [`RpcError::ShortRead`] when the buffer can't satisfy `frag_length`. + pub fn read_auth_value(buf: &[u8]) -> Result { + let header = PduHeader::decode(buf)?; + if header.auth_length == 0 { + return Err(RpcError::MissingAuthValue); + } + let frag_length = header.fragment_length as usize; + let auth_length = header.auth_length as usize; + if buf.len() < frag_length { + return Err(RpcError::InvalidFragmentLength { + frag_length, + buffer_len: buf.len(), + auth_length, + }); + } + // trailer_offset = frag_length - auth_length - 8 (cs:341). + let trailer_offset = match frag_length + .checked_sub(auth_length) + .and_then(|v| v.checked_sub(AuthTrailer::LENGTH)) + { + Some(v) => v, + None => { + return Err(RpcError::InvalidAuthTrailer { offset: 0 }); + } + }; + if trailer_offset < PduHeader::LENGTH { + return Err(RpcError::InvalidAuthTrailer { + offset: trailer_offset, + }); + } + + let trailer = + AuthTrailer::decode(&buf[trailer_offset..trailer_offset + AuthTrailer::LENGTH])?; + let token_start = frag_length - auth_length; + let token = buf[token_start..frag_length].to_vec(); + Ok(AuthValue { trailer, token }) + } +} + +// --------------------------------------------------------------------------- +// Request PDU — `DceRpcPdu.cs:78-132` +// --------------------------------------------------------------------------- + +/// Request PDU (`[C706]` §12.6.4.9). Encoded layout per +/// `DceRpcPdu.cs:114-131`: +/// +/// ```text +/// offset size field +/// 0 16 PduHeader +/// 16 4 allocation_hint u32 LE +/// 20 2 context_id u16 LE +/// 22 2 opnum u16 LE +/// 24+ var stub_data +/// ``` +/// +/// The .NET `Encode` (`DceRpcPdu.cs:118-124`) defaults `packet_flags` to +/// `0x03` (PFC_FIRST_FRAG | PFC_LAST_FRAG) only when the supplied flags are +/// 0; the Rust port keeps the same exact behaviour in [`encode`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestPdu { + pub header: PduHeader, + pub allocation_hint: u32, + pub context_id: u16, + pub opnum: u16, + pub stub_data: Vec, +} + +const REQUEST_BODY_OFFSET: usize = 24; + +impl RequestPdu { + /// Decode a Request PDU (`DceRpcPdu.cs:85-112`). + /// + /// # Errors + /// + /// - [`RpcError::UnexpectedPacketType`] if not Request. + /// - [`RpcError::InvalidFragmentLength`] when frag_length / auth_length + /// produce a negative stub size or overflow the buffer. + pub fn decode(buf: &[u8]) -> Result { + let header = PduHeader::decode(buf)?; + if header.packet_type != PacketType::Request { + return Err(RpcError::UnexpectedPacketType { + expected: PacketType::Request.as_byte(), + actual: header.packet_type.as_byte(), + }); + } + let frag_length = header.fragment_length as usize; + let auth_length = header.auth_length as usize; + if buf.len() < REQUEST_BODY_OFFSET || frag_length > buf.len() { + return Err(RpcError::InvalidFragmentLength { + frag_length, + buffer_len: buf.len(), + auth_length, + }); + } + + // Trailer length per cs:99 — auth_length + 8-byte trailer when present. + let trailer_length = if auth_length == 0 { + 0 + } else { + auth_length + AuthTrailer::LENGTH + }; + let stub_length = frag_length + .checked_sub(REQUEST_BODY_OFFSET) + .and_then(|v| v.checked_sub(trailer_length)) + .ok_or(RpcError::InvalidFragmentLength { + frag_length, + buffer_len: buf.len(), + auth_length, + })?; + + Ok(Self { + header, + allocation_hint: read_u32_le(buf, 16), + context_id: read_u16_le(buf, 20), + opnum: read_u16_le(buf, 22), + stub_data: buf[REQUEST_BODY_OFFSET..REQUEST_BODY_OFFSET + stub_length].to_vec(), + }) + } + + /// Encode a Request PDU (`DceRpcPdu.cs:114-131`). + /// + /// Sets `frag_length` to the full PDU length, `auth_length=0`, and + /// `packet_flags=0x03` if the supplied flags were 0 (matching the + /// `Header.PacketFlags == 0 ? 0x03 : Header.PacketFlags` ternary at + /// `DceRpcPdu.cs:123`). + pub fn encode(&self) -> Vec { + let length = REQUEST_BODY_OFFSET + self.stub_data.len(); + let mut out = vec![0u8; length]; + let frag_length = u16::try_from(length).unwrap_or(u16::MAX); + let packet_flags = if self.header.packet_flags == 0 { + 0x03 + } else { + self.header.packet_flags + }; + let header = PduHeader { + packet_type: PacketType::Request, + fragment_length: frag_length, + auth_length: 0, + packet_flags, + ..self.header + }; + let _ = header.encode(&mut out); + write_u32_le(&mut out, 16, self.allocation_hint); + write_u16_le(&mut out, 20, self.context_id); + write_u16_le(&mut out, 22, self.opnum); + out[REQUEST_BODY_OFFSET..].copy_from_slice(&self.stub_data); + out + } +} + +// --------------------------------------------------------------------------- +// Response PDU — `DceRpcPdu.cs:134-169` +// --------------------------------------------------------------------------- + +/// Response PDU (`[C706]` §12.6.4.10). Encoded layout per +/// `DceRpcPdu.cs:141-168`: +/// +/// ```text +/// offset size field +/// 0 16 PduHeader +/// 16 4 allocation_hint u32 LE +/// 20 2 context_id u16 LE +/// 22 1 cancel_count u8 +/// 23 1 reserved23 u8 preserved verbatim +/// 24+ var stub_data +/// ``` +/// +/// The .NET parse skips byte 23 (`DceRpcPdu.cs:166`); the Rust port +/// preserves it under CLAUDE.md unknown-bytes rule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponsePdu { + pub header: PduHeader, + pub allocation_hint: u32, + pub context_id: u16, + pub cancel_count: u8, + /// Reserved byte at offset 23. Preserved verbatim per CLAUDE.md + /// unknown-bytes rule. + pub reserved23: u8, + pub stub_data: Vec, +} + +impl ResponsePdu { + /// Decode a Response PDU (`DceRpcPdu.cs:141-168`). The .NET reference + /// has no encoder for Response — the Rust port adds [`encode`] for tests + /// and round-trip use. + pub fn decode(buf: &[u8]) -> Result { + let header = PduHeader::decode(buf)?; + if header.packet_type != PacketType::Response { + return Err(RpcError::UnexpectedPacketType { + expected: PacketType::Response.as_byte(), + actual: header.packet_type.as_byte(), + }); + } + let frag_length = header.fragment_length as usize; + let auth_length = header.auth_length as usize; + if buf.len() < REQUEST_BODY_OFFSET || frag_length > buf.len() { + return Err(RpcError::InvalidFragmentLength { + frag_length, + buffer_len: buf.len(), + auth_length, + }); + } + let trailer_length = if auth_length == 0 { + 0 + } else { + auth_length + AuthTrailer::LENGTH + }; + let stub_length = frag_length + .checked_sub(REQUEST_BODY_OFFSET) + .and_then(|v| v.checked_sub(trailer_length)) + .ok_or(RpcError::InvalidFragmentLength { + frag_length, + buffer_len: buf.len(), + auth_length, + })?; + + Ok(Self { + header, + allocation_hint: read_u32_le(buf, 16), + context_id: read_u16_le(buf, 20), + cancel_count: buf[22], + reserved23: buf[23], + stub_data: buf[REQUEST_BODY_OFFSET..REQUEST_BODY_OFFSET + stub_length].to_vec(), + }) + } + + /// Encode a Response PDU. The .NET reference has no equivalent — see + /// followup F3. + pub fn encode(&self) -> Vec { + let length = REQUEST_BODY_OFFSET + self.stub_data.len(); + let mut out = vec![0u8; length]; + let frag_length = u16::try_from(length).unwrap_or(u16::MAX); + let header = PduHeader { + packet_type: PacketType::Response, + fragment_length: frag_length, + auth_length: 0, + ..self.header + }; + let _ = header.encode(&mut out); + write_u32_le(&mut out, 16, self.allocation_hint); + write_u16_le(&mut out, 20, self.context_id); + out[22] = self.cancel_count; + out[23] = self.reserved23; + out[REQUEST_BODY_OFFSET..].copy_from_slice(&self.stub_data); + out + } +} + +// --------------------------------------------------------------------------- +// Fault PDU — `DceRpcPdu.cs:171-208` +// --------------------------------------------------------------------------- + +/// Fault PDU (`[C706]` §12.6.4.7). Encoded layout per +/// `DceRpcPdu.cs:179-207`: +/// +/// ```text +/// offset size field +/// 0 16 PduHeader +/// 16 4 allocation_hint u32 LE +/// 20 2 context_id u16 LE +/// 22 1 cancel_count u8 +/// 23 1 reserved23 u8 preserved verbatim +/// 24 4 status u32 LE fault status (HRESULT-style) +/// 28+ var stub_data +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FaultPdu { + pub header: PduHeader, + pub allocation_hint: u32, + pub context_id: u16, + pub cancel_count: u8, + /// Reserved byte at offset 23. Preserved verbatim per CLAUDE.md + /// unknown-bytes rule (the .NET parse at `DceRpcPdu.cs:204` skips it). + pub reserved23: u8, + pub status: u32, + pub stub_data: Vec, +} + +const FAULT_BODY_OFFSET: usize = 28; + +impl FaultPdu { + pub fn decode(buf: &[u8]) -> Result { + let header = PduHeader::decode(buf)?; + if header.packet_type != PacketType::Fault { + return Err(RpcError::UnexpectedPacketType { + expected: PacketType::Fault.as_byte(), + actual: header.packet_type.as_byte(), + }); + } + let frag_length = header.fragment_length as usize; + let auth_length = header.auth_length as usize; + if buf.len() < FAULT_BODY_OFFSET || frag_length > buf.len() { + return Err(RpcError::InvalidFragmentLength { + frag_length, + buffer_len: buf.len(), + auth_length, + }); + } + let trailer_length = if auth_length == 0 { + 0 + } else { + auth_length + AuthTrailer::LENGTH + }; + let stub_length = frag_length + .checked_sub(FAULT_BODY_OFFSET) + .and_then(|v| v.checked_sub(trailer_length)) + .ok_or(RpcError::InvalidFragmentLength { + frag_length, + buffer_len: buf.len(), + auth_length, + })?; + + Ok(Self { + header, + allocation_hint: read_u32_le(buf, 16), + context_id: read_u16_le(buf, 20), + cancel_count: buf[22], + reserved23: buf[23], + status: read_u32_le(buf, 24), + stub_data: buf[FAULT_BODY_OFFSET..FAULT_BODY_OFFSET + stub_length].to_vec(), + }) + } + + /// Encode a Fault PDU. The .NET reference has no equivalent — added for + /// round-trip tests and Rust-side fault construction; see followup F3. + pub fn encode(&self) -> Vec { + let length = FAULT_BODY_OFFSET + self.stub_data.len(); + let mut out = vec![0u8; length]; + let frag_length = u16::try_from(length).unwrap_or(u16::MAX); + let header = PduHeader { + packet_type: PacketType::Fault, + fragment_length: frag_length, + auth_length: 0, + ..self.header + }; + let _ = header.encode(&mut out); + write_u32_le(&mut out, 16, self.allocation_hint); + write_u16_le(&mut out, 20, self.context_id); + out[22] = self.cancel_count; + out[23] = self.reserved23; + write_u32_le(&mut out, 24, self.status); + out[FAULT_BODY_OFFSET..].copy_from_slice(&self.stub_data); + out + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +#[inline] +fn read_u16_le(buf: &[u8], offset: usize) -> u16 { + u16::from_le_bytes([buf[offset], buf[offset + 1]]) +} + +#[inline] +fn read_u32_le(buf: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + buf[offset], + buf[offset + 1], + buf[offset + 2], + buf[offset + 3], + ]) +} + +#[inline] +fn write_u16_le(buf: &mut [u8], offset: usize, value: u16) { + let le = value.to_le_bytes(); + buf[offset..offset + 2].copy_from_slice(&le); +} + +#[inline] +fn write_u32_le(buf: &mut [u8], offset: usize, value: u32) { + let le = value.to_le_bytes(); + buf[offset..offset + 4].copy_from_slice(&le); +} + +/// Round `value` up to the next multiple of `alignment`. Mirrors +/// `DceRpcPdu.cs:375-379`. +#[inline] +fn align(value: usize, alignment: usize) -> usize { + let remainder = value % alignment; + if remainder == 0 { + value + } else { + value + alignment - remainder + } +} + +// Compile-time invariants ---------------------------------------------------- + +const _: () = assert!(PduHeader::LENGTH == 16); +const _: () = assert!(AuthTrailer::LENGTH == 8); +const _: () = assert!(SyntaxId::LENGTH == 20); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + fn sample_header(packet_type: PacketType) -> PduHeader { + PduHeader { + version: 5, + version_minor: 0, + packet_type, + packet_flags: 0x03, + // 0x10000000: little-endian / ASCII / IEEE 754 — the canonical + // value used by all `DceRpcTcpClient` traffic. + data_representation: 0x1000_0000, + fragment_length: 0, + auth_length: 0, + call_id: 0x42, + } + } + + // --- PduHeader ----------------------------------------------------------- + + #[test] + fn header_round_trip() { + let header = PduHeader { + fragment_length: 16, + ..sample_header(PacketType::Bind) + }; + let mut buf = vec![0u8; 16]; + header.encode(&mut buf).unwrap(); + let decoded = PduHeader::decode(&buf).unwrap(); + assert_eq!(decoded, header); + } + + #[test] + fn header_short_buffer_errors() { + assert!(matches!( + PduHeader::decode(&[0u8; 15]), + Err(RpcError::ShortRead { + expected: 16, + actual: 15 + }) + )); + } + + #[test] + fn header_unknown_packet_type() { + let mut buf = vec![0u8; 16]; + sample_header(PacketType::Bind).encode(&mut buf).unwrap(); + buf[2] = 99; + assert!(matches!( + PduHeader::decode(&buf), + Err(RpcError::UnknownPacketType(99)) + )); + } + + #[test] + fn data_representation_is_le() { + let header = sample_header(PacketType::Bind); + let mut buf = vec![0u8; 16]; + header.encode(&mut buf).unwrap(); + // 0x1000_0000 LE = 00 00 00 10 + assert_eq!(&buf[4..8], &[0x00, 0x00, 0x00, 0x10]); + } + + // --- AuthTrailer --------------------------------------------------------- + + fn sample_trailer() -> AuthTrailer { + AuthTrailer { + auth_type: AuthType::WinNt, + auth_level: AuthLevel::PacketIntegrity, + auth_pad_length: 0, + auth_reserved: 0, + auth_context_id: 0xDEAD_BEEF, + } + } + + #[test] + fn trailer_round_trip() { + let trailer = sample_trailer(); + let mut buf = vec![0u8; 8]; + trailer.encode(&mut buf).unwrap(); + // type=10, level=5, pad=0, reserved=0, ctx_id=DEADBEEF (LE = EF BE AD DE) + assert_eq!(buf, vec![10, 5, 0, 0, 0xEF, 0xBE, 0xAD, 0xDE]); + let decoded = AuthTrailer::decode(&buf).unwrap(); + assert_eq!(decoded, trailer); + } + + #[test] + fn trailer_preserves_reserved_byte() { + let trailer = AuthTrailer { + auth_reserved: 0x7F, + ..sample_trailer() + }; + let mut buf = vec![0u8; 8]; + trailer.encode(&mut buf).unwrap(); + assert_eq!(buf[3], 0x7F); + let decoded = AuthTrailer::decode(&buf).unwrap(); + assert_eq!(decoded.auth_reserved, 0x7F); + } + + #[test] + fn trailer_short_buffer() { + assert!(matches!( + AuthTrailer::decode(&[0u8; 7]), + Err(RpcError::ShortRead { + expected: 8, + actual: 7 + }) + )); + } + + // --- BindPdu ------------------------------------------------------------- + + fn sample_bind() -> BindPdu { + BindPdu { + header: sample_header(PacketType::Bind), + max_transmit_fragment: 5840, + max_receive_fragment: 5840, + association_group_id: 0, + presentation_contexts: vec![PresentationContext { + context_id: 0, + // INmxService bind context UUID — `40-protocol-invariants.md:13`. + abstract_syntax: SyntaxId { + uuid_bytes: [ + 0xdf, 0x90, 0x0c, 0x4e, 0x9d, 0xe3, 0x64, 0x41, 0xa4, 0x21, 0xac, 0xe8, + 0x94, 0x84, 0xc6, 0x02, + ], + version_major: 0, + version_minor: 0, + }, + transfer_syntaxes: vec![SyntaxId::NDR20], + }], + reserved25_28: [0; 3], + } + } + + #[test] + fn bind_round_trip() { + let pdu = sample_bind(); + let encoded = pdu.encode(); + // 28 fixed + (4 + 20 + 20) for one context with one transfer syntax. + assert_eq!(encoded.len(), 28 + 44); + // frag_length = encoded.len() + assert_eq!(read_u16_le(&encoded, 8) as usize, encoded.len()); + let decoded = BindPdu::decode(&encoded).unwrap(); + // Header.fragment_length is rewritten by encode; align expectation. + let mut expected = pdu; + expected.header.fragment_length = encoded.len() as u16; + assert_eq!(decoded, expected); + } + + #[test] + fn bind_alter_context_decodes_as_bind() { + let pdu = BindPdu { + header: sample_header(PacketType::AlterContext), + ..sample_bind() + }; + let encoded = pdu.encode(); + let decoded = BindPdu::decode(&encoded).unwrap(); + assert_eq!(decoded.header.packet_type, PacketType::AlterContext); + } + + #[test] + fn bind_rejects_request_packet_type() { + let mut encoded = sample_bind().encode(); + encoded[2] = PacketType::Request.as_byte(); + let err = BindPdu::decode(&encoded).unwrap_err(); + assert!(matches!(err, RpcError::UnexpectedPacketType { .. })); + } + + #[test] + fn bind_rejects_short_buffer() { + let err = BindPdu::decode(&[0u8; 16]).unwrap_err(); + // Only 16 bytes — robust-decode path: regardless of which validation + // (unknown packet_type=0, fragment_length=0 < BIND_BODY_OFFSET=28, + // or short read) trips first, the surface is a typed error rather + // than a panic. + assert!(matches!( + err, + RpcError::InvalidFragmentLength { .. } + | RpcError::ShortRead { .. } + | RpcError::UnknownPacketType { .. } + | RpcError::UnexpectedPacketType { .. } + | RpcError::TruncatedBindBody { .. } + )); + } + + #[test] + fn bind_rejects_frag_length_overflow() { + let mut encoded = sample_bind().encode(); + // Claim frag_length is one greater than the actual buffer. + let bad = (encoded.len() + 1) as u16; + write_u16_le(&mut encoded, 8, bad); + let err = BindPdu::decode(&encoded).unwrap_err(); + assert!(matches!(err, RpcError::InvalidFragmentLength { .. })); + } + + #[test] + fn bind_preserves_reserved_25_28() { + let pdu = BindPdu { + reserved25_28: [0xCA, 0xFE, 0xBA], + ..sample_bind() + }; + let encoded = pdu.encode(); + assert_eq!(&encoded[25..28], &[0xCA, 0xFE, 0xBA]); + let decoded = BindPdu::decode(&encoded).unwrap(); + assert_eq!(decoded.reserved25_28, [0xCA, 0xFE, 0xBA]); + } + + #[test] + fn bind_with_auth_round_trip_signature() { + // 16-byte NTLM signature payload, hand-built per [MS-NLMP] §2.2.2.9.1. + // (Real signatures are computed by NTLM SEAL/SIGN; for the trailer + // round-trip we just need 16 arbitrary bytes.) + let token = [ + 0x01, 0x00, 0x00, 0x00, // version + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, // checksum + 0x00, 0x00, 0x00, 0x01, // seq num + ]; + let trailer = sample_trailer(); + let pdu = sample_bind(); + let encoded = pdu.encode_with_auth(trailer, &token); + + // frag_length must now reflect the auth tail. + let frag_length = read_u16_le(&encoded, 8) as usize; + assert_eq!(frag_length, encoded.len()); + // auth_length = token.len() (NOT including the 8-byte trailer). + assert_eq!(read_u16_le(&encoded, 10) as usize, token.len()); + + let extracted = BindPdu::read_auth_value(&encoded).unwrap(); + assert_eq!(extracted.token, token); + assert_eq!(extracted.trailer.auth_type, AuthType::WinNt); + assert_eq!(extracted.trailer.auth_level, AuthLevel::PacketIntegrity); + } + + #[test] + fn auth_value_missing_when_auth_length_zero() { + let encoded = sample_bind().encode(); + let err = BindPdu::read_auth_value(&encoded).unwrap_err(); + assert!(matches!(err, RpcError::MissingAuthValue)); + } + + #[test] + fn encode_auth3_layout() { + let header = sample_header(PacketType::Bind); + let trailer = sample_trailer(); + let token = [0xAAu8; 16]; + let encoded = BindPdu::encode_auth3(header, trailer, &token); + // header(16) + body(4) + pad(0, since 20 % 4 == 0) + trailer(8) + token(16) + assert_eq!(encoded.len(), 16 + 4 + 8 + 16); + // Packet type was rewritten to Auth3 (=16). + assert_eq!(encoded[2], 16); + // frag_length matches. + assert_eq!(read_u16_le(&encoded, 8) as usize, encoded.len()); + // Body is 4 ASCII spaces (cs:315). + assert_eq!(&encoded[16..20], b" "); + // Token at the tail. + assert_eq!(&encoded[encoded.len() - 16..], &token); + } + + #[test] + fn encode_with_auth_pad_when_unaligned() { + // Build a bind whose body length % 4 != 0. Add a dummy second context + // with zero transfer syntaxes to bump body by 4 + 20 = 24 bytes + // (still aligned). Instead, hand-construct an unaligned base. + let mut pdu = sample_bind(); + // Push a second context that has 0 transfer syntaxes -> 4 + 20 = 24 bytes. + // Total bind body = 28 + 44 (first ctx) + 24 (second) = 96, still %4=0. + // To force misalignment, we need the bind body length itself unaligned. + // The bind body is always %4-aligned by construction (every context is + // 24 + 20*N bytes, all %4). So the .NET encode_with_auth always inserts + // 0 pad. Verify that case explicitly. + pdu.presentation_contexts.push(PresentationContext { + context_id: 1, + abstract_syntax: SyntaxId::NDR20, + transfer_syntaxes: vec![], + }); + let token = [0u8; 4]; + let encoded = pdu.encode_with_auth(sample_trailer(), &token); + let body_len = pdu.encode().len(); + assert_eq!(body_len % 4, 0); + // pad_length=0, trailer=8, token=4 -> total = body_len + 12. + assert_eq!(encoded.len(), body_len + 12); + let extracted = BindPdu::read_auth_value(&encoded).unwrap(); + assert_eq!(extracted.trailer.auth_pad_length, 0); + assert_eq!(extracted.token, token); + } + + // --- RequestPdu ---------------------------------------------------------- + + fn sample_request() -> RequestPdu { + RequestPdu { + header: PduHeader { + packet_flags: 0, + ..sample_header(PacketType::Request) + }, + allocation_hint: 0, + context_id: 1, + // TransferData opnum per `40-protocol-invariants.md:28` (opnum 6). + opnum: 6, + stub_data: vec![0xAA, 0xBB, 0xCC, 0xDD], + } + } + + #[test] + fn request_round_trip() { + let pdu = sample_request(); + let encoded = pdu.encode(); + assert_eq!(encoded.len(), 24 + 4); + // packet_flags was 0 -> encoder bumps to 0x03 (cs:123). + assert_eq!(encoded[3], 0x03); + let decoded = RequestPdu::decode(&encoded).unwrap(); + // Decoded reflects encoded flags + length. + assert_eq!(decoded.header.packet_flags, 0x03); + assert_eq!(decoded.header.fragment_length as usize, encoded.len()); + assert_eq!(decoded.allocation_hint, pdu.allocation_hint); + assert_eq!(decoded.context_id, pdu.context_id); + assert_eq!(decoded.opnum, pdu.opnum); + assert_eq!(decoded.stub_data, pdu.stub_data); + } + + #[test] + fn request_keeps_explicit_packet_flags() { + let pdu = RequestPdu { + header: PduHeader { + packet_flags: 0x0B, + ..sample_header(PacketType::Request) + }, + ..sample_request() + }; + let encoded = pdu.encode(); + assert_eq!(encoded[3], 0x0B); + } + + #[test] + fn request_short_buffer() { + let err = RequestPdu::decode(&[0u8; 16]).unwrap_err(); + // header parses, then frag_length=0 fails the body-offset check. + assert!(matches!( + err, + RpcError::InvalidFragmentLength { .. } | RpcError::ShortRead { .. } + )); + } + + #[test] + fn request_rejects_inconsistent_frag_length() { + let mut encoded = sample_request().encode(); + // Declare frag_length one greater than what the buffer actually holds. + let bogus = (encoded.len() + 1) as u16; + write_u16_le(&mut encoded, 8, bogus); + let err = RequestPdu::decode(&encoded).unwrap_err(); + assert!(matches!(err, RpcError::InvalidFragmentLength { .. })); + } + + // --- ResponsePdu --------------------------------------------------------- + + #[test] + fn response_round_trip() { + let pdu = ResponsePdu { + header: sample_header(PacketType::Response), + allocation_hint: 32, + context_id: 1, + cancel_count: 0, + reserved23: 0xEE, + stub_data: vec![0x01, 0x02, 0x03, 0x04], + }; + let encoded = pdu.encode(); + let decoded = ResponsePdu::decode(&encoded).unwrap(); + assert_eq!(decoded.cancel_count, 0); + assert_eq!(decoded.reserved23, 0xEE); + assert_eq!(decoded.stub_data, pdu.stub_data); + } + + #[test] + fn response_short_buffer() { + let err = ResponsePdu::decode(&[0u8; 16]).unwrap_err(); + // Same robust-decode rationale as bind_rejects_short_buffer: + // any typed error (not a panic) is acceptable for a 16-byte + // all-zero buffer. + assert!(matches!( + err, + RpcError::InvalidFragmentLength { .. } + | RpcError::ShortRead { .. } + | RpcError::UnknownPacketType { .. } + | RpcError::UnexpectedPacketType { .. } + )); + } + + #[test] + fn response_rejects_wrong_packet_type() { + let mut encoded = sample_request().encode(); + // Overwrite packet type to Response so the request-shaped buffer + // tries to decode as a response. The body offsets still match (24). + encoded[2] = PacketType::Response.as_byte(); + // This should *not* error on packet type — it should succeed. + let decoded = ResponsePdu::decode(&encoded).unwrap(); + assert_eq!(decoded.header.packet_type, PacketType::Response); + // Now do the negative case: ask for Fault decoding of a Response buffer. + assert!(matches!( + FaultPdu::decode(&encoded), + Err(RpcError::UnexpectedPacketType { .. }) + )); + } + + // --- FaultPdu ------------------------------------------------------------ + + #[test] + fn fault_round_trip() { + let pdu = FaultPdu { + header: sample_header(PacketType::Fault), + allocation_hint: 0, + context_id: 1, + cancel_count: 0, + reserved23: 0, + // 0x80004005 = E_FAIL — one of the fault-status values + // documented in `[MS-RPCE]` §3.1.1.5.5. + status: 0x8000_4005, + stub_data: vec![], + }; + let encoded = pdu.encode(); + assert_eq!(encoded.len(), FAULT_BODY_OFFSET); + let decoded = FaultPdu::decode(&encoded).unwrap(); + assert_eq!(decoded.status, 0x8000_4005); + assert_eq!(decoded.stub_data.len(), 0); + } + + #[test] + fn fault_short_buffer() { + // Build a header that claims Fault but provide < FAULT_BODY_OFFSET. + let header = PduHeader { + fragment_length: 16, + ..sample_header(PacketType::Fault) + }; + let mut buf = vec![0u8; 16]; + header.encode(&mut buf).unwrap(); + let err = FaultPdu::decode(&buf).unwrap_err(); + assert!(matches!(err, RpcError::InvalidFragmentLength { .. })); + } + + // --- Misc invariants ----------------------------------------------------- + + #[test] + fn align_helper_matches_dotnet() { + // Mirrors `Align(value, 4)` from `DceRpcPdu.cs:375-379`. + assert_eq!(align(0, 4), 0); + assert_eq!(align(1, 4), 4); + assert_eq!(align(3, 4), 4); + assert_eq!(align(4, 4), 4); + assert_eq!(align(5, 4), 8); + } + + #[test] + fn ndr20_constant_bytes() { + // `NDR20` is `8a885d04-1ceb-11c9-9fe8-08002b104860` v2.0. + // Bytes are the .NET `Guid.ToByteArray()` order. + // Source: `40-protocol-invariants.md:15`, `DceRpcPdu.cs:19-22`. + assert_eq!(SyntaxId::NDR20.uuid_bytes[0], 0x04); + assert_eq!(SyntaxId::NDR20.uuid_bytes[1], 0x5d); + assert_eq!(SyntaxId::NDR20.uuid_bytes[2], 0x88); + assert_eq!(SyntaxId::NDR20.uuid_bytes[3], 0x8a); + assert_eq!(SyntaxId::NDR20.version_major, 2); + assert_eq!(SyntaxId::NDR20.version_minor, 0); + } + + #[test] + fn auth_type_round_trip_known_values() { + for byte in [0u8, 9, 10] { + let typed = AuthType::from_byte(byte); + assert_eq!(typed.as_byte(), byte); + } + // Unknown byte funnels to None. + assert_eq!(AuthType::from_byte(99), AuthType::None); + } + + #[test] + fn auth_level_round_trip_known_values() { + for byte in [1u8, 2, 5, 6] { + let typed = AuthLevel::from_byte(byte); + assert_eq!(typed.as_byte(), byte); + } + // Unknown byte funnels to None. + assert_eq!(AuthLevel::from_byte(99), AuthLevel::None); + } +}