[F4+F5] mxaccess-rpc: BindAck/AlterContextResponse parser + live-capture round-trip
rust / build / test / clippy / fmt (push) Has been cancelled

Adds BindAckPdu + per-result BindAckResult per [C706] §12.6.3.4: u16
result + u16 reason + 20-byte SyntaxId, preceded by port_any_t secondary
address, n_results, and 3 reserved bytes. Encode/decode handle both
PacketType::BindAck and PacketType::AlterContextResponse (same body
shape).

The new bind_ack_round_trips_live_capture test takes the first 84 bytes
of the server's first response in
captures/013-loopback-subscribe-scalars/tcp-stream-__1_49704-to-__1_55690.bin
(real BindAck observed against local AVEVA), decodes the shape
(secondary address "49704\0", n_results=2, NDR transfer syntax accepted,
DCOM negotiate_ack reason=3), then re-encodes and asserts byte-identical
to the original frame. Stronger evidence of wire parity than the prior
synthetic-frame tests, and lets the rest of the M2 stack reason about
the negotiated transfer syntax instead of relying on request-side
context to infer it.

Closes F4 and F5 in design/followups.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 21:44:54 -04:00
parent 826f7b3f89
commit 9501080170
2 changed files with 307 additions and 11 deletions
+3 -11
View File
@@ -205,17 +205,6 @@ The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/M
**Why deferred:** All current NTLM fixtures are single-domain (the local AVEVA install). Tracked separately in `design/70-risks-and-open-questions.md` R8 (P1 risk) and the open-evidence-gaps table.
**Resolves when:** A multi-domain AVEVA test harness lands and a successful cross-domain authenticate round-trip captures Type1/2/3 bytes. Notes: this clears R8.
### F4 — BindAck / AlterContextResponse body parser
**Severity:** P2
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/pdu.rs`
**Why deferred:** The .NET reference (`DceRpcPdu.cs:217-262`) parses Bind and AlterContext into the same struct but does not decode the corresponding *response* body (result list + secondary address). The Rust port's `BindPdu::decode` accepts `BindAck` packet type but does not interpret the body. The negotiated transfer syntax — needed before opnum dispatch — is currently inferred from request-side context.
**Resolves when:** A captured BindAck frame from `captures/013-loopback-subscribe-scalars/nmx-stream-*.bin` is decoded and the body shape is documented in `docs/Loopback-Protocol-Findings.md`.
### F5 — Captured DCE/RPC bind-frame fixture round-trip
**Severity:** P2
**Source:** M2 wave 1, `crates/mxaccess-rpc/src/pdu.rs`
**Why deferred:** Existing PDU tests build hand-constructed `[C706]`-conformant frames. A capture-driven round-trip (extract bind/alter PDUs from `captures/013-loopback-subscribe-scalars/nmx-stream-*.bin`, decode → encode → assert byte-identical) would be stronger evidence of parity with the live wire.
**Resolves when:** Bytes from that capture are extracted into `tests/fixtures/m2-pdu/` and the round-trip test lands.
### F6 — Port `ComObjRefProvider.cs` (OBJREF emitter via Win32 CoMarshalInterface)
**Severity:** P2
@@ -256,6 +245,9 @@ The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/M
## Resolved
### F4 + F5 — BindAck body parser + captured-bytes round-trip
**Resolved:** 2026-05-05 (commit `<this commit>`). Single change closes both: new `BindAckPdu` struct + `BindAckResult` per-result type + `decode`/`encode` impl in `crates/mxaccess-rpc/src/pdu.rs`. Body layout per `[C706]` §12.6.3.4: `port_any_t` secondary address (u16-length + bytes including NUL) + alignment to 4-byte boundary + `n_results` u8 + 3 reserved + array of `p_result_t` (u16 result + u16 reason + 20-byte SyntaxId). Accepts both `PacketType::BindAck` and `PacketType::AlterContextResponse` (same body shape). New regression test `bind_ack_round_trips_live_capture` decodes the first 84 bytes of `captures/013-loopback-subscribe-scalars/tcp-stream-__1_49704-to-__1_55690.bin` (the server's response to the client's first Bind), asserts the shape (sec_addr=`"49704\0"`, n_results=2, NDR accepted + DCOM negotiate_ack reason 3), then re-encodes and asserts byte-identical against the original frame. Stronger live-wire parity than the prior synthetic-frame tests. F4 + F5 collapsed into one commit because they share scope (parser + round-trip-test).
### F29 — Align `mxaccess-asb-nettcp::nbfs` static dictionary ids with canonical `[MC-NBFS]` table
**Resolved:** 2026-05-05 (commit `<this commit>`). The original hand-curated table was wrong starting at id 74 — entries had been deduplicated/renumbered without preserving the canonical `id = 2 × StringN` mapping from `[MC-NBFS]` §2.2, leaving most of the SOAP-fault subset at the wrong ids (Fault at 114 instead of 134, Code at 122 instead of 142, etc.). Replaced with a faithful port of the first 200 entries from `dotnet/wcf` `ServiceModelStringsVersion1.cs` (covering id 0..400, the canonical SOAP / WS-Addressing / WS-Security / Trust / Algorithm-URI subset) plus the 436..444 xsi/xsd/nil extras already in place. Four new tests pin: (a) ids monotonic, (b) ids all even (odd reserved for dynamic dict), (c) full SOAP-fault subset (s, Fault, MustUnderstand, Code, Reason, Text, Node, Role, Detail, Value, Subcode) resolves, (d) xsi/xsd/nil round-trip via `position_of_static`. Future extensions: append more `ServiceModelStringsVersion1.StringN` entries as captures show new ids; mechanical extension.
+304
View File
@@ -702,6 +702,241 @@ impl BindPdu {
}
}
// ---------------------------------------------------------------------------
// BindAck PDU — `[C706]` §12.6.3.4 / DCOM extensions per `[MS-RPCE]` §2.2.2.4
// ---------------------------------------------------------------------------
/// One result entry inside a BindAck (or AlterContextResp) PDU.
/// Layout per `[C706]` §12.6.3.4 (`p_result_t`): u16 result + u16 reason
/// + 20-byte syntax (UUID + version).
///
/// Result values:
/// * `0` — `acceptance` (the offered transfer syntax was accepted).
/// * `1` — `user_rejection` (caller-level reject; reason field carries
/// why).
/// * `2` — `provider_rejection` (transport / RPC runtime reject).
/// * `3` — `negotiate_ack` (DCOM negotiation; reason carries the
/// negotiated capability flags per `[MS-RPCE]` §2.2.2.4). Captured
/// live from `NmxSvc` when the proposed Bind included the standard
/// NDR transfer syntax + a DCOM-specific negotiation context.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BindAckResult {
pub result: u16,
pub reason: u16,
pub transfer_syntax: SyntaxId,
}
impl BindAckResult {
/// Per-result wire size: 2 (result) + 2 (reason) + 20 (SyntaxId) = 24.
pub const LENGTH: usize = 24;
}
/// BindAck PDU (also covers AlterContext_response, which has the same
/// body shape). The result list is `n_results × p_result_t` — each
/// entry pairs a transfer-syntax UUID with an accept/reject result.
///
/// Wire layout (post-header):
///
/// ```text
/// offset size field
/// 0 16 PduHeader (common)
/// 16 2 max_xmit_frag u16 LE
/// 18 2 max_recv_frag u16 LE
/// 20 4 assoc_group_id u32 LE
/// 24 2 sec_addr.length u16 LE (`port_any_t`, `[C706]` §12.6.3)
/// 26 L sec_addr.port_spec L bytes (incl. NUL terminator)
/// ? P pad-to-4 (`[C706]` §12.6.3.4 alignment requirement)
/// ? 1 n_results u8
/// ? 3 reserved (always zero on the .NET writer)
/// ? 24×N result_list
/// ? opt auth_verifier (when header.auth_length > 0)
/// ```
///
/// The `sec_addr` is an OEM-encoded ASCII string with a leading u16
/// byte count that *includes* the trailing `NUL`. The .NET reference
/// reads this as raw bytes (`DceRpcPdu.cs` does not parse BindAck);
/// the Rust port preserves the same byte slice so callers can
/// round-trip frames captured from the wire.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BindAckPdu {
pub header: PduHeader,
pub max_transmit_fragment: u16,
pub max_receive_fragment: u16,
pub association_group_id: u32,
/// Secondary address, OEM-encoded string. Includes the trailing
/// `NUL` byte; the leading u16 length on the wire is recomputed
/// on encode from `secondary_address.len()`. Stored as bytes
/// rather than `String` because the spec doesn't promise UTF-8.
pub secondary_address: Vec<u8>,
pub results: Vec<BindAckResult>,
/// 3 reserved bytes after the n_results count. The .NET reference
/// emits zeros; live captures we've seen also have them as zero,
/// but we round-trip whatever the wire sent (CLAUDE.md
/// preserve-unknown-bytes rule).
pub reserved_after_n_results: [u8; 3],
}
impl BindAckPdu {
/// Fixed offset to `sec_addr.length` from the start of the PDU.
/// Everything before this point is fixed-size.
pub const SEC_ADDR_OFFSET: usize = 24;
/// Decode a BindAck (or AlterContext_response) PDU per `[C706]`
/// §12.6.3.4. Walks the variable-length secondary-address field
/// and the `n_results` result list.
///
/// # Errors
///
/// - [`RpcError::UnexpectedPacketType`] if the header type isn't
/// `BindAck` or `AlterContextResponse`.
/// - [`RpcError::InvalidFragmentLength`] when the declared
/// `frag_length` exceeds the buffer or is shorter than the
/// minimum 28-byte body.
/// - [`RpcError::TruncatedBindBody`] if the `sec_addr` length or
/// any result entry overflows the declared fragment length.
pub fn decode(buf: &[u8]) -> Result<Self, RpcError> {
let header = PduHeader::decode(buf)?;
if !matches!(
header.packet_type,
PacketType::BindAck | PacketType::AlterContextResponse
) {
return Err(RpcError::UnexpectedPacketType {
expected: PacketType::BindAck.as_byte(),
actual: header.packet_type.as_byte(),
});
}
let frag_length = header.fragment_length as usize;
if buf.len() < Self::SEC_ADDR_OFFSET || frag_length > buf.len() {
return Err(RpcError::InvalidFragmentLength {
frag_length,
buffer_len: buf.len(),
auth_length: header.auth_length as usize,
});
}
let max_transmit_fragment = read_u16_le(buf, 16);
let max_receive_fragment = read_u16_le(buf, 18);
let association_group_id = read_u32_le(buf, 20);
// sec_addr: u16 LE length, then `length` bytes.
let sec_addr_len = read_u16_le(buf, Self::SEC_ADDR_OFFSET) as usize;
let sec_addr_start = Self::SEC_ADDR_OFFSET + 2;
let sec_addr_end = sec_addr_start + sec_addr_len;
if sec_addr_end > frag_length {
return Err(RpcError::TruncatedBindBody {
offset: sec_addr_start,
need: sec_addr_len,
frag_length,
});
}
let secondary_address = buf
.get(sec_addr_start..sec_addr_end)
.unwrap_or(&[])
.to_vec();
// Pad to 4-byte boundary before the results header.
let unaligned = sec_addr_end;
let aligned = align(unaligned, 4);
if aligned + 4 > frag_length {
return Err(RpcError::TruncatedBindBody {
offset: aligned,
need: 4,
frag_length,
});
}
let n_results = *buf.get(aligned).unwrap_or(&0) as usize;
let mut reserved_after_n_results = [0u8; 3];
reserved_after_n_results.copy_from_slice(
buf.get(aligned + 1..aligned + 4)
.unwrap_or(&[0u8, 0u8, 0u8]),
);
let mut offset = aligned + 4;
let mut results = Vec::with_capacity(n_results);
for _ in 0..n_results {
if offset + BindAckResult::LENGTH > frag_length {
return Err(RpcError::TruncatedBindBody {
offset,
need: BindAckResult::LENGTH,
frag_length,
});
}
let result = read_u16_le(buf, offset);
let reason = read_u16_le(buf, offset + 2);
let mut syntax_offset = offset + 4;
let transfer_syntax = read_syntax(buf, &mut syntax_offset)?;
results.push(BindAckResult {
result,
reason,
transfer_syntax,
});
offset += BindAckResult::LENGTH;
}
Ok(Self {
header,
max_transmit_fragment,
max_receive_fragment,
association_group_id,
secondary_address,
results,
reserved_after_n_results,
})
}
/// Encode the BindAck PDU. Produces the wire bytes for the body
/// (no auth verifier — call `extract_auth` / a higher-level helper
/// to add one if the original frame had one). Sets `auth_length=0`
/// and recomputes `frag_length` from the body length on the way
/// out.
pub fn encode(&self) -> Vec<u8> {
let sec_addr_len = self.secondary_address.len();
let unaligned_after_sec = Self::SEC_ADDR_OFFSET + 2 + sec_addr_len;
let aligned = align(unaligned_after_sec, 4);
let length = aligned + 4 + self.results.len() * BindAckResult::LENGTH;
let mut out = vec![0u8; length];
let frag_length = u16::try_from(length).unwrap_or(u16::MAX);
let header = PduHeader {
fragment_length: frag_length,
auth_length: 0,
..self.header
};
let _ = header.encode(&mut out);
write_u16_le(&mut out, 16, self.max_transmit_fragment);
write_u16_le(&mut out, 18, self.max_receive_fragment);
write_u32_le(&mut out, 20, self.association_group_id);
write_u16_le(
&mut out,
Self::SEC_ADDR_OFFSET,
u16::try_from(sec_addr_len).unwrap_or(u16::MAX),
);
let sec_addr_start = Self::SEC_ADDR_OFFSET + 2;
if let Some(slot) = out.get_mut(sec_addr_start..sec_addr_start + sec_addr_len) {
slot.copy_from_slice(&self.secondary_address);
}
if let Some(slot) = out.get_mut(aligned) {
*slot = u8::try_from(self.results.len()).unwrap_or(u8::MAX);
}
if let Some(slot) = out.get_mut(aligned + 1..aligned + 4) {
slot.copy_from_slice(&self.reserved_after_n_results);
}
let mut offset = aligned + 4;
for r in &self.results {
write_u16_le(&mut out, offset, r.result);
write_u16_le(&mut out, offset + 2, r.reason);
let mut syntax_offset = offset + 4;
write_syntax(&mut out, &mut syntax_offset, &r.transfer_syntax);
offset += BindAckResult::LENGTH;
}
debug_assert_eq!(offset, length);
out
}
}
// ---------------------------------------------------------------------------
// Request PDU — `DceRpcPdu.cs:78-132`
// ---------------------------------------------------------------------------
@@ -1546,4 +1781,73 @@ mod tests {
// Unknown byte funnels to None.
assert_eq!(AuthLevel::from_byte(99), AuthLevel::None);
}
/// Round-trip a real BindAck PDU captured from a live `NmxSvc`
/// loopback session. The bytes come from the very start of the
/// server-to-client TCP stream in
/// `captures/013-loopback-subscribe-scalars/tcp-stream-__1_49704-
/// to-__1_55690.bin` — the response to the client's first Bind.
/// Decode → re-encode → assert byte-identical, plus shape
/// assertions on the decoded fields. Stronger evidence of parity
/// with the live wire than the hand-constructed `[C706]` frames
/// the other tests use.
#[test]
fn bind_ack_round_trips_live_capture() {
// First 84 bytes of the SERVER → CLIENT stream. Verified by
// hand: header.frag_length=0x54=84, packet_type=0x0c=BindAck,
// sec_addr="49704\0", n_results=2 (NDR accepted +
// negotiate_ack reason 3).
const FRAME: [u8; 84] = [
// Common header (16 bytes).
0x05, 0x00, 0x0c, 0x03, 0x10, 0x00, 0x00, 0x00, 0x54, 0x00, 0x00, 0x00, 0x02, 0x00,
0x00, 0x00,
// max_xmit_frag, max_recv_frag, assoc_group_id (8 bytes).
0xd0, 0x16, 0xd0, 0x16, 0xb3, 0xb9, 0x00, 0x00,
// sec_addr: u16 length 6, then "49704\0" (8 bytes total).
0x06, 0x00, 0x34, 0x39, 0x37, 0x30, 0x34, 0x00,
// n_results=2 + 3 reserved bytes (4 bytes).
0x02, 0x00, 0x00, 0x00,
// result[0] = acceptance with NDR transfer syntax (24 bytes).
0x00, 0x00, 0x00, 0x00, 0x04, 0x5d, 0x88, 0x8a, 0xeb, 0x1c, 0xc9, 0x11, 0x9f, 0xe8,
0x08, 0x00, 0x2b, 0x10, 0x48, 0x60, 0x02, 0x00, 0x00, 0x00,
// result[1] = negotiate_ack (3) with reason=3, zero UUID,
// zero version (24 bytes). DCOM uses this slot to ack
// capability negotiation per `[MS-RPCE]` §2.2.2.4.
0x03, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
let pdu = BindAckPdu::decode(&FRAME).unwrap();
assert_eq!(pdu.header.packet_type, PacketType::BindAck);
assert_eq!(pdu.header.fragment_length, 84);
assert_eq!(pdu.max_transmit_fragment, 0x16d0);
assert_eq!(pdu.max_receive_fragment, 0x16d0);
assert_eq!(pdu.association_group_id, 0x0000_b9b3);
assert_eq!(&pdu.secondary_address, b"49704\0");
assert_eq!(pdu.results.len(), 2);
// First result: NDR accepted. UUID `04 5d 88 8a eb 1c c9 11
// 9f e8 08 00 2b 10 48 60` is the canonical NDR transfer
// syntax per `[C706]`.
assert_eq!(pdu.results[0].result, 0);
assert_eq!(pdu.results[0].reason, 0);
assert_eq!(
pdu.results[0].transfer_syntax.uuid_bytes,
[
0x04, 0x5d, 0x88, 0x8a, 0xeb, 0x1c, 0xc9, 0x11, 0x9f, 0xe8, 0x08, 0x00, 0x2b, 0x10,
0x48, 0x60,
]
);
// Second result: DCOM negotiate_ack with capability flags.
assert_eq!(pdu.results[1].result, 3);
assert_eq!(pdu.results[1].reason, 3);
// Round-trip: re-encode and compare against the original
// bytes. Strict equality — any silent re-padding or field
// reordering would surface here.
let re_encoded = pdu.encode();
assert_eq!(re_encoded, FRAME, "BindAck round-trip is not byte-identical");
}
}