[M5] mxaccess-asb-nettcp/asb: Connect handshake live + SOAP fault detection

Live-bring-up reconciliation against AVEVA's MxDataProvider on Windows.
Connect now completes end-to-end (real DH key exchange, apollo:V2
encryption, ServicePublicKey/ServiceAuthenticationData populated). Five
fixes land:

1. NBFX `PrefixElement_a..z` (0x5E-0x77) and `PrefixAttribute_a..z`
   (0x26-0x3F) decode + encode arms. The server's ConnectResponse hit
   `0x65 = PrefixElement_h` for a dynamically-named element and our
   decoder bailed with `unknown NBFX record byte 0x65`. Both directions
   now round-trip; encoder picks short-form when prefix is a single
   lowercase ASCII letter.

2. xmlns redeclaration on `<Data>` AND `<InitializationVector>` inside
   `AuthenticationData` / `PublicKey`. `[XmlType(Namespace = ...)]` on
   AuthenticationData / PublicKey (`AsbContracts.cs:350-381`) means
   XmlSerializer emits `xmlns="..."` on each direct child. The default-
   ns scope ends at `</Data>`, so `<InitializationVector>` needs its own
   redeclaration to stay in the data namespace; without it the server
   fell back to messages-namespace and the deserialiser threw an
   `InternalServiceFault`.

3. SOAP-fault detection in `AsbClient::send_envelope`. New
   `ClientError::SoapFault { action, code, reason }` surfaces when the
   response Action header matches the canonical `dispatcher/fault`
   template; previously body decoders blindly ran and surfaced
   `MissingField { field: "Status" }` masking the actual fault. Reason
   text is extracted as the longest `NbfxText::Chars` in the body —
   robust against the `nbfs.rs` static-dictionary id mismatches.

4. Identified blocker (filed as F28): signed-request HMAC currently
   covers the NBFX wire bytes, but .NET's `AsbSystemAuthenticator.Sign`
   HMACs `Encoding.UTF8.GetBytes(request.ToXml())` — the canonical XML
   serialisation via `XmlSerializer` with namespace
   `urn:invensys.schemas` (`AsbSerialization.cs:12-48`). Until the Rust
   port emits identical XML bytes for `ConnectedRequest` subclasses,
   AuthenticateMe / RegisterItems / every signed RPC fault on the
   server. Connect itself is unsigned (`ServiceMessage` not
   `ConnectedRequest`) which is why it works today.

5. Identified `nbfs.rs` static-dictionary id drift (filed as F29): wire
   uses Fault=134/Code=142/Reason=144/Text=146/Value=154/Subcode=156
   but our table has them at 114/122/124/126/134/136. Off by 20 from
   id 114+ — 10 missing entries between `s` (id 112) and `Fault`. No
   request-side impact (we only encode IDs ≤44, all correct); the SOAP
   fault decode walks text records directly so it sidesteps the issue.

Workspace: 702 tests pass (no test count delta — wire-only fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 16:29:12 -04:00
parent 4c4177050c
commit d1e887b91b
4 changed files with 172 additions and 1 deletions
@@ -229,23 +229,64 @@ fn public_key_data_field(data: &[u8]) -> Vec<NbfxToken> {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
// .NET's `PublicKey` class has
// `[XmlType(Namespace = "http://asb.contracts.data/20111111")]`
// (`AsbContracts.cs:350-362`). XmlSerializer emits an
// `xmlns="..."` redeclaration on `<Data>` to switch from the
// outer messages namespace into the data namespace. Without
// this, the server's deserialiser fails and dispatches a
// generic InternalServiceFault. Verified against .NET probe
// wire capture.
NbfxToken::DefaultNamespace {
value: NbfxText::Chars("http://asb.contracts.data/20111111".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(data.to_vec())),
NbfxToken::EndElement,
]
}
/// `AuthenticationData` per `AsbContracts.cs:364-381`:
///
/// ```csharp
/// [XmlType(Namespace = "http://asb.contracts.data/20111111")]
/// public sealed class AuthenticationData {
/// public byte[]? Data { get; set; }
/// public byte[]? InitializationVector { get; set; }
/// }
/// ```
///
/// Same data-namespace switch as `<PublicKey><Data>` — the `<Data>`
/// element gets the `xmlns="...data/20111111"` redeclaration. The
/// `<InitializationVector>` element is in the same data namespace
/// (already-in-scope because of the prior `<Data>` redeclaration's
/// `xmlns` lasts until end of `<AuthenticationData>`).
fn authentication_data_fields(data: &[u8], iv: &[u8]) -> Vec<NbfxToken> {
// The default-namespace declaration on `<Data>` only stays in
// scope until `</Data>` closes. `<InitializationVector>` opens
// afterwards and therefore needs its OWN xmlns redeclaration to
// stay in the `http://asb.contracts.data/20111111` namespace
// (matching `[XmlType]` on the `AuthenticationData` class). Without
// the second redeclaration the IV element falls back to the parent
// (messages) namespace and the server's XmlSerializer rejects the
// request with a generic InternalServiceFault.
let data_ns = "http://asb.contracts.data/20111111".to_string();
vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(data_ns.clone()),
},
NbfxToken::Text(NbfxText::Bytes(data.to_vec())),
NbfxToken::EndElement,
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("InitializationVector".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(data_ns),
},
NbfxToken::Text(NbfxText::Bytes(iv.to_vec())),
NbfxToken::EndElement,
]