f14580e0db
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>
206 lines
9.3 KiB
PowerShell
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
|