From 432f1102b765a7d627c6afde00b123b176d3f7d3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 07:47:42 -0400 Subject: [PATCH] [M2/M3] mxaccess-rpc: tokio DCE/RPC TCP transport (DceRpcTcpClient port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the async DCE/RPC TCP client — the transport that bridges the M2 PDU codec to a real socket. Unblocks M3 stream B (mxaccess-nmx, the NmxClient) and brings F9 (ResolveOxid wrappers) within reach. New - transport.rs (~700 LoC, 10 tests including 2 real-socket tokio tests) — port of src/MxNativeClient/DceRpcTcpClient.cs. - DceRpcTcpClient::connect/bind/bind_with_managed_ntlm_packet_integrity/ call/call_bound/call_bound_object — async over tokio::net::TcpStream. - encode_packet_integrity_request: 4-byte 0xBB pad + 8-byte AuthTrailer + 16-byte NtlmClientContext::sign signature, frag_length and auth_length rewritten in the embedded header per cs:201-250. - encode_request_bytes: PFC_OBJECT_UUID flag (0x80) and inserted 16-byte object UUID slot per cs:269-278. - TransportError enum unifies io / codec / NTLM / fault / not-connected surfaces. Mirrors DceRpcFaultException as the typed Fault variant. - NTLM_AUTH_CONTEXT_ID = 79232 = 0x13580 (cs:90,133) exposed publicly. Deliberately skipped: BindWithNtlmConnect / BindWithNtlmPacketIntegrity (SSPI flavours at cs:55-63,108-149) — those wrap .NET's System.Net.Security.SspiClientContext, which has no portable analogue. Managed-NTLM path covers what the production Rust client needs. mxaccess-rpc/Cargo.toml: added tokio (workspace-pinned). design/followups.md: F9 downgraded P1 → P2 (transport landed; only the two pure-codec ResolveOxid wrappers remain). Test count delta: 354 -> 364 (+10). Open followups touched: F9 partially advanced. Co-Authored-By: Claude Opus 4.7 (1M context) --- design/followups.md | 8 +- rust/Cargo.lock | 1 + rust/crates/mxaccess-rpc/Cargo.toml | 1 + rust/crates/mxaccess-rpc/src/lib.rs | 1 + rust/crates/mxaccess-rpc/src/transport.rs | 862 ++++++++++++++++++++++ 5 files changed, 869 insertions(+), 4 deletions(-) create mode 100644 rust/crates/mxaccess-rpc/src/transport.rs diff --git a/design/followups.md b/design/followups.md index ed3ffeb..7a9757c 100644 --- a/design/followups.md +++ b/design/followups.md @@ -42,11 +42,11 @@ move to `## Resolved` with a date + commit hash. **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. -### F9 — Port `ObjectExporterClient.cs` (4 NTLM-variant ResolveOxid transport wrappers) -**Severity:** P1 +### F9 — `ObjectExporterClient.cs` ResolveOxid wrapper methods +**Severity:** P2 (was P1 — downgraded after `DceRpcTcpClient` transport landed) **Source:** M2 wave 2, `crates/mxaccess-rpc/src/object_exporter.rs` -**Why deferred:** Those four methods (`ResolveOxidUnauthenticated`, `ResolveOxidWithNtlmConnect`, `ResolveOxidWithNtlmPacketIntegrity`, `ResolveOxidWithManagedNtlmPacketIntegrity`) drive a `DceRpcTcpClient` that has not been ported. They are transport-layer code, not pure-codec, so they sit one wave below the OXID body codec that M2 wave 2 landed. -**Resolves when:** A `DceRpcTcpClient` Rust port lands (likely M2 wave 3 alongside the callback exporter, or M3 if the NMX session takes the lead). Then the four `resolve_oxid_with_*` flavors can be a thin call layer over `encode_resolve_oxid_request` + `parse_resolve_oxid_result`. +**Why deferred:** The transport prerequisite (`DceRpcTcpClient`) is now ported in `crates/mxaccess-rpc/src/transport.rs`. What remains is two thin wrapper methods that wire the codec to the transport: `resolve_oxid_unauthenticated(addr, oxid, protseqs) -> Result` (mirrors `ObjectExporterClient.cs:14-30`) and `resolve_oxid_with_managed_ntlm_packet_integrity(addr, oxid, protseqs, ntlm) -> Result` (mirrors `cs:66-81`). The two SSPI variants (`ResolveOxidWithNtlmConnect` at `cs:32-47` and `ResolveOxidWithNtlmPacketIntegrity` at `cs:49-64`) are .NET-specific (`System.Net.Security.SspiClientContext`) and explicitly out of scope. +**Resolves when:** Both wrapper methods land, calling `DceRpcTcpClient::connect`/`bind`/`call_bound` against `IObjectExporter` opnum 0 and parsing via `parse_resolve_oxid_result` / `parse_resolve_oxid_failure`. ### F10 — `IObjectExporter::ResolveOxid2` (opnum 4) body codec **Severity:** P2 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b98426f..6bb5039 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -225,6 +225,7 @@ dependencies = [ "rand", "rc4", "thiserror", + "tokio", ] [[package]] diff --git a/rust/crates/mxaccess-rpc/Cargo.toml b/rust/crates/mxaccess-rpc/Cargo.toml index 9dd2756..73e6321 100644 --- a/rust/crates/mxaccess-rpc/Cargo.toml +++ b/rust/crates/mxaccess-rpc/Cargo.toml @@ -10,6 +10,7 @@ authors.workspace = true [dependencies] thiserror = { workspace = true } +tokio = { workspace = true } hmac = "0.12" md-5 = "0.10" md4 = "0.10" diff --git a/rust/crates/mxaccess-rpc/src/lib.rs b/rust/crates/mxaccess-rpc/src/lib.rs index e4a04a9..e7dd060 100644 --- a/rust/crates/mxaccess-rpc/src/lib.rs +++ b/rust/crates/mxaccess-rpc/src/lib.rs @@ -25,3 +25,4 @@ pub mod objref; pub mod orpc; pub mod pdu; pub mod rem_unknown; +pub mod transport; diff --git a/rust/crates/mxaccess-rpc/src/transport.rs b/rust/crates/mxaccess-rpc/src/transport.rs new file mode 100644 index 0000000..6ead326 --- /dev/null +++ b/rust/crates/mxaccess-rpc/src/transport.rs @@ -0,0 +1,862 @@ +//! DCE/RPC TCP transport — async port of `DceRpcTcpClient.cs`. +//! +//! Direct port of `src/MxNativeClient/DceRpcTcpClient.cs` over tokio. +//! Provides: +//! +//! - [`DceRpcTcpClient::connect`] — open a TCP connection +//! - [`DceRpcTcpClient::bind`] — unauthenticated bind (`cs:33-53`) +//! - [`DceRpcTcpClient::bind_with_managed_ntlm_packet_integrity`] — +//! NTLMv2 packet-integrity bind using [`crate::ntlm::NtlmClientContext`] +//! (`cs:65-106`) +//! - [`DceRpcTcpClient::call`] / [`DceRpcTcpClient::call_bound`] / +//! [`DceRpcTcpClient::call_bound_object`] — request dispatch +//! (`cs:151-182,252-282`) +//! +//! The `BindWithNtlmConnect` / `BindWithNtlmPacketIntegrity` flavours from +//! the .NET reference (`cs:55-63,108-149`) wrap `System.Net.Security.SspiClientContext`, +//! which is .NET-specific. They're explicitly out of scope for the Rust +//! port — the managed-NTLM path is the only one we need (cite +//! `design/00-overview.md` principle 3 and `design/40-protocol-invariants.md`). +//! +//! ## Packet integrity +//! +//! When [`DceRpcTcpClient::bind_with_managed_ntlm_packet_integrity`] is used, +//! every subsequent `call*` PDU is wrapped per `cs:201-250`: +//! +//! ```text +//! pdu_layout = unauthenticated_request +//! || pad to 4-byte align (filled with 0xBB, cs:215) +//! || DceRpcAuthTrailer (8 bytes) +//! || 16-byte NTLM signature +//! ``` +//! +//! The header's `frag_length` is rewritten to the new full length and +//! `auth_length` is set to 16 (the signature size). `NtlmClientContext::sign` +//! is called over `pdu[0..length-16]` and the result is written into the +//! trailing 16 bytes. Mirrors `cs:201-250` exactly. + +#![allow(clippy::indexing_slicing)] + +use std::net::SocketAddr; + +use thiserror::Error; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::error::RpcError; +use crate::guid::Guid; +use crate::ntlm::{NtlmClientContext, NtlmError, SIGNATURE_LEN}; +use crate::pdu::{ + AuthLevel, AuthTrailer, AuthType, BindPdu, FaultPdu, PacketType, PduHeader, + PresentationContext, ResponsePdu, SyntaxId, +}; + +/// Errors raised by the TCP transport. Mirrors the wrap of +/// `IOException` / `InvalidOperationException` / `DceRpcFaultException` +/// at `DceRpcTcpClient.cs:170-174,205,245,393`. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum TransportError { + /// I/O failure on the underlying TCP stream. + #[error("transport I/O: {0}")] + Io(#[from] std::io::Error), + + /// PDU codec failure — wraps [`RpcError`]. + #[error("PDU codec: {0}")] + Codec(#[from] RpcError), + + /// NTLM signing failed (signing called before authenticate completed + /// or buffer length issues). Mirrors `cs:245`. + #[error("NTLM signing: {0}")] + Ntlm(#[from] NtlmError), + + /// `Connect()` was not called or the socket was closed + /// (`cs:402-407`). + #[error("DCE/RPC TCP client is not connected")] + NotConnected, + + /// A `Call*` arrived with `auth_level == PacketIntegrity` but no + /// auth trailer was set up by a prior bind (`cs:203-206,245`). + #[error("packet-integrity auth requested without an auth trailer")] + AuthContextMissing, + + /// Server returned a `Fault` PDU. Mirrors `DceRpcFaultException` + /// (`cs:411-420`). + #[error("DCE/RPC fault 0x{status:08x}")] + Fault { status: u32 }, + + /// Server replied with a packet type the transport doesn't expect at + /// that point in the conversation (e.g. a `Request` where a Response + /// was expected). + #[error("unexpected response packet type: {actual:?}")] + UnexpectedResponsePacketType { actual: PacketType }, +} + +/// Fixed `auth_context_id` used by the .NET reference for every +/// authenticated PDU (`cs:90,133`). The same value is reused across the +/// connection so the server can correlate the trailer back to the +/// negotiated NTLM context. +pub const NTLM_AUTH_CONTEXT_ID: u32 = 79232; + +/// Fixed PDU header constants used for outbound frames (`cs:336-347`). +const FRAME_VERSION: u8 = 5; +const FRAME_VERSION_MINOR: u8 = 0; +const FRAME_PACKET_FLAGS: u8 = 0x03; +const FRAME_DATA_REPRESENTATION: u32 = 0x10; + +/// `DceRpcTcpClient` — a single-connection async DCE/RPC client. +/// +/// Construct with [`connect`](Self::connect), then call one of the +/// `bind*` methods, then dispatch one or more `call*` requests, then drop +/// to close the socket. +/// +/// Not Clone (the underlying `TcpStream` is single-owner) and not Sync +/// (mutable internal state — call id counter, NTLM context, auth trailer). +pub struct DceRpcTcpClient { + stream: TcpStream, + next_call_id: u32, + bound_context_id: u16, + ntlm: Option, + auth_trailer: Option, + auth_level: AuthLevel, +} + +impl DceRpcTcpClient { + /// Open a TCP connection to `addr`. Mirrors `Connect()` + /// (`DceRpcTcpClient.cs:26-31`). + /// + /// # Errors + /// Propagates [`std::io::Error`] from [`TcpStream::connect`]. + pub async fn connect(addr: SocketAddr) -> std::io::Result { + let stream = TcpStream::connect(addr).await?; + Ok(Self { + stream, + next_call_id: 1, + bound_context_id: 0, + ntlm: None, + auth_trailer: None, + auth_level: AuthLevel::None, + }) + } + + /// Local socket address (for tests / diagnostics). + /// + /// # Errors + /// Propagates [`std::io::Error`] if the socket is invalid. + pub fn local_addr(&self) -> std::io::Result { + self.stream.local_addr() + } + + /// Currently negotiated `bound_context_id`. Set by the bind methods. + #[must_use] + pub fn bound_context_id(&self) -> u16 { + self.bound_context_id + } + + /// Currently negotiated `auth_level`. Tracks `_authLevel` from the + /// .NET reference (`cs:18,104,147`). + #[must_use] + pub fn auth_level(&self) -> AuthLevel { + self.auth_level + } + + /// Unauthenticated bind. Mirrors `Bind` + /// (`DceRpcTcpClient.cs:33-53`). + /// + /// On success returns the response PDU header (typically `BindAck` + /// per `[C706]` §12.6.4.4); the bound presentation context id is + /// always 0 for this transport (the .NET reference only ever + /// presents one context at a time). + /// + /// # Errors + /// I/O, codec, or unexpected packet type. + pub async fn bind( + &mut self, + interface_id: Guid, + version_major: u16, + version_minor: u16, + ) -> Result { + let call_id = self.next_call_id; + self.next_call_id = self.next_call_id.wrapping_add(1); + + let pdu = make_bind_pdu(interface_id, version_major, version_minor, call_id); + let bytes = pdu.encode(); + + self.stream.write_all(&bytes).await?; + let response = read_pdu(&mut self.stream).await?; + let header = PduHeader::decode(&response)?; + Ok(header) + } + + /// Bind + Auth3 round-trip using the managed NTLMv2 packet-integrity + /// path. Mirrors `BindWithManagedNtlmPacketIntegrity` + /// (`cs:65-106`). + /// + /// Takes ownership of the [`NtlmClientContext`] for the lifetime of + /// the connection; subsequent `call*` requests are signed with it. + /// The .NET reference creates the context via + /// `ManagedNtlmClientContext.FromEnvironment()` (cs:70) — that + /// helper is open follow-up F1 in the Rust port; for now the caller + /// constructs `NtlmClientContext::new(user, password, domain, workstation)` + /// explicitly. + /// + /// # Errors + /// I/O, codec, or NTLM (Type1/Type3 building). + pub async fn bind_with_managed_ntlm_packet_integrity( + &mut self, + interface_id: Guid, + version_major: u16, + version_minor: u16, + mut ntlm: NtlmClientContext, + ) -> Result { + let call_id = self.next_call_id; + self.next_call_id = self.next_call_id.wrapping_add(1); + + let type1 = ntlm.create_type1(); + let pdu = make_bind_pdu(interface_id, version_major, version_minor, call_id); + let trailer = AuthTrailer { + auth_type: AuthType::WinNt, + auth_level: AuthLevel::PacketIntegrity, + auth_pad_length: 0, + auth_reserved: 0, + auth_context_id: NTLM_AUTH_CONTEXT_ID, + }; + + let bind_with_auth = pdu.encode_with_auth(trailer, &type1); + self.stream.write_all(&bind_with_auth).await?; + + let response = read_pdu(&mut self.stream).await?; + let response_header = PduHeader::decode(&response)?; + let challenge = BindPdu::read_auth_value(&response)?; + let mut inputs = crate::ntlm::OsInputs; + let type3 = ntlm.create_type3(&challenge.token, &mut inputs)?; + + let auth3_header = make_request_header(PacketType::Auth3, response_header.call_id); + let auth3 = BindPdu::encode_auth3(auth3_header, trailer, &type3); + self.stream.write_all(&auth3).await?; + + self.bound_context_id = 0; + self.ntlm = Some(ntlm); + self.auth_trailer = Some(trailer); + self.auth_level = AuthLevel::PacketIntegrity; + Ok(response_header) + } + + /// Dispatch a Request on the explicit context id. Mirrors `Call` + /// (`DceRpcTcpClient.cs:151-154`). + /// + /// # Errors + /// I/O, codec, NTLM signing, or `TransportError::Fault` if the server + /// returned a Fault PDU. + pub async fn call( + &mut self, + context_id: u16, + opnum: u16, + stub_data: &[u8], + ) -> Result { + self.call_core(context_id, opnum, stub_data, None).await + } + + /// Dispatch a Request on the bound context with no object UUID. + /// Mirrors `CallBound` (`cs:179-182`). + /// + /// # Errors + /// As for [`call`](Self::call). + pub async fn call_bound( + &mut self, + opnum: u16, + stub_data: &[u8], + ) -> Result { + let cid = self.bound_context_id; + self.call_core(cid, opnum, stub_data, None).await + } + + /// Dispatch a Request on the bound context with an object UUID + /// (sets `PFC_OBJECT_UUID = 0x80` in the packet flags). Mirrors + /// `CallBoundObject` (`cs:156-159`). + /// + /// # Errors + /// As for [`call`](Self::call). + pub async fn call_bound_object( + &mut self, + object_uuid: Guid, + opnum: u16, + stub_data: &[u8], + ) -> Result { + let cid = self.bound_context_id; + self.call_core(cid, opnum, stub_data, Some(object_uuid)) + .await + } + + async fn call_core( + &mut self, + context_id: u16, + opnum: u16, + stub_data: &[u8], + object_uuid: Option, + ) -> Result { + let call_id = self.next_call_id; + self.next_call_id = self.next_call_id.wrapping_add(1); + + let header = make_request_header(PacketType::Request, call_id); + let request = encode_request_bytes(header, context_id, opnum, stub_data, object_uuid); + + let pdu = if self.auth_level == AuthLevel::PacketIntegrity { + let trailer = self + .auth_trailer + .ok_or(TransportError::AuthContextMissing)?; + let ntlm = self + .ntlm + .as_mut() + .ok_or(TransportError::AuthContextMissing)?; + encode_packet_integrity_request(&request, trailer, ntlm)? + } else { + request + }; + + self.stream.write_all(&pdu).await?; + let response = read_pdu(&mut self.stream).await?; + let response_header = PduHeader::decode(&response)?; + match response_header.packet_type { + PacketType::Response => Ok(ResponsePdu::decode(&response)?), + PacketType::Fault => { + let fault = FaultPdu::decode(&response)?; + Err(TransportError::Fault { + status: fault.status, + }) + } + other => Err(TransportError::UnexpectedResponsePacketType { actual: other }), + } + } +} + +/// Build the standard outbound bind PDU (`cs:33-48,73-84,116-127`). +fn make_bind_pdu( + interface_id: Guid, + version_major: u16, + version_minor: u16, + call_id: u32, +) -> BindPdu { + BindPdu { + header: make_request_header(PacketType::Bind, call_id), + max_transmit_fragment: 4280, + max_receive_fragment: 4280, + association_group_id: 0, + presentation_contexts: vec![PresentationContext { + context_id: 0, + abstract_syntax: SyntaxId { + uuid_bytes: *interface_id.as_bytes(), + version_major, + version_minor, + }, + transfer_syntaxes: vec![SyntaxId::NDR20], + }], + reserved25_28: [0; 3], + } +} + +/// Build a fresh outbound PDU header. Mirrors `CreateHeader` +/// (`cs:336-347`). `fragment_length` and `auth_length` are 0; the PDU +/// encoder fills `fragment_length` later. `packet_flags = 0x03` matches +/// `cs:342`. +fn make_request_header(packet_type: PacketType, call_id: u32) -> PduHeader { + PduHeader { + version: FRAME_VERSION, + version_minor: FRAME_VERSION_MINOR, + packet_type, + packet_flags: FRAME_PACKET_FLAGS, + data_representation: FRAME_DATA_REPRESENTATION, + fragment_length: 0, + auth_length: 0, + call_id, + } +} + +/// Build the unauthenticated `Request` PDU bytes. Mirrors +/// `EncodeRequestBytes` (`DceRpcTcpClient.cs:252-282`). +/// +/// Layout: +/// +/// ```text +/// offset size field +/// 0 16 PduHeader +/// 16 4 allocation_hint u32 LE = stub.len() +/// 20 2 context_id u16 LE +/// 22 2 opnum u16 LE +/// 24..(24+16 if object) 16 object_uuid (only when PFC_OBJECT_UUID) +/// stub_offset.. var stub_data +/// ``` +/// +/// Sets `packet_flags |= 0x80` (`PFC_OBJECT_UUID`) when `object_uuid` is +/// `Some`, mirroring `cs:269`. +pub(crate) fn encode_request_bytes( + header: PduHeader, + context_id: u16, + opnum: u16, + stub_data: &[u8], + object_uuid: Option, +) -> Vec { + let object_length = if object_uuid.is_some() { 16 } else { 0 }; + let fixed_offset = PduHeader::LENGTH; + let stub_offset = fixed_offset + 8 + object_length; + let length = stub_offset + stub_data.len(); + let mut pdu = vec![0u8; length]; + + let request_header = PduHeader { + packet_type: PacketType::Request, + fragment_length: u16::try_from(length).unwrap_or(u16::MAX), + auth_length: 0, + packet_flags: { + let base = if header.packet_flags == 0 { + 0x03 + } else { + header.packet_flags + }; + if object_uuid.is_some() { + base | 0x80 + } else { + base + } + }, + ..header + }; + let _ = request_header.encode(&mut pdu); + + pdu[fixed_offset..fixed_offset + 4].copy_from_slice( + &u32::try_from(stub_data.len()) + .unwrap_or(u32::MAX) + .to_le_bytes(), + ); + pdu[fixed_offset + 4..fixed_offset + 6].copy_from_slice(&context_id.to_le_bytes()); + pdu[fixed_offset + 6..fixed_offset + 8].copy_from_slice(&opnum.to_le_bytes()); + if let Some(uuid) = object_uuid { + pdu[fixed_offset + 8..fixed_offset + 24].copy_from_slice(uuid.as_bytes()); + } + pdu[stub_offset..stub_offset + stub_data.len()].copy_from_slice(stub_data); + pdu +} + +/// Wrap an unauthenticated Request PDU with packet-integrity padding, +/// auth trailer, and 16-byte NTLM signature. Mirrors +/// `EncodePacketIntegrityRequest` (`DceRpcTcpClient.cs:201-250`). +/// +/// Layout: +/// +/// ```text +/// 0..N unauthenticated PDU (header + body) +/// N..N+pad 0xBB pad bytes to 4-byte boundary (cs:215) +/// N+pad.. AuthTrailer (8 bytes; auth_pad_length set to pad) +/// last 16 bytes NTLM signature over [0..length-16] +/// ``` +/// +/// The PDU header inside is rewritten to set `fragment_length = length` +/// and `auth_length = 16`. +pub(crate) fn encode_packet_integrity_request( + unauthenticated: &[u8], + trailer: AuthTrailer, + ntlm: &mut NtlmClientContext, +) -> Result, TransportError> { + let pad_length = align_up(unauthenticated.len(), 4) - unauthenticated.len(); + let length = unauthenticated.len() + pad_length + AuthTrailer::LENGTH + SIGNATURE_LEN; + let mut pdu = vec![0u8; length]; + pdu[..unauthenticated.len()].copy_from_slice(unauthenticated); + if pad_length > 0 { + pdu[unauthenticated.len()..unauthenticated.len() + pad_length].fill(0xBB); + } + + // Rewrite the embedded PDU header. + let parsed_header = PduHeader::decode(unauthenticated)?; + let header = PduHeader { + packet_type: PacketType::Request, + packet_flags: if parsed_header.packet_flags == 0 { + 0x03 + } else { + parsed_header.packet_flags + }, + fragment_length: u16::try_from(length).unwrap_or(u16::MAX), + auth_length: u16::try_from(SIGNATURE_LEN).unwrap_or(u16::MAX), + ..parsed_header + }; + let _ = header.encode(&mut pdu); + + // Write the auth trailer (with auth_pad_length reflecting our pad). + let trailer = AuthTrailer { + auth_pad_length: u8::try_from(pad_length).unwrap_or(u8::MAX), + ..trailer + }; + let trailer_offset = unauthenticated.len() + pad_length; + let mut trailer_buf = [0u8; AuthTrailer::LENGTH]; + trailer.encode(&mut trailer_buf)?; + pdu[trailer_offset..trailer_offset + AuthTrailer::LENGTH].copy_from_slice(&trailer_buf); + + // Sign over [0..length - SIGNATURE_LEN] and write the signature into the + // trailing 16 bytes. The .NET reference fills the placeholder with 0x20 + // before signing (`cs:231`); since `Sign` reads only [0..length-16], + // the placeholder doesn't affect the MAC, but we keep the same + // initial bytes so any future test that compares full PDUs has a + // consistent shape. + pdu[length - SIGNATURE_LEN..].fill(0x20); + let signature = ntlm.sign(&pdu[..length - SIGNATURE_LEN])?; + pdu[length - SIGNATURE_LEN..].copy_from_slice(&signature); + + Ok(pdu) +} + +const fn align_up(value: usize, alignment: usize) -> usize { + let r = value % alignment; + if r == 0 { value } else { value + alignment - r } +} + +/// Read one full PDU from `stream`. Mirrors `ReadPdu` + `ReadExact` +/// (`DceRpcTcpClient.cs:372-400`). Returns the full bytes including the +/// 16-byte header. +async fn read_pdu(stream: &mut TcpStream) -> Result, TransportError> { + let mut header_bytes = [0u8; PduHeader::LENGTH]; + stream.read_exact(&mut header_bytes).await?; + let header = PduHeader::decode(&header_bytes)?; + let frag = header.fragment_length as usize; + if frag < PduHeader::LENGTH { + return Err(TransportError::Codec(RpcError::InvalidFragmentLength { + frag_length: frag, + buffer_len: header_bytes.len(), + auth_length: header.auth_length as usize, + })); + } + let mut pdu = vec![0u8; frag]; + pdu[..PduHeader::LENGTH].copy_from_slice(&header_bytes); + if frag > PduHeader::LENGTH { + stream.read_exact(&mut pdu[PduHeader::LENGTH..]).await?; + } + Ok(pdu) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic +)] +mod tests { + use super::*; + use tokio::net::TcpListener; + + #[test] + fn align_up_matches_dotnet_align() { + assert_eq!(align_up(0, 4), 0); + assert_eq!(align_up(1, 4), 4); + assert_eq!(align_up(28, 4), 28); + assert_eq!(align_up(29, 4), 32); + } + + #[test] + fn ntlm_auth_context_id_matches_dotnet() { + // .NET hard-codes 79232 = 0x13580 at cs:90,133. + assert_eq!(NTLM_AUTH_CONTEXT_ID, 79232); + assert_eq!(NTLM_AUTH_CONTEXT_ID, 0x0001_3580); + } + + #[test] + fn make_request_header_uses_v5_drep_0x10_flags_03() { + let h = make_request_header(PacketType::Bind, 7); + assert_eq!(h.version, 5); + assert_eq!(h.version_minor, 0); + assert_eq!(h.packet_type, PacketType::Bind); + assert_eq!(h.packet_flags, 0x03); + assert_eq!(h.data_representation, 0x10); + assert_eq!(h.fragment_length, 0); + assert_eq!(h.auth_length, 0); + assert_eq!(h.call_id, 7); + } + + #[test] + fn encode_request_bytes_no_object_uuid_layout() { + let header = make_request_header(PacketType::Request, 42); + let stub = [0xAAu8, 0xBB, 0xCC, 0xDD]; + let bytes = encode_request_bytes(header, 0, 6, &stub, None); + + // Total = header(16) + 8 fixed + stub(4) = 28. + assert_eq!(bytes.len(), 28); + + // PFC_OBJECT_UUID bit must NOT be set (cs:269). + assert_eq!(bytes[3] & 0x80, 0); + + // allocation_hint = 4 + assert_eq!( + u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]), + 4 + ); + // context_id = 0 + assert_eq!(u16::from_le_bytes([bytes[20], bytes[21]]), 0); + // opnum = 6 + assert_eq!(u16::from_le_bytes([bytes[22], bytes[23]]), 6); + // stub bytes follow at offset 24. + assert_eq!(&bytes[24..28], &stub); + } + + #[test] + fn encode_request_bytes_with_object_uuid_sets_pfc_bit_and_inserts_uuid() { + let header = make_request_header(PacketType::Request, 99); + let stub = [0x01u8, 0x02]; + let uuid = Guid::new([0x11; 16]); + let bytes = encode_request_bytes(header, 0, 0, &stub, Some(uuid)); + + // Total = header(16) + 8 fixed + 16 object UUID + 2 stub = 42. + assert_eq!(bytes.len(), 42); + + // PFC_OBJECT_UUID bit must be set (cs:269). + assert_eq!(bytes[3] & 0x80, 0x80); + + // Object UUID at offset 24..40. + assert_eq!(&bytes[24..40], uuid.as_bytes()); + + // Stub at offset 40. + assert_eq!(&bytes[40..42], &stub); + } + + #[test] + fn encode_packet_integrity_request_pad_and_signature_layout() { + // Build an unauthenticated request that's NOT 4-byte aligned to + // exercise the pad branch (cs:208,213-216). + let header = make_request_header(PacketType::Request, 1); + let stub = [0xDE, 0xAD, 0xBE]; // 3 bytes -> total 27 (header 16 + 8 fixed + 3 stub) + let unauth = encode_request_bytes(header, 0, 0, &stub, None); + assert_eq!(unauth.len(), 27); + + // Build an NTLM context that's authenticated enough for sign() to + // succeed (Type1 + Type3 with fixed inputs). + let mut ntlm = NtlmClientContext::new("U", "P", "D", Some("")); + ntlm.create_type1(); + let challenge = make_dummy_challenge(); + ntlm.create_type3( + &challenge, + &mut crate::ntlm::FixedInputs { + client_challenge: [0u8; 8], + exported_session_key: [0u8; 16], + filetime: 0, + }, + ) + .unwrap(); + + let trailer = AuthTrailer { + auth_type: AuthType::WinNt, + auth_level: AuthLevel::PacketIntegrity, + auth_pad_length: 0, + auth_reserved: 0, + auth_context_id: NTLM_AUTH_CONTEXT_ID, + }; + + let pdu = encode_packet_integrity_request(&unauth, trailer, &mut ntlm).unwrap(); + + // pad = 1 byte (27 -> 28); total = 27 + 1 + 8 + 16 = 52. + assert_eq!(pdu.len(), 52); + + // Pad byte at offset 27 must be 0xBB. + assert_eq!(pdu[27], 0xBB); + + // Trailer auth_pad_length at offset 28+2 = 30 (per AuthTrailer + // encode: auth_type, auth_level, auth_pad_length, ...). + assert_eq!(pdu[28], AuthType::WinNt.as_byte()); + assert_eq!(pdu[29], AuthLevel::PacketIntegrity.as_byte()); + assert_eq!(pdu[30], 1); + + // Embedded header: fragment_length=52, auth_length=16. + let h = PduHeader::decode(&pdu).unwrap(); + assert_eq!(h.fragment_length as usize, 52); + assert_eq!(h.auth_length as usize, SIGNATURE_LEN); + + // The trailing 16 bytes are the NTLM signature; they MUST not be + // the 0x20 placeholder fill. + assert_ne!(&pdu[36..52], &[0x20u8; 16]); + } + + #[test] + fn encode_packet_integrity_request_no_pad_when_already_aligned() { + // 28-byte unauth (header 16 + 8 fixed + 4 stub) is already aligned. + let header = make_request_header(PacketType::Request, 1); + let stub = [0xDEu8, 0xAD, 0xBE, 0xEF]; + let unauth = encode_request_bytes(header, 0, 0, &stub, None); + assert_eq!(unauth.len(), 28); + + let mut ntlm = NtlmClientContext::new("U", "P", "D", Some("")); + ntlm.create_type1(); + let challenge = make_dummy_challenge(); + ntlm.create_type3( + &challenge, + &mut crate::ntlm::FixedInputs { + client_challenge: [0u8; 8], + exported_session_key: [0u8; 16], + filetime: 0, + }, + ) + .unwrap(); + + let trailer = AuthTrailer { + auth_type: AuthType::WinNt, + auth_level: AuthLevel::PacketIntegrity, + auth_pad_length: 0, + auth_reserved: 0, + auth_context_id: NTLM_AUTH_CONTEXT_ID, + }; + + let pdu = encode_packet_integrity_request(&unauth, trailer, &mut ntlm).unwrap(); + // Total = 28 + 0 (no pad) + 8 trailer + 16 sig = 52. + assert_eq!(pdu.len(), 52); + // auth_pad_length at offset 30 is 0. + assert_eq!(pdu[30], 0); + } + + /// Build a minimum-viable Type2 challenge so create_type3 succeeds in + /// tests. Mirrors what a real server would send. + fn make_dummy_challenge() -> Vec { + // 48-byte minimum Type2: signature(8) + msg_type(4) + target_name_fields(8) + // + flags(4) + server_challenge(8) + reserved(8) + target_info_fields(8) + // = 56 bytes when including target_info fields. Use the same scaffold + // ntlm::tests uses internally. + let mut buf = vec![0u8; 56]; + buf[..8].copy_from_slice(b"NTLMSSP\0"); + // message_type = 2 + buf[8..12].copy_from_slice(&2u32.to_le_bytes()); + // target_name fields zero (no target name) + // flags + buf[20..24].copy_from_slice( + &(crate::ntlm::NEGOTIATE_UNICODE + | crate::ntlm::NEGOTIATE_NTLM + | crate::ntlm::NEGOTIATE_EXTENDED_SESSION_SECURITY + | crate::ntlm::NEGOTIATE_TARGET_INFO + | crate::ntlm::NEGOTIATE_KEY_EXCHANGE + | crate::ntlm::NEGOTIATE_128 + | crate::ntlm::NEGOTIATE_56) + .to_le_bytes(), + ); + // server challenge bytes 24..32 left zero is fine + // reserved 32..40 zero + // target_info fields 40..48: length(2) + max_length(2) + offset(4) + // We'll point target_info at offset 48 with length 8 (one EOL pair). + buf[40..42].copy_from_slice(&8u16.to_le_bytes()); + buf[42..44].copy_from_slice(&8u16.to_le_bytes()); + buf[44..48].copy_from_slice(&48u32.to_le_bytes()); + // target_info: 4 bytes EOL marker (id=0, len=0) repeated to pad + buf + } + + /// Round-trip test using a hand-rolled tokio echo-bind server. + /// Verifies the client can `connect` -> `bind` -> read a BindAck. + #[tokio::test] + async fn bind_round_trip_with_local_listener() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Spawn a one-shot server that accepts one connection, reads a + // Bind PDU, and writes back a minimal BindAck-shaped PDU. + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + // Read the 16-byte header. + let mut hdr = [0u8; 16]; + sock.read_exact(&mut hdr).await.unwrap(); + let parsed = PduHeader::decode(&hdr).unwrap(); + // Drain the rest. + let mut body = vec![0u8; parsed.fragment_length as usize - 16]; + sock.read_exact(&mut body).await.unwrap(); + + // Send a fake BindAck — header only, length=16, no body. + let resp = PduHeader { + version: 5, + version_minor: 0, + packet_type: PacketType::BindAck, + packet_flags: 0x03, + data_representation: 0x10, + fragment_length: 16, + auth_length: 0, + call_id: parsed.call_id, + }; + let mut out = [0u8; 16]; + resp.encode(&mut out).unwrap(); + sock.write_all(&out).await.unwrap(); + }); + + let mut client = DceRpcTcpClient::connect(addr).await.unwrap(); + let header = client.bind(Guid::new([0x99; 16]), 1, 0).await.unwrap(); + assert_eq!(header.packet_type, PacketType::BindAck); + server.await.unwrap(); + } + + /// `call` over a server that echoes back a Fault must surface as + /// `TransportError::Fault`. + #[tokio::test] + async fn call_returns_fault_when_server_responds_with_fault() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + + // 1. Drain the Bind, send a minimal BindAck. + let mut hdr = [0u8; 16]; + sock.read_exact(&mut hdr).await.unwrap(); + let bind = PduHeader::decode(&hdr).unwrap(); + let mut body = vec![0u8; bind.fragment_length as usize - 16]; + sock.read_exact(&mut body).await.unwrap(); + let resp = PduHeader { + version: 5, + version_minor: 0, + packet_type: PacketType::BindAck, + packet_flags: 0x03, + data_representation: 0x10, + fragment_length: 16, + auth_length: 0, + call_id: bind.call_id, + }; + let mut out = [0u8; 16]; + resp.encode(&mut out).unwrap(); + sock.write_all(&out).await.unwrap(); + + // 2. Drain the Request, reply with a Fault carrying status=0xDEADBEEF. + sock.read_exact(&mut hdr).await.unwrap(); + let req = PduHeader::decode(&hdr).unwrap(); + let mut body = vec![0u8; req.fragment_length as usize - 16]; + sock.read_exact(&mut body).await.unwrap(); + let fault = FaultPdu { + header: PduHeader { + version: 5, + version_minor: 0, + packet_type: PacketType::Fault, + packet_flags: 0x03, + data_representation: 0x10, + fragment_length: 0, // overwritten by encode + auth_length: 0, + call_id: req.call_id, + }, + allocation_hint: 0, + context_id: 0, + cancel_count: 0, + reserved23: 0, + status: 0xDEAD_BEEF, + stub_data: Vec::new(), + }; + let bytes = fault.encode(); + sock.write_all(&bytes).await.unwrap(); + }); + + let mut client = DceRpcTcpClient::connect(addr).await.unwrap(); + let _ = client.bind(Guid::new([0x99; 16]), 1, 0).await.unwrap(); + let err = client.call(0, 0, &[]).await.unwrap_err(); + match err { + TransportError::Fault { status } => assert_eq!(status, 0xDEAD_BEEF), + other => panic!("expected Fault, got {other:?}"), + } + server.await.unwrap(); + } + + #[test] + fn auth_context_missing_when_packet_integrity_set_without_trailer() { + // Direct unit test: forcing auth_level high without setting up the + // trailer/ntlm pair should yield `AuthContextMissing` from + // call_core via the runtime gate. We simulate that gate inline. + // (The full async path would need a server; this test just + // confirms the variant exists and matches the documented error.) + let err = TransportError::AuthContextMissing; + let msg = format!("{err}"); + assert!(msg.contains("auth trailer")); + } +}