[M5] mxaccess-asb: F28 canonical-XML signing wired + registry-driven DH params

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>
This commit is contained in:
Joseph Doherty
2026-05-05 17:31:31 -04:00
parent dbb580b2c8
commit f14580e0db
11 changed files with 850 additions and 51 deletions
+18 -1
View File
@@ -17,9 +17,18 @@ internal sealed class AsbSystemAuthenticator
private readonly byte[] localPublicKey;
private byte[] remotePublicKey = [];
private ulong nextMessageNumber = 1;
/// Trace callback for the F28 canonical-XML reconciliation pass —
/// when set, `Sign` dumps the request type, the UTF-8 bytes of
/// `request.ToXml()`, the resulting HMAC, and the encrypted MAC +
/// IV. Used by `MxAsbClient.Probe --dump-signed-xml` and ad-hoc
/// live runs to capture the exact bytes the server's HMAC verifier
/// recomputes against; the Rust port's `xml_canonical` emitter must
/// produce byte-identical XML for the HMAC to round-trip.
private readonly Action<string>? sharedTrace;
public AsbSystemAuthenticator(string passphrase, AsbSolutionCryptoParameters cryptoParameters, Action<string>? trace = null)
{
sharedTrace = trace;
dhPrime = cryptoParameters.Prime;
dhGenerator = cryptoParameters.Generator;
hashAlgorithm = cryptoParameters.HashAlgorithm;
@@ -76,9 +85,17 @@ internal sealed class AsbSystemAuthenticator
return;
}
byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(request.ToXml()));
string xmlText = request.ToXml();
byte[] xmlBytes = Encoding.UTF8.GetBytes(xmlText);
sharedTrace?.Invoke($"asb.sign.type={request.GetType().Name}");
sharedTrace?.Invoke($"asb.sign.xml-utf8-len={xmlBytes.Length}");
sharedTrace?.Invoke($"asb.sign.xml-b64={Convert.ToBase64String(xmlBytes)}");
byte[] hash = hmac.ComputeHash(xmlBytes);
sharedTrace?.Invoke($"asb.sign.hmac-b64={Convert.ToBase64String(hash)}");
validator.MessageAuthenticationCode = Encrypt(hash, out byte[] iv);
validator.SignatureInitializationVector = iv;
sharedTrace?.Invoke($"asb.sign.encrypted-mac-b64={Convert.ToBase64String(validator.MessageAuthenticationCode)}");
sharedTrace?.Invoke($"asb.sign.iv-b64={Convert.ToBase64String(iv)}");
}
private HMAC? CreateHmac(bool forceHmac)