[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
+53
View File
@@ -68,6 +68,31 @@ function Resolve-AsbSolutionName {
return $default
}
function Get-AsbCryptoParameters {
param([string]$Solution)
# Read the per-solution `prime`, `generator`, `hashAlgorithm`, and
# `keySize` registry values. Each AVEVA install picks its own DH
# group at provisioning time, so the Rust port must use the
# registry-stored values rather than a hardcoded constant — the
# default in `CryptoParameters::defaults` is the .NET reference's
# 1024-bit fallback (`AsbRegistry.cs:66-83`), but real installs use
# smaller group sizes (768-bit prime is common). Mismatch produces a
# working `Connect` (the wire bytes are exchanged) but a broken
# `AuthenticateMe` (encrypted ConsumerData decrypts to garbage on
# the server side because the shared secret derivation diverges).
$path = "$ServicesKeyPath\$Solution"
if (-not (Test-Path $path)) {
throw "Solution registry key not found at $path."
}
$key = Get-ItemProperty -Path $path -ErrorAction Stop
return [pscustomobject]@{
Prime = if ($key.PSObject.Properties['prime']) { $key.prime } else { $null }
Generator = if ($key.PSObject.Properties['generator']) { $key.generator } else { $null }
HashAlgorithm = if ($key.PSObject.Properties['hashAlgorithm']) { $key.hashAlgorithm } else { $null }
KeySize = if ($key.PSObject.Properties['keySize']) { $key.keySize } else { $null }
}
}
function Get-AsbSharedSecretBytes {
param([string]$Solution)
$path = "$ServicesKeyPath\$Solution"
@@ -147,6 +172,34 @@ Set-LiveEnvVar -Name 'MX_ASB_SOLUTION' -Value $solution
Set-LiveEnvVar -Name 'MX_ASB_GALAXY_NAME' -Value $GalaxyName
Set-LiveEnvVar -Name 'MX_ASB_PASSPHRASE' -Value $passphrase -Sensitive
# Per-solution DH crypto parameters from the registry — must override
# the Rust port's hardcoded `CryptoParameters::defaults()` (which uses
# the .NET reference's 1024-bit default; real installs use whatever
# was provisioned at install time, often a smaller 768-bit prime).
$crypto = Get-AsbCryptoParameters -Solution $solution
if ($crypto.Prime) {
# Strip whitespace/newlines that PowerShell display would wrap into
# the shown value; the registry-stored decimal must be a single
# contiguous integer.
$primeClean = $crypto.Prime -replace '\s+', ''
Set-LiveEnvVar -Name 'MX_ASB_DH_PRIME' -Value $primeClean
} else {
Write-Host "[WARN] no `prime` value in registry — leaving Rust default in place" -ForegroundColor Yellow
}
if ($crypto.Generator) {
$genClean = ($crypto.Generator.ToString()) -replace '\s+', ''
Set-LiveEnvVar -Name 'MX_ASB_DH_GENERATOR' -Value $genClean
}
# Always export, even if empty — empty string in the registry means
# "use the forceHmac fallback (HMAC-SHA1)" per `AsbSystemAuthenticator
# .cs:91-92`. The example must distinguish "no env var" (use library
# default, MD5) from "registry says empty" (Unrecognised → SHA1 when
# forced). We pick the empty-string sentinel.
Set-LiveEnvVar -Name 'MX_ASB_DH_HASH_ALGORITHM' -Value ($crypto.HashAlgorithm ?? '')
if ($crypto.KeySize) {
Set-LiveEnvVar -Name 'MX_ASB_DH_KEY_SIZE' -Value ($crypto.KeySize.ToString())
}
Write-Host ''
Write-Host 'Done. Run the example with:' -ForegroundColor Green
Write-Host ' cargo run -p mxaccess --example asb-subscribe' -ForegroundColor DarkGray