[M2/M3] mxaccess-rpc: tokio DCE/RPC TCP transport (DceRpcTcpClient port)

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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 07:47:42 -04:00
parent b0954b2672
commit 432f1102b7
5 changed files with 869 additions and 4 deletions
+4 -4
View File
@@ -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<ResolveOxidResult, _>` (mirrors `ObjectExporterClient.cs:14-30`) and `resolve_oxid_with_managed_ntlm_packet_integrity(addr, oxid, protseqs, ntlm) -> Result<ResolveOxidResult, _>` (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
+1
View File
@@ -225,6 +225,7 @@ dependencies = [
"rand",
"rc4",
"thiserror",
"tokio",
]
[[package]]
+1
View File
@@ -10,6 +10,7 @@ authors.workspace = true
[dependencies]
thiserror = { workspace = true }
tokio = { workspace = true }
hmac = "0.12"
md-5 = "0.10"
md4 = "0.10"
+1
View File
@@ -25,3 +25,4 @@ pub mod objref;
pub mod orpc;
pub mod pdu;
pub mod rem_unknown;
pub mod transport;
+862
View File
@@ -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<NtlmClientContext>,
auth_trailer: Option<AuthTrailer>,
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<Self> {
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<SocketAddr> {
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<PduHeader, TransportError> {
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<PduHeader, TransportError> {
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<ResponsePdu, TransportError> {
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<ResponsePdu, TransportError> {
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<ResponsePdu, TransportError> {
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<Guid>,
) -> Result<ResponsePdu, TransportError> {
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<Guid>,
) -> Vec<u8> {
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<Vec<u8>, 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<Vec<u8>, 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<u8> {
// 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"));
}
}