[F4+F5] mxaccess-rpc: BindAck/AlterContextResponse parser + live-capture round-trip
rust / build / test / clippy / fmt (push) Has been cancelled
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:
+3
-11
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user