Adds `xml_canonical` module that emits XmlSerializer-compatible canonical XML for the five primary `ConnectedRequest` shapes (AuthenticateMe, Disconnect, KeepAlive, RegisterItemsRequest, UnregisterItemsRequest). Six fixture-comparison tests verify byte-exact match against captured .NET output, including the empty-MAC-IV variant that the live signing flow uses (`authenticate-me-empty-mac-iv.xml`, 896 bytes; new `emit_data_ns_byte_array` helper picks self-closing form for empty byte[]). Plumbing: `AsbAuthenticator::peek_next_message_number` exposes the pre-allocated message number; `AsbClient::send_signed_envelope[_one_way]` gain an `xml_for_signing: Option<&[u8]>` parameter. `connect`, `disconnect`, `keep_alive`, `register_items`, `unregister_items` now build a pre-signing `ConnectionValidator` (empty MAC + IV) + emit the canonical XML + pass the bytes through to HMAC. Other ops (Read, Write, Subscription) keep the legacy NBFX-bytes path until F28 expands to cover their request shapes. Live-bring-up wiring: - `tools/Get-AsbPassphrase.ps1` now exports `MX_ASB_DH_PRIME`, `MX_ASB_DH_GENERATOR`, `MX_ASB_DH_HASH_ALGORITHM` (always — even when empty, so the example can distinguish "no env var" from "registry says empty"), and `MX_ASB_DH_KEY_SIZE`. - `examples/asb-subscribe.rs` honours those env vars to override `CryptoParameters::defaults()`. Each AVEVA install picks its own DH group at provisioning time (768-bit prime is typical, vs the .NET reference's 1024-bit fallback that we previously hardcoded). Empty hashAlgorithm in the registry maps to `HashAlgorithm::Unrecognised`, matching `AsbSystemAuthenticator.CreateHmac:84-93` semantics where empty + forceHmac=true → HMAC-SHA1. - `MxAsbClient.Probe --dump-signed-xml` flag (added in earlier commit) now traces the live HMAC inputs (`asb.sign.xml-utf8-len`, `asb.sign.xml-b64`, `asb.sign.hmac-b64`, etc.) so the Rust port can diff its canonical XML against .NET's byte-for-byte for any live scenario (env-driven via `Action<string>? sharedTrace`). Wire-format alignment for `XmlSerializer` parity: - `ItemIdentity::default()` and `absolute_by_name` now use `Some(String::new())` for null-able strings (matches .NET's `CreateAbsoluteItem` setting `ContextName = string.Empty` not null). - `read_unicode_string` returns `Some(String::new())` for length-0 rather than `None` — mirrors .NET's `AsbBinary.ReadUnicodeString: return string.Empty for byteLength == 0`. Wire format genuinely cannot distinguish null from empty (both encode as 4 bytes of zero); callers that need to preserve the distinction MUST track it in their domain types before encoding. Live status (post-fix): Connect handshake completes end-to-end. The canonical XML our emitter produces matches .NET's structure byte-for- byte (verified by fixture comparison). DH prime/generator/hash now match the live registry values. Despite all this, AuthenticateMe still produces a generic dispatcher fault on the server — there's at least one more subtle wire-byte or crypto mismatch that needs isolation. F28 stays open with that note. Workspace: 709 unit tests pass (was 702 + 7 new xml_canonical tests). Clippy: clean (`-D warnings`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-request XML fixtures
Canonical XmlSerializer output for every ConnectedRequest shape that
the .NET reference HMACs in AsbSystemAuthenticator.Sign
(src/MxAsbClient/AsbSystemAuthenticator.cs:79). The Rust port's
canonical-XML emitter (F28) must produce these exact UTF-8 bytes for
the HMAC to match the server's recomputation.
Capture procedure
dotnet run --project src\MxAsbClient.Probe -c Release -- --dump-signed-xml > capture.txt
The probe's --dump-signed-xml flag (added 2026-05-05) builds each
shape with deterministic field values and prints the output of
AsbSerialization.ToXml(...) (src/MxAsbClient/AsbSerialization.cs:12).
Pinned values
All shapes use the same ConnectionValidator:
ConnectionId = 8cba964a-74c1-ef74-f6aa-761b3540191bMessageNumber = 42MessageAuthenticationCode = AAECAwQFBgcICQoLDA0ODw==(base64 of bytes 0..15)SignatureInitializationVector = EBESExQVFhcYGRobHB0eHw==(base64 of bytes 16..31)
AuthenticateMe and Disconnect use AuthenticationData with:
Data = "deterministic-ciphertext-bytes"(base64-encoded)InitializationVector = "0123456789abcdef"(base64-encoded)
RegisterItemsRequest uses one ItemIdentity with
Type = Name (0), ReferenceType = Absolute (1),
Name = "TestChildObject.TestInt", ContextName = "".
UnregisterItemsRequest uses one ItemIdentity with
Type = Id (1), ReferenceType = Absolute (1), Name = null,
ContextName = null, Id = 0xCAFEBABEDEADBEEF (14627333968688430831),
IdSpecified = true.
Observed serialiser behaviour
These rules were inferred from the captured output and from the .NET
source for XmlSerializer:
-
Element name = class name, NOT
[MessageContract.WrapperName].XmlSerializerdoes not honour WCF's MessageContract attributes. -
Top-element xmlns ordering (after
<?xml ... ?>):xmlns:xsi, thenxmlns:xsd, then defaultxmlns. TheAsbSerialization.ToXmlpost-process (AsbSerialization.cs:36-47) reparses withXDocument.Loadand reorders to putxsibeforexsd—XmlSerializer's native order is the opposite. -
Field order = C# declaration order (with inherited fields first), NOT
[MessageBodyMember.Order]. -
[XmlType(Namespace = ...)]on a field's type triggers anxmlns="..."redeclaration on EACH child element of that type's instance, NOT on the wrapper element itself. e.g. inside<ConnectionValidator>, every direct child getsxmlns="http://asb.contracts.data/20111111". -
byte[]fields serialise as base64 text content.Guidas canonical lowercase D-format (8cba964a-74c1-...).ulongas decimal.boolas"true"/"false". -
Null reference-type fields with
[XmlElement(IsNullable = true)]produce<Name xsi:nil="true" xmlns="..." />. Empty string fields produce a self-closing<ContextName xmlns="..." />. -
*Specifiedpattern: a public bool field namedXxxSpecified=truecauses XmlSerializer to emit the corresponding<Xxx>element.IdSpecified = false(default) →<Id>omitted.IdSpecified = true→<Id>emitted with the int value. The*Specifiedfield itself is[XmlIgnore]and never emitted. -
Self-closing elements use
/>(space before/>). -
Indentation: 2 spaces,
\r\nline endings, no trailing newline after the closing tag. -
XML declaration:
<?xml version="1.0" encoding="utf-16"?>— noteutf-16even thoughAsbSystemAuthenticator.SignHMACsEncoding.UTF8.GetBytes(...)of this string. The declaration is a static .NET StringWriter default; the actual byte encoding fed to HMAC is UTF-8.
Files
authenticate-me.xml—AuthenticateMedisconnect.xml—Disconnectkeep-alive.xml—KeepAliveregister-items.xml—RegisterItemsRequestunregister-items.xml—UnregisterItemsRequest
Each file is the verbatim UTF-8 representation of request.ToXml(),
with literal \r\n line endings preserved. Treat as binary (don't
let your editor reformat).