[M5] mxaccess-asb: F28 wire-format fixes — AuthenticateMe accepted live

Three wire-level bugs surfaced by side-by-side relay capture against
the .NET probe routed via the new --via flag:

1. **Dynamic-dictionary id drift**. Our `encode_envelope` hardcoded
   action_dict_id=1 / to_dict_id=3, which is correct for the FIRST
   message in a session but wrong for every subsequent one. The
   per-session dynamic dict accumulates across messages: Connect's
   binary header pre-pops [action,to] at ids 1,3; AuthenticateMe must
   reference the new action at id 5 (continuing the odd sequence) and
   the To URL at id 3 (still in the dict from Connect). Fix uses
   `DynamicDictionary::position_of` + `intern` to compute the right
   wire id, only pre-popping strings that are NEW to the session.
   Captured against .NET probe via asb-relay: AuthenticateMe binary
   header has only one string (action) at offset 0x260 (`06 de 08 2f
   2e ...`), and `<a:Action>` value `ab 05` references the new id 5.

2. **ConnectionValidator wire format depends on operation**. .NET's
   `IAsbDataV2` declares `[XmlSerializerFormat]` on AuthenticateMe,
   Disconnect, KeepAlive (one-way ops) — those use XmlSerializer for
   the ENTIRE message including the [MessageHeader] ConnectionValid-
   ator. Other ops use the default DataContractSerializer. The wire
   shapes differ:
     XmlSerializer: `<ConnectionId xmlns="http://asb.contracts.data/
       20111111">guid</ConnectionId>` (PascalCase property name in
       data namespace)
     DataContract: `<connectionIdField xmlns="http://schemas.data
       contract.org/2004/07/ArchestrAServices.ASBContract">guid</…>`
       (private "fooField" name in datacontract namespace)
   New `ValidatorWireFormat::for_action` picks the right shape per
   action; `encode_validator` now branches on it. New helpers
   `push_xml_text_field` / `push_xml_byte_array_field` for the
   XmlSerializer form. The DataContract form is preserved verbatim
   for Register/Read/Write/etc.

3. **Decoder missing 0x0A** (`ShortDictionaryXmlnsAttribute`). The
   server's RegisterItemsResponse uses `0x0A {dict-id}` to set the
   default namespace from the static dict; our decoder bailed out
   with `UnknownRecord(10)`. New decode arm produces a
   `DefaultNamespace` token with `DictionaryStatic` value.

**.NET probe gains a `--via` flag** (`AsbConnectionOptions.Via` →
`ChannelFactory.CreateChannel(addr, viaUri)`) so the probe can be
routed through asb-relay for byte-level capture without triggering
an `AddressFilterMismatch` fault. CoreWCF / .NET 10 dropped
`ClientViaBehavior`; the `CreateChannel(addr, via)` overload is the
modern equivalent.

Live status (this commit): Connect handshake works, AuthenticateMe
no longer faults (canonical XML + crypto + wire-format all match
.NET now), RegisterItemsResponse comes back from the server (a real
response, not a dispatcher fault). One remaining issue: our response
decoder hits `MissingField { field: "Status" }` — the server's
RegisterItemsResponse uses a slightly different element naming or
encoding than `collect_asbidata_payloads` expects. Next iteration
hunts that.

