Files
mxaccess/tools/Get-AsbPassphrase.ps1
T
Joseph Doherty f14580e0db [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>
2026-05-05 17:31:31 -04:00

206 lines
9.3 KiB
PowerShell

# Get-AsbPassphrase.ps1 — read the ASB solution shared secret from the local
# Windows registry + DPAPI and export the env vars the Rust port's
# `asb-subscribe` example expects.
#
# Mirrors `src/MxAsbClient/AsbRegistry.cs:21-41`:
# 1. Look up the default solution name at
# HKLM\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices\DefaultASBSolution
# (or the value passed via -SolutionName).
# 2. Read the `sharedsecret` REG_BINARY at
# HKLM\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices\<solution>\sharedsecret.
# 3. DPAPI-decrypt the bytes (LocalMachine scope, entropy = "wonderware"
# UTF-16LE-encoded — the .NET reference's `Entropy` constant at
# `AsbRegistry.cs:10`).
# 4. UTF-16LE-decode the cleartext into the passphrase string.
# 5. Export $env:MX_ASB_PASSPHRASE plus convenience-derived $env:MX_ASB_HOST
# and $env:MX_ASB_VIA. Sets $env:MX_LIVE=1 to enable the example's
# live path.
#
# Hard rules:
# - Plaintext passphrase NEVER printed to the console (use -Show to opt in).
# - DPAPI scope is LocalMachine; the caller's Windows account must be
# authorised against the encrypted blob (typically: any local
# Administrator or the AVEVA service account).
# - Dot-source so env vars persist in the calling pwsh session.
#
# Usage:
# . .\tools\Get-AsbPassphrase.ps1 # default solution from registry
# . .\tools\Get-AsbPassphrase.ps1 -SolutionName 'Default_ZB_MxDataProvider'
# . .\tools\Get-AsbPassphrase.ps1 -GalaxyName 'ZB' # builds MX_ASB_VIA
# .\tools\Get-AsbPassphrase.ps1 -DryRun # print what would be set
# .\tools\Get-AsbPassphrase.ps1 -Show # print plaintext (CI / debug only!)
[CmdletBinding()]
param(
[string]$SolutionName,
[string]$GalaxyName = 'ZB',
[string]$AsbHost = $env:COMPUTERNAME,
[switch]$DryRun,
[switch]$Show
)
$ErrorActionPreference = 'Stop'
# Mirror `AsbRegistry.RegistryPath` at `cs:12-14`. The registry layout uses
# the WoW64 redirector path on 64-bit hosts because the AVEVA service that
# wrote the value runs as 32-bit.
$ServicesKeyPath = if ([Environment]::Is64BitOperatingSystem) {
'HKLM:\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices'
} else {
'HKLM:\SOFTWARE\ArchestrA\ArchestrAServices'
}
# DPAPI entropy — `AsbRegistry.cs:10`.
$DpapiEntropy = [System.Text.Encoding]::Unicode.GetBytes('wonderware')
function Resolve-AsbSolutionName {
param([string]$Override)
if ($Override) {
return $Override
}
if (-not (Test-Path $ServicesKeyPath)) {
throw "ArchestrAServices registry key not found at $ServicesKeyPath. Is AVEVA System Platform installed?"
}
$default = (Get-ItemProperty -Path $ServicesKeyPath -ErrorAction Stop).DefaultASBSolution
if (-not $default) {
throw "DefaultASBSolution registry value is empty under $ServicesKeyPath. Pass -SolutionName explicitly."
}
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"
if (-not (Test-Path $path)) {
throw "Solution registry key not found at $path. Solution=$Solution may be misspelt."
}
$value = (Get-ItemProperty -Path $path -ErrorAction Stop).sharedsecret
if (-not $value) {
throw "sharedsecret value missing under $path."
}
if ($value -isnot [byte[]]) {
throw "sharedsecret value at $path is not REG_BINARY (got $($value.GetType().Name))."
}
return $value
}
function Unprotect-AsbSecret {
param([byte[]]$Protected)
Add-Type -AssemblyName System.Security
try {
$clear = [System.Security.Cryptography.ProtectedData]::Unprotect(
$Protected,
$DpapiEntropy,
[System.Security.Cryptography.DataProtectionScope]::LocalMachine
)
} catch {
throw "DPAPI decrypt failed: $_. Possible causes: this account isn't authorised against the LocalMachine-scope blob; the AVEVA service was provisioned under a different machine identity; the sharedsecret bytes are corrupt."
}
return [System.Text.Encoding]::Unicode.GetString($clear).TrimEnd("`0")
}
function Set-LiveEnvVar {
param([string]$Name, [string]$Value, [switch]$Sensitive)
$display = if ($Sensitive -and -not $Show) { '***redacted***' } else { $Value }
if ($DryRun) {
Write-Host "[DRY] $Name = $display" -ForegroundColor Yellow
} else {
Set-Item -Path "Env:$Name" -Value $Value
Write-Host "[SET] $Name = $display" -ForegroundColor Green
}
}
# --- main flow -------------------------------------------------------------
Write-Host 'mxaccess ASB passphrase loader' -ForegroundColor Cyan
Write-Host " registry: $ServicesKeyPath" -ForegroundColor DarkGray
$solution = Resolve-AsbSolutionName -Override $SolutionName
Write-Host " solution: $solution" -ForegroundColor DarkGray
$protected = Get-AsbSharedSecretBytes -Solution $solution
Write-Host " sharedsecret bytes: $($protected.Length) (DPAPI-protected)" -ForegroundColor DarkGray
$passphrase = Unprotect-AsbSecret -Protected $protected
Write-Host " passphrase chars: $($passphrase.Length) (decrypted)" -ForegroundColor DarkGray
Write-Host ''
Set-LiveEnvVar -Name 'MX_LIVE' -Value '1'
Set-LiveEnvVar -Name 'MX_ASB_HOST' -Value $AsbHost
# The endpoint URL targets the per-Galaxy MxDataProvider WCF service —
# `Default_<galaxy>_MxDataProvider`. That's a DIFFERENT name from the
# system-wide ArchestrA solution that owns the sharedsecret (typically
# `Archestra_<HOST>`); both live under the same registry root but the
# .NET probe at `src/MxAsbClient.Probe/Program.cs:5` hardcodes the
# MxDataProvider segment because that's what serves IASBIDataV2.
$mxDataProvider = "Default_${GalaxyName}_MxDataProvider"
# Lowercase the host segment of the URL — WCF's NetTcpPortSharing
# SMSvcHost matches the registered service URL case-sensitively in
# the host part; the .NET probe at `src/MxAsbClient.Probe/Program.cs:5`
# hardcodes the lowercase form (`desktop-6jl3kko`) which is what
# AVEVA actually registered. We keep $AsbHost as-cased for TCP DNS
# resolution (`MX_ASB_HOST`) but lowercase it for the Via URL.
$viaHost = $AsbHost.ToLowerInvariant()
$via = "net.tcp://$viaHost/ASBService/$mxDataProvider/IDataV2"
Set-LiveEnvVar -Name 'MX_ASB_VIA' -Value $via
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