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>