diff --git a/design/followups.md b/design/followups.md index 48bece6..40c0e2e 100644 --- a/design/followups.md +++ b/design/followups.md @@ -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 ``). 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 ``). 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. diff --git a/rust/crates/mxaccess-rpc/src/pdu.rs b/rust/crates/mxaccess-rpc/src/pdu.rs index 1cb540a..d343c34 100644 --- a/rust/crates/mxaccess-rpc/src/pdu.rs +++ b/rust/crates/mxaccess-rpc/src/pdu.rs @@ -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, + pub results: Vec, + /// 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 { + 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 { + 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"); + } + }