[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
+51 -2
View File
@@ -34,7 +34,7 @@ use std::time::Duration;
use mxaccess::AsbTransport;
use mxaccess_asb::ItemIdentity;
use mxaccess_asb_nettcp::auth::CryptoParameters;
use mxaccess_asb_nettcp::auth::{CryptoParameters, HashAlgorithm};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -48,10 +48,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("connecting ASB at {} via {} ...", env.addr, env.via_uri);
let connection_id = generate_connection_id();
// Each AVEVA install picks its own DH group at install time and
// stores it under HKLM\SOFTWARE\Wow6432Node\ArchestrA\
// ArchestrAServices\<solution>\{prime,generator,hashAlgorithm,
// keySize}. `CryptoParameters::defaults` falls back to the .NET
// reference's 1024-bit default — fine for unit tests but will not
// match a live AVEVA install (768-bit primes are typical). The
// companion loader `tools/Get-AsbPassphrase.ps1` exports the
// registry-stored values as MX_ASB_DH_* env vars; if they're set,
// honour them.
let crypto = build_crypto_parameters_from_env();
let (mut transport, response) = AsbTransport::connect(
env.addr,
&env.passphrase,
&CryptoParameters::defaults(),
&crypto,
&env.via_uri,
connection_id,
)
@@ -157,3 +167,42 @@ fn generate_connection_id() -> [u8; 16] {
rand::thread_rng().fill_bytes(&mut bytes);
bytes
}
/// Build `CryptoParameters` from `MX_ASB_DH_*` env vars, falling back
/// to `CryptoParameters::defaults()` for any missing field. Each
/// AVEVA install stores its own DH group (prime, generator, hash,
/// key-size) under
/// `HKLM\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices\<solution>\`;
/// the companion loader `tools/Get-AsbPassphrase.ps1` exports those
/// values so the live-bring-up example doesn't have to read the
/// registry directly (which would pull in a Windows-only crate dep
/// for what is supposed to be a portable example).
fn build_crypto_parameters_from_env() -> CryptoParameters {
let mut params = CryptoParameters::defaults();
if let Ok(prime) = std::env::var("MX_ASB_DH_PRIME") {
params.prime_decimal = prime;
}
if let Ok(generator) = std::env::var("MX_ASB_DH_GENERATOR") {
params.generator_decimal = generator;
}
if let Ok(hash) = std::env::var("MX_ASB_DH_HASH_ALGORITHM") {
// Empty / unrecognised maps to `Unrecognised`, NOT to the
// library default. .NET's `AsbSystemAuthenticator.CreateHmac`
// (`AsbSystemAuthenticator.cs:84-93`) treats an empty
// hashAlgorithm registry value as "fall through to forceHmac
// path" (HMAC-SHA1 for AuthenticateMe). Our `Unrecognised`
// variant has matching semantics (`auth.rs:303-309`).
params.hash_algorithm = match hash.to_ascii_lowercase().as_str() {
"md5" => HashAlgorithm::Md5,
"sha1" => HashAlgorithm::Sha1,
"sha512" => HashAlgorithm::Sha512,
_ => HashAlgorithm::Unrecognised,
};
}
if let Ok(size) = std::env::var("MX_ASB_DH_KEY_SIZE") {
if let Ok(parsed) = size.parse::<u32>() {
params.key_size_bits = parsed;
}
}
params
}