Workspace: 710 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 19:29:48 -04:00
parent ce27b63010
commit 104efc4e9b
5 changed files with 236 additions and 55 deletions
@@ -759,6 +759,16 @@ pub fn decode_tokens(
value: NbfxText::DictionaryStatic(id),
});
}
// 0x0A — no-prefix (default xmlns) variant of 0x0B. Sets
// the default namespace to a dict-resolved string. WCF
// emits this on the response side when the default ns is
// a well-known string (e.g. urn:invensys.schemas).
REC_SHORT_DICT_XMLNS_ATTRIBUTE => {
let id = decode_int31(input, &mut cursor)?;
tokens.push(NbfxToken::DefaultNamespace {
value: NbfxText::DictionaryStatic(id),
});
}
// Text records — directly produce a Text token, plus an
// implicit EndElement when the `*WithEndElement` variant was
// used (record byte LSB = 1).
+179 -51
View File
@@ -129,6 +129,45 @@ impl ConnectionValidator {
/// pre-built NBFX token stream for the operation-specific element
/// (`<RegisterItemsRequest .../>`, etc.); operation-encoder helpers
/// (next F25 iteration) produce these.
/// Serialization shape for the `<ConnectionValidator>` SOAP header.
/// .NET picks one based on the operation's `[XmlSerializerFormat]`
/// attribute (set on `AuthenticateMe`/`Disconnect`/`KeepAlive` and
/// nothing else); the Rust port must match.
///
/// * `XmlSerializer` — public-property names (`ConnectionId`,
/// `MessageNumber`, `MessageAuthenticationCode`,
/// `SignatureInitializationVector`) in the
/// `http://asb.contracts.data/20111111` namespace (per the
/// `[XmlType]` on the class).
/// * `DataContract` — private-field names (`connectionIdField`,
/// `messageAuthenticationCodeField`, `messageNumberField`,
/// `signatureInitializationVectorField`) in the
/// `http://schemas.datacontract.org/2004/07/ArchestrAServices
/// .ASBContract` namespace (per the `[DataContract]` on the class).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidatorWireFormat {
XmlSerializer,
DataContract,
}
impl ValidatorWireFormat {
/// Pick the wire format based on the SOAP action. Mirrors
/// `IAsbDataV2`'s `[XmlSerializerFormat]` attribute distribution
/// (`AsbContracts.cs:14-58`): authenticateMeIn, disconnectIn,
/// keepAliveIn carry the attribute; everything else uses the
/// default DataContract format.
pub fn for_action(action: &str) -> Self {
if action.ends_with(":authenticateMeIn")
|| action.ends_with(":disconnectIn")
|| action.ends_with(":keepAliveIn")
{
Self::XmlSerializer
} else {
Self::DataContract
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SoapEnvelope {
pub action: String,
@@ -211,15 +250,42 @@ pub fn encode_envelope(
// ---- Binary header block ----
//
// Pre-populate the per-session dynamic dictionary with strings the
// NBFX envelope below references. Each string gets an odd dict id
// (`1, 3, 5, ...` — even ids are reserved for [MC-NBFS] static).
// We always include action + to even when `to_uri` is None: in
// that case we use an empty string for the To slot. WCF's default
// service dispatcher requires both populated for net.tcp.
// NBFX envelope below references. The dynamic dict is *cumulative*
// per session — each `intern` returns a 0-based slot whose wire
// dict id is `slot * 2 + 1` (odd ids only; even ids are reserved
// for `[MC-NBFS]` static).
//
// The binary message header pre-pops only **new** strings (those
// not already in the dict), in order. Strings interned by an
// earlier message stay in the dict at their original wire id and
// can be referenced by later messages without being re-popped.
//
// Captured against `MxAsbClient.Probe --via …` through `asb-relay`:
// Connect's binary header pre-pops `[action, to]` (ids 1, 3).
// AuthenticateMe's binary header only pre-pops the new action
// (id 5); the `<a:To>` element references id 3, still in the dict
// from Connect's pre-pop. Earlier `encode_envelope` versions
// hardcoded action_dict_id=1 / to_dict_id=3 every message —
// wrong for any non-first message in a session, because id 1 /
// id 3 then resolved to whatever the *first* message put there.
let to_uri = envelope.to_uri.clone().unwrap_or_default();
let action_dict_id: u32 = 1;
let to_dict_id: u32 = 3;
let header_strings = [envelope.action.as_str(), to_uri.as_str()];
let mut header_strings: Vec<String> = Vec::with_capacity(2);
let action_dict_id: u32 = match dynamic.position_of(&envelope.action) {
Some(slot) => slot * 2 + 1,
None => {
let slot = dynamic.intern(&envelope.action);
header_strings.push(envelope.action.clone());
slot * 2 + 1
}
};
let to_dict_id: u32 = match dynamic.position_of(&to_uri) {
Some(slot) => slot * 2 + 1,
None => {
let slot = dynamic.intern(&to_uri);
header_strings.push(to_uri.clone());
slot * 2 + 1
}
};
// ---- NBFX envelope tokens ----
let mut tokens = vec![
@@ -257,7 +323,8 @@ pub fn encode_envelope(
// <h:ConnectionValidator …/> (when present, comes before
// MessageID/ReplyTo per the .NET dump's element order)
if let Some(v) = &envelope.validator {
encode_validator(&mut tokens, v, dynamic);
let fmt = ValidatorWireFormat::for_action(&envelope.action);
encode_validator(&mut tokens, v, dynamic, fmt);
}
// <a:MessageID>{16-byte UUID via UniqueIdText}</a:MessageID>
@@ -327,7 +394,8 @@ pub fn encode_envelope(
let mut nbfx_bytes = Vec::with_capacity(estimate_envelope_size(envelope));
encode_tokens(&tokens, dynamic, &mut nbfx_bytes)?;
let header_bytes = encode_binary_header(&header_strings)?;
let header_strs: Vec<&str> = header_strings.iter().map(String::as_str).collect();
let header_bytes = encode_binary_header(&header_strs)?;
let mut out = Vec::with_capacity(header_bytes.len() + nbfx_bytes.len());
out.extend_from_slice(&header_bytes);
@@ -534,58 +602,118 @@ fn encode_validator(
out: &mut Vec<NbfxToken>,
v: &ConnectionValidator,
_dynamic: &mut DynamicDictionary,
fmt: ValidatorWireFormat,
) {
// <h:ConnectionValidator xmlns:i="http://www.w3.org/2001/XMLSchema-instance"
// xmlns:h="http://asb.contracts.headers/20111111">
// <connectionIdField xmlns="...ASBContract">guid</connectionIdField>
// <messageAuthenticationCodeField xmlns="...ASBContract" /> (empty when no MAC)
// <messageNumberField xmlns="...ASBContract">n</messageNumberField>
// <signatureInitializationVectorField xmlns="...ASBContract" />
// <h:ConnectionValidator xmlns:h="http://asb.contracts.headers/20111111"
// ... shape depends on fmt ...>
// ... per-format children ...
// </h:ConnectionValidator>
//
// Inner element names are the .NET DataContract member names
// (private backing fields with `[DataMember(Name = "fooField")]`),
// NOT public PascalCase property names. Captured via
// `MxAsbClient.Probe --dump-messages`. The `xmlns:i` declaration
// is required even though we don't emit any `i:nil` attributes —
// WCF does, and SMSvcHost / WCF parser consistency expects it.
out.push(NbfxToken::Element {
prefix: Some("h".to_string()),
name: NbfxName::Inline("ConnectionValidator".to_string()),
});
out.push(NbfxToken::NamespaceDeclaration {
prefix: "i".to_string(),
value: NbfxText::DictionaryStatic(440), // ...XMLSchema-instance
});
out.push(NbfxToken::NamespaceDeclaration {
prefix: "h".to_string(),
value: NbfxText::Chars(asb_ns::HEADERS.to_string()),
});
let dc_ns = "http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBContract";
push_dc_field(
out,
"connectionIdField",
dc_ns,
&format_uuid(&v.connection_id),
);
push_dc_field(out, "messageAuthenticationCodeField", dc_ns, &v.mac_base64);
push_dc_field(
out,
"messageNumberField",
dc_ns,
&v.message_number.to_string(),
);
push_dc_field(
out,
"signatureInitializationVectorField",
dc_ns,
&v.iv_base64,
);
match fmt {
ValidatorWireFormat::DataContract => {
// DataContractSerializer form: private "fooField" names in
// the ASBContract namespace, plus an `xmlns:i` declaration
// for the `i:nil="true"` attribute that WCF emits on empty
// byte[] members. Captured via .NET probe wire dump.
out.push(NbfxToken::NamespaceDeclaration {
prefix: "i".to_string(),
value: NbfxText::DictionaryStatic(440), // ...XMLSchema-instance
});
out.push(NbfxToken::NamespaceDeclaration {
prefix: "h".to_string(),
value: NbfxText::Chars(asb_ns::HEADERS.to_string()),
});
let dc_ns = "http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBContract";
push_dc_field(
out,
"connectionIdField",
dc_ns,
&format_uuid(&v.connection_id),
);
push_dc_field(out, "messageAuthenticationCodeField", dc_ns, &v.mac_base64);
push_dc_field(
out,
"messageNumberField",
dc_ns,
&v.message_number.to_string(),
);
push_dc_field(
out,
"signatureInitializationVectorField",
dc_ns,
&v.iv_base64,
);
}
ValidatorWireFormat::XmlSerializer => {
// XmlSerializer form: public property names (PascalCase) in
// the data namespace from `[XmlType]` on the class. No
// `xmlns:i` declaration here — XmlSerializer doesn't emit
// `xsi:nil` for empty byte[] (it uses self-closing
// elements instead, but byte[] in the wire format actually
// shows a Chars text record holding the base64-encoded
// empty content per the .NET probe capture; see
// push_xml_byte_array_field). Captured via
// `MxAsbClient.Probe --via …` for AuthenticateMe.
out.push(NbfxToken::NamespaceDeclaration {
prefix: "h".to_string(),
value: NbfxText::Chars(asb_ns::HEADERS.to_string()),
});
let data_ns = "http://asb.contracts.data/20111111";
push_xml_text_field(out, "ConnectionId", data_ns, &format_uuid(&v.connection_id));
push_xml_text_field(
out,
"MessageNumber",
data_ns,
&v.message_number.to_string(),
);
push_xml_byte_array_field(out, "MessageAuthenticationCode", data_ns, &v.mac_base64);
push_xml_byte_array_field(
out,
"SignatureInitializationVector",
data_ns,
&v.iv_base64,
);
}
}
out.push(NbfxToken::EndElement); // </h:ConnectionValidator>
}
/// Emit `<{name} xmlns="{ns}">{value}</{name}>` for an XmlSerializer-
/// formatted text field. No xmlns:i (no nil semantics).
fn push_xml_text_field(out: &mut Vec<NbfxToken>, name: &str, ns: &str, value: &str) {
out.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline(name.to_string()),
});
out.push(NbfxToken::DefaultNamespace {
value: NbfxText::Chars(ns.to_string()),
});
out.push(NbfxToken::Text(NbfxText::Chars(value.to_string())));
out.push(NbfxToken::EndElement);
}
/// Emit a `byte[]` field in XmlSerializer format. Empty value →
/// `<{name} xmlns="{ns}" />` (self-closing, NO xsi:nil — that's the
/// DataContract behaviour). Non-empty → `<{name} xmlns="{ns}">b64</…>`.
fn push_xml_byte_array_field(out: &mut Vec<NbfxToken>, name: &str, ns: &str, b64: &str) {
out.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline(name.to_string()),
});
out.push(NbfxToken::DefaultNamespace {
value: NbfxText::Chars(ns.to_string()),
});
if !b64.is_empty() {
out.push(NbfxToken::Text(NbfxText::Chars(b64.to_string())));
}
out.push(NbfxToken::EndElement);
}
/// Emit a `<{name} xmlns="{dc_ns}">{value}</{name}>` element. When
/// `value` is empty we emit just `<{name} xmlns="{dc_ns}"/>` to match
/// .NET's WCF DataContractSerializer output for null/empty byte arrays.