432f1102b7
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>
863 lines
32 KiB
Rust
863 lines
32 KiB
Rust
//! 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"));
|
|
}
|
|
}
|